From 7105edd3143886e723655cef50d10981d0815984 Mon Sep 17 00:00:00 2001 From: Phil Rukin Date: Thu, 17 Sep 2026 20:30:41 +0200 Subject: [PATCH 1/5] perf(color): make cluster cleanup linear and memory-bounded --- vectorizing/solvers/color/quantize.py | 116 +++++---------- vectorizing/tests/test.py | 194 +++++++++++++++++++++++++- 2 files changed, 225 insertions(+), 85 deletions(-) diff --git a/vectorizing/solvers/color/quantize.py b/vectorizing/solvers/color/quantize.py index 5963217..380e3a6 100644 --- a/vectorizing/solvers/color/quantize.py +++ b/vectorizing/solvers/color/quantize.py @@ -20,12 +20,6 @@ def bilateral_filter( return cv2.bilateralFilter(img_arr, d, s, s) -def dilate(matrix: np.ndarray) -> np.ndarray: - """Dilate labels with a two-by-two kernel and OpenCV's default anchor.""" - kernel = np.ones((2, 2)) - return cv2.dilate(matrix, kernel) - - 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( @@ -42,89 +36,43 @@ def enhance( labels: np.ndarray, colors: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: - """Remove components mostly covered by neighboring colors, then fill holes.""" - dims = img_arr.shape[:2] - - # 0 will be reserved for "unwritten" cells, or holes + """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 - - clusters = [ - # Even though we don't allow it, technically the max - # value in labels (before adding 1) is 255, so it's safer - # to convert to uint16 to avoid overflows - np.where(labels == idx + 1, idx + 1, 0).astype(np.uint16) - for idx, _ in enumerate(colors) - ] - - # For each cluster, store a matrix of its connected components - # skimage's default connectivity includes diagonal neighbors. - original_connected_components_list = [label(cluster) + 1 for cluster in clusters] - - # Bincounts for each connected components matrix - # Meaning bincount[i] = num_appearances(matrix, i) - # It's used later to determine what should be erased - connected_components_bincounts = [ - np.bincount(connected_components.flatten()) - for connected_components in original_connected_components_list - ] - - # Make a copy of the connected components - # These will be modified and compared with the originals later - connected_components_list = [ - np.array(item, copy=True) for item in original_connected_components_list - ] - - for x, cluster_x in enumerate(clusters): - dilated_cluster_x = dilate(cluster_x) - - for y, cluster_y in enumerate(clusters): - if x == y: - continue - - # The overlap between dilated_cluster_x and cluster_y, - # meaning, the part of cluster_y that dilated_cluster_x covers - overlap = np.logical_and(dilated_cluster_x, cluster_y) - - # Remove overlap from connected components - 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, # Force same length + 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]) + + cleaned = np.zeros(img_arr.shape[:2], dtype=np.uint16 if len(colors) else np.uint8) + for index in range(len(colors)): + cluster = labels == index + 1 + if not cluster.any(): + continue + # Keep full (8-neighbor) connectivity, including diagonal contacts. + components = label(cluster, connectivity=2) + original_counts = np.bincount(components.ravel()) + interior_counts = np.bincount( + components[~boundary], + minlength=len(original_counts), ) - 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, - ) - - # Update cluster - # Connected components that lost more than 0.9 of their original area - # are completely removed, others are kept - clusters[x] = np.where( - areas_ratio[original_connected_components_list[x]] <= 0.1, - 0, - clusters[x], + interior_counts.astype(np.float32), + original_counts.astype(np.float32), + out=np.ones(len(original_counts), dtype=np.float32), + where=original_counts != 0, ) + # Retain whole components, using the same float32, inclusive <=0.1 cutoff. + cleaned[cluster & (areas_ratio[components] > 0.1)] = index + 1 + # Release this map before allocating the next color's component map. + del components - # Update labels with new cluster data - labels = np.zeros(dims).astype(np.uint8) - for cluster in clusters: - labels = np.where(labels == 0, cluster, labels) - - # Fill holes - labels = fill_holes(labels) - return labels - 1, colors + return fill_holes(cleaned) - 1, colors # Try to get the cluster of pixels that represent a transparent background diff --git a/vectorizing/tests/test.py b/vectorizing/tests/test.py index edd5e40..ed0fe19 100644 --- a/vectorizing/tests/test.py +++ b/vectorizing/tests/test.py @@ -5,11 +5,13 @@ 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 PIL import Image +from skimage.measure import label from vectorizing.geometry.bounds import compound_paths_bounds from vectorizing.server.s3 import get_s3_client @@ -266,7 +268,193 @@ def test_bitmap_layering_preserves_overlap_and_transparency() -> None: np.testing.assert_array_equal(labels, [[0, 1, 3, 4]]) -@pytest.mark.parametrize("optimization", ["centroids", "bitmaps"]) +def legacy_enhance( + img_arr: np.ndarray, + labels: np.ndarray, + colors: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + """Freeze main's pairwise cleanup as an independent regression oracle.""" + 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) + ] + 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 + ] + 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], + ) + 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 + + +@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", + ], +) +@pytest.mark.parametrize("color_count", [1, 2, 6, 16, 64, 65]) +@pytest.mark.parametrize("seed", range(3)) +def test_cluster_cleanup_matches_original( + before_fill: bool, + variant: str, + color_count: int, + seed: int, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preserve component decisions and filled labels without mutating inputs.""" + 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.setflags(write=False) + colors.setflags(write=False) + pixels.setflags(write=False) + original_labels = labels.copy() + if before_fill: + 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) + 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] = np.iinfo(np.uint16).max + 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) + 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] = np.iinfo(np.uint16).max + 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] = np.iinfo(np.uint16).max + 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) + 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("optimization", ["centroids", "bitmaps", "cleanup"]) @pytest.mark.parametrize( "image_name, color_count", [ @@ -298,6 +486,10 @@ def test_color_optimizations_preserve_vectorization( "vectorizing.solvers.color.ColorSolver.create_bitmaps", legacy_create_bitmaps, ), + "cleanup": ( + "vectorizing.solvers.color.quantize.enhance", + legacy_enhance, + ), }[optimization] # Patch each lookup site and verify the legacy implementation was used. reference = Mock(wraps=legacy) From a1467dcda475f511d231152d0bc92b98f6f0e533 Mon Sep 17 00:00:00 2001 From: Phil Rukin Date: Thu, 17 Sep 2026 20:58:26 +0200 Subject: [PATCH 2/5] test(color): split regression suites and document invariants --- README.md | 25 +- docker-compose.yml | 2 +- pytest.ini | 2 +- vectorizing/tests/color_reference.py | 105 ++++ vectorizing/tests/test.py | 541 -------------------- vectorizing/tests/test_color_bitmaps.py | 85 +++ vectorizing/tests/test_color_centroids.py | 59 +++ vectorizing/tests/test_color_cleanup.py | 186 +++++++ vectorizing/tests/test_color_equivalence.py | 83 +++ vectorizing/tests/test_integration.py | 133 +++++ 10 files changed, 677 insertions(+), 544 deletions(-) create mode 100644 vectorizing/tests/color_reference.py delete mode 100644 vectorizing/tests/test.py create mode 100644 vectorizing/tests/test_color_bitmaps.py create mode 100644 vectorizing/tests/test_color_centroids.py create mode 100644 vectorizing/tests/test_color_cleanup.py create mode 100644 vectorizing/tests/test_color_equivalence.py create mode 100644 vectorizing/tests/test_integration.py diff --git a/README.md b/README.md index b2e2bc0..9c1104a 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,30 @@ To run tests against the local Moto service, run: docker compose --profile testing run --build --rm test ``` -When running pytest outside Compose, configure the AWS variables for an S3-compatible service first. +Compose and CI use `python -m pytest`, which discovers all test modules under +`vectorizing/tests` via `pytest.ini`. When running outside Compose, configure the +AWS variables for an S3-compatible service first. + +### Test layout + +- `test_integration.py`: HTTP/S3, persisted raster baselines, and rendered alpha/seam checks. +- `test_color_centroids.py`: used-palette colors, ordering, and dtype. +- `test_color_bitmaps.py`: cumulative masks, palette gaps, and independent storage. +- `test_color_cleanup.py`: component removal, morphology, dtype, and map lifetime. +- `test_color_equivalence.py`: exact SVG, palette, dimensions, and bounds comparisons. +- `color_reference.py`: deliberately frozen, slow pre-optimization algorithms. + +Keep reference algorithms independent of optimized production helpers. Raster tests +seed missing baselines, so a fresh baseline is not proof of unchanged output; the +exact-equivalence tests provide that check. Add new Python tests as `test_*.py` +modules so the standard command includes them automatically. + +To run just the cleanup tests in Docker: + +```sh +docker compose --profile testing run --build --rm test \ + python -m pytest vectorizing/tests/test_color_cleanup.py +``` ### Required setup diff --git a/docker-compose.yml b/docker-compose.yml index 9389ea5..c0a2cee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,4 +57,4 @@ services: depends_on: moto-init: condition: service_completed_successfully - command: python -m pytest vectorizing/tests + command: python -m pytest diff --git a/pytest.ini b/pytest.ini index 7a166d7..ab009e8 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ [pytest] pythonpath = . -python_files = test.py test_*.py *_test.py \ No newline at end of file +testpaths = vectorizing/tests diff --git a/vectorizing/tests/color_reference.py b/vectorizing/tests/color_reference.py new file mode 100644 index 0000000..c67b9e8 --- /dev/null +++ b/vectorizing/tests/color_reference.py @@ -0,0 +1,105 @@ +"""Frozen pre-optimization algorithms used as independent regression oracles. + +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. +""" + +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: + """Preserve the RGB-pixel centroid algorithm from before PR 71.""" + img = ( + Image.fromarray(img_arr) + .quantize(color_count, method=Image.Quantize.FASTOCTREE) + .convert("RGB") + ) + pixels = np.asarray(img) + return np.unique(pixels.reshape(-1, pixels.shape[-1]), axis=0).astype(np.uint8) + + +def legacy_create_bitmaps( + labels: np.ndarray, + colors: np.ndarray, + has_background: bool, +) -> tuple[list[np.ndarray], list[np.ndarray]]: + """Preserve the pairwise bitmap accumulation from before PR 73.""" + bitmaps = [ + np.where(labels == index, 1, 0).astype(np.uint32) + for index in range(len(colors)) + ] + if has_background: + bitmaps = bitmaps[1:] + colors = colors[1:] + zipped = list(zip(bitmaps, colors)) + zipped = [[bitmap, color] for bitmap, color in zipped if np.sum(bitmap) > 0] + bitmaps = [bitmap for bitmap, _ in zipped] + colors = [color for _, color in zipped] + for x in range(len(bitmaps)): + bitmap_x = bitmaps[x] + for y in range(x + 1, len(bitmaps)): + bitmap_x += bitmaps[y] + bitmaps[x] = bitmap_x + return bitmaps, colors + + +def legacy_enhance( + img_arr: np.ndarray, + 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], + ) + 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 diff --git a/vectorizing/tests/test.py b/vectorizing/tests/test.py deleted file mode 100644 index ed0fe19..0000000 --- a/vectorizing/tests/test.py +++ /dev/null @@ -1,541 +0,0 @@ -"""Check image rendering, storage and exact color-optimization equivalence.""" - -import os -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 PIL import Image -from skimage.measure import label - -from vectorizing.geometry.bounds import compound_paths_bounds -from vectorizing.server.s3 import get_s3_client -from vectorizing.server.timer import Timer -from vectorizing.solvers.color import quantize as color_quantize -from vectorizing.solvers.color.bitmaps import create_bitmaps -from vectorizing.solvers.color.ColorSolver import ColorSolver -from vectorizing.svg.markup import generate_SVG_markup -from vectorizing.tests import testutil -from vectorizing.tests.config import TESTS -from vectorizing.tests.testutil import ( - MAX_IMAGE_DIFFERENCE, - RESULTS_FOLDER_PATH, - compute_img_difference, - get_image_url, - get_markup, - write_img_difference, -) - - -def test(client: FlaskClient) -> None: - """Render the configured gallery and compare or seed its local PNG baselines.""" - results_path = Path(RESULTS_FOLDER_PATH) - - # Create results folder if non-existent - if not results_path.exists(): - results_path.mkdir() - - # Cache test results to allow all tests to run - diffs = [] - - for img_name, options_list in TESTS.items(): - for options in options_list: - id = options.get("id") - output_name = f"{id}.png" - - # Vectorized SVG markup - markup = get_markup(client, img_name, options) - - # Rasterized SVG - png = svg2png(bytestring=markup) - img = Image.open(BytesIO(png)).convert("RGB") - - # Store result if it doesn't exist yet - results_output_path = Path(RESULTS_FOLDER_PATH / output_name) - if not results_output_path.exists(): - img.save(results_output_path) - - else: - # Difference between result and expected result - difference = compute_img_difference( - img, - RESULTS_FOLDER_PATH / output_name, - ) - - # Write image difference to diff_output - if difference >= MAX_IMAGE_DIFFERENCE: - write_img_difference( - img, - RESULTS_FOLDER_PATH / output_name, - output_name, - ) - - diffs.append(difference) - - assert all([diff <= MAX_IMAGE_DIFFERENCE for diff in diffs]) - - -def test_write_img_difference_handles_rgba_baselines( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Normalize transparent baseline pixels before computing RGB differences.""" - baseline = tmp_path / "baseline.png" - output = tmp_path / "difference.png" - Image.new("RGBA", (2, 2), (0, 0, 0, 0)).save(baseline) - monkeypatch.setattr(testutil, "DIFF_OUTPUT_FOLDER_PATH", tmp_path) - - testutil.write_img_difference( - Image.new("RGB", (2, 2), (255, 255, 255)), - baseline, - output.name, - ) - - assert output.exists() - - -def test_color_solver_preserves_empty_transparent_image() -> None: - """Keep every rendered pixel transparent for an empty input image.""" - image = Image.open(Path(__file__).parent / "images" / "empty.png") - paths, colors, width, height = ColorSolver(image, 6, Timer()).solve() - markup = generate_SVG_markup(paths, colors, width, height) - rendered = Image.open(BytesIO(svg2png(bytestring=markup))).convert("RGBA") - - assert rendered.getchannel("A").getextrema() == (0, 0) - - -def legacy_initial_centroids(img_arr: np.ndarray, color_count: int) -> np.ndarray: - """Freeze the original RGB-pixel algorithm as an independent regression oracle.""" - img = ( - Image.fromarray(img_arr) - .quantize(color_count, method=Image.Quantize.FASTOCTREE) - .convert("RGB") - ) - pixels = np.asarray(img) - return np.unique(pixels.reshape(-1, pixels.shape[-1]), axis=0).astype(np.uint8) - - -@pytest.mark.parametrize( - "variant", - ["noise", "strided", "one-pixel", "solid", "sparse"], -) -@pytest.mark.parametrize("color_count", [2, 6, 16, 64]) -@pytest.mark.parametrize("seed", range(5)) -def test_initial_centroids_match_original( - color_count: int, - seed: int, - variant: str, -) -> None: - """Preserve exact dtype, values and ordering on random and sparse RGB inputs.""" - rng = np.random.default_rng(seed) - noise = rng.integers(0, 256, size=(63, 67, 3), dtype=np.uint8) - pixels = { - "noise": noise, - "strided": noise[::2, ::2], - "one-pixel": noise[:1, :1], - "solid": np.full_like(noise, (175, 80, 230)), # Ignore unused black entries. - "sparse": (noise // 128) * 128, - }[variant] - expected = legacy_initial_centroids(pixels, color_count) - actual = color_quantize.get_initial_centroids(pixels, color_count) - np.testing.assert_equal(actual.dtype, expected.dtype) - np.testing.assert_equal(actual.dtype, np.dtype(np.uint8)) - np.testing.assert_array_equal(actual, expected) - - -def test_initial_centroids_ignore_unused_and_duplicate_palette_entries( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Keep only used RGB colors, deduplicated and sorted rather than palette order.""" - indexed = Image.new("P", (3, 1)) - indexed.putdata([7, 3, 5]) - palette = np.zeros((256, 3), dtype=np.uint8) - palette[1] = (255, 0, 0) # Unused nonblack entry. - palette[3] = palette[7] = (90, 10, 20) # Duplicate used RGB entries. - palette[5] = (10, 20, 30) - indexed.putpalette(palette.ravel().tolist()) - with monkeypatch.context() as patch: - patch.setattr(Image.Image, "quantize", lambda *args, **kwargs: indexed) - pixels = np.zeros((1, 3, 3), dtype=np.uint8) - actual = color_quantize.get_initial_centroids(pixels, 64) - expected = legacy_initial_centroids(pixels, 64) - np.testing.assert_array_equal(actual, expected) - np.testing.assert_array_equal(actual, [[10, 20, 30], [90, 10, 20]]) - - -def legacy_create_bitmaps( - labels: np.ndarray, - colors: np.ndarray, - has_background: bool, -) -> tuple[list[np.ndarray], list[np.ndarray]]: - """Freeze the original pairwise accumulation as an independent regression oracle.""" - bitmaps = [ - np.where(labels == index, 1, 0).astype(np.uint32) - for index in range(len(colors)) - ] - if has_background: - bitmaps = bitmaps[1:] - colors = colors[1:] - zipped = list(zip(bitmaps, colors)) - zipped = [[bitmap, color] for bitmap, color in zipped if np.sum(bitmap) > 0] - bitmaps = [bitmap for bitmap, _ in zipped] - colors = [color for _, color in zipped] - for x in range(len(bitmaps)): - bitmap_x = bitmaps[x] - for y in range(x + 1, len(bitmaps)): - bitmap_x += bitmaps[y] - bitmaps[x] = bitmap_x - return bitmaps, colors - - -@pytest.mark.parametrize( - "variant", - ["noise", "strided", "one-row", "one-column", "solid", "background", "sparse"], -) -@pytest.mark.parametrize("color_count", [1, 2, 6, 16, 64, 65]) -@pytest.mark.parametrize("has_background", [False, True]) -@pytest.mark.parametrize("seed", range(3)) -def test_bitmap_layering_matches_original( - color_count: int, - has_background: bool, - seed: int, - variant: str, -) -> None: - """Preserve masks, dtype, color order and read-only inputs across label patterns.""" - rng = np.random.default_rng(seed) - noise = rng.integers(0, color_count, size=(19, 23), dtype=np.uint16) - colors = rng.integers(0, 256, size=(color_count, 4), dtype=np.uint8) - colors.setflags(write=False) - labels = { - "noise": noise, - "strided": noise[::2, ::2], - "one-row": noise[:1, :], - "one-column": noise[:, :1], - "solid": np.full_like(noise, color_count - 1), - "background": np.zeros_like(noise), - "sparse": noise % 2, - }[variant] - labels.setflags(write=False) - expected_bitmaps, expected_colors = legacy_create_bitmaps( - labels, - colors, - has_background, - ) - actual_bitmaps, actual_colors = create_bitmaps(labels, colors, has_background) - np.testing.assert_array_equal(actual_colors, expected_colors) - np.testing.assert_equal(len(actual_bitmaps), len(expected_bitmaps)) - for actual, expected in zip(actual_bitmaps, expected_bitmaps): - np.testing.assert_equal(actual.dtype, np.dtype(np.uint32)) - np.testing.assert_array_equal(actual, expected) - - -@pytest.mark.parametrize("has_background", [False, True]) -@pytest.mark.parametrize("color_count", [0, 1, 6]) -def test_bitmap_layering_handles_empty_inputs( - has_background: bool, - color_count: int, -) -> None: - """Return no layers for empty labels or an empty palette.""" - colors = np.zeros((color_count, 3), dtype=np.uint8) - labels = np.empty((0, 3), dtype=np.uint8) - np.testing.assert_equal(create_bitmaps(labels, colors, has_background), ([], [])) - np.testing.assert_equal( - create_bitmaps(np.zeros((2, 2), dtype=np.uint8), colors[:0], has_background), - ([], []), - ) - - -def test_bitmap_layering_preserves_overlap_and_transparency() -> None: - """Keep suffix unions in palette order without including transparent pixels.""" - labels = np.array([[0, 1, 3, 4]], dtype=np.uint8) - colors = np.arange(20, dtype=np.uint8).reshape(5, 4) - # Label zero is transparent; label two is unused between visible layers. - bitmaps, visible_colors = create_bitmaps(labels, colors, True) - expected = np.array( - [[[0, 1, 1, 1]], [[0, 0, 1, 1]], [[0, 0, 0, 1]]], - dtype=np.uint32, - ) - np.testing.assert_array_equal(bitmaps, expected) - np.testing.assert_array_equal(visible_colors, colors[[1, 3, 4]]) - # The in-place accumulation must not make different layers share storage. - bitmaps[0][0, 0] = 1 - np.testing.assert_array_equal(bitmaps[1:], expected[1:]) - np.testing.assert_array_equal(labels, [[0, 1, 3, 4]]) - - -def legacy_enhance( - img_arr: np.ndarray, - labels: np.ndarray, - colors: np.ndarray, -) -> tuple[np.ndarray, np.ndarray]: - """Freeze main's pairwise cleanup as an independent regression oracle.""" - 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) - ] - 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 - ] - 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], - ) - 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 - - -@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", - ], -) -@pytest.mark.parametrize("color_count", [1, 2, 6, 16, 64, 65]) -@pytest.mark.parametrize("seed", range(3)) -def test_cluster_cleanup_matches_original( - before_fill: bool, - variant: str, - color_count: int, - seed: int, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Preserve component decisions and filled labels without mutating inputs.""" - 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.setflags(write=False) - colors.setflags(write=False) - pixels.setflags(write=False) - original_labels = labels.copy() - if before_fill: - 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) - 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] = np.iinfo(np.uint16).max - 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) - 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] = np.iinfo(np.uint16).max - 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] = np.iinfo(np.uint16).max - 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) - 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("optimization", ["centroids", "bitmaps", "cleanup"]) -@pytest.mark.parametrize( - "image_name, color_count", - [ - ("bubbles.png", 5), - ("shapes_2.png", 7), - ("aftermath.png", 2), - ("aftermath.png", 6), - ("aftermath.png", 16), - ("aftermath.png", 64), - ("empty.png", 6), - ("1px.jpg", 9), - ], -) -def test_color_optimizations_preserve_vectorization( - image_name: str, - color_count: int, - optimization: str, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Compare final SVG, colors, dimensions and bounds against the old algorithm.""" - with Image.open(Path(__file__).parent / "images" / image_name) as image: - actual = ColorSolver(image, color_count, Timer()).solve() - target, legacy = { - "centroids": ( - "vectorizing.solvers.color.quantize.get_initial_centroids", - legacy_initial_centroids, - ), - "bitmaps": ( - "vectorizing.solvers.color.ColorSolver.create_bitmaps", - legacy_create_bitmaps, - ), - "cleanup": ( - "vectorizing.solvers.color.quantize.enhance", - legacy_enhance, - ), - }[optimization] - # Patch each lookup site and verify the legacy implementation was used. - reference = Mock(wraps=legacy) - monkeypatch.setattr(target, reference) - expected = ColorSolver(image, color_count, Timer()).solve() - reference.assert_called_once() - - actual_paths, actual_colors, actual_width, actual_height = actual - expected_paths, expected_colors, expected_width, expected_height = expected - np.testing.assert_array_equal(actual_colors, expected_colors) - np.testing.assert_equal( - (actual_width, actual_height), - (expected_width, expected_height), - ) - np.testing.assert_equal( - compound_paths_bounds(actual_paths), - compound_paths_bounds(expected_paths), - ) - # Byte-identical SVG is stricter than a raster tolerance and includes transparency. - np.testing.assert_equal( - generate_SVG_markup(*actual), - generate_SVG_markup(*expected), - ) - - -def test_uploads_markup_to_s3(client: FlaskClient) -> 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, - "raw": False, - }, - ) - - assert response.status_code == 200 - payload = response.get_json() - test_bucket = os.environ["S3_TEST_BUCKET"] - s3 = get_s3_client() - try: - stored = s3.get_object( - Bucket=test_bucket, - Key=payload["objectId"], - ) - assert stored["ContentType"] == "image/svg+xml" - assert stored["Body"].read().startswith(b" None: + """Preserve masks, dtype, color order and read-only inputs across label patterns.""" + rng = np.random.default_rng(seed) + noise = rng.integers(0, color_count, size=(19, 23), dtype=np.uint16) + colors = rng.integers(0, 256, size=(color_count, 4), dtype=np.uint8) + colors.setflags(write=False) + labels = { + "noise": noise, + "strided": noise[::2, ::2], + "one-row": noise[:1, :], + "one-column": noise[:, :1], + "solid": np.full_like(noise, color_count - 1), + "background": np.zeros_like(noise), + "sparse": noise % 2, + }[variant] + labels.setflags(write=False) + expected_bitmaps, expected_colors = legacy_create_bitmaps( + labels, + colors, + has_background, + ) + actual_bitmaps, actual_colors = create_bitmaps(labels, colors, has_background) + np.testing.assert_array_equal(actual_colors, expected_colors) + np.testing.assert_equal(len(actual_bitmaps), len(expected_bitmaps)) + for actual, expected in zip(actual_bitmaps, expected_bitmaps): + np.testing.assert_equal(actual.dtype, np.dtype(np.uint32)) + np.testing.assert_array_equal(actual, expected) + + +@pytest.mark.parametrize("has_background", [False, True]) +@pytest.mark.parametrize("color_count", [0, 1, 6]) +def test_bitmap_layering_handles_empty_inputs( + has_background: bool, + color_count: int, +) -> None: + """Return no layers for empty labels or an empty palette.""" + colors = np.zeros((color_count, 3), dtype=np.uint8) + labels = np.empty((0, 3), dtype=np.uint8) + np.testing.assert_equal(create_bitmaps(labels, colors, has_background), ([], [])) + np.testing.assert_equal( + create_bitmaps(np.zeros((2, 2), dtype=np.uint8), colors[:0], has_background), + ([], []), + ) + + +def test_bitmap_layering_preserves_overlap_and_transparency() -> None: + """Keep suffix unions in palette order without including transparent pixels.""" + labels = np.array([[0, 1, 3, 4]], dtype=np.uint8) + colors = np.arange(20, dtype=np.uint8).reshape(5, 4) + # Label zero is transparent; label two is unused between visible layers. + bitmaps, visible_colors = create_bitmaps(labels, colors, True) + expected = np.array( + [[[0, 1, 1, 1]], [[0, 0, 1, 1]], [[0, 0, 0, 1]]], + dtype=np.uint32, + ) + np.testing.assert_array_equal(bitmaps, expected) + np.testing.assert_array_equal(visible_colors, colors[[1, 3, 4]]) + # The in-place accumulation must not make different layers share storage. + bitmaps[0][0, 0] = 1 + np.testing.assert_array_equal(bitmaps[1:], expected[1:]) + np.testing.assert_array_equal(labels, [[0, 1, 3, 4]]) diff --git a/vectorizing/tests/test_color_centroids.py b/vectorizing/tests/test_color_centroids.py new file mode 100644 index 0000000..faa63b9 --- /dev/null +++ b/vectorizing/tests/test_color_centroids.py @@ -0,0 +1,59 @@ +"""Used-palette centroid regressions: exact values, dtype and ordering. + +Centroid ordering changes K-means results, so set equality is not sufficient. +""" + +import numpy as np +import pytest +from PIL import Image + +from vectorizing.solvers.color import quantize as color_quantize +from vectorizing.tests.color_reference import legacy_initial_centroids + + +@pytest.mark.parametrize( + "variant", + ["noise", "strided", "one-pixel", "solid", "sparse"], +) +@pytest.mark.parametrize("color_count", [2, 6, 16, 64]) +@pytest.mark.parametrize("seed", range(5)) +def test_initial_centroids_match_original( + color_count: int, + seed: int, + variant: str, +) -> None: + """Preserve exact dtype, values and ordering on random and sparse RGB inputs.""" + rng = np.random.default_rng(seed) + noise = rng.integers(0, 256, size=(63, 67, 3), dtype=np.uint8) + pixels = { + "noise": noise, + "strided": noise[::2, ::2], + "one-pixel": noise[:1, :1], + "solid": np.full_like(noise, (175, 80, 230)), # Ignore unused black entries. + "sparse": (noise // 128) * 128, + }[variant] + expected = legacy_initial_centroids(pixels, color_count) + actual = color_quantize.get_initial_centroids(pixels, color_count) + np.testing.assert_equal(actual.dtype, expected.dtype) + np.testing.assert_equal(actual.dtype, np.dtype(np.uint8)) + np.testing.assert_array_equal(actual, expected) + + +def test_initial_centroids_ignore_unused_and_duplicate_palette_entries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep only used RGB colors, deduplicated and sorted rather than palette order.""" + indexed = Image.new("P", (3, 1)) + indexed.putdata([7, 3, 5]) + palette = np.zeros((256, 3), dtype=np.uint8) + palette[1] = (255, 0, 0) # Unused nonblack entry. + palette[3] = palette[7] = (90, 10, 20) # Duplicate used RGB entries. + palette[5] = (10, 20, 30) + indexed.putpalette(palette.ravel().tolist()) + with monkeypatch.context() as patch: + patch.setattr(Image.Image, "quantize", lambda *args, **kwargs: indexed) + pixels = np.zeros((1, 3, 3), dtype=np.uint8) + actual = color_quantize.get_initial_centroids(pixels, 64) + expected = legacy_initial_centroids(pixels, 64) + np.testing.assert_array_equal(actual, expected) + np.testing.assert_array_equal(actual, [[10, 20, 30], [90, 10, 20]]) diff --git a/vectorizing/tests/test_color_cleanup.py b/vectorizing/tests/test_color_cleanup.py new file mode 100644 index 0000000..dde36d6 --- /dev/null +++ b/vectorizing/tests/test_color_cleanup.py @@ -0,0 +1,186 @@ +"""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 + +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]) +@pytest.mark.parametrize("seed", range(3)) +def test_cluster_cleanup_matches_original( + before_fill: bool, + variant: str, + color_count: int, + seed: int, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preserve component decisions and filled labels without mutating inputs.""" + 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.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) + + +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) diff --git a/vectorizing/tests/test_color_equivalence.py b/vectorizing/tests/test_color_equivalence.py new file mode 100644 index 0000000..2be8255 --- /dev/null +++ b/vectorizing/tests/test_color_equivalence.py @@ -0,0 +1,83 @@ +"""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. +""" + +from pathlib import Path +from unittest.mock import Mock + +import numpy as np +import pytest +from PIL import Image + +from vectorizing.geometry.bounds import compound_paths_bounds +from vectorizing.server.timer import Timer +from vectorizing.solvers.color.ColorSolver import ColorSolver +from vectorizing.svg.markup import generate_SVG_markup +from vectorizing.tests.color_reference import ( + legacy_create_bitmaps, + legacy_enhance, + legacy_initial_centroids, +) + + +@pytest.mark.parametrize("optimization", ["centroids", "bitmaps", "cleanup"]) +@pytest.mark.parametrize( + "image_name, color_count", + [ + ("bubbles.png", 5), + ("shapes_2.png", 7), + ("aftermath.png", 2), + ("aftermath.png", 6), + ("aftermath.png", 16), + ("aftermath.png", 64), + ("empty.png", 6), + ("1px.jpg", 9), + ], +) +def test_color_optimizations_preserve_vectorization( + image_name: str, + color_count: int, + optimization: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Compare final SVG, colors, dimensions and bounds against the old algorithm.""" + with Image.open(Path(__file__).parent / "images" / image_name) as image: + actual = ColorSolver(image, color_count, Timer()).solve() + target, legacy = { + "centroids": ( + "vectorizing.solvers.color.quantize.get_initial_centroids", + legacy_initial_centroids, + ), + "bitmaps": ( + "vectorizing.solvers.color.ColorSolver.create_bitmaps", + legacy_create_bitmaps, + ), + "cleanup": ( + "vectorizing.solvers.color.quantize.enhance", + legacy_enhance, + ), + }[optimization] + # Patch each lookup site and verify the legacy implementation was used. + reference = Mock(wraps=legacy) + monkeypatch.setattr(target, reference) + expected = ColorSolver(image, color_count, Timer()).solve() + reference.assert_called_once() + + actual_paths, actual_colors, actual_width, actual_height = actual + expected_paths, expected_colors, expected_width, expected_height = expected + np.testing.assert_array_equal(actual_colors, expected_colors) + np.testing.assert_equal( + (actual_width, actual_height), + (expected_width, expected_height), + ) + np.testing.assert_equal( + compound_paths_bounds(actual_paths), + compound_paths_bounds(expected_paths), + ) + # Byte-identical SVG is stricter than a raster tolerance and includes transparency. + np.testing.assert_equal( + generate_SVG_markup(*actual), + generate_SVG_markup(*expected), + ) diff --git a/vectorizing/tests/test_integration.py b/vectorizing/tests/test_integration.py new file mode 100644 index 0000000..8cb8231 --- /dev/null +++ b/vectorizing/tests/test_integration.py @@ -0,0 +1,133 @@ +"""HTTP/S3 integration, raster diagnostics and transparent SVG checks. + +The persisted raster gallery is a tolerant smoke test and creates missing +baselines. Exact before/after output checks live in test_color_equivalence.py. +Layer editability and anti-aliased edges are covered by the color layer suites. +""" + +import os +from io import BytesIO +from pathlib import Path + +import pytest +from cairosvg import svg2png +from flask.testing import FlaskClient +from PIL import Image + +from vectorizing.server.s3 import get_s3_client +from vectorizing.server.timer import Timer +from vectorizing.solvers.color.ColorSolver import ColorSolver +from vectorizing.svg.markup import generate_SVG_markup +from vectorizing.tests import testutil +from vectorizing.tests.config import TESTS +from vectorizing.tests.testutil import ( + MAX_IMAGE_DIFFERENCE, + RESULTS_FOLDER_PATH, + compute_img_difference, + get_image_url, + get_markup, + write_img_difference, +) + + +def test(client: FlaskClient) -> None: + """Render the configured gallery and compare or seed its local PNG baselines.""" + results_path = Path(RESULTS_FOLDER_PATH) + + # Create results folder if non-existent + if not results_path.exists(): + results_path.mkdir() + + # Cache test results to allow all tests to run + diffs = [] + + for img_name, options_list in TESTS.items(): + for options in options_list: + id = options.get("id") + output_name = f"{id}.png" + + # Vectorized SVG markup + markup = get_markup(client, img_name, options) + + # Rasterized SVG + png = svg2png(bytestring=markup) + img = Image.open(BytesIO(png)).convert("RGB") + + # Store result if it doesn't exist yet + results_output_path = Path(RESULTS_FOLDER_PATH / output_name) + if not results_output_path.exists(): + img.save(results_output_path) + + else: + # Difference between result and expected result + difference = compute_img_difference( + img, + RESULTS_FOLDER_PATH / output_name, + ) + + # Write image difference to diff_output + if difference >= MAX_IMAGE_DIFFERENCE: + write_img_difference( + img, + RESULTS_FOLDER_PATH / output_name, + output_name, + ) + + diffs.append(difference) + + assert all([diff <= MAX_IMAGE_DIFFERENCE for diff in diffs]) + + +def test_write_img_difference_handles_rgba_baselines( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Normalize transparent baseline pixels before computing RGB differences.""" + baseline = tmp_path / "baseline.png" + output = tmp_path / "difference.png" + Image.new("RGBA", (2, 2), (0, 0, 0, 0)).save(baseline) + monkeypatch.setattr(testutil, "DIFF_OUTPUT_FOLDER_PATH", tmp_path) + + testutil.write_img_difference( + Image.new("RGB", (2, 2), (255, 255, 255)), + baseline, + output.name, + ) + + assert output.exists() + + +def test_color_solver_preserves_empty_transparent_image() -> None: + """Keep every rendered pixel transparent for an empty input image.""" + image = Image.open(Path(__file__).parent / "images" / "empty.png") + paths, colors, width, height = ColorSolver(image, 6, Timer()).solve() + markup = generate_SVG_markup(paths, colors, width, height) + rendered = Image.open(BytesIO(svg2png(bytestring=markup))).convert("RGBA") + + assert rendered.getchannel("A").getextrema() == (0, 0) + + +def test_uploads_markup_to_s3(client: FlaskClient) -> 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, + "raw": False, + }, + ) + + assert response.status_code == 200 + payload = response.get_json() + test_bucket = os.environ["S3_TEST_BUCKET"] + s3 = get_s3_client() + try: + stored = s3.get_object( + Bucket=test_bucket, + Key=payload["objectId"], + ) + assert stored["ContentType"] == "image/svg+xml" + assert stored["Body"].read().startswith(b" Date: Thu, 17 Sep 2026 21:06:52 +0200 Subject: [PATCH 3/5] perf(color): reuse the invariant interior mask --- vectorizing/solvers/color/quantize.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vectorizing/solvers/color/quantize.py b/vectorizing/solvers/color/quantize.py index 380e3a6..b4504d6 100644 --- a/vectorizing/solvers/color/quantize.py +++ b/vectorizing/solvers/color/quantize.py @@ -49,6 +49,8 @@ def enhance( 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 cleaned = np.zeros(img_arr.shape[:2], dtype=np.uint16 if len(colors) else np.uint8) for index in range(len(colors)): cluster = labels == index + 1 @@ -58,7 +60,7 @@ def enhance( components = label(cluster, connectivity=2) original_counts = np.bincount(components.ravel()) interior_counts = np.bincount( - components[~boundary], + components[interior], minlength=len(original_counts), ) areas_ratio = np.divide( From 0154c197351dcac148dcef4d33cc90dc061e4b3b Mon Sep 17 00:00:00 2001 From: Phil Rukin Date: Thu, 17 Sep 2026 21:19:00 +0200 Subject: [PATCH 4/5] docs: shorten the test setup notes --- README.md | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9c1104a..2d534bb 100644 --- a/README.md +++ b/README.md @@ -144,30 +144,10 @@ To run tests against the local Moto service, run: docker compose --profile testing run --build --rm test ``` -Compose and CI use `python -m pytest`, which discovers all test modules under -`vectorizing/tests` via `pytest.ini`. When running outside Compose, configure the -AWS variables for an S3-compatible service first. +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. -### Test layout - -- `test_integration.py`: HTTP/S3, persisted raster baselines, and rendered alpha/seam checks. -- `test_color_centroids.py`: used-palette colors, ordering, and dtype. -- `test_color_bitmaps.py`: cumulative masks, palette gaps, and independent storage. -- `test_color_cleanup.py`: component removal, morphology, dtype, and map lifetime. -- `test_color_equivalence.py`: exact SVG, palette, dimensions, and bounds comparisons. -- `color_reference.py`: deliberately frozen, slow pre-optimization algorithms. - -Keep reference algorithms independent of optimized production helpers. Raster tests -seed missing baselines, so a fresh baseline is not proof of unchanged output; the -exact-equivalence tests provide that check. Add new Python tests as `test_*.py` -modules so the standard command includes them automatically. - -To run just the cleanup tests in Docker: - -```sh -docker compose --profile testing run --build --rm test \ - python -m pytest vectorizing/tests/test_color_cleanup.py -``` +When running pytest outside Compose, configure the AWS variables for an S3-compatible service first. ### Required setup From addd3343f648e9c2f4383be2cfc24bbe60652e59 Mon Sep 17 00:00:00 2001 From: Phil Rukin Date: Tue, 22 Sep 2026 16:14:21 +0200 Subject: [PATCH 5/5] perf(color): bound component labeling to each color region Release unused boundary masks and crop labeling, counts and writes to each color's bounding box. Keep neighbor influence global so cropping preserves cleanup decisions and editable holes. Add sparse-region regressions covering disconnected islands, borders, unused colors and transposed inputs. Bounding-box overhead can outweigh savings for colors spanning nearly the full image. --- vectorizing/solvers/color/quantize.py | 15 ++++++---- vectorizing/tests/test_color_cleanup.py | 38 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/vectorizing/solvers/color/quantize.py b/vectorizing/solvers/color/quantize.py index b4504d6..109210a 100644 --- a/vectorizing/solvers/color/quantize.py +++ b/vectorizing/solvers/color/quantize.py @@ -51,16 +51,21 @@ def enhance( # 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) - for index in range(len(colors)): - cluster = labels == index + 1 - if not cluster.any(): + # 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( - components[interior], + # Slice the global mask: other colors outside the box still influence it. + components[interior[region]], minlength=len(original_counts), ) areas_ratio = np.divide( @@ -70,7 +75,7 @@ def enhance( where=original_counts != 0, ) # Retain whole components, using the same float32, inclusive <=0.1 cutoff. - cleaned[cluster & (areas_ratio[components] > 0.1)] = index + 1 + cleaned[region][cluster & (areas_ratio[components] > 0.1)] = index + 1 # Release this map before allocating the next color's component map. del components diff --git a/vectorizing/tests/test_color_cleanup.py b/vectorizing/tests/test_color_cleanup.py index dde36d6..adc6576 100644 --- a/vectorizing/tests/test_color_cleanup.py +++ b/vectorizing/tests/test_color_cleanup.py @@ -161,6 +161,44 @@ def test_cluster_cleanup_preserves_unassigned_labels_and_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: