From 7c90766394bd08a2153616bf24ab637ef5fb73dc Mon Sep 17 00:00:00 2001 From: minjaesong Date: Fri, 6 Mar 2026 23:27:57 +0900 Subject: [PATCH] optimised contour generation --- OTFbuild/bitmap_tracer.py | 175 +++++++++++++++++++++++++------------- 1 file changed, 115 insertions(+), 60 deletions(-) diff --git a/OTFbuild/bitmap_tracer.py b/OTFbuild/bitmap_tracer.py index ffae93e..4bc5d11 100644 --- a/OTFbuild/bitmap_tracer.py +++ b/OTFbuild/bitmap_tracer.py @@ -1,14 +1,15 @@ """ -Convert 1-bit bitmap arrays to TrueType quadratic outlines. +Convert 1-bit bitmap arrays to CFF outlines by tracing connected pixel blobs. -Each set pixel becomes part of a rectangle contour drawn clockwise. -Adjacent identical horizontal runs are merged vertically into rectangles. +Each connected component of filled pixels becomes a single closed contour +(plus additional contours for any holes). Adjacent collinear edges are +merged, minimising vertex count. -Scale: x_left = col * SCALE, y_top = (BASELINE_ROW - row) * SCALE +Scale: x = col * SCALE, y = (BASELINE_ROW - row) * SCALE where BASELINE_ROW = 16 (ascent in pixels). """ -from typing import Dict, List, Tuple +from typing import List, Tuple import sheet_config as SC @@ -16,15 +17,56 @@ SCALE = SC.SCALE BASELINE_ROW = 16 # pixels from top to baseline +def _turn_priority(in_dx, in_dy, out_dx, out_dy): + """ + Return priority for outgoing direction relative to incoming. + Lower = preferred (rightmost turn in y-down grid coordinates). + + This produces outer contours that are CW in font coordinates (y-up) + and hole contours that are CCW, matching the non-zero winding rule. + """ + # Normalise to unit directions + nidx = (1 if in_dx > 0 else -1) if in_dx else 0 + nidy = (1 if in_dy > 0 else -1) if in_dy else 0 + ndx = (1 if out_dx > 0 else -1) if out_dx else 0 + ndy = (1 if out_dy > 0 else -1) if out_dy else 0 + + # Right turn in y-down coords: (-in_dy, in_dx) + if (ndx, ndy) == (-nidy, nidx): + return 0 + # Straight + if (ndx, ndy) == (nidx, nidy): + return 1 + # Left turn: (in_dy, -in_dx) + if (ndx, ndy) == (nidy, -nidx): + return 2 + # U-turn + return 3 + + +def _simplify(contour): + """Remove collinear intermediate vertices from a rectilinear contour.""" + n = len(contour) + if n < 3: + return contour + result = [] + for i in range(n): + p = contour[(i - 1) % n] + c = contour[i] + q = contour[(i + 1) % n] + # Cross product of consecutive edge vectors + if (c[0] - p[0]) * (q[1] - c[1]) - (c[1] - p[1]) * (q[0] - c[0]) != 0: + result.append(c) + return result if len(result) >= 3 else contour + + def trace_bitmap(bitmap, glyph_width_px): """ - Convert a bitmap to a list of rectangle contours. + Convert a bitmap to polygon contours by tracing connected pixel blobs. - Each rectangle is ((x0, y0), (x1, y1)) in font units, where: - - (x0, y0) is bottom-left - - (x1, y1) is top-right - - Returns list of (x0, y0, x1, y1) tuples representing rectangles. + Returns a list of contours, where each contour is a list of (x, y) + tuples in font units. Outer contours are clockwise, hole contours + counter-clockwise (non-zero winding rule). """ if not bitmap or not bitmap[0]: return [] @@ -32,66 +74,79 @@ def trace_bitmap(bitmap, glyph_width_px): h = len(bitmap) w = len(bitmap[0]) - # Step 1: Find horizontal runs per row - runs = [] # list of (row, col_start, col_end) - for row in range(h): - col = 0 - while col < w: - if bitmap[row][col]: - start = col - while col < w and bitmap[row][col]: - col += 1 - runs.append((row, start, col)) - else: - col += 1 + def filled(r, c): + return 0 <= r < h and 0 <= c < w and bitmap[r][c] - # Step 2: Merge vertically adjacent identical runs into rectangles - rects = [] # (row_start, row_end, col_start, col_end) - used = [False] * len(runs) + # -- Step 1: collect directed boundary edges -- + # Pixel (r, c) occupies grid square (c, r)-(c+1, r+1). + # Edge direction keeps the filled region to the left (in y-down coords). + edge_map = {} # start_vertex -> [end_vertex, ...] - for i, (row, cs, ce) in enumerate(runs): - if used[i]: - continue - # Try to extend this run downward - row_end = row + 1 - j = i + 1 - while j < len(runs): - r2, cs2, ce2 = runs[j] - if r2 > row_end: - break - if r2 == row_end and cs2 == cs and ce2 == ce and not used[j]: - used[j] = True - row_end = r2 + 1 - j += 1 - rects.append((row, row_end, cs, ce)) + for r in range(h): + for c in range(w): + if not bitmap[r][c]: + continue + if not filled(r - 1, c): # top boundary + edge_map.setdefault((c, r), []).append((c + 1, r)) + if not filled(r + 1, c): # bottom boundary + edge_map.setdefault((c + 1, r + 1), []).append((c, r + 1)) + if not filled(r, c - 1): # left boundary + edge_map.setdefault((c, r + 1), []).append((c, r)) + if not filled(r, c + 1): # right boundary + edge_map.setdefault((c + 1, r), []).append((c + 1, r + 1)) - # Step 3: Convert to font coordinates + if not edge_map: + return [] + + # -- Step 2: trace contours using rightmost-turn rule -- + used = set() contours = [] - for row_start, row_end, col_start, col_end in rects: - x0 = col_start * SCALE - x1 = col_end * SCALE - y_top = (BASELINE_ROW - row_start) * SCALE - y_bottom = (BASELINE_ROW - row_end) * SCALE - contours.append((x0, y_bottom, x1, y_top)) + + for sv in sorted(edge_map): + for ev in edge_map[sv]: + if (sv, ev) in used: + continue + + path = [sv] + prev, curr = sv, ev + used.add((sv, ev)) + + while curr != sv: + path.append(curr) + idx, idy = curr[0] - prev[0], curr[1] - prev[1] + + candidates = [e for e in edge_map.get(curr, []) + if (curr, e) not in used] + if not candidates: + break + + best = min(candidates, + key=lambda e: _turn_priority( + idx, idy, e[0] - curr[0], e[1] - curr[1])) + + used.add((curr, best)) + prev, curr = curr, best + + path = _simplify(path) + if len(path) >= 3: + contours.append([ + (x * SCALE, (BASELINE_ROW - y) * SCALE) + for x, y in path + ]) return contours def draw_glyph_to_pen(contours, pen, x_offset=0, y_offset=0): """ - Draw rectangle contours to a TTGlyphPen or similar pen. - Each rectangle is drawn as a clockwise closed contour (4 on-curve points). + Draw polygon contours to a T2CharStringPen (or compatible pen). + Each contour is a list of (x, y) vertices forming a closed polygon. x_offset/y_offset shift all contours (used for alignment positioning). """ - for x0, y0, x1, y1 in contours: - ax0 = x0 + x_offset - ax1 = x1 + x_offset - ay0 = y0 + y_offset - ay1 = y1 + y_offset - # Clockwise: bottom-left -> top-left -> top-right -> bottom-right - pen.moveTo((ax0, ay0)) - pen.lineTo((ax0, ay1)) - pen.lineTo((ax1, ay1)) - pen.lineTo((ax1, ay0)) + for contour in contours: + x, y = contour[0] + pen.moveTo((x + x_offset, y + y_offset)) + for x, y in contour[1:]: + pen.lineTo((x + x_offset, y + y_offset)) pen.closePath()