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'"
+ 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 == (
'"
+ '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) == (
''
+ 'viewBox="0 0 8 9">'
)