[interpolatable] Move some code into a helper file
This commit is contained in:
parent
d9b9b3a1f6
commit
67a8706ed4
@ -6,22 +6,19 @@ Call as:
|
|||||||
$ fonttools varLib.interpolatable font1 font2 ...
|
$ fonttools varLib.interpolatable font1 font2 ...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fontTools.pens.basePen import AbstractPen, BasePen, DecomposingPen
|
from .interpolatableHelpers import *
|
||||||
from fontTools.pens.pointPen import AbstractPointPen, SegmentToPointPen
|
|
||||||
from fontTools.pens.recordingPen import RecordingPen, DecomposingRecordingPen
|
from fontTools.pens.recordingPen import RecordingPen, DecomposingRecordingPen
|
||||||
from fontTools.pens.transformPen import TransformPen
|
from fontTools.pens.transformPen import TransformPen
|
||||||
from fontTools.pens.boundsPen import ControlBoundsPen
|
|
||||||
from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen
|
from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen
|
||||||
from fontTools.pens.momentsPen import OpenContourError
|
from fontTools.pens.momentsPen import OpenContourError
|
||||||
from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation
|
from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation
|
||||||
from fontTools.misc.fixedTools import floatToFixedToStr
|
from fontTools.misc.fixedTools import floatToFixedToStr
|
||||||
from fontTools.misc.transform import Transform
|
from fontTools.misc.transform import Transform
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
from math import sqrt, copysign, atan2, pi
|
from math import sqrt, atan2, pi
|
||||||
import itertools
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
log = logging.getLogger("fontTools.varLib.interpolatable")
|
log = logging.getLogger("fontTools.varLib.interpolatable")
|
||||||
@ -32,382 +29,6 @@ DEFAULT_KINKINESS_LENGTH = 0.002 # ratio of UPEM
|
|||||||
DEFAULT_UPEM = 1000
|
DEFAULT_UPEM = 1000
|
||||||
|
|
||||||
|
|
||||||
def _rot_list(l, k):
|
|
||||||
"""Rotate list by k items forward. Ie. item at position 0 will be
|
|
||||||
at position k in returned list. Negative k is allowed."""
|
|
||||||
return l[-k:] + l[:-k]
|
|
||||||
|
|
||||||
|
|
||||||
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 _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)
|
|
||||||
|
|
||||||
|
|
||||||
class SimpleRecordingPointPen(AbstractPointPen):
|
|
||||||
def __init__(self):
|
|
||||||
self.value = []
|
|
||||||
|
|
||||||
def beginPath(self, identifier=None, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def endPath(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def addPoint(self, pt, segmentType=None):
|
|
||||||
self.value.append((pt, False if segmentType is None else True))
|
|
||||||
|
|
||||||
|
|
||||||
def _vdiff_hypot2(v0, v1):
|
|
||||||
s = 0
|
|
||||||
for x0, x1 in zip(v0, v1):
|
|
||||||
d = x1 - x0
|
|
||||||
s += d * d
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
# This does the same but seems to be slower:
|
|
||||||
# s += (d * d.conjugate()).real
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def _hypot2_complex(d):
|
|
||||||
return d.real * d.real + d.imag * d.imag
|
|
||||||
|
|
||||||
|
|
||||||
def _matching_cost(G, matching):
|
|
||||||
return sum(G[i][j] for i, j in enumerate(matching))
|
|
||||||
|
|
||||||
|
|
||||||
def min_cost_perfect_bipartite_matching_scipy(G):
|
|
||||||
n = len(G)
|
|
||||||
rows, cols = linear_sum_assignment(G)
|
|
||||||
assert (rows == list(range(n))).all()
|
|
||||||
return list(cols), _matching_cost(G, cols)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
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'")
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
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 _contour_vector_from_stats(stats):
|
|
||||||
# Don't change the order of items here.
|
|
||||||
# It's okay to add to the end, but otherwise, other
|
|
||||||
# code depends on it. Search for "covariance".
|
|
||||||
size = sqrt(abs(stats.area))
|
|
||||||
return (
|
|
||||||
copysign((size), stats.area),
|
|
||||||
stats.meanX,
|
|
||||||
stats.meanY,
|
|
||||||
stats.stddevX * 2,
|
|
||||||
stats.stddevY * 2,
|
|
||||||
stats.correlation * size,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _matching_for_vectors(m0, m1):
|
|
||||||
n = len(m0)
|
|
||||||
|
|
||||||
identity_matching = list(range(n))
|
|
||||||
|
|
||||||
costs = [[_vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0]
|
|
||||||
(
|
|
||||||
matching,
|
|
||||||
matching_cost,
|
|
||||||
) = min_cost_perfect_bipartite_matching(costs)
|
|
||||||
identity_cost = sum(costs[i][i] for i in range(n))
|
|
||||||
return matching, matching_cost, identity_cost
|
|
||||||
|
|
||||||
|
|
||||||
def _points_characteristic_bits(points):
|
|
||||||
bits = 0
|
|
||||||
for pt, b in reversed(points):
|
|
||||||
bits = (bits << 1) | b
|
|
||||||
return bits
|
|
||||||
|
|
||||||
|
|
||||||
_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR = 4
|
|
||||||
|
|
||||||
|
|
||||||
def _points_complex_vector(points):
|
|
||||||
vector = []
|
|
||||||
if not points:
|
|
||||||
return vector
|
|
||||||
points = [complex(*pt) for pt, _ in points]
|
|
||||||
n = len(points)
|
|
||||||
assert _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR == 4
|
|
||||||
points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1])
|
|
||||||
while len(points) < _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR:
|
|
||||||
points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1])
|
|
||||||
for i in range(n):
|
|
||||||
# The weights are magic numbers.
|
|
||||||
|
|
||||||
# The point itself
|
|
||||||
p0 = points[i]
|
|
||||||
vector.append(p0)
|
|
||||||
|
|
||||||
# The vector to the next point
|
|
||||||
p1 = points[i + 1]
|
|
||||||
d0 = p1 - p0
|
|
||||||
vector.append(d0 * 3)
|
|
||||||
|
|
||||||
# The turn vector
|
|
||||||
p2 = points[i + 2]
|
|
||||||
d1 = p2 - p1
|
|
||||||
vector.append(d1 - d0)
|
|
||||||
|
|
||||||
# The angle to the next point, as a cross product;
|
|
||||||
# Square root of, to match dimentionality of distance.
|
|
||||||
cross = d0.real * d1.imag - d0.imag * d1.real
|
|
||||||
cross = copysign(sqrt(abs(cross)), cross)
|
|
||||||
vector.append(cross * 4)
|
|
||||||
|
|
||||||
return vector
|
|
||||||
|
|
||||||
|
|
||||||
def _add_isomorphisms(points, isomorphisms, reverse):
|
|
||||||
reference_bits = _points_characteristic_bits(points)
|
|
||||||
n = len(points)
|
|
||||||
|
|
||||||
# if points[0][0] == points[-1][0]:
|
|
||||||
# abort
|
|
||||||
|
|
||||||
if reverse:
|
|
||||||
points = points[::-1]
|
|
||||||
bits = _points_characteristic_bits(points)
|
|
||||||
else:
|
|
||||||
bits = reference_bits
|
|
||||||
|
|
||||||
vector = _points_complex_vector(points)
|
|
||||||
|
|
||||||
assert len(vector) % n == 0
|
|
||||||
mult = len(vector) // n
|
|
||||||
mask = (1 << n) - 1
|
|
||||||
|
|
||||||
for i in range(n):
|
|
||||||
b = ((bits << (n - i)) & mask) | (bits >> i)
|
|
||||||
if b == reference_bits:
|
|
||||||
isomorphisms.append(
|
|
||||||
(_rot_list(vector, -i * mult), n - 1 - i if reverse else i, reverse)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _find_parents_and_order(glyphsets, locations):
|
|
||||||
parents = [None] + list(range(len(glyphsets) - 1))
|
|
||||||
order = list(range(len(glyphsets)))
|
|
||||||
if locations:
|
|
||||||
# Order base master first
|
|
||||||
bases = (i for i, l in enumerate(locations) if all(v == 0 for v in l.values()))
|
|
||||||
if bases:
|
|
||||||
base = next(bases)
|
|
||||||
logging.info("Base master index %s, location %s", base, locations[base])
|
|
||||||
else:
|
|
||||||
base = 0
|
|
||||||
logging.warning("No base master location found")
|
|
||||||
|
|
||||||
# Form a minimum spanning tree of the locations
|
|
||||||
try:
|
|
||||||
from scipy.sparse.csgraph import minimum_spanning_tree
|
|
||||||
|
|
||||||
graph = [[0] * len(locations) for _ in range(len(locations))]
|
|
||||||
axes = set()
|
|
||||||
for l in locations:
|
|
||||||
axes.update(l.keys())
|
|
||||||
axes = sorted(axes)
|
|
||||||
vectors = [tuple(l.get(k, 0) for k in axes) for l in locations]
|
|
||||||
for i, j in itertools.combinations(range(len(locations)), 2):
|
|
||||||
graph[i][j] = _vdiff_hypot2(vectors[i], vectors[j])
|
|
||||||
|
|
||||||
tree = minimum_spanning_tree(graph)
|
|
||||||
rows, cols = tree.nonzero()
|
|
||||||
graph = defaultdict(set)
|
|
||||||
for row, col in zip(rows, cols):
|
|
||||||
graph[row].add(col)
|
|
||||||
graph[col].add(row)
|
|
||||||
|
|
||||||
# Traverse graph from the base and assign parents
|
|
||||||
parents = [None] * len(locations)
|
|
||||||
order = []
|
|
||||||
visited = set()
|
|
||||||
queue = deque([base])
|
|
||||||
while queue:
|
|
||||||
i = queue.popleft()
|
|
||||||
visited.add(i)
|
|
||||||
order.append(i)
|
|
||||||
for j in sorted(graph[i]):
|
|
||||||
if j not in visited:
|
|
||||||
parents[j] = i
|
|
||||||
queue.append(j)
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
log.info("Parents: %s", parents)
|
|
||||||
log.info("Order: %s", order)
|
|
||||||
return parents, order
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_from_stats(stats, inverse=False):
|
|
||||||
# https://cookierobotics.com/007/
|
|
||||||
a = stats.varianceX
|
|
||||||
b = stats.covariance
|
|
||||||
c = stats.varianceY
|
|
||||||
|
|
||||||
delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5
|
|
||||||
lambda1 = (a + c) * 0.5 + delta # Major eigenvalue
|
|
||||||
lambda2 = (a + c) * 0.5 - delta # Minor eigenvalue
|
|
||||||
theta = atan2(lambda1 - a, b) if b != 0 else (pi * 0.5 if a < c else 0)
|
|
||||||
trans = Transform()
|
|
||||||
|
|
||||||
if lambda2 < 0:
|
|
||||||
# XXX This is a hack.
|
|
||||||
# The problem is that the covariance matrix is singular.
|
|
||||||
# This happens when the contour is a line, or a circle.
|
|
||||||
# In that case, the covariance matrix is not a good
|
|
||||||
# representation of the contour.
|
|
||||||
# We should probably detect this earlier and avoid
|
|
||||||
# computing the covariance matrix in the first place.
|
|
||||||
# But for now, we just avoid the division by zero.
|
|
||||||
lambda2 = 0
|
|
||||||
|
|
||||||
if inverse:
|
|
||||||
trans = trans.translate(-stats.meanX, -stats.meanY)
|
|
||||||
trans = trans.rotate(-theta)
|
|
||||||
trans = trans.scale(1 / sqrt(lambda1), 1 / sqrt(lambda2))
|
|
||||||
else:
|
|
||||||
trans = trans.scale(sqrt(lambda1), sqrt(lambda2))
|
|
||||||
trans = trans.rotate(theta)
|
|
||||||
trans = trans.translate(stats.meanX, stats.meanY)
|
|
||||||
|
|
||||||
return trans
|
|
||||||
|
|
||||||
|
|
||||||
class LerpGlyphSet:
|
|
||||||
def __init__(self, glyphset1, glyphset2, factor=0.5):
|
|
||||||
self.glyphset1 = glyphset1
|
|
||||||
self.glyphset2 = glyphset2
|
|
||||||
self.factor = factor
|
|
||||||
|
|
||||||
def __getitem__(self, glyphname):
|
|
||||||
return LerpGlyph(glyphname, self)
|
|
||||||
|
|
||||||
|
|
||||||
class LerpGlyph:
|
|
||||||
def __init__(self, glyphname, glyphset):
|
|
||||||
self.glyphset = glyphset
|
|
||||||
self.glyphname = glyphname
|
|
||||||
|
|
||||||
def draw(self, pen):
|
|
||||||
recording1 = DecomposingRecordingPen(self.glyphset.glyphset1)
|
|
||||||
self.glyphset.glyphset1[self.glyphname].draw(recording1)
|
|
||||||
recording2 = DecomposingRecordingPen(self.glyphset.glyphset2)
|
|
||||||
self.glyphset.glyphset2[self.glyphname].draw(recording2)
|
|
||||||
|
|
||||||
factor = self.glyphset.factor
|
|
||||||
for (op1, args1), (op2, args2) in zip(recording1.value, recording2.value):
|
|
||||||
if op1 != op2:
|
|
||||||
raise ValueError("Mismatching operations: %s, %s" % (op1, op2))
|
|
||||||
mid_args = [
|
|
||||||
(x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor)
|
|
||||||
for (x1, y1), (x2, y2) in zip(args1, args2)
|
|
||||||
]
|
|
||||||
getattr(pen, op1)(*mid_args)
|
|
||||||
|
|
||||||
|
|
||||||
def lerp_recordings(recording1, recording2, factor=0.5):
|
|
||||||
pen = RecordingPen()
|
|
||||||
value = pen.value
|
|
||||||
for (op1, args1), (op2, args2) in zip(recording1.value, recording2.value):
|
|
||||||
if op1 != op2:
|
|
||||||
raise ValueError("Mismatched operations: %s, %s" % (op1, op2))
|
|
||||||
if op1 == "addComponent":
|
|
||||||
mid_args = args1 # XXX Interpolate transformation?
|
|
||||||
else:
|
|
||||||
mid_args = [
|
|
||||||
(x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor)
|
|
||||||
for (x1, y1), (x2, y2) in zip(args1, args2)
|
|
||||||
]
|
|
||||||
value.append((op1, mid_args))
|
|
||||||
return pen
|
|
||||||
|
|
||||||
|
|
||||||
def test_gen(
|
def test_gen(
|
||||||
glyphsets,
|
glyphsets,
|
||||||
glyphs=None,
|
glyphs=None,
|
||||||
@ -434,7 +55,7 @@ def test_gen(
|
|||||||
# ... risks the sparse master being the first one, and only processing a subset of the glyphs
|
# ... risks the sparse master being the first one, and only processing a subset of the glyphs
|
||||||
glyphs = {g for glyphset in glyphsets for g in glyphset.keys()}
|
glyphs = {g for glyphset in glyphsets for g in glyphset.keys()}
|
||||||
|
|
||||||
parents, order = _find_parents_and_order(glyphsets, locations)
|
parents, order = find_parents_and_order(glyphsets, locations)
|
||||||
|
|
||||||
def grand_parent(i, glyphname):
|
def grand_parent(i, glyphname):
|
||||||
if i is None:
|
if i is None:
|
||||||
@ -521,15 +142,15 @@ def test_gen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
contourGreenVectors.append(_contour_vector_from_stats(greenStats))
|
contourGreenVectors.append(contour_vector_from_stats(greenStats))
|
||||||
contourControlVectors.append(_contour_vector_from_stats(controlStats))
|
contourControlVectors.append(contour_vector_from_stats(controlStats))
|
||||||
|
|
||||||
# Save a "normalized" version of the outlines
|
# Save a "normalized" version of the outlines
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rpen = DecomposingRecordingPen(glyphset)
|
rpen = DecomposingRecordingPen(glyphset)
|
||||||
tpen = TransformPen(
|
tpen = TransformPen(
|
||||||
rpen, _transform_from_stats(greenStats, inverse=True)
|
rpen, transform_from_stats(greenStats, inverse=True)
|
||||||
)
|
)
|
||||||
contour.replay(tpen)
|
contour.replay(tpen)
|
||||||
contourPensNormalized.append(rpen)
|
contourPensNormalized.append(rpen)
|
||||||
@ -539,7 +160,7 @@ def test_gen(
|
|||||||
greenStats = StatisticsPen(glyphset=glyphset)
|
greenStats = StatisticsPen(glyphset=glyphset)
|
||||||
rpen.replay(greenStats)
|
rpen.replay(greenStats)
|
||||||
contourGreenVectorsNormalized.append(
|
contourGreenVectorsNormalized.append(
|
||||||
_contour_vector_from_stats(greenStats)
|
contour_vector_from_stats(greenStats)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check starting point
|
# Check starting point
|
||||||
@ -558,9 +179,9 @@ def test_gen(
|
|||||||
contourIsomorphisms.append(isomorphisms)
|
contourIsomorphisms.append(isomorphisms)
|
||||||
|
|
||||||
# Add rotations
|
# Add rotations
|
||||||
_add_isomorphisms(points.value, isomorphisms, False)
|
add_isomorphisms(points.value, isomorphisms, False)
|
||||||
# Add mirrored rotations
|
# Add mirrored rotations
|
||||||
_add_isomorphisms(points.value, isomorphisms, True)
|
add_isomorphisms(points.value, isomorphisms, True)
|
||||||
|
|
||||||
contourPoints.append(points.value)
|
contourPoints.append(points.value)
|
||||||
|
|
||||||
@ -658,7 +279,7 @@ def test_gen(
|
|||||||
matching_control,
|
matching_control,
|
||||||
matching_cost_control,
|
matching_cost_control,
|
||||||
identity_cost_control,
|
identity_cost_control,
|
||||||
) = _matching_for_vectors(m0Control, m1Control)
|
) = matching_for_vectors(m0Control, m1Control)
|
||||||
done = matching_cost_control == identity_cost_control
|
done = matching_cost_control == identity_cost_control
|
||||||
if not done:
|
if not done:
|
||||||
m1Green = allGreenVectors[m1idx]
|
m1Green = allGreenVectors[m1idx]
|
||||||
@ -667,7 +288,7 @@ def test_gen(
|
|||||||
matching_green,
|
matching_green,
|
||||||
matching_cost_green,
|
matching_cost_green,
|
||||||
identity_cost_green,
|
identity_cost_green,
|
||||||
) = _matching_for_vectors(m0Green, m1Green)
|
) = matching_for_vectors(m0Green, m1Green)
|
||||||
done = matching_cost_green == identity_cost_green
|
done = matching_cost_green == identity_cost_green
|
||||||
|
|
||||||
if not done:
|
if not done:
|
||||||
@ -682,7 +303,7 @@ def test_gen(
|
|||||||
matching_control_reversed,
|
matching_control_reversed,
|
||||||
matching_cost_control_reversed,
|
matching_cost_control_reversed,
|
||||||
identity_cost_control_reversed,
|
identity_cost_control_reversed,
|
||||||
) = _matching_for_vectors(m0Control, m1ControlReversed)
|
) = matching_for_vectors(m0Control, m1ControlReversed)
|
||||||
done = (
|
done = (
|
||||||
matching_cost_control_reversed == identity_cost_control_reversed
|
matching_cost_control_reversed == identity_cost_control_reversed
|
||||||
)
|
)
|
||||||
@ -692,7 +313,7 @@ def test_gen(
|
|||||||
matching_control_reversed,
|
matching_control_reversed,
|
||||||
matching_cost_control_reversed,
|
matching_cost_control_reversed,
|
||||||
identity_cost_control_reversed,
|
identity_cost_control_reversed,
|
||||||
) = _matching_for_vectors(m0Control, m1ControlReversed)
|
) = matching_for_vectors(m0Control, m1ControlReversed)
|
||||||
done = (
|
done = (
|
||||||
matching_cost_control_reversed == identity_cost_control_reversed
|
matching_cost_control_reversed == identity_cost_control_reversed
|
||||||
)
|
)
|
||||||
@ -775,7 +396,7 @@ def test_gen(
|
|||||||
|
|
||||||
c0 = contour0[0]
|
c0 = contour0[0]
|
||||||
# Next few lines duplicated below.
|
# Next few lines duplicated below.
|
||||||
costs = [_vdiff_hypot2_complex(c0[0], c1[0]) for c1 in contour1]
|
costs = [vdiff_hypot2_complex(c0[0], c1[0]) for c1 in contour1]
|
||||||
min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1])
|
min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1])
|
||||||
first_cost = costs[0]
|
first_cost = costs[0]
|
||||||
|
|
||||||
@ -860,7 +481,7 @@ def test_gen(
|
|||||||
|
|
||||||
# Next few lines duplicate from above.
|
# Next few lines duplicate from above.
|
||||||
costs = [
|
costs = [
|
||||||
_vdiff_hypot2_complex(new_c0[0], new_c1[0])
|
vdiff_hypot2_complex(new_c0[0], new_c1[0])
|
||||||
for new_c1 in new_contour1
|
for new_c1 in new_contour1
|
||||||
]
|
]
|
||||||
min_cost_idx, min_cost = min(
|
min_cost_idx, min_cost = min(
|
||||||
@ -905,14 +526,14 @@ def test_gen(
|
|||||||
if normalized:
|
if normalized:
|
||||||
midStats = StatisticsPen(glyphset=None)
|
midStats = StatisticsPen(glyphset=None)
|
||||||
tpen = TransformPen(
|
tpen = TransformPen(
|
||||||
midStats, _transform_from_stats(midStats, inverse=True)
|
midStats, transform_from_stats(midStats, inverse=True)
|
||||||
)
|
)
|
||||||
contour.replay(tpen)
|
contour.replay(tpen)
|
||||||
else:
|
else:
|
||||||
midStats = StatisticsPen(glyphset=None)
|
midStats = StatisticsPen(glyphset=None)
|
||||||
contour.replay(midStats)
|
contour.replay(midStats)
|
||||||
|
|
||||||
midVector = _contour_vector_from_stats(midStats)
|
midVector = contour_vector_from_stats(midStats)
|
||||||
|
|
||||||
m0Vec = (
|
m0Vec = (
|
||||||
m0Vectors[ix] if not normalized else m0VectorsNormalized[ix]
|
m0Vectors[ix] if not normalized else m0VectorsNormalized[ix]
|
||||||
|
384
Lib/fontTools/varLib/interpolatableHelpers.py
Normal file
384
Lib/fontTools/varLib/interpolatableHelpers.py
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
from fontTools.pens.basePen import AbstractPen, BasePen, DecomposingPen
|
||||||
|
from fontTools.pens.pointPen import AbstractPointPen, SegmentToPointPen
|
||||||
|
from fontTools.pens.recordingPen import RecordingPen, DecomposingRecordingPen
|
||||||
|
from fontTools.misc.transform import Transform
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from math import sqrt, copysign, atan2, pi
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger("fontTools.varLib.interpolatable")
|
||||||
|
|
||||||
|
def rot_list(l, k):
|
||||||
|
"""Rotate list by k items forward. Ie. item at position 0 will be
|
||||||
|
at position k in returned list. Negative k is allowed."""
|
||||||
|
return l[-k:] + l[:-k]
|
||||||
|
|
||||||
|
|
||||||
|
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 _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)
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleRecordingPointPen(AbstractPointPen):
|
||||||
|
def __init__(self):
|
||||||
|
self.value = []
|
||||||
|
|
||||||
|
def beginPath(self, identifier=None, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def endPath(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def addPoint(self, pt, segmentType=None):
|
||||||
|
self.value.append((pt, False if segmentType is None else True))
|
||||||
|
|
||||||
|
|
||||||
|
def vdiff_hypot2(v0, v1):
|
||||||
|
s = 0
|
||||||
|
for x0, x1 in zip(v0, v1):
|
||||||
|
d = x1 - x0
|
||||||
|
s += d * d
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
# This does the same but seems to be slower:
|
||||||
|
# s += (d * d.conjugate()).real
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def matching_cost(G, matching):
|
||||||
|
return sum(G[i][j] for i, j in enumerate(matching))
|
||||||
|
|
||||||
|
|
||||||
|
def min_cost_perfect_bipartite_matching_scipy(G):
|
||||||
|
n = len(G)
|
||||||
|
rows, cols = linear_sum_assignment(G)
|
||||||
|
assert (rows == list(range(n))).all()
|
||||||
|
return list(cols), matching_cost(G, cols)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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'")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
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 contour_vector_from_stats(stats):
|
||||||
|
# Don't change the order of items here.
|
||||||
|
# It's okay to add to the end, but otherwise, other
|
||||||
|
# code depends on it. Search for "covariance".
|
||||||
|
size = sqrt(abs(stats.area))
|
||||||
|
return (
|
||||||
|
copysign((size), stats.area),
|
||||||
|
stats.meanX,
|
||||||
|
stats.meanY,
|
||||||
|
stats.stddevX * 2,
|
||||||
|
stats.stddevY * 2,
|
||||||
|
stats.correlation * size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def matching_for_vectors(m0, m1):
|
||||||
|
n = len(m0)
|
||||||
|
|
||||||
|
identity_matching = list(range(n))
|
||||||
|
|
||||||
|
costs = [[vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0]
|
||||||
|
(
|
||||||
|
matching,
|
||||||
|
matching_cost,
|
||||||
|
) = min_cost_perfect_bipartite_matching(costs)
|
||||||
|
identity_cost = sum(costs[i][i] for i in range(n))
|
||||||
|
return matching, matching_cost, identity_cost
|
||||||
|
|
||||||
|
|
||||||
|
def points_characteristic_bits(points):
|
||||||
|
bits = 0
|
||||||
|
for pt, b in reversed(points):
|
||||||
|
bits = (bits << 1) | b
|
||||||
|
return bits
|
||||||
|
|
||||||
|
|
||||||
|
_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR = 4
|
||||||
|
|
||||||
|
|
||||||
|
def points_complex_vector(points):
|
||||||
|
vector = []
|
||||||
|
if not points:
|
||||||
|
return vector
|
||||||
|
points = [complex(*pt) for pt, _ in points]
|
||||||
|
n = len(points)
|
||||||
|
assert _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR == 4
|
||||||
|
points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1])
|
||||||
|
while len(points) < _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR:
|
||||||
|
points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1])
|
||||||
|
for i in range(n):
|
||||||
|
# The weights are magic numbers.
|
||||||
|
|
||||||
|
# The point itself
|
||||||
|
p0 = points[i]
|
||||||
|
vector.append(p0)
|
||||||
|
|
||||||
|
# The vector to the next point
|
||||||
|
p1 = points[i + 1]
|
||||||
|
d0 = p1 - p0
|
||||||
|
vector.append(d0 * 3)
|
||||||
|
|
||||||
|
# The turn vector
|
||||||
|
p2 = points[i + 2]
|
||||||
|
d1 = p2 - p1
|
||||||
|
vector.append(d1 - d0)
|
||||||
|
|
||||||
|
# The angle to the next point, as a cross product;
|
||||||
|
# Square root of, to match dimentionality of distance.
|
||||||
|
cross = d0.real * d1.imag - d0.imag * d1.real
|
||||||
|
cross = copysign(sqrt(abs(cross)), cross)
|
||||||
|
vector.append(cross * 4)
|
||||||
|
|
||||||
|
return vector
|
||||||
|
|
||||||
|
|
||||||
|
def add_isomorphisms(points, isomorphisms, reverse):
|
||||||
|
reference_bits = points_characteristic_bits(points)
|
||||||
|
n = len(points)
|
||||||
|
|
||||||
|
# if points[0][0] == points[-1][0]:
|
||||||
|
# abort
|
||||||
|
|
||||||
|
if reverse:
|
||||||
|
points = points[::-1]
|
||||||
|
bits = points_characteristic_bits(points)
|
||||||
|
else:
|
||||||
|
bits = reference_bits
|
||||||
|
|
||||||
|
vector = points_complex_vector(points)
|
||||||
|
|
||||||
|
assert len(vector) % n == 0
|
||||||
|
mult = len(vector) // n
|
||||||
|
mask = (1 << n) - 1
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
b = ((bits << (n - i)) & mask) | (bits >> i)
|
||||||
|
if b == reference_bits:
|
||||||
|
isomorphisms.append(
|
||||||
|
(rot_list(vector, -i * mult), n - 1 - i if reverse else i, reverse)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_parents_and_order(glyphsets, locations):
|
||||||
|
parents = [None] + list(range(len(glyphsets) - 1))
|
||||||
|
order = list(range(len(glyphsets)))
|
||||||
|
if locations:
|
||||||
|
# Order base master first
|
||||||
|
bases = (i for i, l in enumerate(locations) if all(v == 0 for v in l.values()))
|
||||||
|
if bases:
|
||||||
|
base = next(bases)
|
||||||
|
logging.info("Base master index %s, location %s", base, locations[base])
|
||||||
|
else:
|
||||||
|
base = 0
|
||||||
|
logging.warning("No base master location found")
|
||||||
|
|
||||||
|
# Form a minimum spanning tree of the locations
|
||||||
|
try:
|
||||||
|
from scipy.sparse.csgraph import minimum_spanning_tree
|
||||||
|
|
||||||
|
graph = [[0] * len(locations) for _ in range(len(locations))]
|
||||||
|
axes = set()
|
||||||
|
for l in locations:
|
||||||
|
axes.update(l.keys())
|
||||||
|
axes = sorted(axes)
|
||||||
|
vectors = [tuple(l.get(k, 0) for k in axes) for l in locations]
|
||||||
|
for i, j in itertools.combinations(range(len(locations)), 2):
|
||||||
|
graph[i][j] = vdiff_hypot2(vectors[i], vectors[j])
|
||||||
|
|
||||||
|
tree = minimum_spanning_tree(graph)
|
||||||
|
rows, cols = tree.nonzero()
|
||||||
|
graph = defaultdict(set)
|
||||||
|
for row, col in zip(rows, cols):
|
||||||
|
graph[row].add(col)
|
||||||
|
graph[col].add(row)
|
||||||
|
|
||||||
|
# Traverse graph from the base and assign parents
|
||||||
|
parents = [None] * len(locations)
|
||||||
|
order = []
|
||||||
|
visited = set()
|
||||||
|
queue = deque([base])
|
||||||
|
while queue:
|
||||||
|
i = queue.popleft()
|
||||||
|
visited.add(i)
|
||||||
|
order.append(i)
|
||||||
|
for j in sorted(graph[i]):
|
||||||
|
if j not in visited:
|
||||||
|
parents[j] = i
|
||||||
|
queue.append(j)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
log.info("Parents: %s", parents)
|
||||||
|
log.info("Order: %s", order)
|
||||||
|
return parents, order
|
||||||
|
|
||||||
|
|
||||||
|
def transform_from_stats(stats, inverse=False):
|
||||||
|
# https://cookierobotics.com/007/
|
||||||
|
a = stats.varianceX
|
||||||
|
b = stats.covariance
|
||||||
|
c = stats.varianceY
|
||||||
|
|
||||||
|
delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5
|
||||||
|
lambda1 = (a + c) * 0.5 + delta # Major eigenvalue
|
||||||
|
lambda2 = (a + c) * 0.5 - delta # Minor eigenvalue
|
||||||
|
theta = atan2(lambda1 - a, b) if b != 0 else (pi * 0.5 if a < c else 0)
|
||||||
|
trans = Transform()
|
||||||
|
|
||||||
|
if lambda2 < 0:
|
||||||
|
# XXX This is a hack.
|
||||||
|
# The problem is that the covariance matrix is singular.
|
||||||
|
# This happens when the contour is a line, or a circle.
|
||||||
|
# In that case, the covariance matrix is not a good
|
||||||
|
# representation of the contour.
|
||||||
|
# We should probably detect this earlier and avoid
|
||||||
|
# computing the covariance matrix in the first place.
|
||||||
|
# But for now, we just avoid the division by zero.
|
||||||
|
lambda2 = 0
|
||||||
|
|
||||||
|
if inverse:
|
||||||
|
trans = trans.translate(-stats.meanX, -stats.meanY)
|
||||||
|
trans = trans.rotate(-theta)
|
||||||
|
trans = trans.scale(1 / sqrt(lambda1), 1 / sqrt(lambda2))
|
||||||
|
else:
|
||||||
|
trans = trans.scale(sqrt(lambda1), sqrt(lambda2))
|
||||||
|
trans = trans.rotate(theta)
|
||||||
|
trans = trans.translate(stats.meanX, stats.meanY)
|
||||||
|
|
||||||
|
return trans
|
||||||
|
|
||||||
|
|
||||||
|
class LerpGlyphSet:
|
||||||
|
def __init__(self, glyphset1, glyphset2, factor=0.5):
|
||||||
|
self.glyphset1 = glyphset1
|
||||||
|
self.glyphset2 = glyphset2
|
||||||
|
self.factor = factor
|
||||||
|
|
||||||
|
def __getitem__(self, glyphname):
|
||||||
|
return LerpGlyph(glyphname, self)
|
||||||
|
|
||||||
|
|
||||||
|
class LerpGlyph:
|
||||||
|
def __init__(self, glyphname, glyphset):
|
||||||
|
self.glyphset = glyphset
|
||||||
|
self.glyphname = glyphname
|
||||||
|
|
||||||
|
def draw(self, pen):
|
||||||
|
recording1 = DecomposingRecordingPen(self.glyphset.glyphset1)
|
||||||
|
self.glyphset.glyphset1[self.glyphname].draw(recording1)
|
||||||
|
recording2 = DecomposingRecordingPen(self.glyphset.glyphset2)
|
||||||
|
self.glyphset.glyphset2[self.glyphname].draw(recording2)
|
||||||
|
|
||||||
|
factor = self.glyphset.factor
|
||||||
|
for (op1, args1), (op2, args2) in zip(recording1.value, recording2.value):
|
||||||
|
if op1 != op2:
|
||||||
|
raise ValueError("Mismatching operations: %s, %s" % (op1, op2))
|
||||||
|
mid_args = [
|
||||||
|
(x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor)
|
||||||
|
for (x1, y1), (x2, y2) in zip(args1, args2)
|
||||||
|
]
|
||||||
|
getattr(pen, op1)(*mid_args)
|
||||||
|
|
||||||
|
|
||||||
|
def lerp_recordings(recording1, recording2, factor=0.5):
|
||||||
|
pen = RecordingPen()
|
||||||
|
value = pen.value
|
||||||
|
for (op1, args1), (op2, args2) in zip(recording1.value, recording2.value):
|
||||||
|
if op1 != op2:
|
||||||
|
raise ValueError("Mismatched operations: %s, %s" % (op1, op2))
|
||||||
|
if op1 == "addComponent":
|
||||||
|
mid_args = args1 # XXX Interpolate transformation?
|
||||||
|
else:
|
||||||
|
mid_args = [
|
||||||
|
(x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor)
|
||||||
|
for (x1, y1), (x2, y2) in zip(args1, args2)
|
||||||
|
]
|
||||||
|
value.append((op1, mid_args))
|
||||||
|
return pen
|
||||||
|
|
||||||
|
|
@ -226,9 +226,10 @@ class InterpolatablePlot:
|
|||||||
cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.line_height)
|
cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.line_height)
|
||||||
cr.set_source_rgb(*self.fill_color)
|
cr.set_source_rgb(*self.fill_color)
|
||||||
cr.fill_preserve()
|
cr.fill_preserve()
|
||||||
cr.set_source_rgb(*self.stroke_color)
|
if self.stroke_color:
|
||||||
cr.set_line_width(self.stroke_width)
|
cr.set_source_rgb(*self.stroke_color)
|
||||||
cr.stroke_preserve()
|
cr.set_line_width(self.stroke_width)
|
||||||
|
cr.stroke_preserve()
|
||||||
cr.set_source_rgba(*self.weight_issue_contour_color)
|
cr.set_source_rgba(*self.weight_issue_contour_color)
|
||||||
cr.fill()
|
cr.fill()
|
||||||
y -= self.pad + self.line_height
|
y -= self.pad + self.line_height
|
||||||
@ -237,11 +238,13 @@ class InterpolatablePlot:
|
|||||||
"Colored contours: contours with the wrong order", x=xxx, y=y, width=width
|
"Colored contours: contours with the wrong order", x=xxx, y=y, width=width
|
||||||
)
|
)
|
||||||
cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.line_height)
|
cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.line_height)
|
||||||
cr.set_source_rgb(*self.fill_color)
|
if self.fill_color:
|
||||||
cr.fill_preserve()
|
cr.set_source_rgb(*self.fill_color)
|
||||||
cr.set_source_rgb(*self.stroke_color)
|
cr.fill_preserve()
|
||||||
cr.set_line_width(self.stroke_width)
|
if self.stroke_color:
|
||||||
cr.stroke_preserve()
|
cr.set_source_rgb(*self.stroke_color)
|
||||||
|
cr.set_line_width(self.stroke_width)
|
||||||
|
cr.stroke_preserve()
|
||||||
cr.set_source_rgba(*self.contour_colors[0], self.contour_alpha)
|
cr.set_source_rgba(*self.contour_colors[0], self.contour_alpha)
|
||||||
cr.fill()
|
cr.fill()
|
||||||
y -= self.pad + self.line_height
|
y -= self.pad + self.line_height
|
||||||
|
Loading…
x
Reference in New Issue
Block a user