Merge pull request #3300 from fonttools/faster-interpolatable

[varLib.interpolatable] Speed up working on variable fonts
This commit is contained in:
Behdad Esfahbod 2023-10-16 07:47:39 -06:00 committed by GitHub
commit fadbb7d8bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -11,7 +11,7 @@ from fontTools.pens.pointPen import SegmentToPointPen
from fontTools.pens.recordingPen import RecordingPen from fontTools.pens.recordingPen import RecordingPen
from fontTools.pens.statisticsPen import StatisticsPen from fontTools.pens.statisticsPen import StatisticsPen
from fontTools.pens.momentsPen import OpenContourError from fontTools.pens.momentsPen import OpenContourError
from collections import OrderedDict from collections import OrderedDict, defaultdict
import math import math
import itertools import itertools
import sys import sys
@ -81,48 +81,43 @@ class RecordingPointPen(BasePen):
self.value.append((pt, False if segmentType is None else True)) self.value.append((pt, False if segmentType is None else True))
def _vdiff(v0, v1): def _vdiff_hypot2(v0, v1):
return tuple(b - a for a, b in zip(v0, v1)) s = 0
for x0, x1 in zip(v0, v1):
d = x1 - x0
s += d * d
return s
def _vlen(vec): def _vdiff_hypot2_complex(v0, v1):
v = 0 s = 0
for x in vec: for x0, x1 in zip(v0, v1):
v += x * x d = x1 - x0
return v s += d.real * d.real + d.imag * d.imag
return s
def _complex_vlen(vec):
v = 0
for x in vec:
v += abs(x) * abs(x)
return v
def _matching_cost(G, matching): def _matching_cost(G, matching):
return sum(G[i][j] for i, j in enumerate(matching)) return sum(G[i][j] for i, j in enumerate(matching))
def min_cost_perfect_bipartite_matching(G): def min_cost_perfect_bipartite_matching_scipy(G):
n = len(G) n = len(G)
try: rows, cols = linear_sum_assignment(G)
from scipy.optimize import linear_sum_assignment assert (rows == list(range(n))).all()
return list(cols), _matching_cost(G, cols)
rows, cols = linear_sum_assignment(G)
assert (rows == list(range(n))).all()
return list(cols), _matching_cost(G, cols)
except ImportError:
pass
try: def min_cost_perfect_bipartite_matching_munkres(G):
from munkres import Munkres n = len(G)
cols = [None] * n
for row, col in Munkres().compute(G):
cols[row] = col
return cols, _matching_cost(G, cols)
cols = [None] * n
for row, col in Munkres().compute(G): def min_cost_perfect_bipartite_matching_bruteforce(G):
cols[row] = col n = len(G)
return cols, _matching_cost(G, cols)
except ImportError:
pass
if n > 6: if n > 6:
raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'") raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'")
@ -138,6 +133,23 @@ def min_cost_perfect_bipartite_matching(G):
return best, best_cost return best, best_cost
try:
from scipy.optimize import linear_sum_assignment
min_cost_perfect_bipartite_matching = min_cost_perfect_bipartite_matching_scipy
except ImportError:
try:
from munkres import Munkres
min_cost_perfect_bipartite_matching = (
min_cost_perfect_bipartite_matching_munkres
)
except ImportError:
min_cost_perfect_bipartite_matching = (
min_cost_perfect_bipartite_matching_bruteforce
)
def test(glyphsets, glyphs=None, names=None, ignore_missing=False): def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
if names is None: if names is None:
names = glyphsets names = glyphsets
@ -158,9 +170,10 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
allVectors = [] allVectors = []
allNodeTypes = [] allNodeTypes = []
allContourIsomorphisms = [] allContourIsomorphisms = []
for glyphset, name in zip(glyphsets, names): allGlyphs = [glyphset[glyph_name] for glyphset in glyphsets]
glyph = glyphset[glyph_name] if len([1 for glyph in allGlyphs if glyph is not None]) <= 1:
continue
for glyph, glyphset, name in zip(allGlyphs, glyphsets, names):
if glyph is None: if glyph is None:
if not ignore_missing: if not ignore_missing:
add_problem(glyph_name, {"type": "missing", "master": name}) add_problem(glyph_name, {"type": "missing", "master": name})
@ -247,11 +260,12 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
) )
# m0idx should be the index of the first non-None item in allNodeTypes, # m0idx should be the index of the first non-None item in allNodeTypes,
# else give it the first index of None, which is likely 0 # else give it the last item.
m0idx = allNodeTypes.index( m0idx = next(
next((x for x in allNodeTypes if x is not None), None) (i for i, x in enumerate(allNodeTypes) if x is not None),
len(allNodeTypes) - 1,
) )
# m0 is the first non-None item in allNodeTypes, or the first item if all are None # m0 is the first non-None item in allNodeTypes, or last one if all None
m0 = allNodeTypes[m0idx] m0 = allNodeTypes[m0idx]
for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]): for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]):
if m1 is None: if m1 is None:
@ -302,39 +316,39 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
continue continue
# m0idx should be the index of the first non-None item in allVectors, # m0idx should be the index of the first non-None item in allVectors,
# else give it the first index of None, which is likely 0 # else give it the last item.
m0idx = allVectors.index( m0idx = next(
next((x for x in allVectors if x is not None), None) (i for i, x in enumerate(allVectors) if x is not None),
len(allVectors) - 1,
) )
# m0 is the first non-None item in allVectors, or the first item if all are None # m0 is the first non-None item in allVectors, or last one if all None
m0 = allVectors[m0idx] m0 = allVectors[m0idx]
for i, m1 in enumerate(allVectors[m0idx + 1 :]): if m0 is not None and len(m0) > 1:
if m1 is None: for i, m1 in enumerate(allVectors[m0idx + 1 :]):
continue if m1 is None:
if len(m0) != len(m1): continue
# We already reported this if len(m0) != len(m1):
continue # We already reported this
if not m0: continue
continue costs = [[_vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0]
costs = [[_vlen(_vdiff(v0, v1)) for v1 in m1] for v0 in m0] matching, matching_cost = min_cost_perfect_bipartite_matching(costs)
matching, matching_cost = min_cost_perfect_bipartite_matching(costs) identity_matching = list(range(len(m0)))
identity_matching = list(range(len(m0))) identity_cost = sum(costs[i][i] for i in range(len(m0)))
identity_cost = sum(costs[i][i] for i in range(len(m0))) if (
if ( matching != identity_matching
matching != identity_matching and matching_cost < identity_cost * 0.95
and matching_cost < identity_cost * 0.95 ):
): add_problem(
add_problem( glyph_name,
glyph_name, {
{ "type": "contour_order",
"type": "contour_order", "master_1": names[m0idx],
"master_1": names[m0idx], "master_2": names[m0idx + i + 1],
"master_2": names[m0idx + i + 1], "value_1": list(range(len(m0))),
"value_1": list(range(len(m0))), "value_2": matching,
"value_2": matching, },
}, )
) break
break
# m0idx should be the index of the first non-None item in allContourIsomorphisms, # m0idx should be the index of the first non-None item in allContourIsomorphisms,
# else give it the first index of None, which is likely 0 # else give it the first index of None, which is likely 0
@ -343,31 +357,31 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
) )
# m0 is the first non-None item in allContourIsomorphisms, or the first item if all are None # m0 is the first non-None item in allContourIsomorphisms, or the first item if all are None
m0 = allContourIsomorphisms[m0idx] m0 = allContourIsomorphisms[m0idx]
for i, m1 in enumerate(allContourIsomorphisms[m0idx + 1 :]): if m0:
if m1 is None: for i, m1 in enumerate(allContourIsomorphisms[m0idx + 1 :]):
continue if m1 is None:
if len(m0) != len(m1): continue
# We already reported this if len(m0) != len(m1):
continue # We already reported this
if not m0: continue
continue for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
for ix, (contour0, contour1) in enumerate(zip(m0, m1)): c0 = contour0[0]
c0 = contour0[0] costs = [
costs = [ v
v for v in (_complex_vlen(_vdiff(c0, c1)) for c1 in contour1) for v in (_vdiff_hypot2_complex(c0, c1) for c1 in contour1)
] ]
min_cost = min(costs) min_cost = min(costs)
first_cost = costs[0] first_cost = costs[0]
if min_cost < first_cost * 0.95: if min_cost < first_cost * 0.95:
add_problem( add_problem(
glyph_name, glyph_name,
{ {
"type": "wrong_start_point", "type": "wrong_start_point",
"contour": ix, "contour": ix,
"master_1": names[m0idx], "master_1": names[m0idx],
"master_2": names[m0idx + i + 1], "master_2": names[m0idx + i + 1],
}, },
) )
except ValueError as e: except ValueError as e:
add_problem( add_problem(
@ -377,6 +391,15 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
return problems return problems
def recursivelyAddGlyph(glyphname, glyphset, ttGlyphSet, glyf):
if glyphname in glyphset:
return
glyphset[glyphname] = ttGlyphSet[glyphname]
for component in getattr(glyf[glyphname], "components", []):
recursivelyAddGlyph(component.glyphName, glyphset, ttGlyphSet, glyf)
def main(args=None): def main(args=None):
"""Test for interpolatability issues between fonts""" """Test for interpolatability issues between fonts"""
import argparse import argparse
@ -415,7 +438,7 @@ def main(args=None):
args = parser.parse_args(args) args = parser.parse_args(args)
glyphs = set(args.glyphs.split()) if args.glyphs else None glyphs = args.glyphs.split() if args.glyphs else None
from os.path import basename from os.path import basename
@ -444,30 +467,37 @@ def main(args=None):
if "gvar" in font: if "gvar" in font:
# Is variable font # Is variable font
gvar = font["gvar"] gvar = font["gvar"]
# Gather all "master" locations glyf = font["glyf"]
locs = set() # Gather all glyphs at their "master" locations
for variations in gvar.variations.values(): ttGlyphSets = {}
for var in variations: glyphsets = defaultdict(dict)
if glyphs is None:
glyphs = sorted(gvar.variations.keys())
for glyphname in glyphs:
for var in gvar.variations[glyphname]:
locDict = {}
loc = [] loc = []
for tag, val in sorted(var.axes.items()): for tag, val in sorted(var.axes.items()):
locDict[tag] = val[1]
loc.append((tag, val[1])) loc.append((tag, val[1]))
locs.add(tuple(loc))
# Rebuild locs as dictionaries
new_locs = [{}]
names.append("()")
for loc in sorted(locs, key=lambda v: (len(v), v)):
names.append(str(loc))
l = {}
for tag, val in loc:
l[tag] = val
new_locs.append(l)
locs = new_locs
del new_locs
# locs is all master locations now
for loc in locs: locTuple = tuple(loc)
fonts.append(font.getGlyphSet(location=loc, normalized=True)) if locTuple not in ttGlyphSets:
ttGlyphSets[locTuple] = font.getGlyphSet(
location=locDict, normalized=True
)
recursivelyAddGlyph(
glyphname, glyphsets[locTuple], ttGlyphSets[locTuple], glyf
)
names = ["()"]
fonts = [font.getGlyphSet()]
for locTuple in sorted(glyphsets.keys(), key=lambda v: (len(v), v)):
names.append(str(locTuple))
fonts.append(glyphsets[locTuple])
args.ignore_missing = True
args.inputs = [] args.inputs = []
for filename in args.inputs: for filename in args.inputs:
@ -491,11 +521,12 @@ def main(args=None):
glyphsets.append({k: glyphset[k] for k in glyphset.keys()}) glyphsets.append({k: glyphset[k] for k in glyphset.keys()})
if not glyphs: if not glyphs:
glyphs = set([gn for glyphset in glyphsets for gn in glyphset.keys()]) glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()]))
glyphsSet = set(glyphs)
for glyphset in glyphsets: for glyphset in glyphsets:
glyphSetGlyphNames = set(glyphset.keys()) glyphSetGlyphNames = set(glyphset.keys())
diff = glyphs - glyphSetGlyphNames diff = glyphsSet - glyphSetGlyphNames
if diff: if diff:
for gn in diff: for gn in diff:
glyphset[gn] = None glyphset[gn] = None