diff --git a/README.md b/README.md index b2e2bc0..2d534bb 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,9 @@ To run tests against the local Moto service, run: 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. + When running pytest outside Compose, configure the AWS variables for an S3-compatible service first. ### 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/solvers/color/quantize.py b/vectorizing/solvers/color/quantize.py index 5963217..109210a 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,50 @@ 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]) + + # 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( - 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[region][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/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 edd5e40..0000000 --- a/vectorizing/tests/test.py +++ /dev/null @@ -1,349 +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 numpy as np -import pytest -from cairosvg import svg2png -from flask.testing import FlaskClient -from PIL import Image - -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]]) - - -@pytest.mark.parametrize("optimization", ["centroids", "bitmaps"]) -@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, - ), - }[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..adc6576 --- /dev/null +++ b/vectorizing/tests/test_color_cleanup.py @@ -0,0 +1,224 @@ +"""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) + + +@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) 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"