Merge pull request #2109 from simoncozens/interpolatable-fixes

Various fixes to varLib.interpolatable
This commit is contained in:
Simon Cozens 2020-11-24 07:30:33 -08:00 committed by GitHub
commit 855f1e4cb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 331 additions and 162 deletions

View File

@ -7,6 +7,10 @@ from fontTools.pens.basePen import BasePen
__all__ = ["MomentsPen"]
class OpenContourError(NotImplementedError):
pass
class MomentsPen(BasePen):
def __init__(self, glyphset=None):
@ -30,8 +34,9 @@ class MomentsPen(BasePen):
def _endPath(self):
p0 = self._getCurrentPoint()
if p0 != self.__startPoint:
# Green theorem is not defined on open contours.
raise NotImplementedError
raise OpenContourError(
"Green theorem is not defined on open contours."
)
def _lineTo(self, p1):
x0,y0 = self._getCurrentPoint()

View File

@ -9,200 +9,364 @@ $ fonttools varLib.interpolatable font1 font2 ...
from fontTools.pens.basePen import AbstractPen, BasePen
from fontTools.pens.recordingPen import RecordingPen
from fontTools.pens.statisticsPen import StatisticsPen
from fontTools.pens.momentsPen import OpenContourError
from collections import OrderedDict
import itertools
import sys
class PerContourPen(BasePen):
def __init__(self, Pen, glyphset=None):
BasePen.__init__(self, glyphset)
self._glyphset = glyphset
self._Pen = Pen
self._pen = None
self.value = []
def _moveTo(self, p0):
self._newItem()
self._pen.moveTo(p0)
def _lineTo(self, p1):
self._pen.lineTo(p1)
def _qCurveToOne(self, p1, p2):
self._pen.qCurveTo(p1, p2)
def _curveToOne(self, p1, p2, p3):
self._pen.curveTo(p1, p2, p3)
def _closePath(self):
self._pen.closePath()
self._pen = None
def _endPath(self):
self._pen.endPath()
self._pen = None
def __init__(self, Pen, glyphset=None):
BasePen.__init__(self, glyphset)
self._glyphset = glyphset
self._Pen = Pen
self._pen = None
self.value = []
def _moveTo(self, p0):
self._newItem()
self._pen.moveTo(p0)
def _lineTo(self, p1):
self._pen.lineTo(p1)
def _qCurveToOne(self, p1, p2):
self._pen.qCurveTo(p1, p2)
def _curveToOne(self, p1, p2, p3):
self._pen.curveTo(p1, p2, p3)
def _closePath(self):
self._pen.closePath()
self._pen = None
def _endPath(self):
self._pen.endPath()
self._pen = None
def _newItem(self):
self._pen = pen = self._Pen()
self.value.append(pen)
def _newItem(self):
self._pen = pen = self._Pen()
self.value.append(pen)
class PerContourOrComponentPen(PerContourPen):
def addComponent(self, glyphName, transformation):
self._newItem()
self.value[-1].addComponent(glyphName, transformation)
def addComponent(self, glyphName, transformation):
self._newItem()
self.value[-1].addComponent(glyphName, transformation)
def _vdiff(v0, v1):
return tuple(b-a for a,b in zip(v0,v1))
return tuple(b - a for a, b in zip(v0, v1))
def _vlen(vec):
v = 0
for x in vec:
v += x*x
return v
v = 0
for x in vec:
v += x * x
return v
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):
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)
except ImportError:
pass
n = len(G)
try:
from scipy.optimize import linear_sum_assignment
try:
from munkres import Munkres
cols = [None] * n
for row,col in Munkres().compute(G):
cols[row] = col
return cols, _matching_cost(G, cols)
except ImportError:
pass
rows, cols = linear_sum_assignment(G)
assert (rows == list(range(n))).all()
return list(cols), _matching_cost(G, cols)
except ImportError:
pass
if n > 6:
raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'")
try:
from munkres import Munkres
# Otherwise just brute-force
permutations = itertools.permutations(range(n))
best = list(next(permutations))
best_cost = _matching_cost(G, best)
for p in permutations:
cost = _matching_cost(G, p)
if cost < best_cost:
best, best_cost = list(p), cost
return best, best_cost
cols = [None] * n
for row, col in Munkres().compute(G):
cols[row] = col
return cols, _matching_cost(G, cols)
except ImportError:
pass
if n > 6:
raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'")
# Otherwise just brute-force
permutations = itertools.permutations(range(n))
best = list(next(permutations))
best_cost = _matching_cost(G, best)
for p in permutations:
cost = _matching_cost(G, p)
if cost < best_cost:
best, best_cost = list(p), cost
return best, best_cost
def test(glyphsets, glyphs=None, names=None):
if names is None:
names = glyphsets
if glyphs is None:
glyphs = glyphsets[0].keys()
if names is None:
names = glyphsets
if glyphs is None:
glyphs = glyphsets[0].keys()
hist = []
for glyph_name in glyphs:
#print()
#print(glyph_name)
hist = []
problems = OrderedDict()
try:
allVectors = []
allNodeTypes = []
for glyphset,name in zip(glyphsets, names):
#print('.', end='')
glyph = glyphset[glyph_name]
def add_problem(glyphname, problem):
problems.setdefault(glyphname, []).append(problem)
perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset)
glyph.draw(perContourPen)
contourPens = perContourPen.value
del perContourPen
for glyph_name in glyphs:
# print()
# print(glyph_name)
contourVectors = []
nodeTypes = []
allNodeTypes.append(nodeTypes)
allVectors.append(contourVectors)
for contour in contourPens:
nodeTypes.append(tuple(instruction[0] for instruction in contour.value))
stats = StatisticsPen(glyphset=glyphset)
contour.replay(stats)
size = abs(stats.area) ** .5 * .5
vector = (
int(size),
int(stats.meanX),
int(stats.meanY),
int(stats.stddevX * 2),
int(stats.stddevY * 2),
int(stats.correlation * size),
)
contourVectors.append(vector)
#print(vector)
try:
allVectors = []
allNodeTypes = []
for glyphset, name in zip(glyphsets, names):
# print('.', end='')
if glyph_name not in glyphset:
add_problem(glyph_name, {"type": "missing", "master": name})
continue
glyph = glyphset[glyph_name]
# Check each master against the next one in the list.
for i, (m0, m1) in enumerate(zip(allNodeTypes[:-1], allNodeTypes[1:])):
if len(m0) != len(m1):
print('%s: %s+%s: Glyphs not compatible (wrong number of paths %i+%i)!!!!!' % (glyph_name, names[i], names[i+1], len(m0), len(m1)))
if m0 == m1:
continue
for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)):
if nodes1 == nodes2:
continue
print('%s: %s+%s: Glyphs not compatible at path %i!!!!!' % (glyph_name, names[i], names[i+1], pathIx))
if len(nodes1) != len(nodes2):
print("%s has %i nodes, %s has %i nodes" % (names[i], len(nodes1), names[i+1], len(nodes2)))
continue
for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)):
if n1 != n2:
print("At node %i, %s has %s, %s has %s" % (nodeIx, names[i], n1, names[i+1], n2))
continue
perContourPen = PerContourOrComponentPen(
RecordingPen, glyphset=glyphset
)
glyph.draw(perContourPen)
contourPens = perContourPen.value
del perContourPen
for i,(m0,m1) in enumerate(zip(allVectors[:-1],allVectors[1:])):
if len(m0) != len(m1):
print('%s: %s+%s: Glyphs not compatible!!!!!' % (glyph_name, names[i], names[i+1]))
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)
if matching != list(range(len(m0))):
print('%s: %s+%s: Glyph has wrong contour/component order: %s' % (glyph_name, names[i], names[i+1], matching)) #, m0, m1)
break
upem = 2048
item_cost = round((matching_cost / len(m0) / len(m0[0])) ** .5 / upem * 100)
hist.append(item_cost)
threshold = 7
if item_cost >= threshold:
print('%s: %s+%s: Glyph has very high cost: %d%%' % (glyph_name, names[i], names[i+1], item_cost))
contourVectors = []
nodeTypes = []
allNodeTypes.append(nodeTypes)
allVectors.append(contourVectors)
for ix, contour in enumerate(contourPens):
nodeTypes.append(
tuple(instruction[0] for instruction in contour.value)
)
stats = StatisticsPen(glyphset=glyphset)
try:
contour.replay(stats)
except OpenContourError as e:
add_problem(
glyph_name,
{"master": name, "contour": ix, "type": "open_path"},
)
continue
size = abs(stats.area) ** 0.5 * 0.5
vector = (
int(size),
int(stats.meanX),
int(stats.meanY),
int(stats.stddevX * 2),
int(stats.stddevY * 2),
int(stats.correlation * size),
)
contourVectors.append(vector)
# print(vector)
# Check each master against the next one in the list.
for i, (m0, m1) in enumerate(zip(allNodeTypes[:-1], allNodeTypes[1:])):
if len(m0) != len(m1):
add_problem(
glyph_name,
{
"type": "path_count",
"master_1": names[i],
"master_2": names[i + 1],
"value_1": len(m0),
"value_2": len(m1),
},
)
if m0 == m1:
continue
for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)):
if nodes1 == nodes2:
continue
if len(nodes1) != len(nodes2):
add_problem(
glyph_name,
{
"type": "node_count",
"path": pathIx,
"master_1": names[i],
"master_2": names[i + 1],
"value_1": len(nodes1),
"value_2": len(nodes2),
},
)
continue
for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)):
if n1 != n2:
add_problem(
glyph_name,
{
"type": "node_incompatibility",
"path": pathIx,
"node": nodeIx,
"master_1": names[i],
"master_2": names[i + 1],
"value_1": n1,
"value_2": n2,
},
)
continue
for i, (m0, m1) in enumerate(zip(allVectors[:-1], allVectors[1:])):
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)
if matching != list(range(len(m0))):
add_problem(
glyph_name,
{
"type": "contour_order",
"master_1": names[i],
"master_2": names[i + 1],
"value_1": list(range(len(m0))),
"value_2": matching,
},
)
break
upem = 2048
item_cost = round(
(matching_cost / len(m0) / len(m0[0])) ** 0.5 / upem * 100
)
hist.append(item_cost)
threshold = 7
if item_cost >= threshold:
add_problem(
glyph_name,
{
"type": "high_cost",
"master_1": names[i],
"master_2": names[i + 1],
"value_1": item_cost,
"value_2": threshold,
},
)
except ValueError as e:
add_problem(
glyph_name,
{"type": "math_error", "master": name, "error": e},
)
return problems
except ValueError as e:
print('%s: %s: math error %s; skipping glyph.' % (glyph_name, name, e))
print(contour.value)
#raise
#for x in hist:
# print(x)
def main(args=None):
"""Test for interpolatability issues between fonts"""
import argparse
parser = argparse.ArgumentParser(
"fonttools varLib.interpolatable",
description=main.__doc__,
)
parser.add_argument('inputs', metavar='FILE', type=str, nargs='+',
help="Input TTF files")
"""Test for interpolatability issues between fonts"""
import argparse
args = parser.parse_args(args)
glyphs = None
#glyphs = ['uni08DB', 'uniFD76']
#glyphs = ['uni08DE', 'uni0034']
#glyphs = ['uni08DE', 'uni0034', 'uni0751', 'uni0753', 'uni0754', 'uni08A4', 'uni08A4.fina', 'uni08A5.fina']
parser = argparse.ArgumentParser(
"fonttools varLib.interpolatable",
description=main.__doc__,
)
parser.add_argument(
"--json",
action="store_true",
help="Output report in JSON format",
)
parser.add_argument(
"inputs", metavar="FILE", type=str, nargs="+", help="Input TTF/UFO files"
)
from os.path import basename
names = [basename(filename).rsplit('.', 1)[0] for filename in args.inputs]
args = parser.parse_args(args)
glyphs = None
# glyphs = ['uni08DB', 'uniFD76']
# glyphs = ['uni08DE', 'uni0034']
# glyphs = ['uni08DE', 'uni0034', 'uni0751', 'uni0753', 'uni0754', 'uni08A4', 'uni08A4.fina', 'uni08A5.fina']
from fontTools.ttLib import TTFont
fonts = [TTFont(filename) for filename in args.inputs]
from os.path import basename
glyphsets = [font.getGlyphSet() for font in fonts]
test(glyphsets, glyphs=glyphs, names=names)
names = [basename(filename).rsplit(".", 1)[0] for filename in args.inputs]
if __name__ == '__main__':
import sys
main()
fonts = []
for filename in args.inputs:
if filename.endswith(".ufo"):
from fontTools.ufoLib import UFOReader
fonts.append(UFOReader(filename))
else:
from fontTools.ttLib import TTFont
fonts.append(TTFont(filename))
glyphsets = [font.getGlyphSet() for font in fonts]
problems = test(glyphsets, glyphs=glyphs, names=names)
if args.json:
import json
print(json.dumps(problems))
else:
for glyph, glyph_problems in problems.items():
print(f"Glyph {glyph} was not compatible: ")
for p in glyph_problems:
if p["type"] == "missing":
print(" Glyph was missing in master %s" % p["master"])
if p["type"] == "open_path":
print(" Glyph has an open path in master %s" % p["master"])
if p["type"] == "path_count":
print(
" Path count differs: %i in %s, %i in %s"
% (p["value_1"], p["master_1"], p["value_2"], p["master_2"])
)
if p["type"] == "node_count":
print(
" Node count differs in path %i: %i in %s, %i in %s"
% (
p["path"],
p["value_1"],
p["master_1"],
p["value_2"],
p["master_2"],
)
)
if p["type"] == "node_incompatibility":
print(
" Node %o incompatible in path %i: %s in %s, %s in %s"
% (
p["node"],
p["path"],
p["value_1"],
p["master_1"],
p["value_2"],
p["master_2"],
)
)
if p["type"] == "contour_order":
print(
" Contour order differs: %s in %s, %s in %s"
% (
p["value_1"],
p["master_1"],
p["value_2"],
p["master_2"],
)
)
if p["type"] == "high_cost":
print(
" Interpolation has high cost: cost of %s to %s = %i, threshold %i"
% (
p["master_1"],
p["master_2"],
p["value_1"],
p["value_2"],
)
)
if problems:
return problems
if __name__ == "__main__":
import sys
problems = main()
sys.exit(int(bool(problems)))