Merge pull request #2214 from fonttools/varLib-round

[varLib] Move rounding to VariationModel, to avoid error accumulation from multiple deltas
This commit is contained in:
Behdad Esfahbod 2021-03-04 16:40:40 -07:00 committed by GitHub
commit 062b7c434e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 119 additions and 124 deletions

View File

@ -1,7 +1,7 @@
"""Helpers for manipulating 2D points and vectors in COLR table."""
from math import copysign, cos, hypot, pi
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import otRound
def _vector_between(origin, target):

View File

@ -22,7 +22,7 @@ from fontTools.ttLib.tables.otConverters import (
IntValue,
FloatValue,
)
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import otRound
class BuildCallback(enum.Enum):

View File

@ -3,7 +3,7 @@ so on.
"""
from fontTools.misc.py23 import *
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import otRound
from fontTools.misc.vector import Vector as _Vector
import math
import warnings

View File

@ -18,6 +18,7 @@ functions for converting between fixed-point, float and string representations.
"""
from fontTools.misc.py23 import *
from .roundTools import otRound
import math
import logging
@ -25,7 +26,6 @@ log = logging.getLogger(__name__)
__all__ = [
"MAX_F2DOT14",
"otRound",
"fixedToFloat",
"floatToFixed",
"floatToFixedToFloat",
@ -41,30 +41,6 @@ __all__ = [
MAX_F2DOT14 = 0x7FFF / (1 << 14)
def otRound(value):
"""Round float value to nearest integer towards ``+Infinity``.
The OpenType spec (in the section on `"normalization" of OpenType Font Variations <https://docs.microsoft.com/en-us/typography/opentype/spec/otvaroverview#coordinate-scales-and-normalization>`_)
defines the required method for converting floating point values to
fixed-point. In particular it specifies the following rounding strategy:
for fractional values of 0.5 and higher, take the next higher integer;
for other fractional values, truncate.
This function rounds the floating-point value according to this strategy
in preparation for conversion to fixed-point.
Args:
value (float): The input floating-point value.
Returns
float: The rounded value.
"""
# See this thread for how we ended up with this implementation:
# https://github.com/fonttools/fonttools/issues/1248#issuecomment-383198166
return int(math.floor(value + 0.5))
def fixedToFloat(value, precisionBits):
"""Converts a fixed-point number to a float given the number of
precision bits.

View File

@ -0,0 +1,58 @@
"""
Various round-to-integer helpers.
"""
import math
import functools
import logging
log = logging.getLogger(__name__)
__all__ = [
"noRound",
"otRound",
"maybeRound",
"roundFunc",
]
def noRound(value):
return value
def otRound(value):
"""Round float value to nearest integer towards ``+Infinity``.
The OpenType spec (in the section on `"normalization" of OpenType Font Variations <https://docs.microsoft.com/en-us/typography/opentype/spec/otvaroverview#coordinate-scales-and-normalization>`_)
defines the required method for converting floating point values to
fixed-point. In particular it specifies the following rounding strategy:
for fractional values of 0.5 and higher, take the next higher integer;
for other fractional values, truncate.
This function rounds the floating-point value according to this strategy
in preparation for conversion to fixed-point.
Args:
value (float): The input floating-point value.
Returns
float: The rounded value.
"""
# See this thread for how we ended up with this implementation:
# https://github.com/fonttools/fonttools/issues/1248#issuecomment-383198166
return int(math.floor(value + 0.5))
def maybeRound(v, tolerance, round=otRound):
rounded = round(v)
return rounded if abs(rounded - v) <= tolerance else v
def roundFunc(tolerance, round=otRound):
if tolerance < 0:
raise ValueError("Rounding tolerance must be positive")
if tolerance == 0:
return noRound
if tolerance >= .5:
return round
return functools.partial(maybeRound, tolerance=tolerance, round=round)

View File

@ -76,7 +76,7 @@ class Vector(tuple):
def __neg__(self):
return self._unaryOp(operator.neg)
def __round__(self):
def __round__(self, *, round=round):
return self._unaryOp(round)
def __eq__(self, other):

View File

@ -1,4 +1,4 @@
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import otRound
from fontTools.misc.transform import Transform
from fontTools.pens.filterPen import FilterPen, FilterPointPen

View File

@ -1,36 +1,12 @@
# Copyright (c) 2009 Type Supply LLC
# Author: Tal Leming
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import otRound, roundFunc
from fontTools.misc.psCharStrings import T2CharString
from fontTools.pens.basePen import BasePen
from fontTools.cffLib.specializer import specializeCommands, commandsToProgram
def t2c_round(number, tolerance=0.5):
if tolerance == 0:
return number # no-op
rounded = otRound(number)
# return rounded integer if the tolerance >= 0.5, or if the absolute
# difference between the original float and the rounded integer is
# within the tolerance
if tolerance >= .5 or abs(rounded - number) <= tolerance:
return rounded
else:
# else return the value un-rounded
return number
def makeRoundFunc(tolerance):
if tolerance < 0:
raise ValueError("Rounding tolerance must be positive")
def roundPoint(point):
x, y = point
return t2c_round(x, tolerance), t2c_round(y, tolerance)
return roundPoint
class T2CharStringPen(BasePen):
"""Pen to draw Type 2 CharStrings.
@ -44,7 +20,7 @@ class T2CharStringPen(BasePen):
def __init__(self, width, glyphSet, roundTolerance=0.5, CFF2=False):
super(T2CharStringPen, self).__init__(glyphSet)
self.roundPoint = makeRoundFunc(roundTolerance)
self.round = roundFunc(roundTolerance)
self._CFF2 = CFF2
self._width = width
self._commands = []
@ -52,7 +28,7 @@ class T2CharStringPen(BasePen):
def _p(self, pt):
p0 = self._p0
pt = self._p0 = self.roundPoint(pt)
pt = self._p0 = (self.round(pt[0]), self.round(pt[1]))
return [pt[0]-p0[0], pt[1]-p0[1]]
def _moveTo(self, pt):

View File

@ -1,5 +1,6 @@
from array import array
from fontTools.misc.fixedTools import MAX_F2DOT14, otRound, floatToFixedToFloat
from fontTools.misc.roundTools import otRound
from fontTools.pens.basePen import LoggingPen
from fontTools.pens.transformPen import TransformPen
from fontTools.ttLib.tables import ttProgram

View File

@ -2,7 +2,7 @@
#
# Google Author(s): Behdad Esfahbod
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import otRound
from fontTools import ttLib
from fontTools.ttLib.tables import otTables
from fontTools.otlLib.maxContextCalc import maxCtxFont

View File

@ -1,7 +1,7 @@
from fontTools.misc import psCharStrings
from fontTools import ttLib
from fontTools.pens.basePen import NullPen
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import otRound
from fontTools.varLib.varStore import VarStoreInstancer
def _add_method(*clazzes):

View File

@ -1506,12 +1506,12 @@ class GlyphCoordinates(object):
p = self._checkFloat(p)
self._a.extend(p)
def toInt(self):
def toInt(self, *, round=otRound):
if not self.isFloat():
return
a = array.array("h")
for n in self._a:
a.append(otRound(n))
a.append(round(n))
self._a = a
def relativeToAbsolute(self):
@ -1626,13 +1626,9 @@ class GlyphCoordinates(object):
for i in range(len(a)):
a[i] = -a[i]
return r
def __round__(self):
"""
Note: This is Python 3 only. Python 2 does not call __round__.
As such, we cannot test this method either. :(
"""
def __round__(self, *, round=otRound):
r = self.copy()
r.toInt()
r.toInt(round=round)
return r
def __add__(self, other): return self.copy().__iadd__(other)

View File

@ -1,5 +1,5 @@
from fontTools.misc.py23 import *
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import otRound
from fontTools import ttLib
from fontTools.misc.textTools import safeEval
from . import DefaultTable

View File

@ -9,7 +9,7 @@ from enum import IntEnum
import itertools
from collections import namedtuple
from fontTools.misc.py23 import *
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import otRound
from fontTools.misc.textTools import pad, safeEval
from .otBase import (
BaseTable, FormatSwitchingBaseTable, ValueRecord, CountReference,

View File

@ -19,7 +19,7 @@ Then you can make a variable-font this way:
API *will* change in near future.
"""
from fontTools.misc.py23 import *
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import noRound, otRound
from fontTools.misc.vector import Vector
from fontTools.ttLib import TTFont, newTable
from fontTools.ttLib.tables._f_v_a_r import Axis, NamedInstance
@ -34,6 +34,7 @@ from fontTools.varLib.mvar import MVAR_ENTRIES
from fontTools.varLib.iup import iup_delta_optimize
from fontTools.varLib.featureVars import addFeatureVariations
from fontTools.designspaceLib import DesignSpaceDocument
from functools import partial
from collections import OrderedDict, namedtuple
import os.path
import logging
@ -253,7 +254,7 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True):
# Update gvar
gvar.variations[glyph] = []
deltas = model.getDeltas(allCoords)
deltas = model.getDeltas(allCoords, round=partial(GlyphCoordinates.__round__, round=round))
supports = model.supports
assert len(deltas) == len(supports)
@ -262,7 +263,7 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True):
endPts = control.endPts
for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])):
if all(abs(v) <= tolerance for v in delta.array) and not isComposite:
if all(v == 0 for v in delta.array) and not isComposite:
continue
var = TupleVariation(support, delta)
if optimize:
@ -304,7 +305,7 @@ def _remove_TTHinting(font):
font["glyf"].removeHinting()
# TODO: Modify gasp table to deactivate gridfitting for all ranges?
def _merge_TTHinting(font, masterModel, master_ttfs, tolerance=0.5):
def _merge_TTHinting(font, masterModel, master_ttfs):
log.info("Merging TT hinting")
assert "cvar" not in font
@ -363,10 +364,9 @@ def _merge_TTHinting(font, masterModel, master_ttfs, tolerance=0.5):
return
variations = []
deltas, supports = masterModel.getDeltasAndSupports(all_cvs)
deltas, supports = masterModel.getDeltasAndSupports(all_cvs, round=round) # builtin round calls into Vector.__round__, which uses builtin round as we like
for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])):
delta = [otRound(d) for d in delta]
if all(abs(v) <= tolerance for v in delta):
if all(v == 0 for v in delta):
continue
var = TupleVariation(support, delta)
variations.append(var)
@ -441,7 +441,7 @@ def _get_advance_metrics(font, masterModel, master_ttfs,
vOrigDeltasAndSupports = {}
for glyph in glyphOrder:
vhAdvances = [metrics[glyph][0] if glyph in metrics else None for metrics in advMetricses]
vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vhAdvances)
vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vhAdvances, round=round)
singleModel = models.allEqual(id(v[1]) for v in vhAdvanceDeltasAndSupports.values())
@ -453,7 +453,7 @@ def _get_advance_metrics(font, masterModel, master_ttfs,
# glyphs which have a non-default vOrig.
vOrigs = [metrics[glyph] if glyph in metrics else defaultVOrig
for metrics, defaultVOrig in vOrigMetricses]
vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vOrigs)
vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vOrigs, round=round)
directStore = None
if singleModel:
@ -463,7 +463,7 @@ def _get_advance_metrics(font, masterModel, master_ttfs,
varTupleIndexes = list(range(len(supports)))
varData = builder.buildVarData(varTupleIndexes, [], optimize=False)
for glyphName in glyphOrder:
varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0])
varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0], round=noRound)
varData.optimize()
directStore = builder.buildVarStore(varTupleList, [varData])
@ -473,14 +473,14 @@ def _get_advance_metrics(font, masterModel, master_ttfs,
for glyphName in glyphOrder:
deltas, supports = vhAdvanceDeltasAndSupports[glyphName]
storeBuilder.setSupports(supports)
advMapping[glyphName] = storeBuilder.storeDeltas(deltas)
advMapping[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound)
if vOrigMetricses:
vOrigMap = {}
for glyphName in glyphOrder:
deltas, supports = vOrigDeltasAndSupports[glyphName]
storeBuilder.setSupports(supports)
vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas)
vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound)
indirectStore = storeBuilder.finish()
mapping2 = indirectStore.optimize()

View File

@ -17,8 +17,10 @@ from fontTools.cffLib.specializer import (
from fontTools.ttLib import newTable
from fontTools import varLib
from fontTools.varLib.models import allEqual
from fontTools.misc.roundTools import roundFunc
from fontTools.misc.psCharStrings import T2CharString, T2OutlineExtractor
from fontTools.pens.t2CharStringPen import T2CharStringPen, t2c_round
from fontTools.pens.t2CharStringPen import T2CharStringPen
from functools import partial
from .errors import VarLibCFFDictMergeError, VarLibCFFPointTypeMergeError, VarLibMergeError
@ -422,16 +424,6 @@ def merge_charstrings(glyphOrder, num_masters, top_dicts, masterModel):
return cvData
def makeRoundNumberFunc(tolerance):
if tolerance < 0:
raise ValueError("Rounding tolerance must be positive")
def roundNumber(val):
return t2c_round(val, tolerance)
return roundNumber
class CFFToCFF2OutlineExtractor(T2OutlineExtractor):
""" This class is used to remove the initial width from the CFF
charstring without trying to add the width to self.nominalWidthX,
@ -518,7 +510,7 @@ class CFF2CharStringMergePen(T2CharStringPen):
self.prev_move_idx = 0
self.seen_moveto = False
self.glyphName = glyphName
self.roundNumber = makeRoundNumberFunc(roundTolerance)
self.round = roundFunc(roundTolerance, round=round)
def add_point(self, point_type, pt_coords):
if self.m_index == 0:
@ -594,7 +586,7 @@ class CFF2CharStringMergePen(T2CharStringPen):
def getCommands(self):
return self._commands
def reorder_blend_args(self, commands, get_delta_func, round_func):
def reorder_blend_args(self, commands, get_delta_func):
"""
We first re-order the master coordinate values.
For a moveto to lineto, the args are now arranged as:
@ -637,8 +629,6 @@ class CFF2CharStringMergePen(T2CharStringPen):
else:
# convert to deltas
deltas = get_delta_func(coord)[1:]
if round_func:
deltas = [round_func(delta) for delta in deltas]
coord = [coord[0]] + deltas
new_coords.append(coord)
cmd[1] = new_coords
@ -649,8 +639,7 @@ class CFF2CharStringMergePen(T2CharStringPen):
self, private=None, globalSubrs=None,
var_model=None, optimize=True):
commands = self._commands
commands = self.reorder_blend_args(commands, var_model.getDeltas,
self.roundNumber)
commands = self.reorder_blend_args(commands, partial (var_model.getDeltas, round=self.round))
if optimize:
commands = specializeCommands(
commands, generalizeFirst=False,

View File

@ -3,8 +3,8 @@ Merge OpenType Layout tables (GDEF / GPOS / GSUB).
"""
import copy
from operator import ior
from fontTools.misc.fixedTools import otRound
from fontTools.misc import classifyTools
from fontTools.misc.roundTools import otRound
from fontTools.ttLib.tables import otTables as ot
from fontTools.ttLib.tables import otBase as otBase
from fontTools.ttLib.tables.DefaultTable import DefaultTable

View File

@ -5,6 +5,7 @@ __all__ = ['nonNone', 'allNone', 'allEqual', 'allEqualTo', 'subList',
'supportScalar',
'VariationModel']
from fontTools.misc.roundTools import noRound
from .errors import VariationModelError
@ -367,7 +368,7 @@ class VariationModel(object):
deltaWeights.append(deltaWeight)
self.deltaWeights = deltaWeights
def getDeltas(self, masterValues):
def getDeltas(self, masterValues, *, round=noRound):
assert len(masterValues) == len(self.deltaWeights)
mapping = self.reverseMapping
out = []
@ -375,12 +376,12 @@ class VariationModel(object):
delta = masterValues[mapping[i]]
for j,weight in weights.items():
delta -= out[j] * weight
out.append(delta)
out.append(round(delta))
return out
def getDeltasAndSupports(self, items):
def getDeltasAndSupports(self, items, *, round=noRound):
model, items = self.getSubModel(items)
return model.getDeltas(items), model.supports
return model.getDeltas(items, round=round), model.supports
def getScalars(self, loc):
return [supportScalar(loc, support) for support in self.supports]
@ -402,12 +403,12 @@ class VariationModel(object):
scalars = self.getScalars(loc)
return self.interpolateFromDeltasAndScalars(deltas, scalars)
def interpolateFromMasters(self, loc, masterValues):
deltas = self.getDeltas(masterValues)
def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
deltas = self.getDeltas(masterValues, round=round)
return self.interpolateFromDeltas(loc, deltas)
def interpolateFromMastersAndScalars(self, masterValues, scalars):
deltas = self.getDeltas(masterValues)
def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
deltas = self.getDeltas(masterValues, round=round)
return self.interpolateFromDeltasAndScalars(deltas, scalars)

View File

@ -3,7 +3,8 @@ Instantiate a variation font. Run, eg:
$ fonttools varLib.mutator ./NotoSansArabic-VF.ttf wght=140 wdth=85
"""
from fontTools.misc.fixedTools import floatToFixedToFloat, otRound, floatToFixed
from fontTools.misc.fixedTools import floatToFixedToFloat, floatToFixed
from fontTools.misc.roundTools import otRound
from fontTools.pens.boundsPen import BoundsPen
from fontTools.ttLib import TTFont, newTable
from fontTools.ttLib.tables import ttProgram

View File

@ -2,8 +2,8 @@
from fontTools.varLib.models import VariationModel, supportScalar
from fontTools.designspaceLib import DesignSpaceDocument
from mpl_toolkits.mplot3d import axes3d
from matplotlib import pyplot
from mpl_toolkits.mplot3d import axes3d
from itertools import cycle
import math
import logging
@ -68,10 +68,10 @@ def plotLocations(locations, fig, names=None, **kwargs):
def _plotLocations2D(model, axis, fig, cols, rows, names, **kwargs):
subplot = fig.add_subplot(111)
for i, (support, color, name) in enumerate(
zip(model.supports, cycle(pyplot.cm.Set1.colors), cycle(names))
):
subplot = fig.add_subplot(rows, cols, i + 1)
if name is not None:
subplot.set_title(name)
subplot.set_xlabel(axis)
@ -91,10 +91,10 @@ def _plotLocations2D(model, axis, fig, cols, rows, names, **kwargs):
def _plotLocations3D(model, axes, fig, rows, cols, names, **kwargs):
ax1, ax2 = axes
axis3D = fig.add_subplot(111, projection='3d')
for i, (support, color, name) in enumerate(
zip(model.supports, cycle(pyplot.cm.Set1.colors), cycle(names))
):
axis3D = fig.add_subplot(rows, cols, i + 1, projection='3d')
if name is not None:
axis3D.set_title(name)
axis3D.set_xlabel(ax1)

View File

@ -1,4 +1,4 @@
from fontTools.misc.fixedTools import otRound
from fontTools.misc.roundTools import noRound, otRound
from fontTools.ttLib.tables import otTables as ot
from fontTools.varLib.models import supportScalar
from fontTools.varLib.builder import (buildVarRegionList, buildVarStore,
@ -83,15 +83,12 @@ class OnlineVarStoreBuilder(object):
def storeMasters(self, master_values):
deltas = self._model.getDeltas(master_values)
base = otRound(deltas.pop(0))
return base, self.storeDeltas(deltas)
deltas = self._model.getDeltas(master_values, round=round)
base = deltas.pop(0)
return base, self.storeDeltas(deltas, round=noRound)
def storeDeltas(self, deltas):
# Pity that this exists here, since VarData_addItem
# does the same. But to look into our cache, it's
# good to adjust deltas here as well...
deltas = [otRound(d) for d in deltas]
def storeDeltas(self, deltas, *, round=round):
deltas = [round(d) for d in deltas]
if len(deltas) == len(self._supports) + 1:
deltas = tuple(deltas[1:])
else:
@ -109,14 +106,14 @@ class OnlineVarStoreBuilder(object):
# Full array. Start new one.
self._add_VarData()
return self.storeDeltas(deltas)
self._data.addItem(deltas)
self._data.addItem(deltas, round=noRound)
varIdx = (self._outer << 16) + inner
self._cache[deltas] = varIdx
return varIdx
def VarData_addItem(self, deltas):
deltas = [otRound(d) for d in deltas]
def VarData_addItem(self, deltas, *, round=round):
deltas = [round(d) for d in deltas]
countUs = self.VarRegionCount
countThem = len(deltas)

View File

@ -290,7 +290,7 @@
</CharString>
<CharString name="cid06449" fdSelectIndex="1">
2 vsindex
-60 30 203 30 -9 9 67 7 -7 14 -14 30 -20 20 80 30 59 30 121 30 18 93 -30 30 -30 108 -23 0 -26 67 2 76 -98 -2 -111 42 0 47 -13 0 -14 13 0 14 -33 0 -37 11 0 13 -11 0 -13 8 0 9 -7 0 -8 53 0 60 -32 0 -36 32 0 36 -52 0 -59 57 1 65 -33 0 -38 53 0 60 -83 -1 -93 54 0 60 -6 -19 -24 33 19 55 -76 -1 -86 76 1 86 -76 -1 -86 59 1 67 26 blend
-60 30 203 30 -9 9 67 7 -7 14 -14 30 -20 20 80 30 59 30 121 30 18 93 -30 30 -30 108 -23 0 -26 67 2 76 -98 -2 -111 42 0 47 -13 0 -14 13 0 14 -33 0 -37 11 0 13 -11 0 -13 8 0 8 -8 0 -8 53 0 60 -32 0 -36 32 0 36 -52 0 -59 57 1 65 -33 0 -38 53 0 60 -83 -1 -93 54 0 60 -6 -19 -24 33 19 55 -76 -1 -86 76 1 86 -76 -1 -86 59 1 67 26 blend
hstemhm
77 30 42 30 139 30 23 30 71 10 74 30 15 30 16 30 158 30 28 30 -4 29 -14 0 -16 88 1 99 -82 -1 -92 87 1 98 -130 -1 -146 102 1 114 -73 -1 -82 74 2 84 -112 -2 -126 27 0 30 13 0 15 90 1 101 -126 -1 -142 75 1 84 -68 -1 -76 102 1 115 -144 -1 -162 94 1 105 -79 -1 -88 95 1 106 -81 -1 -91 74 1 83 22 blend
vstemhm