diff --git a/README.md b/README.md index d3f6754..9471bd1 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ A typical response would be Or, if `raw = true` was supplied, just plain SVG markup -For color requests, set `color_count` to `"auto"` to estimate 1–16 dominant colors from the image (ignoring background pixels and small blended edges). When any pixel has zero alpha, pixels below half opacity are treated as background, matching color quantization. For example, `midnight_strike.png`, `geo_logo.png` and `test_logo.png` each select three. Auto compares octree and median-cut palettes to retain both vivid accents and subtly patterned backgrounds. Auto replaces background RGB with a representative visible color before quantization. Numeric requests retry with the same safeguard only when a cluster contains background pixels but no visible pixels before component cleanup. Clusters lost solely to cleanup do not trigger a retry; other requests retain the original quantization. Numeric requests keep their requested count and existing initialization method; omitting `color_count` still defaults to six, with the existing 2–64 clamp. The count is a palette budget, not a guarantee of that many output colors: quantization and component cleanup can retain fewer. Automatic selection is a heuristic: very small accents may not contribute to the count. +For color requests, set `color_count` to `"auto"` to estimate 1–16 dominant colors from the image (ignoring background pixels and small blended edges). When any pixel has zero alpha, pixels below half opacity are treated as background, matching color quantization. For example, `midnight_strike.png`, `geo_logo.png` and `test_logo.png` each select three. Auto compares octree and median-cut palettes to retain both vivid accents and subtly patterned backgrounds. Auto replaces background RGB with a representative visible color before quantization. Numeric requests retry with the same safeguard only when a cluster contains background pixels but no visible pixels before component cleanup. Clusters lost solely to cleanup do not trigger a retry. When hidden RGB shares a visible cluster, numeric requests compensate its contribution to the color's mean RGB without reassigning pixels or changing layer masks. Numeric requests keep their requested count and existing initialization method; omitting `color_count` still defaults to six, with the existing 2–64 clamp. The count is a palette budget, not a guarantee of that many output colors: quantization and component cleanup can retain fewer. Automatic selection is a heuristic: very small accents may not contribute to the count. When auto selects one visible color, the binary solver traces it as black on transparent; an entirely transparent image remains empty. Auto requests use the color solver: omitting `solver` selects it, while explicitly sending `solver: 0` with `"auto"` is rejected rather than silently running a binary job. diff --git a/vectorizing/solvers/color/legacy.py b/vectorizing/solvers/color/legacy.py index eb1364d..a13d07d 100644 --- a/vectorizing/solvers/color/legacy.py +++ b/vectorizing/solvers/color/legacy.py @@ -11,6 +11,7 @@ from vectorizing.geometry.potrace import potrace_path_to_compound_path from vectorizing.solvers.color.quantize import ( _recover_background_slots, + _remove_background_color_bias, get_background_cluster, get_initial_centroids, kmeans, @@ -24,7 +25,7 @@ def quantize( auto_method: Image.Quantize | None = None, recover_background_slots: bool = False, ) -> tuple[np.ndarray, np.ndarray, bool]: - """Run original cleanup, optionally recovering background-only palette slots.""" + """Run original cleanup with optional numeric background color recovery.""" background = get_background_cluster(img_arr) if img_arr.shape[-1] == 4 else None for attempt in range(2): rgb = ( @@ -47,6 +48,8 @@ def quantize( break # Keep the original numeric seeding and defer cleanup until recovery ends. img_arr = recovered + if recover_background_slots and auto_method is None: + colors = _remove_background_color_bias(rgb, labels, colors, background) if background is not None: labels = np.where(background > 0, 0, labels + 1) colors = np.vstack( diff --git a/vectorizing/solvers/color/quantize.py b/vectorizing/solvers/color/quantize.py index beb7bc5..50e8422 100644 --- a/vectorizing/solvers/color/quantize.py +++ b/vectorizing/solvers/color/quantize.py @@ -120,6 +120,52 @@ def _recover_background_slots( return recovered +def _remove_background_color_bias( + rgb: np.ndarray, + labels: np.ndarray, + colors: np.ndarray, + background: np.ndarray | None, +) -> np.ndarray: + """Compensate shared centroids for hidden RGB without changing their labels.""" + if background is None: + return colors + visible = (background == 0).ravel() + if not visible.any(): + return colors + assigned = labels.ravel() + counts = np.bincount(assigned, minlength=len(colors)) + visible_labels = assigned[visible] + visible_counts = np.bincount(visible_labels, minlength=len(colors)) + shared = (visible_counts > 0) & (visible_counts < counts) + if not shared.any(): + return colors + + pixels = rgb.reshape(-1, 3) + visible_pixels = pixels[visible] + offset = np.empty((np.count_nonzero(shared), 3), dtype=np.float64) + for channel in range(3): + total = np.bincount( + assigned, + weights=pixels[:, channel], + minlength=len(colors), + )[shared] + seen = np.bincount( + visible_labels, + weights=visible_pixels[:, channel], + minlength=len(colors), + )[shared] + offset[:, channel] = seen / visible_counts[shared] - total / counts[shared] + # Keep FAISS's sampled centers and fixed assignments; remove only the mean + # contribution of masked pixels. Reassigning pixels would change layer masks. + corrected = colors.copy() + corrected[shared] = np.clip( + np.rint(colors[shared].astype(np.float64) + offset), + 0, + 255, + ).astype(colors.dtype) + return corrected + + def bilateral_filter(img_arr: np.ndarray) -> np.ndarray: """Gently smooth local color noise without the broad blur of a large kernel.""" return cv2.bilateralFilter(img_arr, 3, 12, 1) @@ -251,7 +297,7 @@ def quantize( auto_method: Image.Quantize | None = None, recover_background_slots: bool = False, ) -> tuple[np.ndarray, np.ndarray, bool]: - """Return cleaned labels/palette, optionally recovering background-only slots.""" + """Return cleaned labels/palette with optional numeric background recovery.""" background = get_background_cluster(img_arr) if img_arr.shape[-1] == 4 else None for attempt in range(2): rgb = bilateral_filter(img_arr[:, :, :3].copy()) @@ -274,6 +320,9 @@ def quantize( order = np.lexsort(colors.T[::-1]) labels = np.argsort(order)[labels].astype(np.uint16) colors = colors[order] + if recover_background_slots and auto_method is None: + # Correct fills after ordering so paint order and mask geometry stay fixed. + colors = _remove_background_color_bias(rgb, labels, colors, background) if background is not None: labels = write_background_cluster(labels, background) colors = np.vstack( diff --git a/vectorizing/tests/images/snail.png b/vectorizing/tests/images/snail.png new file mode 100644 index 0000000..55b2b8e Binary files /dev/null and b/vectorizing/tests/images/snail.png differ diff --git a/vectorizing/tests/test_color_numeric.py b/vectorizing/tests/test_color_numeric.py index 8f19caa..8d0cb9f 100644 --- a/vectorizing/tests/test_color_numeric.py +++ b/vectorizing/tests/test_color_numeric.py @@ -1,6 +1,7 @@ """Keep numeric palette slots available for visible colors on transparent images.""" from io import BytesIO +from pathlib import Path from unittest.mock import Mock import numpy as np @@ -11,7 +12,11 @@ from vectorizing.server.timer import Timer from vectorizing.solvers.color import legacy from vectorizing.solvers.color.ColorSolver import ColorSolver -from vectorizing.solvers.color.quantize import _recover_background_slots, quantize +from vectorizing.solvers.color.quantize import ( + _recover_background_slots, + _remove_background_color_bias, + quantize, +) from vectorizing.svg.markup import generate_original_SVG_markup, generate_SVG_markup @@ -51,6 +56,34 @@ def test_numeric_transparent_canvas_does_not_take_a_color_slot( np.testing.assert_array_equal(solver.img_arr, pixels) +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_snail_numeric_three_preserves_navy(configuration: str) -> None: + """Hidden black must not darken navy when all three palette slots survive.""" + with Image.open(Path(__file__).parent / "images" / "snail.png") as source: + image = source.copy() + pixels = np.asarray(image) + assert np.count_nonzero(pixels[:, :, 3] == 0) == 43_742 + np.testing.assert_array_equal(pixels[pixels[:, :, 3] == 0, :3], 0) + paths, colors, width, height = ColorSolver(image, 3, Timer(), configuration).solve() + assert len(paths) == len(colors) == 3 + navy = min(colors, key=lambda color: int(np.sum(color[:3]))) + # This opaque pixel is well inside the snail, away from blended edges. + expected = pixels[390, 180, :3] + np.testing.assert_allclose(navy[:3], expected, atol=4) + serialize = ( + generate_original_SVG_markup + if configuration == "current" + else generate_SVG_markup + ) + raster = Image.open( + BytesIO(svg2png(bytestring=serialize(paths, colors, width, height).encode())), + ).convert("RGBA") + np.testing.assert_allclose(raster.getpixel((180, 390))[:3], expected, atol=4) + assert raster.getpixel((180, 390))[3] == 255 + assert raster.getpixel((0, 0))[3] == 0 + np.testing.assert_array_equal(np.asarray(image), pixels) + + @pytest.mark.parametrize("configuration", ["current", "experimental"]) def test_numeric_color_count_does_not_become_automatic(configuration: str) -> None: """A two-color request stays at two even when three visible colors exist.""" @@ -93,6 +126,32 @@ def test_numeric_palette_keeps_clusters_shared_with_background( estimator.assert_not_called() +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +@pytest.mark.parametrize("alpha", [0, 60, 127, 128, 255]) +def test_numeric_color_correction_keeps_alpha_classification( + configuration: str, + alpha: int, +) -> None: + """Color correction cannot move masks or reinterpret half-opacity visibility.""" + pixels = np.zeros((16, 16, 4), dtype=np.uint8) + pixels[:8, :, 3] = alpha + pixels[0, 0, 3] = 0 + pixels[8:12] = (32, 64, 96, 255) + pixels[12:] = (240, 240, 240, 255) + quantizer = legacy.quantize if configuration == "current" else quantize + before_labels, before_colors, before_background = quantizer(pixels, 2) + labels, colors, background = quantizer(pixels, 2, recover_background_slots=True) + assert background is before_background is True + np.testing.assert_array_equal(labels, before_labels) + assert colors.shape == before_colors.shape + assert colors.dtype == before_colors.dtype == np.uint8 + if alpha >= 128: + # Non-masked black must still contribute to the shared visible color. + assert np.sum(colors[1, :3]) < 120 + else: + np.testing.assert_allclose(colors[1, :3], (32, 64, 96), atol=4) + + @pytest.mark.parametrize("configuration", ["current", "experimental"]) def test_numeric_component_cleanup_does_not_trigger_recovery( configuration: str, @@ -152,3 +211,52 @@ def test_unused_palette_entry_does_not_trigger_recovery( ) assert _recover_background_slots(pixels, labels, 2, background) is None estimator.assert_not_called() + + +@pytest.mark.parametrize("count", [2, 8, 64]) +@pytest.mark.parametrize("seed", range(4)) +def test_background_color_bias_matches_independent_reference( + count: int, + seed: int, +) -> None: + """Match per-cluster means, including shared, unused and background-only slots.""" + rng = np.random.default_rng(seed) + rgb = rng.integers(0, 256, (7, 9, 3), dtype=np.uint8) + labels = rng.integers(0, count, (7, 9), dtype=np.uint16) + colors = rng.integers(0, 256, (count, 3), dtype=np.uint8) + background = (rng.random((7, 9)) < 0.4).astype(np.uint8) + before_rgb, before_labels, before_colors = rgb.copy(), labels.copy(), colors.copy() + expected = colors.copy() + for index, color in enumerate(colors): + cluster = labels == index + all_pixels = rgb[cluster] + visible = rgb[cluster & (background == 0)] + if len(visible) and len(visible) < len(all_pixels): + bias = visible.mean(axis=0) - all_pixels.mean(axis=0) + expected[index] = np.clip( + np.rint(color.astype(np.float64) + bias), + 0, + 255, + ).astype(np.uint8) + actual = _remove_background_color_bias(rgb, labels, colors, background) + np.testing.assert_array_equal(actual, expected) + assert actual.dtype == colors.dtype + np.testing.assert_array_equal(rgb, before_rgb) + np.testing.assert_array_equal(labels, before_labels) + np.testing.assert_array_equal(colors, before_colors) + + +@pytest.mark.parametrize("background_value", [None, 0, 1]) +def test_background_color_correction_without_shared_pixels_is_noop( + background_value: int | None, +) -> None: + """Do not alter palettes for opaque images, empty images or unused slots.""" + rgb = np.full((8, 8, 3), 80, dtype=np.uint8) + labels = np.zeros((8, 8), dtype=np.uint8) + colors = np.array([[79, 80, 79], [255, 255, 255]], dtype=np.uint8) + background = ( + None + if background_value is None + else np.full((8, 8), background_value, dtype=np.uint8) + ) + assert _remove_background_color_bias(rgb, labels, colors, background) is colors diff --git a/vectorizing/tests/test_color_p3.py b/vectorizing/tests/test_color_p3.py index 71e4a6d..91d0661 100644 --- a/vectorizing/tests/test_color_p3.py +++ b/vectorizing/tests/test_color_p3.py @@ -61,8 +61,35 @@ def capture_rims(bitmaps: list[np.ndarray]) -> None: "23d8c97dee23ea3f0b22d164d39f726f36ead7f2ce47048ca73326bf2731c7e8", # ARM64 "1f52fe3d8589574cc561bf5ab0dff6a9ce410292d79b9e05dcf0a781f2d919f3", # x86_64 } + # Numeric fills compensate hidden RGB without reassigning pixels or + # changing the approved layer ordering and geometry. np.testing.assert_array_equal( np.asarray(colors)[:, :3], + [ + [6, 9, 22], + [19, 60, 148], + [27, 100, 202], + [40, 35, 63], + [193, 149, 161], + [212, 55, 118], + [234, 214, 216], + [248, 57, 61], + ], + ) + assert (width, height) == (1024, 1024) + svg = generate_SVG_markup(paths, colors, width, height) + # Approved pre-encoding P3: 1,376,657 bytes. Do not discard geometry for size. + assert len(svg.encode()) < 760_000 + + +def test_aftermath_default_quantizer_keeps_original_palette() -> None: + """Keep the original lower-level palette when numeric recovery is not enabled.""" + with Image.open(IMAGES / "aftermath.png") as image: + pixels = ColorSolver(image, 8, Timer(), "experimental").img_arr + _, colors, has_background = quantize(pixels, 8) + assert has_background + np.testing.assert_array_equal( + colors[1:, :3], [ [2, 3, 8], [19, 60, 148], @@ -74,10 +101,6 @@ def capture_rims(bitmaps: list[np.ndarray]) -> None: [248, 57, 61], ], ) - assert (width, height) == (1024, 1024) - svg = generate_SVG_markup(paths, colors, width, height) - # Approved pre-encoding P3: 1,376,657 bytes. Do not discard geometry for size. - assert len(svg.encode()) < 760_000 @pytest.fixture(scope="module")