From 0d7d7d4e1168ddb56f1626291ac4845e36ee0e9a Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 13 Jun 2022 05:54:57 -0600 Subject: [PATCH] [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). --- Lib/fontTools/varLib/iup.py | 69 ++++++++++++++++++++++++------------- Tests/varLib/iup_test.py | 53 ++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 Tests/varLib/iup_test.py diff --git a/Lib/fontTools/varLib/iup.py b/Lib/fontTools/varLib/iup.py index 45a7a5edf..0bd797a24 100644 --- a/Lib/fontTools/varLib/iup.py +++ b/Lib/fontTools/varLib/iup.py @@ -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)] diff --git a/Tests/varLib/iup_test.py b/Tests/varLib/iup_test.py new file mode 100644 index 000000000..76b2af512 --- /dev/null +++ b/Tests/varLib/iup_test.py @@ -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))