diff --git a/README.md b/README.md index 2d534bb..3b500ad 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,29 @@ 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. 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 +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 +168,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..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 +from pathops import Path, PathOp, PathOpsError, op from PIL import Image from vectorizing.geometry.potrace import potrace_path_to_compound_path @@ -12,6 +14,53 @@ 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. + 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 + # 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() + 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 + + class ColorSolver: """Resize and quantize an image, then trace its ordered color layers.""" @@ -40,13 +89,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..d38c1fb 100644 --- a/vectorizing/solvers/color/quantize.py +++ b/vectorizing/solvers/color/quantize.py @@ -1,92 +1,91 @@ -"""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 scipy.ndimage import distance_transform_edt 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, + 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]] + 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, ) - # 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 + cleaned[unassigned] = labels[tuple(indices[:, unassigned])] + 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 +98,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 +108,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 +134,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..9dfb8a9 100644 --- a/vectorizing/svg/markup.py +++ b/vectorizing/svg/markup.py @@ -1,9 +1,12 @@ -"""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 +from pathops import Path, PathVerb + +Point = tuple[int, int] +Command = tuple[str, tuple[int, ...]] def truncate(number: float) -> str: @@ -14,13 +17,99 @@ 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 = "" + # 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 + previous = "z" + continue + # Canvas clipping can reduce a cubic to a quadratic; retain those too. + command = { + 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) + 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 +118,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..6ad3452 100644 --- a/vectorizing/tests/test_color_cleanup.py +++ b/vectorizing/tests/test_color_cleanup.py @@ -1,224 +1,113 @@ -"""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), + ) + + +@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) + 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..73e3dab --- /dev/null +++ b/vectorizing/tests/test_color_p3.py @@ -0,0 +1,252 @@ +"""Acceptance checks for the approved detail-preserving color pipeline.""" + +import hashlib +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 +from vectorizing.tests.test_svg_geometry import expected_commands + +IMAGES = Path(__file__).parent / "images" + + +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() + # 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 in { + "23d8c97dee23ea3f0b22d164d39f726f36ead7f2ce47048ca73326bf2731c7e8", # ARM64 + "1f52fe3d8589574cc561bf5ab0dff6a9ce410292d79b9e05dcf0a781f2d919f3", # x86_64 + } + 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, + ) + + +@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: + 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_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) + 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..c4f913d --- /dev/null +++ b/vectorizing/tests/test_svg_geometry.py @@ -0,0 +1,196 @@ +"""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.""" + 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", + "curveTo": "C", + "closePath": "Z", + }[kind] + expanded.append((command, points)) + return [ + ( + command, + tuple(int(Decimal(f"{n:.2f}") * 100) for point in points for n in point), + ) + for command, points in expanded + ] + + +@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_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() + 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">' )