[varLib.iup] Rewrite force-set conditions & limit DP lookback length

This does two things:

Fixes forced-set computation, which was wrong in multiple ways.
Debugged it. Is solid now... Famous last words.

Speeds up DP time by limiting DP lookback length. For Noto Sans,
IUP time drops from 23s down to 9s, with only a slight size increase
in the final font. This basically turns the algorithm from O(n^3) into
O(n).
This commit is contained in:
Behdad Esfahbod 2022-06-13 05:54:57 -06:00
parent d0020b2536
commit 0d7d7d4e11
2 changed files with 99 additions and 23 deletions

View File

@ -1,3 +1,5 @@
MAX_LOOKBACK = 8
def iup_segment(coords, rc1, rd1, rc2, rd2):
# rc1 = reference coord 1
# rd1 = reference delta 1
@ -105,13 +107,13 @@ def _iup_contour_bound_forced_set(delta, coords, tolerance=0):
"""
assert len(delta) == len(coords)
n = len(delta)
forced = set()
# Track "last" and "next" points on the contour as we sweep.
nd, nc = delta[0], coords[0]
ld, lc = delta[-1], coords[-1]
for i in range(len(delta)-1, -1, -1):
d, c = ld, lc
ld, lc = delta[i-1], coords[i-1]
d, c = delta[i], coords[i]
nd, nc = delta[i-n+1], coords[i-n+1]
for j in (0,1): # For X and for Y
cj = c[j]
@ -128,39 +130,41 @@ def _iup_contour_bound_forced_set(delta, coords, tolerance=0):
c1, c2 = ncj, lcj
d1, d2 = ndj, ldj
force = False
# If the two coordinates are the same, then the interpolation
# algorithm produces the same delta if both deltas are equal,
# and zero if they differ.
#
# This test has to be before the next one.
if c1 == c2:
if abs(d1 - d2) > tolerance and abs(dj) > tolerance:
force = True
# If coordinate for current point is between coordinate of adjacent
# points on the two sides, but the delta for current point is NOT
# between delta for those adjacent points (considering tolerance
# allowance), then there is no way that current point can be IUP-ed.
# Mark it forced.
force = False
if c1 <= cj <= c2:
elif c1 <= cj <= c2: # and c1 != c2
if not (min(d1,d2)-tolerance <= dj <= max(d1,d2)+tolerance):
force = True
# Otherwise, the delta should either match the closest, or have the
# same sign as the interpolation of the two deltas.
else: # cj < c1 or c2 < cj
if c1 == c2:
if d1 == d2:
if abs(dj - d1) > tolerance:
force = True
else:
if abs(dj) > tolerance:
# Disabled the following because the "d1 == d2" does
# check does not take tolerance into consideration...
pass # force = True
elif d1 != d2:
if d1 != d2:
if cj < c1:
if dj != d1 and ((dj-tolerance < d1) != (d1 < d2)):
if abs(dj) > tolerance and abs(dj - d1) > tolerance and ((dj-tolerance < d1) != (d1 < d2)):
force = True
else: # c2 < cj
if d2 != dj and ((d2 < dj+tolerance) != (d1 < d2)):
if abs(dj) > tolerance and abs(dj - d2) > tolerance and ((d2 < dj+tolerance) != (d1 < d2)):
force = True
if force:
forced.add(i)
break
nd, nc = d, c
return forced
def _iup_contour_optimize_dp(delta, coords, forced={}, tolerance=0, lookback=None):
@ -176,6 +180,7 @@ def _iup_contour_optimize_dp(delta, coords, forced={}, tolerance=0, lookback=Non
n = len(delta)
if lookback is None:
lookback = n
lookback = min(lookback, MAX_LOOKBACK)
costs = {-1:0}
chain = {-1:None}
for i in range(0, n):
@ -239,6 +244,9 @@ def iup_contour_optimize(delta, coords, tolerance=0.):
# To remove this constraint, we use two different methods, depending on
# whether forced set is non-empty or not:
# Debugging: Make the next if always take the second branch and observe
# if the font size changes (reduced); that would mean the forced-set
# has members it should not have.
if forced:
# Forced set is non-empty: rotate the contour start point
# such that the last point in the list is a forced point.
@ -249,6 +257,8 @@ def iup_contour_optimize(delta, coords, tolerance=0.):
coords = _rot_list(coords, k)
forced = _rot_set(forced, k, n)
# Debugging: Pass a set() instead of forced variable to the next call
# to exercise forced-set computation for under-counting.
chain, costs = _iup_contour_optimize_dp(delta, coords, forced, tolerance)
# Assemble solution.
@ -257,18 +267,25 @@ def iup_contour_optimize(delta, coords, tolerance=0.):
while i is not None:
solution.add(i)
i = chain[i]
solution.remove(-1)
#if not forced <= solution:
# print("coord", coords)
# print("delta", delta)
# print("len", len(delta))
assert forced <= solution, (forced, solution)
delta = [delta[i] if i in solution else None for i in range(n)]
delta = _rot_list(delta, -k)
else:
# Repeat the contour an extra time, solve the 2*n case, then look for solutions of the
# circular n-length problem in the solution for 2*n linear case. I cannot prove that
# Repeat the contour an extra time, solve the new case, then look for solutions of the
# circular n-length problem in the solution for new linear case. I cannot prove that
# this always produces the optimal solution...
chain, costs = _iup_contour_optimize_dp(delta+delta, coords+coords, forced, tolerance, n)
chain, costs = _iup_contour_optimize_dp(delta+delta, coords+coords, {}, tolerance, n)
best_sol, best_cost = None, n+1
for start in range(n-1, 2*n-1):
for start in range(n-1, len(costs) - 1):
# Assemble solution.
solution = set()
i = start
@ -280,6 +297,12 @@ def iup_contour_optimize(delta, coords, tolerance=0.):
if cost <= best_cost:
best_sol, best_cost = solution, cost
#if not forced <= best_sol:
# print("coord", coords)
# print("delta", delta)
# print("len", len(delta))
assert forced <= best_sol, (forced, best_sol)
delta = [delta[i] if i in best_sol else None for i in range(n)]

53
Tests/varLib/iup_test.py Normal file
View File

@ -0,0 +1,53 @@
import fontTools.varLib.iup as iup
import sys
import pytest
class IupTest:
# -----
# Tests
# -----
@pytest.mark.parametrize(
"delta, coords, forced",
[
(
[(0, 0)],
[(1, 2)],
set()
),
(
[(0, 0), (0, 0), (0, 0)],
[(1, 2), (3, 2), (2, 3)],
set()
),
(
[(1, 1), (-1, 1), (-1, -1), (1, -1)],
[(0, 0), (2, 0), (2, 2), (0, 2)],
set()
),
(
[(-1, 0), (-1, 0), (-1, 0), (-1, 0), (-1, 0), (0, 0), (0, 0), (0, 0), (0, 0), (0, 0), (0, 0), (-1, 0)],
[(-35, -152), (-86, -101), (-50, -65), (0, -116), (51, -65), (86, -99), (35, -151), (87, -202), (51, -238), (-1, -187), (-53, -239), (-88, -205)],
{11}
),
(
[(0, 0), (1, 0), (2, 0), (2, 0), (0, 0), (1, 0), (3, 0), (3, 0), (2, 0), (2, 0), (0, 0), (0, 0), (-1, 0), (-1, 0), (-1, 0), (-3, 0), (-1, 0), (0, 0), (0, 0), (-2, 0), (-2, 0), (-1, 0), (-1, 0), (-1, 0), (-4, 0)],
[(330, 65), (401, 65), (499, 117), (549, 225), (549, 308), (549, 422), (549, 500), (497, 600), (397, 648), (324, 648), (271, 648), (200, 620), (165, 570), (165, 536), (165, 473), (252, 407), (355, 407), (396, 407), (396, 333), (354, 333), (249, 333), (141, 268), (141, 203), (141, 131), (247, 65)],
{5, 15, 24}
),
]
)
def test_forced_set(self, delta, coords, forced):
f = iup._iup_contour_bound_forced_set(delta, coords)
assert forced == f
chain1, costs1 = iup._iup_contour_optimize_dp(delta, coords, f)
chain2, costs2 = iup._iup_contour_optimize_dp(delta, coords, set())
assert chain1 == chain2, f
assert costs1 == costs2, f
if __name__ == "__main__":
sys.exit(pytest.main(sys.argv))