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.statisticsPen import StatisticsPen
from fontTools.pens.momentsPen import OpenContourError
from collections import OrderedDict
from collections import OrderedDict, defaultdict
import math
import itertools
import sys
@ -81,48 +81,43 @@ class RecordingPointPen(BasePen):
self.value.append((pt, False if segmentType is None else True))
def _vdiff(v0, v1):
return tuple(b - a for a, b in zip(v0, v1))
def _vdiff_hypot2(v0, v1):
s = 0
for x0, x1 in zip(v0, v1):
d = x1 - x0
s += d * d
return s
def _vlen(vec):
v = 0
for x in vec:
v += x * x
return v
def _complex_vlen(vec):
v = 0
for x in vec:
v += abs(x) * abs(x)
return v
def _vdiff_hypot2_complex(v0, v1):
s = 0
for x0, x1 in zip(v0, v1):
d = x1 - x0
s += d.real * d.real + d.imag * d.imag
return s
def _matching_cost(G, 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)
try:
from scipy.optimize import linear_sum_assignment
rows, cols = linear_sum_assignment(G)
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:
from munkres import Munkres
def min_cost_perfect_bipartite_matching_munkres(G):
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):
cols[row] = col
return cols, _matching_cost(G, cols)
except ImportError:
pass
def min_cost_perfect_bipartite_matching_bruteforce(G):
n = len(G)
if n > 6:
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
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):
if names is None:
names = glyphsets
@ -158,9 +170,10 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
allVectors = []
allNodeTypes = []
allContourIsomorphisms = []
for glyphset, name in zip(glyphsets, names):
glyph = glyphset[glyph_name]
allGlyphs = [glyphset[glyph_name] for glyphset in glyphsets]
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 not ignore_missing:
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,
# else give it the first index of None, which is likely 0
m0idx = allNodeTypes.index(
next((x for x in allNodeTypes if x is not None), None)
# else give it the last item.
m0idx = next(
(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]
for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]):
if m1 is None:
@ -302,39 +316,39 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
continue
# 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
m0idx = allVectors.index(
next((x for x in allVectors if x is not None), None)
# else give it the last item.
m0idx = next(
(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]
for i, m1 in enumerate(allVectors[m0idx + 1 :]):
if m1 is None:
continue
if len(m0) != len(m1):
# We already reported this
continue
if not m0:
continue
costs = [[_vlen(_vdiff(v0, v1)) for v1 in m1] for v0 in m0]
matching, matching_cost = min_cost_perfect_bipartite_matching(costs)
identity_matching = list(range(len(m0)))
identity_cost = sum(costs[i][i] for i in range(len(m0)))
if (
matching != identity_matching
and matching_cost < identity_cost * 0.95
):
add_problem(
glyph_name,
{
"type": "contour_order",
"master_1": names[m0idx],
"master_2": names[m0idx + i + 1],
"value_1": list(range(len(m0))),
"value_2": matching,
},
)
break
if m0 is not None and len(m0) > 1:
for i, m1 in enumerate(allVectors[m0idx + 1 :]):
if m1 is None:
continue
if len(m0) != len(m1):
# We already reported this
continue
costs = [[_vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0]
matching, matching_cost = min_cost_perfect_bipartite_matching(costs)
identity_matching = list(range(len(m0)))
identity_cost = sum(costs[i][i] for i in range(len(m0)))
if (
matching != identity_matching
and matching_cost < identity_cost * 0.95
):
add_problem(
glyph_name,
{
"type": "contour_order",
"master_1": names[m0idx],
"master_2": names[m0idx + i + 1],
"value_1": list(range(len(m0))),
"value_2": matching,
},
)
break
# 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
@ -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 = allContourIsomorphisms[m0idx]
for i, m1 in enumerate(allContourIsomorphisms[m0idx + 1 :]):
if m1 is None:
continue
if len(m0) != len(m1):
# We already reported this
continue
if not m0:
continue
for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
c0 = contour0[0]
costs = [
v for v in (_complex_vlen(_vdiff(c0, c1)) for c1 in contour1)
]
min_cost = min(costs)
first_cost = costs[0]
if min_cost < first_cost * 0.95:
add_problem(
glyph_name,
{
"type": "wrong_start_point",
"contour": ix,
"master_1": names[m0idx],
"master_2": names[m0idx + i + 1],
},
)
if m0:
for i, m1 in enumerate(allContourIsomorphisms[m0idx + 1 :]):
if m1 is None:
continue
if len(m0) != len(m1):
# We already reported this
continue
for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
c0 = contour0[0]
costs = [
v
for v in (_vdiff_hypot2_complex(c0, c1) for c1 in contour1)
]
min_cost = min(costs)
first_cost = costs[0]
if min_cost < first_cost * 0.95:
add_problem(
glyph_name,
{
"type": "wrong_start_point",
"contour": ix,
"master_1": names[m0idx],
"master_2": names[m0idx + i + 1],
},
)
except ValueError as e:
add_problem(
@ -377,6 +391,15 @@ def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
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):
"""Test for interpolatability issues between fonts"""
import argparse
@ -415,7 +438,7 @@ def main(args=None):
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
@ -444,30 +467,37 @@ def main(args=None):
if "gvar" in font:
# Is variable font
gvar = font["gvar"]
# Gather all "master" locations
locs = set()
for variations in gvar.variations.values():
for var in variations:
glyf = font["glyf"]
# Gather all glyphs at their "master" locations
ttGlyphSets = {}
glyphsets = defaultdict(dict)
if glyphs is None:
glyphs = sorted(gvar.variations.keys())
for glyphname in glyphs:
for var in gvar.variations[glyphname]:
locDict = {}
loc = []
for tag, val in sorted(var.axes.items()):
locDict[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:
fonts.append(font.getGlyphSet(location=loc, normalized=True))
locTuple = tuple(loc)
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 = []
for filename in args.inputs:
@ -491,11 +521,12 @@ def main(args=None):
glyphsets.append({k: glyphset[k] for k in glyphset.keys()})
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:
glyphSetGlyphNames = set(glyphset.keys())
diff = glyphs - glyphSetGlyphNames
diff = glyphsSet - glyphSetGlyphNames
if diff:
for gn in diff:
glyphset[gn] = None