From 61a847edf1f1e9846be9ebfc6805d1aea387e3ca Mon Sep 17 00:00:00 2001 From: Phil Rukin Date: Fri, 25 Sep 2026 09:52:00 +0200 Subject: [PATCH 1/2] feat: preserve color detail with compact SVG output --- README.md | 23 +- vectorizing/solvers/color/ColorSolver.py | 30 +- vectorizing/solvers/color/quantize.py | 190 +++++------ vectorizing/svg/markup.py | 164 +++++++--- vectorizing/tests/color_reference.py | 93 ++---- vectorizing/tests/test_color_cleanup.py | 308 ++++++------------ vectorizing/tests/test_color_equivalence.py | 14 +- vectorizing/tests/test_color_layer_artwork.py | 25 +- vectorizing/tests/test_color_p3.py | 157 +++++++++ vectorizing/tests/test_integration.py | 9 +- vectorizing/tests/test_svg_geometry.py | 164 ++++++++++ vectorizing/tests/test_svg_markup.py | 17 +- 12 files changed, 733 insertions(+), 461 deletions(-) create mode 100644 vectorizing/tests/test_color_p3.py create mode 100644 vectorizing/tests/test_svg_geometry.py diff --git a/README.md b/README.md index 2d534bb..7f2028b 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,25 @@ A typical response would be Or, if `raw = true` was supplied, just plain SVG markup +Color processing uses a gentle 3-pixel bilateral filter and removes only connected +components smaller than eight processed-image pixels, rather than rejecting thin +regions based on their shape. Transparent pixels are protected; on opaque images, +the most common surviving perimeter color is also protected to retain small +background-colored letter counters. This can retain extra small specks of that +color. Tracing retains small paths and uses a curve optimization tolerance of 0.5. +The existing 1,048,576-pixel area cap, default of six +colors and supported range of 2–64 colors are unchanged. Color paths touching the +canvas are extended before tracing and clipped back to the image so corners remain +covered and returned bounds do not include the padding. + +SVGs use compact absolute/relative commands on an integer hundredth-pixel grid, +inside a `scale(.01)` group. This preserves the previous two-decimal coordinate +rounding, viewport, paint order and opacity without raster images or SVG strokes. +Binary tracing is unchanged, but both solvers use the compact serializer. Equivalent +geometry can produce small renderer-specific antialiasing differences; exact pixel +identity across viewers and zoom levels is not guaranteed. Potrace and pypotrace +remain dependencies under their existing GPL licenses. + Color layers have cutouts with a small overlap along shared edges to hide seams. The overlap is two pixels in the resized image before tracing, not screen pixels. Hiding a color can expose this rim; very thin features may remain covered, and @@ -145,7 +164,9 @@ docker compose --profile testing run --build --rm test ``` Tests are split into `test_*.py` files in `vectorizing/tests`. Compose and CI -run them all. `color_reference.py` keeps the old code for before/after checks. +run them all. `color_reference.py` keeps independent centroid, bitmap and area-cleanup +oracles. The color acceptance tests also check the approved Aftermath geometry, +Bubble Whirl's lettering counters, alpha behavior and compact SVG coordinates. When running pytest outside Compose, configure the AWS variables for an S3-compatible service first. diff --git a/vectorizing/solvers/color/ColorSolver.py b/vectorizing/solvers/color/ColorSolver.py index 545c833..d90c2b1 100644 --- a/vectorizing/solvers/color/ColorSolver.py +++ b/vectorizing/solvers/color/ColorSolver.py @@ -2,7 +2,7 @@ import numpy as np import potrace -from pathops import Path +from pathops import Path, PathOp, op from PIL import Image from vectorizing.geometry.potrace import potrace_path_to_compound_path @@ -12,6 +12,28 @@ from vectorizing.util.limit_size import limit_size +def trace_bitmap(bitmap: np.ndarray) -> Path: + """Trace detail without speck removal, extending then clipping canvas edges.""" + height, width = bitmap.shape + # Potrace rounds exposed mask corners. Extending edge labels moves that + # rounding outside the viewport instead of leaving transparent canvas corners. + padded = np.pad(bitmap.astype(np.uint8), 2, mode="edge") + traced = potrace.Bitmap(padded).trace(turdsize=0, opttolerance=0.5, alphamax=1) + path = potrace_path_to_compound_path(traced).transform(1, 0, 0, 1, -2, -2) + left, top, right, bottom = path.bounds + if left < 0 or top < 0 or right > width or bottom > height: + # Keep returned geometry/bounds inside the image, not just the SVG's + # viewport. This also makes layer exports safe without the original clip. + canvas = Path() + canvas.moveTo(0, 0) + canvas.lineTo(width, 0) + canvas.lineTo(width, height) + canvas.lineTo(0, height) + canvas.close() + path = op(path, canvas, PathOp.INTERSECTION) + return path + + class ColorSolver: """Resize and quantize an image, then trace its ordered color layers.""" @@ -40,13 +62,9 @@ def solve(self) -> list[list[Path] | list[np.ndarray] | int]: self.timer.end_timer() self.timer.start_timer("Bitmap Tracing") - traced_bitmaps = [potrace.Bitmap(bitmap).trace() for bitmap in bitmaps] + compound_paths = [trace_bitmap(bitmap) for bitmap in bitmaps] self.timer.end_timer() - compound_paths = [ - potrace_path_to_compound_path(traced) for traced in traced_bitmaps - ] - return [compound_paths, colors, self.img.size[0], self.img.size[1]] diff --git a/vectorizing/solvers/color/quantize.py b/vectorizing/solvers/color/quantize.py index 109210a..b17c5e1 100644 --- a/vectorizing/solvers/color/quantize.py +++ b/vectorizing/solvers/color/quantize.py @@ -1,92 +1,79 @@ -"""Quantize RGB colors while keeping transparent background pixels separate.""" +"""Quantize colors without discarding connected thin features or alpha holes.""" import cv2 import numpy as np -import scipy.ndimage as ndi from faiss import Kmeans from PIL import Image from skimage.measure import label -BILATERAL_FILTER_DIAMETER = 7 -BILATERAL_FILTER_SIGMA = 50 +MIN_COMPONENT_AREA = 8 -def bilateral_filter( - img_arr: np.ndarray, - d: int = BILATERAL_FILTER_DIAMETER, - s: float = BILATERAL_FILTER_SIGMA, -) -> np.ndarray: - """Smooth clip-art colors while preserving edges; photos may lose detail.""" - return cv2.bilateralFilter(img_arr, d, s, s) +def bilateral_filter(img_arr: np.ndarray) -> np.ndarray: + """Gently smooth local color noise without the broad blur of a large kernel.""" + return cv2.bilateralFilter(img_arr, 3, 12, 1) -def fill_holes(matrix: np.ndarray) -> np.ndarray: - """Replace zero cells with their nearest nonzero value via a distance transform.""" - closest = ndi.distance_transform_edt( - np.logical_not(matrix), - return_distances=False, - return_indices=True, - ) - matrix = np.where(matrix != 0, matrix, matrix[closest[0], closest[1]]) - return matrix +def _perimeter(array: np.ndarray) -> np.ndarray: + """Read each boundary pixel once, including single-row or single-column images.""" + if min(array.shape) == 1: + return array.ravel() + return np.concatenate([array[0], array[-1], array[1:-1, 0], array[1:-1, -1]]) -def enhance( - img_arr: np.ndarray, +def clean_components( labels: np.ndarray, - colors: np.ndarray, -) -> tuple[np.ndarray, np.ndarray]: - """Remove boundary-dominated components with O(K*N) work and O(N) image storage.""" - # Reserve zero for holes, preserving the existing input-dtype shift behavior. - labels = labels + 1 - valid = (labels > 0) & (labels <= len(colors)) - - # OpenCV's 2x2 dilation (default anchor/border) reaches a pixel from above, - # left, and upper-left. Mark overlap from any OTHER palette color once, - # without treating missing/out-of-palette neighbors as a dilated cluster. - boundary = np.zeros(labels.shape, dtype=bool) - boundary[1:, :] |= valid[:-1, :] & (labels[1:, :] != labels[:-1, :]) - boundary[:, 1:] |= valid[:, :-1] & (labels[:, 1:] != labels[:, :-1]) - boundary[1:, 1:] |= valid[:-1, :-1] & (labels[1:, 1:] != labels[:-1, :-1]) - - # The boundary is fixed; reuse its complement for every color's area count. - interior = ~boundary - del boundary, valid - cleaned = np.zeros(img_arr.shape[:2], dtype=np.uint16 if len(colors) else np.uint8) - # One scan finds every color's box, including all disconnected components. - # max_label excludes invalid labels; zero would instead infer the maximum. - regions = ndi.find_objects(labels, max_label=len(colors)) if len(colors) else [] - for index, region in enumerate(regions): - if region is None: - continue - cluster = labels[region] == index + 1 - # Keep full (8-neighbor) connectivity, including diagonal contacts. - components = label(cluster, connectivity=2) - original_counts = np.bincount(components.ravel()) - interior_counts = np.bincount( - # Slice the global mask: other colors outside the box still influence it. - components[interior[region]], - minlength=len(original_counts), - ) - areas_ratio = np.divide( - interior_counts.astype(np.float32), - original_counts.astype(np.float32), - out=np.ones(len(original_counts), dtype=np.float32), - where=original_counts != 0, - ) - # Retain whole components, using the same float32, inclusive <=0.1 cutoff. - cleaned[region][cluster & (areas_ratio[components] > 0.1)] = index + 1 - # Release this map before allocating the next color's component map. - del components - - return fill_holes(cleaned) - 1, colors + background: np.ndarray | None, +) -> np.ndarray: + """Refill tiny components, protecting transparency and background-color counters. + + Area, not the proportion of interior pixels, determines removal, retaining + thin components above the cutoff. Connectivity includes diagonal contacts. + The labeled chamfer transform assigns removed pixels to surviving neighbors; + when nothing survives, retain the input instead of inventing a replacement. + For opaque images, protect the most common surviving perimeter color so small + enclosed letter counters are not filled. Ties use the lowest palette index. + + Parameters + ---------- + labels : numpy.ndarray + Two-dimensional palette indices, including zero for any background. + background : numpy.ndarray or None + Nonzero at protected transparent pixels, if present. + + Returns + ------- + numpy.ndarray + Cleaned labels with the input dtype; the input is never mutated. + """ + components = label(labels.astype(np.uint16) + 1, background=0, connectivity=2) + counts = np.bincount(components.ravel()) + holes = counts[components] < MIN_COMPONENT_AREA + if background is not None: + holes &= background == 0 + elif holes.any() and not holes.all(): + perimeter = _perimeter(labels)[~_perimeter(holes)] + if perimeter.size: + background_color = np.bincount(perimeter).argmax() + holes &= labels != background_color + if not holes.any() or holes.all(): + return labels + + _, nearest = cv2.distanceTransformWithLabels( + holes.astype(np.uint8), + cv2.DIST_L2, + 5, + labelType=cv2.DIST_LABEL_PIXEL, + ) + lookup = np.zeros(int(nearest.max()) + 1, dtype=labels.dtype) + lookup[nearest[~holes]] = labels[~holes] + cleaned = labels.copy() + cleaned[holes] = lookup[nearest[holes]] + return cleaned -# Try to get the cluster of pixels that represent a transparent background -# If there is no transparent background, return None -# This is needed because RGBA quantization is very volatile, and sometimes -# opaque colors get assigned to the same clusters as highly transparent ones. -# So we focus on the solid image + transparent background case for now. +# Keep transparent pixels separate from RGB clustering: otherwise opaque and +# transparent pixels with similar RGB values can be assigned to the same color. def get_background_cluster(img_arr: np.ndarray, t: float = 0.5) -> np.ndarray | None: """Return an alpha mask if any raw alpha is below t, otherwise return None.""" r, g, b, a = cv2.split(img_arr) @@ -99,8 +86,6 @@ def get_background_cluster(img_arr: np.ndarray, t: float = 0.5) -> np.ndarray | return None -# Write the background cluster on top of a matrix of clusters (labels) -# The background cluster has priority def write_background_cluster(labels: np.ndarray, bg_cluster: np.ndarray) -> np.ndarray: """Shift labels by one, reserving zero for the overriding background mask.""" labels = labels + 1 @@ -111,13 +96,9 @@ def write_background_cluster(labels: np.ndarray, bg_cluster: np.ndarray) -> np.n def get_initial_centroids(img_arr: np.ndarray, color_count: int) -> np.ndarray: """Return sorted unique used RGB palette colors for K-means initialization.""" img = Image.fromarray(img_arr) - img = img.quantize(color_count, method=Image.Quantize.FASTOCTREE) - - # Ignore unused entries: palette padding must not add extra K-means clusters. - # Keep np.unique's RGB order and deduplication, but sort only the used palette - # rather than every pixel. Centroid order affects K-means results. - # quantize() returns mode P: at most 256 entries, within getcolors()'s cap. + # Palette padding must not introduce additional clusters. RGB order affects + # initialization, so sort the used entries rather than the entire pixel grid. used_indices = [index for _, index in img.getcolors()] palette = np.asarray(img.getpalette("RGB"), dtype=np.uint8).reshape(-1, 3) return np.unique(palette[used_indices], axis=0) @@ -141,28 +122,25 @@ def quantize( img_arr: np.ndarray, color_count: int, ) -> tuple[np.ndarray, np.ndarray, bool]: - """Return cleaned labels, palette colors and whether a background was isolated.""" - channel_count = img_arr.shape[-1] - - background_cluster = None - if channel_count == 4: - background_cluster = get_background_cluster(img_arr) - img = Image.fromarray(img_arr) - img = img.convert("RGB") - img_arr = np.asarray(img) - - img_arr = bilateral_filter(img_arr) - - labels, colors = kmeans(img_arr, get_initial_centroids(img_arr, color_count)) - - labels = np.reshape(labels, img_arr.shape[:-1]) - - has_background = background_cluster is not None - if has_background: - labels = write_background_cluster(labels, background_cluster) - colors = [[0, 0, 0, 0]] + [[r, g, b, 1] for r, g, b in colors] - colors = np.array(colors) - - colors = colors.astype(np.uint8) - labels, colors = enhance(img_arr, labels, colors) - return labels, colors, has_background + """Return area-cleaned labels, an RGB-ordered palette and background status.""" + background = get_background_cluster(img_arr) if img_arr.shape[-1] == 4 else None + rgb = bilateral_filter(img_arr[:, :, :3].copy()) + labels, colors = kmeans(rgb, get_initial_centroids(rgb, color_count)) + labels = labels.reshape(rgb.shape[:2]) + + # Paint order determines which neighboring colors may receive a bounded rim. + order = np.lexsort(colors.T[::-1]) + labels = np.argsort(order)[labels].astype(np.uint16) + colors = colors[order] + if background is not None: + labels = write_background_cluster(labels, background) + colors = np.vstack( + [ + np.zeros((1, 4), dtype=np.uint8), + np.column_stack([colors, np.ones(len(colors), dtype=np.uint8)]), + ], + ) + labels = clean_components(labels, background) + if background is not None: + labels = np.where(background > 0, 0, labels) + return labels, colors, background is not None diff --git a/vectorizing/svg/markup.py b/vectorizing/svg/markup.py index 643e7b6..b08f6be 100644 --- a/vectorizing/svg/markup.py +++ b/vectorizing/svg/markup.py @@ -1,10 +1,13 @@ -"""Serialize traced paths without strokes or changes to their paint order.""" +"""Serialize editable filled paths on an exact hundredth-pixel coordinate grid.""" from collections.abc import Iterable, Sequence import numpy as np from pathops import Path +Point = tuple[int, int] +Command = tuple[str, tuple[int, ...]] + def truncate(number: float) -> str: """Format a coordinate with two decimal places to limit SVG size.""" @@ -14,13 +17,97 @@ def truncate(number: float) -> str: def to_SVG_color_string(color: Sequence[float] | np.ndarray) -> str: """Format RGB or RGBA components without scaling or rounding their values.""" func = "rgb" if len(color) == 3 else "rgba" - tuple = "".join( - [ - f"{item}," if i != len(color) - 1 else f"{item}" - for i, item in enumerate(color) - ], - ) - return f"{func}({tuple})" + return f"{func}({','.join(str(item) for item in color)})" + + +def _color_string(color: Sequence[float] | np.ndarray) -> str: + """Use short hex for opaque integral RGB, preserving other color/alpha values.""" + opaque = len(color) == 3 or color[3] == 1 + integral = all(0 <= value <= 255 and value == int(value) for value in color[:3]) + if opaque and integral: + rgb = "".join(f"{int(value):02x}" for value in color[:3]) + return "#" + (rgb[::2] if rgb[::2] == rgb[1::2] else rgb) + return to_SVG_color_string(color) + + +def _point(point: tuple[float, float]) -> Point: + """Keep decimal-format rounding, including half ties, before changing units.""" + # Multiplying a float by 100 and rounding can disagree with formatting it + # first (e.g. 2.675). No additional precision is lost by the integer encoding. + return tuple(int(truncate(value).replace(".", "")) for value in point) + + +def _variants( + command: str, + points: tuple[Point, ...], + current: Point, + control: Point | None, +) -> list[Command]: + """Offer equivalent absolute, relative, axial and reflected cubic commands.""" + absolute = tuple(value for point in points for value in point) + relative = tuple(value - current[i % 2] for i, value in enumerate(absolute)) + variants = [(command, absolute), (command.lower(), relative)] + if command == "L": + if points[0][1] == current[1]: + variants.extend([("H", (absolute[0],)), ("h", (relative[0],))]) + if points[0][0] == current[0]: + variants.extend([("V", (absolute[1],)), ("v", (relative[1],))]) + if command == "C" and control is not None: + reflected = (2 * current[0] - control[0], 2 * current[1] - control[1]) + if points[0] == reflected: + variants.extend([("S", absolute[2:]), ("s", relative[2:])]) + return variants + + +def _shortest(variants: list[Command], previous: str) -> tuple[str, str]: + """Choose the shortest legal spelling, eliding repeated commands when allowed.""" + candidates = [] + for command, values in variants: + numbers = " ".join(str(value) for value in values).replace(" -", "-") + candidates.append((command + numbers, command)) + # Extra moveto pairs are lines, not new subpaths: never omit an M/m. + implicit_line = (previous, command) in [("M", "L"), ("m", "l")] + if command.upper() != "M" and (previous == command or implicit_line): + separator = "" if numbers.startswith("-") else " " + candidates.append((separator + numbers, command)) + return min(candidates, key=lambda candidate: len(candidate[0])) + + +def _path_data(path: Path, width: int, height: int) -> str: + """Encode filled geometry without changing contour order or winding.""" + pieces = [] + current = start = (0, 0) + control = None + previous = "" + for kind, coordinates in path.segments: + if kind == "closePath": + pieces.append("z") + current = start + control = None + previous = "z" + continue + # Canvas clipping can reduce a cubic to a quadratic; retain those too. + command = { + "moveTo": "M", + "lineTo": "L", + "curveTo": "C", + "qCurveTo": "Q", + }[kind] + points = tuple(_point(point) for point in coordinates) + variants = _variants(command, points, current, control) + x, y = points[-1] + if x in (0, width * 100) or y in (0, height * 100): + # Some renderers accumulate subpixel error in relative commands. + # Anchor canvas boundaries explicitly (not H/V, which retain one + # current coordinate) to keep opaque edge pixels fully covered. + variants = variants[:1] + text, previous = _shortest(variants, previous) + pieces.append(text) + current = points[-1] + if command == "M": + start = current + control = points[1] if command == "C" else None + return "".join(pieces) def generate_SVG_markup( @@ -29,41 +116,36 @@ def generate_SVG_markup( width: int, height: int, ) -> str: - """Serialize line/cubic paths in paint order, skipping empty geometry.""" - paths_markup = [] + """Emit compact vectors with unchanged viewport, paint order and alpha values. - for compound_path, color in zip(compound_paths, colors): - segments = list(compound_path.segments) - if not len(segments): - continue + Integer coordinates with a 0.01 scale represent the same two-decimal geometry + as absolute pixel coordinates. Relative commands and transforms can still + cause small renderer-specific antialiasing differences; this is not a promise + of pixel-identical rendering at every zoom level. - d = "" - for segment in segments: - command = segment[0] - if command == "moveTo": - x = segment[1][0][0] - y = segment[1][0][1] - d += f"M {truncate(x)} {truncate(y)} " - if command == "lineTo": - x = segment[1][0][0] - y = segment[1][0][1] - d += f"L {truncate(x)} {truncate(y)} " - if command == "curveTo": - d += ( - "C" + f"{truncate(segment[1][0][0])},{truncate(segment[1][0][1])} " - f"{truncate(segment[1][1][0])},{truncate(segment[1][1][1])} " - f"{truncate(segment[1][2][0])},{truncate(segment[1][2][1])} " - ) - if command == "closePath": - d += "Z" - - path_markup = f'' - paths_markup.append(path_markup) - - paths_markup = "\n".join(paths_markup) + Parameters + ---------- + compound_paths : Iterable[Path] + Line/quadratic/cubic compound paths in paint order. + colors : Iterable[Sequence[float] | numpy.ndarray] + Corresponding RGB or RGBA colors; alpha is not rescaled. + width : int + Viewport width in pixels. + height : int + Viewport height in pixels. + + Returns + ------- + str + SVG markup with real filled paths and no strokes. + """ + paths_markup = [] + for path, color in zip(compound_paths, colors): + data = _path_data(path, width, height) + if data: + paths_markup.append(f'') return ( f'\n' - f"\n{paths_markup}\n\n" - f"" + f'viewBox="0 0 {width} {height}">' + '' + "".join(paths_markup) + "" ) diff --git a/vectorizing/tests/color_reference.py b/vectorizing/tests/color_reference.py index c67b9e8..2b93675 100644 --- a/vectorizing/tests/color_reference.py +++ b/vectorizing/tests/color_reference.py @@ -1,17 +1,10 @@ -"""Frozen pre-optimization algorithms used as independent regression oracles. +"""Independent, intentionally slow oracles for optimized color operations.""" -Keep these intentionally slow implementations separate from production helpers: -sharing optimized code would let a regression affect both sides of a comparison. -Cleanup shares only the unchanged fill_holes step so tests can bypass it in both -paths to inspect component removal before nearest-neighbor filling hides holes. -""" +from collections import Counter import cv2 import numpy as np from PIL import Image -from skimage.measure import label - -from vectorizing.solvers.color import quantize as color_quantize def legacy_initial_centroids(img_arr: np.ndarray, color_count: int) -> np.ndarray: @@ -50,56 +43,36 @@ def legacy_create_bitmaps( return bitmaps, colors -def legacy_enhance( - img_arr: np.ndarray, +def reference_clean_components( labels: np.ndarray, - colors: np.ndarray, -) -> tuple[np.ndarray, np.ndarray]: - """Preserve the pairwise cleanup from main at f69c69b, before PR 74.""" - dims = img_arr.shape[:2] - labels = labels + 1 - clusters = [ - np.where(labels == idx + 1, idx + 1, 0).astype(np.uint16) - for idx, _ in enumerate(colors) - ] - # Preserve integer labeling, full connectivity and shifted component IDs. - # The optimized path must match this, not redefine the expected behavior. - original_connected_components_list = [label(cluster) + 1 for cluster in clusters] - connected_components_bincounts = [ - np.bincount(connected_components.flatten()) - for connected_components in original_connected_components_list - ] - connected_components_list = [ - np.array(item, copy=True) for item in original_connected_components_list - ] - # Keep the expensive pairwise dilation scans: independence is the point. - for x, cluster_x in enumerate(clusters): - dilated_cluster_x = cv2.dilate(cluster_x, np.ones((2, 2))) - for y, cluster_y in enumerate(clusters): - if x == y: - continue - overlap = np.logical_and(dilated_cluster_x, cluster_y) - connected_components_list[y] = np.where( - overlap, - 0, - connected_components_list[y], - ) - for x, connected_components in enumerate(connected_components_list): - original_bincount = connected_components_bincounts[x] - count = original_bincount.shape[0] - new_bincount = np.bincount(connected_components.flatten(), minlength=count) - areas_ratio = np.divide( - new_bincount.astype(np.float32), - original_bincount.astype(np.float32), - out=np.ones((count,), dtype=np.float32), - where=original_bincount != 0, - ) - clusters[x] = np.where( - areas_ratio[original_connected_components_list[x]] <= 0.1, - 0, - clusters[x], + background: np.ndarray | None, +) -> np.ndarray: + """Find small regions one color at a time, then use labeled chamfer refill.""" + remove = np.zeros(labels.shape, dtype=bool) + for color in np.unique(labels): + mask = labels == color + _, components = cv2.connectedComponents(mask.astype(np.uint8), connectivity=8) + sizes = np.bincount(components.ravel()) + remove |= mask & (sizes[components] < 8) + if background is not None: + remove &= background == 0 + else: + height, width = labels.shape + edge = {(y, x) for y in (0, height - 1) for x in range(width)} + edge |= {(y, x) for x in (0, width - 1) for y in range(height)} + counts = Counter(int(labels[y, x]) for y, x in edge if not remove[y, x]) + if counts: + color = min(counts, key=lambda value: (-counts[value], value)) + remove &= labels != color + result = labels.copy() + if remove.any() and not remove.all(): + _, nearest = cv2.distanceTransformWithLabels( + remove.astype(np.uint8), + cv2.DIST_L2, + 5, + labelType=cv2.DIST_LABEL_PIXEL, ) - labels = np.zeros(dims).astype(np.uint8) - for cluster in clusters: - labels = np.where(labels == 0, cluster, labels) - return color_quantize.fill_holes(labels) - 1, colors + # OpenCV numbers each zero pixel in scan order, starting from one. + survivors = labels[~remove] + result[remove] = survivors[nearest[remove] - 1] + return result diff --git a/vectorizing/tests/test_color_cleanup.py b/vectorizing/tests/test_color_cleanup.py index adc6576..87ab1cd 100644 --- a/vectorizing/tests/test_color_cleanup.py +++ b/vectorizing/tests/test_color_cleanup.py @@ -1,224 +1,102 @@ -"""Cluster cleanup parity, morphology edge cases and component-map lifetime. - -Unfilled comparisons expose the actual keep/remove decisions: hole filling can -otherwise repaint a wrongly removed component and hide the regression. -""" - -import weakref +"""Area-only cleanup preserves thin connected regions and transparent counters.""" import numpy as np import pytest -from vectorizing.solvers.color import quantize as color_quantize -from vectorizing.tests.color_reference import legacy_enhance - -# After the final label shift, an unfilled zero wraps to uint16's maximum. -# This sentinel is used only by tests bypassing fill_holes with a nonempty palette. -UNFILLED_LABEL = np.iinfo(np.uint16).max - - -@pytest.mark.parametrize("before_fill", [False, True], ids=["filled", "unfilled"]) -@pytest.mark.parametrize( - "variant", - [ - "noise", - "strided", - "one-pixel", - "one-row", - "one-column", - "solid", - "sparse", - "checkerboard", - ], -) -# Transparent artwork can add a background entry to the 64 requested colors. -@pytest.mark.parametrize("color_count", [1, 2, 6, 16, 64, 65]) +from vectorizing.solvers.color.quantize import clean_components +from vectorizing.tests.color_reference import reference_clean_components + + +@pytest.mark.parametrize("count", [1, 2, 6, 16, 64, 65]) +@pytest.mark.parametrize("transparent", [False, True]) +@pytest.mark.parametrize("strided", [False, True]) @pytest.mark.parametrize("seed", range(3)) -def test_cluster_cleanup_matches_original( - before_fill: bool, - variant: str, - color_count: int, +def test_cleanup_matches_per_color_reference( + count: int, + transparent: bool, + strided: bool, seed: int, - monkeypatch: pytest.MonkeyPatch, ) -> None: - """Preserve component decisions and filled labels without mutating inputs.""" + """Match a separate CCL implementation without changing input storage/dtype.""" rng = np.random.default_rng(seed) - noise = rng.integers(0, color_count, (19, 23), dtype=np.uint8) - labels = { - "noise": noise, - "strided": noise[::2, ::2], - "one-pixel": noise[:1, :1], - "one-row": noise[:1, :], - "one-column": noise[:, :1], - "solid": np.full_like(noise, color_count - 1), - "sparse": noise % 2, - "checkerboard": ( - np.indices(noise.shape).sum(axis=0) % min(color_count, 2) - ).astype(np.uint8), - }[variant] - colors = rng.integers(0, 256, (color_count, 4), dtype=np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) + labels = rng.integers(0, count, (39, 47), dtype=np.uint16) + labels[:5] = 0 # Ensure a survivor even in high-color random cases. + if strided: + labels = labels[::2, ::2] + background = (labels == 0).astype(np.uint8) if transparent else None + original = labels.copy() labels.setflags(write=False) - colors.setflags(write=False) - pixels.setflags(write=False) - original_labels = labels.copy() - if before_fill: - # Compare removal decisions, not just the colors painted over their holes. - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - expected, expected_colors = legacy_enhance(pixels, labels, colors) - actual, actual_colors = color_quantize.enhance(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - np.testing.assert_equal(actual.dtype, expected.dtype) - np.testing.assert_array_equal(actual_colors, expected_colors) - np.testing.assert_equal(actual_colors is colors, True) - np.testing.assert_array_equal(labels, original_labels) - - -@pytest.mark.parametrize("area", [9, 10, 11]) -def test_cluster_cleanup_preserves_inclusive_area_threshold( - area: int, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Keep a component with 1/9 interior area, but remove 1/10 and 1/11.""" - labels = np.zeros((8, 16), dtype=np.uint8) - # A 2x2 core has one interior pixel at (3, 3). Extending only its top row - # changes the component's total area without adding any interior pixels. - labels[2, 2:area] = 1 - labels[3, 2:4] = 1 - colors = np.zeros((2, 3), dtype=np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - expected = labels.astype(np.uint16) - if area >= 10: - expected[labels == 1] = UNFILLED_LABEL - for cleaner in (legacy_enhance, color_quantize.enhance): - actual, _ = cleaner(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - - -def test_cluster_cleanup_preserves_diagonal_connectivity( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Treat a 2x2 core and its diagonal thin tail as one removable component.""" - labels = np.zeros((8, 16), dtype=np.uint8) - # Seven tail pixels touch the 4-pixel core only at a corner. With full - # connectivity the combined 11-pixel component has 1/11 interior area; - # with 4-neighbor connectivity the core would incorrectly survive at 1/4. - labels[2:4, 2:4] = 1 - labels[4, 4:11] = 1 - colors = np.zeros((2, 3), dtype=np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - expected = labels.astype(np.uint16) - expected[labels == 1] = UNFILLED_LABEL - for cleaner in (legacy_enhance, color_quantize.enhance): - actual, _ = cleaner(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - - -def test_cluster_cleanup_preserves_dilation_anchor_and_border( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The 2x2 default anchor spares only the isolated top-left corner pixel.""" - labels = np.zeros((5, 5), dtype=np.uint8) - labels[::4, ::4] = 1 - colors = np.zeros((2, 3), dtype=np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - expected = labels.astype(np.uint16) - expected[labels == 1] = UNFILLED_LABEL - # No upper/left source exists at (0, 0); outside the image is not a color. - expected[0, 0] = 1 - for cleaner in (legacy_enhance, color_quantize.enhance): - actual, _ = cleaner(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - - -@pytest.mark.parametrize("before_fill", [False, True], ids=["filled", "unfilled"]) -@pytest.mark.parametrize("color_count", [0, 1, 3, 256]) -@pytest.mark.parametrize("dtype", [np.uint8, np.uint16, np.int16]) -def test_cluster_cleanup_preserves_unassigned_labels_and_dtype( - color_count: int, - dtype: type[np.generic], - before_fill: bool, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Ignore unassigned neighbors and retain existing label-shift overflow behavior.""" - rng = np.random.default_rng(42) - # 255 + 1 wraps only for uint8; do not silently fix that old behavior here. - # Small palettes also leave labels whose pixels must not dilate into others. - labels = rng.choice([0, 1, 2, 3, 254, 255], size=(7, 9)).astype(dtype) - if dtype == np.int16: - labels[1, 1:3] = [-1, -2] - if before_fill: - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - colors = np.zeros((color_count, 3), dtype=np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - expected, _ = legacy_enhance(pixels, labels, colors) - actual, actual_colors = color_quantize.enhance(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - np.testing.assert_equal(actual.dtype, expected.dtype) - np.testing.assert_equal(actual_colors is colors, True) - - -@pytest.mark.parametrize("before_fill", [False, True], ids=["filled", "unfilled"]) -@pytest.mark.parametrize("transpose", [False, True]) -def test_cluster_cleanup_crops_labeling_without_losing_neighbor_influence( - before_fill: bool, - transpose: bool, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Crop sparse colors while preserving disjoint islands and outside influences.""" - labels = np.zeros((13, 17), dtype=np.uint8) - labels[2:4, 3:5] = 1 - labels[8, 11] = 1 # A disconnected pixel of the same color must be removed. - labels[0, 14:17] = 2 # A single-row crop touching the image border. - labels[9, 2] = labels[10, 3] = 3 # Diagonal contact within a tight crop. - if transpose: - labels = labels.T - colors = np.zeros((6, 3), dtype=np.uint8) # Two palette entries are unused. - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - if before_fill: - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - expected, _ = legacy_enhance(pixels, labels, colors) - original_label = color_quantize.label - shapes = [] - - def record_label(cluster: np.ndarray, connectivity: int) -> np.ndarray: - shapes.append(cluster.shape) - return original_label(cluster, connectivity=connectivity) - - monkeypatch.setattr(color_quantize, "label", record_label) - actual, _ = color_quantize.enhance(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - # The background spans the image; each other color gets one minimal box, - # including all of its disconnected pieces, and unused entries get no pass. - expected_shapes = [(13, 17), (7, 9), (1, 3), (2, 2)] - if transpose: - expected_shapes = [(width, height) for height, width in expected_shapes] - np.testing.assert_array_equal(shapes, expected_shapes) - - -def test_cluster_cleanup_releases_each_component_map( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Release the old map before labeling the next color, not only on return.""" - original_label = color_quantize.label - maps: list[weakref.ReferenceType[np.ndarray]] = [] - - def record_label(cluster: np.ndarray, connectivity: int) -> np.ndarray: - # Weak references observe lifetime without keeping the maps alive. - # Checking before allocation catches even a temporary two-map overlap. - np.testing.assert_equal(all(ref() is None for ref in maps), True) - components = original_label(cluster, connectivity=connectivity) - maps.append(weakref.ref(components)) - return components - - monkeypatch.setattr(color_quantize, "label", record_label) - labels = (np.indices((8, 9)).sum(axis=0) % 4).astype(np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - colors = np.zeros((4, 3), dtype=np.uint8) - color_quantize.enhance(pixels, labels, colors) - # All colors are present: a skipped labeler must not make this pass vacuously. - np.testing.assert_equal(len(maps), 4) - np.testing.assert_equal(all(ref() is None for ref in maps), True) + actual = clean_components(labels, background) + np.testing.assert_array_equal( + actual, + reference_clean_components(labels, background), + ) + np.testing.assert_array_equal(labels, original) + assert actual.dtype == labels.dtype + + +@pytest.mark.parametrize("area", [1, 7, 8, 9, 20]) +def test_thin_components_use_area_not_interior_ratio(area: int) -> None: + """Keep one-pixel-wide features of at least eight pixels, including the cutoff.""" + labels = np.zeros((25, 25), dtype=np.uint16) + labels[10, 2 : 2 + area] = 1 + expected = labels if area >= 8 else np.zeros_like(labels) + np.testing.assert_array_equal(clean_components(labels, None), expected) + + +def test_diagonals_form_one_component() -> None: + """Eight corner-connected pixels survive even though none has an interior.""" + labels = np.zeros((16, 16), dtype=np.uint16) + indices = np.arange(3, 11) + labels[indices, indices] = 1 + np.testing.assert_array_equal(clean_components(labels, None), labels) + + +def test_small_transparent_counter_is_protected() -> None: + """Never remove a background hole, even when it is smaller than the area cutoff.""" + labels = np.ones((16, 16), dtype=np.uint16) + labels[8, 8] = 0 + background = (labels == 0).astype(np.uint8) + np.testing.assert_array_equal(clean_components(labels, background), labels) + + +@pytest.mark.parametrize("area", [1, 4, 7, 8]) +def test_enclosed_opaque_counter_survives(area: int) -> None: + """Protect background-colored counters even below the usual area cutoff.""" + labels = np.zeros((16, 16), dtype=np.uint16) + labels[3:13, 3:13] = 1 + labels[7, 4 : 4 + area] = 0 + np.testing.assert_array_equal(clean_components(labels, None), labels) + + +@pytest.mark.parametrize("shape", [(1, 1), (1, 7), (7, 1), (4, 4)]) +def test_no_survivors_retains_original_labels(shape: tuple[int, int]) -> None: + """Do not feed an all-foreground distance transform when every region is tiny.""" + labels = np.arange(np.prod(shape), dtype=np.uint16).reshape(shape) + np.testing.assert_array_equal(clean_components(labels, None), labels) + + +def test_background_selection_ignores_tiny_perimeter_components() -> None: + """Discard border noise rather than mistaking its frequent color for background.""" + labels = np.ones((20, 20), dtype=np.uint16) + labels[0, ::2] = labels[-1, ::2] = 0 + labels[::2, 0] = labels[::2, -1] = 0 + np.testing.assert_array_equal(clean_components(labels, None), 1) + + +@pytest.mark.parametrize("shape", [(1, 20), (20, 1)]) +def test_background_selection_on_narrow_images(shape: tuple[int, int]) -> None: + """Count each perimeter pixel once on a single row or column.""" + labels = np.array([1] + [2] * 8 + [1] * 11, dtype=np.uint16).reshape(shape) + np.testing.assert_array_equal( + clean_components(labels, None), + reference_clean_components(labels, None), + ) + + +def test_small_island_can_refill_from_transparent_background() -> None: + """Remove tiny opaque specks without painting over transparent pixels.""" + labels = np.zeros((12, 12), dtype=np.uint16) + labels[5, 5] = 1 + background = (labels == 0).astype(np.uint8) + np.testing.assert_array_equal(clean_components(labels, background), 0) diff --git a/vectorizing/tests/test_color_equivalence.py b/vectorizing/tests/test_color_equivalence.py index 2be8255..99f9d8d 100644 --- a/vectorizing/tests/test_color_equivalence.py +++ b/vectorizing/tests/test_color_equivalence.py @@ -1,8 +1,4 @@ -"""Full solver regressions comparing exact SVG bytes, colors and geometry. - -Replace one optimization at a time with its frozen reference while retaining the -others. This catches interactions that isolated mask/centroid/cleanup tests miss. -""" +"""Compare optimized P3 operations with independent references end to end.""" from pathlib import Path from unittest.mock import Mock @@ -17,8 +13,8 @@ from vectorizing.svg.markup import generate_SVG_markup from vectorizing.tests.color_reference import ( legacy_create_bitmaps, - legacy_enhance, legacy_initial_centroids, + reference_clean_components, ) @@ -42,7 +38,7 @@ def test_color_optimizations_preserve_vectorization( optimization: str, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Compare final SVG, colors, dimensions and bounds against the old algorithm.""" + """Compare SVG, colors, dimensions and bounds with independent operation oracles.""" with Image.open(Path(__file__).parent / "images" / image_name) as image: actual = ColorSolver(image, color_count, Timer()).solve() target, legacy = { @@ -55,8 +51,8 @@ def test_color_optimizations_preserve_vectorization( legacy_create_bitmaps, ), "cleanup": ( - "vectorizing.solvers.color.quantize.enhance", - legacy_enhance, + "vectorizing.solvers.color.quantize.clean_components", + reference_clean_components, ), }[optimization] # Patch each lookup site and verify the legacy implementation was used. diff --git a/vectorizing/tests/test_color_layer_artwork.py b/vectorizing/tests/test_color_layer_artwork.py index 217e070..d30624f 100644 --- a/vectorizing/tests/test_color_layer_artwork.py +++ b/vectorizing/tests/test_color_layer_artwork.py @@ -5,13 +5,11 @@ from unittest.mock import Mock import numpy as np -import potrace import pytest from cairosvg import svg2png from PIL import Image from scipy.ndimage import distance_transform_edt -from vectorizing.geometry.potrace import potrace_path_to_compound_path from vectorizing.server.timer import Timer from vectorizing.solvers.color.bitmaps import add_bitmap_rims from vectorizing.solvers.color.ColorSolver import ColorSolver @@ -34,9 +32,13 @@ def test_aftermath_retains_holes_when_layers_are_edited( ) -> None: """Keep the exterior transparent and reveal holes, not underpainting, on edits.""" foreground = [] + owned = [] def capture_foreground(masks: list[np.ndarray]) -> None: foreground.append(masks[0].copy()) + for index, mask in enumerate(masks): + above = masks[index + 1] if index + 1 < len(masks) else 0 + owned.append(mask > above) add_bitmap_rims(masks) rim_builder = Mock(side_effect=capture_foreground) @@ -47,11 +49,6 @@ def capture_foreground(masks: list[np.ndarray]) -> None: with Image.open(Path(__file__).parent / "images" / "aftermath.png") as image: paths, colors, width, height = ColorSolver(image, 16, Timer()).solve() rim_builder.assert_called_once() - silhouette = potrace_path_to_compound_path(potrace.Bitmap(foreground[0]).trace()) - silhouette_svg = generate_SVG_markup([silhouette], [[0, 0, 0]], width, height) - silhouette_alpha = np.asarray( - Image.open(BytesIO(svg2png(bytestring=silhouette_svg))).convert("RGBA"), - )[:, :, 3] markup = generate_SVG_markup(paths, colors, width, height) full = np.asarray(Image.open(BytesIO(svg2png(bytestring=markup))).convert("RGBA")) np.testing.assert_equal(full[:, :, 3].min(), 0) @@ -60,10 +57,12 @@ def capture_foreground(masks: list[np.ndarray]) -> None: alpha = full[:, :, 3] # Unlike a total partial-alpha budget, this checks where gaps occur. Allow # a 3-pixel band by the exterior: separately fitted curves can differ there. - interior = distance_transform_edt(silhouette_alpha == 255) > 3 + # Use the actual foreground mask: tracing a reference silhouette with + # Potrace's default speck removal would fill genuine tiny background holes. + interior = distance_transform_edt(foreground[0] > 0) > 3 np.testing.assert_equal(interior.any(), True) np.testing.assert_array_equal(alpha[interior], 255) - exterior = distance_transform_edt(silhouette_alpha == 0) > 3 + exterior = distance_transform_edt(foreground[0] == 0) > 3 np.testing.assert_array_equal(alpha[exterior], 0) np.testing.assert_equal( np.count_nonzero((alpha > 0) & (alpha < 255)) < 10_000, @@ -74,10 +73,12 @@ def capture_foreground(masks: list[np.ndarray]) -> None: solo_alpha = np.asarray( Image.open(BytesIO(svg2png(bytestring=solo))).convert("RGBA"), )[:, :, 3] - # Select a point far inside the color, excluding its intentionally narrow rim. - distances = distance_transform_edt(solo_alpha == 255) - np.testing.assert_equal(distances.max() > 2, True) + # Sample actual ownership, not the solo path: many tiny neighboring + # regions can be covered by bounded rims and appear solid in a solo render. + distances = distance_transform_edt(owned[index]) + np.testing.assert_equal(distances.max() > 4, True) point = np.unravel_index(distances.argmax(), distances.shape) + np.testing.assert_equal(solo_alpha[point], 255) for opacity in [None, 0, 0.5]: edited_paths, edited_colors = list(paths), list(colors) if opacity is None: diff --git a/vectorizing/tests/test_color_p3.py b/vectorizing/tests/test_color_p3.py new file mode 100644 index 0000000..00b6643 --- /dev/null +++ b/vectorizing/tests/test_color_p3.py @@ -0,0 +1,157 @@ +"""Acceptance checks for the approved detail-preserving color pipeline.""" + +import hashlib +import json +from io import BytesIO +from pathlib import Path + +import cv2 +import numpy as np +import pytest +from cairosvg import svg2png +from PIL import Image + +from vectorizing.geometry.bounds import compound_paths_bounds +from vectorizing.server.timer import Timer +from vectorizing.solvers.color.ColorSolver import ColorSolver, trace_bitmap +from vectorizing.solvers.color.quantize import quantize +from vectorizing.svg.markup import generate_SVG_markup +from vectorizing.tests.test_svg_geometry import expected_commands + +IMAGES = Path(__file__).parent / "images" + + +def test_aftermath_matches_approved_p3_geometry() -> None: + """Match the independently captured approved P3 geometry, palette and size.""" + with Image.open(IMAGES / "aftermath.png") as image: + paths, colors, width, height = ColorSolver(image, 8, Timer()).solve() + # This digest was captured from the approved experimental SVG, before the + # production port, with absolute commands in integer hundredths of a pixel. + commands = [expected_commands(path) for path in paths] + digest = hashlib.sha256( + json.dumps(commands, separators=(",", ":")).encode(), + ).hexdigest() + assert digest == "23d8c97dee23ea3f0b22d164d39f726f36ead7f2ce47048ca73326bf2731c7e8" + np.testing.assert_array_equal( + np.asarray(colors)[:, :3], + [ + [2, 3, 8], + [19, 60, 148], + [28, 100, 202], + [41, 36, 63], + [192, 149, 160], + [213, 54, 118], + [235, 215, 217], + [248, 57, 61], + ], + ) + assert (width, height) == (1024, 1024) + svg = generate_SVG_markup(paths, colors, width, height) + # Approved pre-encoding P3: 1,376,657 bytes. Do not discard geometry for size. + assert len(svg.encode()) < 760_000 + + +@pytest.fixture(scope="module") +def bubbles_pixels() -> np.ndarray: + """Render the real 16-color artwork at its original dimensions.""" + with Image.open(IMAGES / "bubbles.png") as image: + result = ColorSolver(image, 16, Timer()).solve() + png = svg2png( + bytestring=generate_SVG_markup(*result), + output_width=image.width, + output_height=image.height, + ) + return np.asarray(Image.open(BytesIO(png)).convert("RGBA")) + + +@pytest.mark.parametrize("threshold", [15, 30, 45]) +@pytest.mark.parametrize( + "box, center, minimum_area", + [((697, 891, 736, 933), (19, 21), 15), ((752, 868, 786, 899), (17, 15), 5)], + ids=["a", "e"], +) +def test_laundry_care_counters_remain_open( + bubbles_pixels: np.ndarray, + threshold: int, + box: tuple[int, int, int, int], + center: tuple[int, int], + minimum_area: int, +) -> None: + """Retain both enclosed light counters, including the smaller 'e'.""" + x0, y0, x1, y1 = box + roi = bubbles_pixels[y0:y1, x0:x1, :3].astype(np.int16) + pink = (roi[:, :, 0] - roi[:, :, 1] > threshold) & ( + roi[:, :, 0] - roi[:, :, 2] > threshold / 2 + ) + _, components, stats, centers = cv2.connectedComponentsWithStats( + (~pink).astype(np.uint8), + connectivity=8, + ) + border = np.unique( + np.concatenate( + [ + components[0], + components[-1], + components[:, 0], + components[:, -1], + ], + ), + ) + holes = [ + index + for index in range(1, len(stats)) + if index not in border + and stats[index, cv2.CC_STAT_AREA] >= minimum_area + and np.linalg.norm(centers[index] - center) <= 6 + ] + assert holes, f"The Laundry Care counter at {box} was filled" + + +@pytest.mark.parametrize( + "count, expected", + [(None, 6), (0, 6), (1, 2), (8, 8), (100, 64)], +) +def test_public_limits_remain_unchanged(count: int | None, expected: int) -> None: + """Keep caller color counts/defaults and the existing pixel-area cap.""" + solver = ColorSolver(Image.new("RGB", (1200, 1200)), count, Timer()) + assert solver.color_count == expected + assert solver.img.size == (1024, 1024) + + +def test_canvas_padding_is_clipped_from_returned_paths() -> None: + """Fill canvas corners without exposing padding in exported geometry or bounds.""" + path = trace_bitmap(np.ones((12, 20), dtype=np.uint8)) + assert compound_paths_bounds([path]) == { + "left": 0, + "top": 0, + "right": 20, + "bottom": 12, + "width": 20, + "height": 12, + } + png = svg2png(bytestring=generate_SVG_markup([path], [[0, 0, 0]], 20, 12)) + np.testing.assert_array_equal( + np.asarray(Image.open(BytesIO(png)).convert("RGBA"))[:, :, 3], + 255, + ) + + +def test_clipped_logo_preserves_opaque_canvas() -> None: + """Keep coverage on a real clipped logo, including the canvas boundary.""" + with Image.open(IMAGES / "geo_logo.png") as image: + result = ColorSolver(image, 8, Timer()).solve() + markup = generate_SVG_markup(*result) + png = svg2png(bytestring=markup) + pixels = np.asarray(Image.open(BytesIO(png)).convert("RGBA")) + np.testing.assert_array_equal(pixels[:, :, 3], 255) + + +def test_fully_transparent_image_has_no_foreground() -> None: + """Area cleanup must not invent opaque pixels in an entirely transparent input.""" + pixels = np.zeros((8, 8, 4), dtype=np.uint8) + labels, palette, background = quantize(pixels, 16) + assert background + np.testing.assert_array_equal(labels, 0) + assert palette.dtype == np.uint8 + result = ColorSolver(Image.fromarray(pixels), 16, Timer()).solve() + assert result[0] == [] diff --git a/vectorizing/tests/test_integration.py b/vectorizing/tests/test_integration.py index 8cb8231..3f74b3f 100644 --- a/vectorizing/tests/test_integration.py +++ b/vectorizing/tests/test_integration.py @@ -107,19 +107,24 @@ def test_color_solver_preserves_empty_transparent_image() -> None: assert rendered.getchannel("A").getextrema() == (0, 0) -def test_uploads_markup_to_s3(client: FlaskClient) -> None: +@pytest.mark.parametrize("solver", [0, 1]) +def test_uploads_markup_to_s3(client: FlaskClient, solver: int) -> None: """Store the generated SVG with its media type and return the object's key.""" response = client.post( "/", json={ "url": get_image_url("black_rectangle.png"), - "solver": 0, + "solver": solver, "raw": False, }, ) assert response.status_code == 200 payload = response.get_json() + info = payload["info"] + bounds = info["bounds"] + assert 0 <= bounds["left"] <= bounds["right"] <= info["image_width"] + assert 0 <= bounds["top"] <= bounds["bottom"] <= info["image_height"] test_bucket = os.environ["S3_TEST_BUCKET"] s3 = get_s3_client() try: diff --git a/vectorizing/tests/test_svg_geometry.py b/vectorizing/tests/test_svg_geometry.py new file mode 100644 index 0000000..c00a4ae --- /dev/null +++ b/vectorizing/tests/test_svg_geometry.py @@ -0,0 +1,164 @@ +"""Decode compact paths independently to verify exact two-decimal geometry.""" + +import re +from decimal import Decimal +from io import BytesIO + +import numpy as np +import pytest +from cairosvg import svg2png +from defusedxml import ElementTree as ET +from pathops import Path +from PIL import Image + +from vectorizing.svg.markup import generate_SVG_markup + +SVG = "{http://www.w3.org/2000/svg}" + + +def decode_path(data: str) -> list[tuple[str, tuple[int, ...]]]: + """Expand SVG's integer path grammar to absolute M/L/Q/C/Z commands.""" + tokens = re.findall(r"[A-Za-z]|-?\d+", data) + result = [] + cursor = np.array([0, 0], dtype=np.int64) + origin = cursor.copy() + control = None + command = "" + i = 0 + while i < len(tokens): + if tokens[i].isalpha(): + command = tokens[i] + i += 1 + kind = command.upper() + if kind == "Z": + result.append(("Z", ())) + cursor = origin.copy() + control = None + command = "" + continue + count = {"M": 2, "L": 2, "H": 1, "V": 1, "Q": 4, "C": 6, "S": 4}[kind] + values = np.array(tokens[i : i + count], dtype=np.int64) + i += count + if kind in ("H", "V"): + axis = int(kind == "V") + point = cursor.copy() + point[axis] = values[0] + (cursor[axis] if command.islower() else 0) + points = point.reshape(1, 2) + kind = "L" + else: + points = values.reshape(-1, 2) + if command.islower(): + points = points + cursor + if kind == "S": + reflected = cursor if control is None else 2 * cursor - control + points = np.vstack([reflected, points]) + kind = "C" + result.append((kind, tuple(int(n) for n in points.ravel()))) + cursor = points[-1].copy() + control = points[-2].copy() if kind == "C" else None + if kind == "M": + origin = cursor.copy() + command = "l" if command.islower() else "L" + return result + + +def expected_commands(path: Path) -> list[tuple[str, tuple[int, ...]]]: + """Use decimal arithmetic on the original segments, not encoder helpers.""" + return [ + ( + { + "moveTo": "M", + "lineTo": "L", + "qCurveTo": "Q", + "curveTo": "C", + "closePath": "Z", + }[kind], + tuple(int(Decimal(f"{n:.2f}") * 100) for point in points for n in point), + ) + for kind, points in path.segments + ] + + +@pytest.mark.parametrize("seed", range(30)) +def test_random_compound_paths_round_trip(seed: int) -> None: + """Preserve every point and closed subpath through mixed relative commands.""" + rng = np.random.default_rng(seed) + path = Path() + for _ in range(5): + path.moveTo(*rng.uniform(-2000, 2000, 2)) + for index in range(20): + if index % 3 == 1: + path.cubicTo(*rng.uniform(-2000, 2000, 6)) + elif index % 3 == 2: + path.quadTo(*rng.uniform(-2000, 2000, 4)) + else: + path.lineTo(*rng.uniform(-2000, 2000, 2)) + path.close() + root = ET.fromstring(generate_SVG_markup([path], [[12, 34, 56]], 1024, 900)) + assert root.attrib == {"width": "1024", "height": "900", "viewBox": "0 0 1024 900"} + assert root.find(f"{SVG}g").get("transform") == "scale(.01)" + assert decode_path(root.find(f".//{SVG}path").get("d")) == expected_commands(path) + + +def test_axes_reflections_and_close_reset() -> None: + """Cover H/V/S, implicit repeats and relative moves after a closed subpath.""" + path = Path() + path.moveTo(100, 100) + path.lineTo(101, 100) + path.lineTo(101, 102) + path.cubicTo(101, 103, 102, 104, 103, 104) + path.cubicTo(104, 104, 105, 103, 105, 102) + path.cubicTo(105, 101, 104, 100, 103, 100) + path.close() + path.moveTo(101, 101) + path.lineTo(101.25, 101.25) + path.lineTo(102.25, 102.25) + path.close() + markup = generate_SVG_markup([path], [[0, 0, 0]], 200, 200) + data = ET.fromstring(markup).find(f".//{SVG}path").get("d") + assert all(command in data.lower() for command in "hvsmz") + assert decode_path(data) == expected_commands(path) + + +def test_rounding_near_half_ties_and_negative_zero() -> None: + """Retain legacy decimal rounding instead of rounding multiplied binary floats.""" + path = Path() + path.moveTo(-0.0, 2.675) + path.cubicTo(-2.675, 1.005, 9.995, -0.005, 1000.125, 1.235) + path.close() + markup = generate_SVG_markup([path], [[255, 0, 0]], 1200, 1200) + data = ET.fromstring(markup).find(f".//{SVG}path").get("d") + assert decode_path(data) == expected_commands(path) + assert 'fill="#f00"' in markup + + +@pytest.mark.parametrize("size", [40, 101, 160]) +def test_compound_hole_rendering(size: int) -> None: + """Keep holes, transparent exterior and half opacity after transformed encoding.""" + path = Path() + path.moveTo(4, 4) + path.lineTo(36, 4) + path.lineTo(36, 36) + path.lineTo(4, 36) + path.close() + # Opposite winding carves a hole in the same compound path. + path.moveTo(12, 12) + path.lineTo(12, 28) + path.lineTo(28, 28) + path.lineTo(28, 12) + path.close() + svg = generate_SVG_markup([path], [[20, 30, 40, 0.5]], 40, 40) + pixels = np.asarray( + Image.open( + BytesIO( + svg2png( + bytestring=svg, + output_width=size, + output_height=size, + ), + ), + ).convert("RGBA"), + ) + assert pixels[size // 2, size // 2, 3] == 0 + assert pixels[0, 0, 3] == 0 + assert pixels[size // 5, size // 5, 3] == 128 diff --git a/vectorizing/tests/test_svg_markup.py b/vectorizing/tests/test_svg_markup.py index 31e2e80..55c4624 100644 --- a/vectorizing/tests/test_svg_markup.py +++ b/vectorizing/tests/test_svg_markup.py @@ -1,4 +1,4 @@ -"""Freeze externally visible SVG formatting, including whitespace and alpha.""" +"""Freeze compact SVG spelling while preserving coordinate rounding and alpha.""" from collections.abc import Sequence @@ -42,7 +42,7 @@ def test_color_formatting(color: Sequence[float] | np.ndarray, expected: str) -> def test_svg_serialization_preserves_exact_markup() -> None: - """Keep paint order, empty-path filtering, cubic syntax and document whitespace.""" + """Keep paint order, empty-path filtering, viewport and fractional alpha.""" curve = Path() curve.moveTo(0, 0) curve.lineTo(10, 0) @@ -60,17 +60,16 @@ def test_svg_serialization_preserves_exact_markup() -> None: ) assert actual == ( '\n' - '\n\n' - '\n' - "\n" + 'viewBox="0 0 40 20">' + '' + '' + "" ) def test_empty_svg_serialization() -> None: - """Keep the empty document's dimensions and blank group unchanged.""" + """Keep the empty document's dimensions and omit all paint.""" assert generate_SVG_markup([], [], 8, 9) == ( '\n\n\n\n' + 'viewBox="0 0 8 9">' ) From 8d36a8177b0281dcfc229a34b026ea41025a98da Mon Sep 17 00:00:00 2001 From: Phil Rukin Date: Fri, 25 Sep 2026 11:42:47 +0200 Subject: [PATCH 2/2] fix: preserve quadratic paths and recover failed canvas clips Handle unassigned distance labels on long images and retain strict validated ARM64/x86 geometry checks with an exact mask checksum. --- README.md | 6 +- vectorizing/solvers/color/ColorSolver.py | 37 ++++++-- vectorizing/solvers/color/quantize.py | 12 +++ vectorizing/svg/markup.py | 16 ++-- vectorizing/tests/test_color_cleanup.py | 11 +++ vectorizing/tests/test_color_p3.py | 105 +++++++++++++++++++++-- vectorizing/tests/test_svg_geometry.py | 44 ++++++++-- 7 files changed, 207 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7f2028b..3b500ad 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,11 @@ color. Tracing retains small paths and uses a curve optimization tolerance of 0. The existing 1,048,576-pixel area cap, default of six colors and supported range of 2–64 colors are unchanged. Color paths touching the canvas are extended before tracing and clipped back to the image so corners remain -covered and returned bounds do not include the padding. +covered and returned bounds do not include the padding. If Skia rejects a canvas +clip, that layer is retraced without padding and a warning is logged. Recovery +constrains any outlying curve control points to the canvas. It keeps smooth editable +vectors instead of failing the request, but can adjust curves near the canvas and +round corners inward, reducing edge coverage on the affected layer. SVGs use compact absolute/relative commands on an integer hundredth-pixel grid, inside a `scale(.01)` group. This preserves the previous two-decimal coordinate diff --git a/vectorizing/solvers/color/ColorSolver.py b/vectorizing/solvers/color/ColorSolver.py index d90c2b1..a4382af 100644 --- a/vectorizing/solvers/color/ColorSolver.py +++ b/vectorizing/solvers/color/ColorSolver.py @@ -1,8 +1,10 @@ """Trace editable color layers with bounded overlap at shared edges.""" +import logging + import numpy as np import potrace -from pathops import Path, PathOp, op +from pathops import Path, PathOp, PathOpsError, op from PIL import Image from vectorizing.geometry.potrace import potrace_path_to_compound_path @@ -12,14 +14,20 @@ from vectorizing.util.limit_size import limit_size +def _trace_mask(bitmap: np.ndarray) -> Path: + """Trace a mask with the same detail settings on normal and recovery paths.""" + traced = potrace.Bitmap(bitmap).trace(turdsize=0, opttolerance=0.5, alphamax=1) + return potrace_path_to_compound_path(traced) + + def trace_bitmap(bitmap: np.ndarray) -> Path: """Trace detail without speck removal, extending then clipping canvas edges.""" height, width = bitmap.shape # Potrace rounds exposed mask corners. Extending edge labels moves that # rounding outside the viewport instead of leaving transparent canvas corners. - padded = np.pad(bitmap.astype(np.uint8), 2, mode="edge") - traced = potrace.Bitmap(padded).trace(turdsize=0, opttolerance=0.5, alphamax=1) - path = potrace_path_to_compound_path(traced).transform(1, 0, 0, 1, -2, -2) + mask = bitmap.astype(np.uint8) + padded = np.pad(mask, 2, mode="edge") + path = _trace_mask(padded).transform(1, 0, 0, 1, -2, -2) left, top, right, bottom = path.bounds if left < 0 or top < 0 or right > width or bottom > height: # Keep returned geometry/bounds inside the image, not just the SVG's @@ -30,7 +38,26 @@ def trace_bitmap(bitmap: np.ndarray) -> Path: canvas.lineTo(width, height) canvas.lineTo(0, height) canvas.close() - path = op(path, canvas, PathOp.INTERSECTION) + try: + path = op(path, canvas, PathOp.INTERSECTION) + except PathOpsError: + # Recovery keeps smooth, bounded vectors and does not repeat the + # failing boolean operation. Unpadded tracing can round canvas + # corners inward; only this exceptional layer loses edge coverage. + logging.getLogger(__name__).warning( + "Canvas clipping failed; retracing without padding", + exc_info=True, + ) + traced = _trace_mask(mask) + path = Path(fillType=traced.fillType) + pen = path.getPen() + # Even unpadded fitted curves can overshoot the canvas. Constrain + # their control hull on recovery, without another boolean operation. + for command, points in traced.segments: + bounded = tuple( + (min(width, max(0, x)), min(height, max(0, y))) for x, y in points + ) + getattr(pen, command)(*bounded) return path diff --git a/vectorizing/solvers/color/quantize.py b/vectorizing/solvers/color/quantize.py index b17c5e1..d38c1fb 100644 --- a/vectorizing/solvers/color/quantize.py +++ b/vectorizing/solvers/color/quantize.py @@ -4,6 +4,7 @@ import numpy as np from faiss import Kmeans from PIL import Image +from scipy.ndimage import distance_transform_edt from skimage.measure import label MIN_COMPONENT_AREA = 8 @@ -69,6 +70,17 @@ def clean_components( lookup[nearest[~holes]] = labels[~holes] cleaned = labels.copy() cleaned[holes] = lookup[nearest[holes]] + unassigned = holes & (nearest == 0) + if unassigned.any(): + # OpenCV can leave zero labels beyond its distance propagation limit + # on very wide/tall images. Zero is not a seed: use an exact fallback + # only there, preserving normal chamfer assignments and tie breaking. + indices = distance_transform_edt( + holes, + return_distances=False, + return_indices=True, + ) + cleaned[unassigned] = labels[tuple(indices[:, unassigned])] return cleaned diff --git a/vectorizing/svg/markup.py b/vectorizing/svg/markup.py index b08f6be..9dfb8a9 100644 --- a/vectorizing/svg/markup.py +++ b/vectorizing/svg/markup.py @@ -3,7 +3,7 @@ from collections.abc import Iterable, Sequence import numpy as np -from pathops import Path +from pathops import Path, PathVerb Point = tuple[int, int] Command = tuple[str, tuple[int, ...]] @@ -79,8 +79,10 @@ def _path_data(path: Path, width: int, height: int) -> str: current = start = (0, 0) control = None previous = "" - for kind, coordinates in path.segments: - if kind == "closePath": + # The segment-pen interface coalesces adjacent quadratics with implied + # endpoints. Raw verbs expose each control/end pair required by SVG Q/q. + for kind, coordinates in path: + if kind == PathVerb.CLOSE: pieces.append("z") current = start control = None @@ -88,10 +90,10 @@ def _path_data(path: Path, width: int, height: int) -> str: continue # Canvas clipping can reduce a cubic to a quadratic; retain those too. command = { - "moveTo": "M", - "lineTo": "L", - "curveTo": "C", - "qCurveTo": "Q", + PathVerb.MOVE: "M", + PathVerb.LINE: "L", + PathVerb.CUBIC: "C", + PathVerb.QUAD: "Q", }[kind] points = tuple(_point(point) for point in coordinates) variants = _variants(command, points, current, control) diff --git a/vectorizing/tests/test_color_cleanup.py b/vectorizing/tests/test_color_cleanup.py index 87ab1cd..6ad3452 100644 --- a/vectorizing/tests/test_color_cleanup.py +++ b/vectorizing/tests/test_color_cleanup.py @@ -94,6 +94,17 @@ def test_background_selection_on_narrow_images(shape: tuple[int, int]) -> None: ) +@pytest.mark.parametrize("shape", [(1, 40_000), (40_000, 1)]) +def test_distant_holes_receive_a_surviving_color(shape: tuple[int, int]) -> None: + """The label propagation limit must not invent palette-zero survivors.""" + labels = (np.arange(40_000) % 2).astype(np.uint16) + labels[-8:] = 2 + labels = labels.reshape(shape) + original = labels.copy() + np.testing.assert_array_equal(clean_components(labels, None), 2) + np.testing.assert_array_equal(labels, original) + + def test_small_island_can_refill_from_transparent_background() -> None: """Remove tiny opaque specks without painting over transparent pixels.""" labels = np.zeros((12, 12), dtype=np.uint16) diff --git a/vectorizing/tests/test_color_p3.py b/vectorizing/tests/test_color_p3.py index 00b6643..73e3dab 100644 --- a/vectorizing/tests/test_color_p3.py +++ b/vectorizing/tests/test_color_p3.py @@ -4,15 +4,19 @@ import json from io import BytesIO from pathlib import Path +from unittest.mock import Mock import cv2 import numpy as np import pytest from cairosvg import svg2png +from flask.testing import FlaskClient +from pathops import PathOpsError from PIL import Image from vectorizing.geometry.bounds import compound_paths_bounds from vectorizing.server.timer import Timer +from vectorizing.solvers.color.bitmaps import add_bitmap_rims from vectorizing.solvers.color.ColorSolver import ColorSolver, trace_bitmap from vectorizing.solvers.color.quantize import quantize from vectorizing.svg.markup import generate_SVG_markup @@ -21,17 +25,37 @@ IMAGES = Path(__file__).parent / "images" -def test_aftermath_matches_approved_p3_geometry() -> None: - """Match the independently captured approved P3 geometry, palette and size.""" +def test_aftermath_matches_approved_p3_geometry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep approved masks, validated native geometry variants, palette and size.""" + masks = [] + + def capture_rims(bitmaps: list[np.ndarray]) -> None: + add_bitmap_rims(bitmaps) + masks.extend(bitmap.astype(np.uint8).tobytes() for bitmap in bitmaps) + + monkeypatch.setattr( + "vectorizing.solvers.color.ColorSolver.add_bitmap_rims", + capture_rims, + ) with Image.open(IMAGES / "aftermath.png") as image: paths, colors, width, height = ColorSolver(image, 8, Timer()).solve() - # This digest was captured from the approved experimental SVG, before the - # production port, with absolute commands in integer hundredths of a pixel. + # Ordered masks are identical across ARM64 and x86_64. Native Potrace makes + # different curve-optimization decisions on those identical inputs. Both + # geometry captures were checked for coverage and composited render error; + # accept only those validated variants, not an arbitrary CI replacement. + assert hashlib.sha256(b"".join(masks)).hexdigest() == ( + "e70cbf1c9d62e8ba853f6fa547975aac8311d9c44ac71decf14e9a6b2bf502bf" + ) commands = [expected_commands(path) for path in paths] digest = hashlib.sha256( json.dumps(commands, separators=(",", ":")).encode(), ).hexdigest() - assert digest == "23d8c97dee23ea3f0b22d164d39f726f36ead7f2ce47048ca73326bf2731c7e8" + assert digest in { + "23d8c97dee23ea3f0b22d164d39f726f36ead7f2ce47048ca73326bf2731c7e8", # ARM64 + "1f52fe3d8589574cc561bf5ab0dff6a9ce410292d79b9e05dcf0a781f2d919f3", # x86_64 + } np.testing.assert_array_equal( np.asarray(colors)[:, :3], [ @@ -136,6 +160,61 @@ def test_canvas_padding_is_clipped_from_returned_paths() -> None: ) +@pytest.mark.parametrize("raw", [False, True]) +def test_clip_failure_retraces_bounded_vectors( + client: FlaskClient, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + raw: bool, +) -> None: + """An exceptional clip failure still yields smooth vectors, holes and bounds.""" + pixels = np.full((20, 32, 4), 255, dtype=np.uint8) + pixels[8:12, 14:18, 3] = 0 + monkeypatch.setattr( + "vectorizing.try_read_image_from_url", + lambda url: Image.fromarray(pixels), + ) + clip = Mock(side_effect=PathOpsError("injected failure")) + monkeypatch.setattr("vectorizing.solvers.color.ColorSolver.op", clip) + upload = Mock(return_value="fallback-svg") + monkeypatch.setattr("vectorizing.upload_markup", upload) + response = client.post("/", json={"url": "unused", "solver": 1, "raw": raw}) + assert response.status_code == 200 + clip.assert_called_once() + assert "retracing without padding" in caplog.text + if raw: + markup = response.data + upload.assert_not_called() + else: + upload.assert_called_once() + markup = upload.call_args.args[0] + bounds = response.get_json()["info"]["bounds"] + assert 0 <= bounds["left"] <= bounds["right"] <= 32 + assert 0 <= bounds["top"] <= bounds["bottom"] <= 20 + rendered = np.asarray( + Image.open(BytesIO(svg2png(bytestring=markup))).convert("RGBA"), + ) + assert rendered[10, 16, 3] == 0 + assert rendered[5, 5, 3] == 255 + + +def test_clip_recovery_constrains_curve_overshoot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unpadded curve fitting can still extend beyond the bitmap's control hull.""" + rng = np.random.default_rng(6) + height, width = rng.integers(1, 45, 2) + mask = (rng.random((height, width)) < rng.uniform(0.03, 0.98)).astype(np.uint8) + clip = Mock(side_effect=PathOpsError("injected failure")) + monkeypatch.setattr("vectorizing.solvers.color.ColorSolver.op", clip) + path = trace_bitmap(mask) + clip.assert_called_once() + for _, points in path: + for x, y in points: + assert 0 <= x <= width + assert 0 <= y <= height + + def test_clipped_logo_preserves_opaque_canvas() -> None: """Keep coverage on a real clipped logo, including the canvas boundary.""" with Image.open(IMAGES / "geo_logo.png") as image: @@ -146,6 +225,22 @@ def test_clipped_logo_preserves_opaque_canvas() -> None: np.testing.assert_array_equal(pixels[:, :, 3], 255) +def test_clipped_consecutive_quadratics_keep_opaque_coverage() -> None: + """Real clipping must not emit malformed Q commands that expose transparent gaps.""" + rng = np.random.default_rng(513) + height, width = rng.integers(1, 45, 2) + mask = rng.random((height, width)) < rng.uniform(0.03, 0.98) + pixels = np.full((height, width, 3), 255, dtype=np.uint8) + pixels[mask] = [255, 0, 0] + result = ColorSolver(Image.fromarray(pixels), 2, Timer()).solve() + rendered = np.asarray( + Image.open( + BytesIO(svg2png(bytestring=generate_SVG_markup(*result))), + ).convert("RGBA"), + ) + assert rendered[:, :, 3].min() >= 240 + + def test_fully_transparent_image_has_no_foreground() -> None: """Area cleanup must not invent opaque pixels in an entirely transparent input.""" pixels = np.zeros((8, 8, 4), dtype=np.uint8) diff --git a/vectorizing/tests/test_svg_geometry.py b/vectorizing/tests/test_svg_geometry.py index c00a4ae..c4f913d 100644 --- a/vectorizing/tests/test_svg_geometry.py +++ b/vectorizing/tests/test_svg_geometry.py @@ -64,18 +64,32 @@ def decode_path(data: str) -> list[tuple[str, tuple[int, ...]]]: def expected_commands(path: Path) -> list[tuple[str, tuple[int, ...]]]: """Use decimal arithmetic on the original segments, not encoder helpers.""" - return [ - ( - { + expanded = [] + for kind, points in path.segments: + if kind == "qCurveTo": + # Independently expand the segment-pen's implied on-curve midpoints; + # production uses raw PathVerb pairs instead of this interface. + for index, control in enumerate(points[:-1]): + endpoint = points[-1] + if index < len(points) - 2: + endpoint = tuple( + (a + b) / 2 for a, b in zip(control, points[index + 1]) + ) + expanded.append(("Q", (control, endpoint))) + else: + command = { "moveTo": "M", "lineTo": "L", - "qCurveTo": "Q", "curveTo": "C", "closePath": "Z", - }[kind], + }[kind] + expanded.append((command, points)) + return [ + ( + command, tuple(int(Decimal(f"{n:.2f}") * 100) for point in points for n in point), ) - for kind, points in path.segments + for command, points in expanded ] @@ -120,6 +134,24 @@ def test_axes_reflections_and_close_reset() -> None: assert decode_path(data) == expected_commands(path) +def test_consecutive_quadratics_expand_implied_endpoints() -> None: + """Skia's segment pen can combine two quadratics into three coordinate pairs.""" + path = Path() + path.moveTo(1, 1) + path.quadTo(2, 3, 4, 4) + path.quadTo(6, 5, 7, 7) + path.close() + assert len(list(path.segments)[1][1]) == 3 + markup = generate_SVG_markup([path], [[0, 0, 0]], 10, 10) + data = ET.fromstring(markup).find(f".//{SVG}path").get("d") + assert decode_path(data) == [ + ("M", (100, 100)), + ("Q", (200, 300, 400, 400)), + ("Q", (600, 500, 700, 700)), + ("Z", ()), + ] + + def test_rounding_near_half_ties_and_negative_zero() -> None: """Retain legacy decimal rounding instead of rounding multiplied binary floats.""" path = Path()