diff --git a/README.md b/README.md index 6b5d2c7..d3f6754 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; numeric quantization is unchanged. Omitting `color_count` still defaults to six; numeric requests retain the existing 2–64 clamp. 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; 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. 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/ColorSolver.py b/vectorizing/solvers/color/ColorSolver.py index 24dfa89..2fb5bcd 100644 --- a/vectorizing/solvers/color/ColorSolver.py +++ b/vectorizing/solvers/color/ColorSolver.py @@ -149,7 +149,11 @@ def solve(self) -> list[list[Path] | list[np.ndarray] | list[list[int]] | int]: labels, colors, has_background = ( quantizer(self.img_arr, self.color_count, auto_method=self.auto_method) if self.auto_method is not None - else quantizer(self.img_arr, self.color_count) + else quantizer( + self.img_arr, + self.color_count, + recover_background_slots=True, + ) ) self.timer.end_timer() diff --git a/vectorizing/solvers/color/legacy.py b/vectorizing/solvers/color/legacy.py index de694a9..eb1364d 100644 --- a/vectorizing/solvers/color/legacy.py +++ b/vectorizing/solvers/color/legacy.py @@ -10,6 +10,7 @@ from vectorizing.geometry.potrace import potrace_path_to_compound_path from vectorizing.solvers.color.quantize import ( + _recover_background_slots, get_background_cluster, get_initial_centroids, kmeans, @@ -21,20 +22,31 @@ def quantize( color_count: int, *, auto_method: Image.Quantize | None = None, + recover_background_slots: bool = False, ) -> tuple[np.ndarray, np.ndarray, bool]: - """Run the original strong-filter and overlap-based component cleanup.""" + """Run original cleanup, optionally recovering background-only palette slots.""" background = get_background_cluster(img_arr) if img_arr.shape[-1] == 4 else None - rgb = ( - cv2.cvtColor(img_arr, cv2.COLOR_RGBA2RGB) if img_arr.shape[-1] == 4 else img_arr - ) - rgb = cv2.bilateralFilter(rgb.copy(), 7, 50, 50) - centroids = ( - get_initial_centroids(rgb, color_count, auto_method) - if auto_method is not None - else get_initial_centroids(rgb, color_count) - ) - labels, colors = kmeans(rgb, centroids) - labels = labels.reshape(rgb.shape[:2]) + for attempt in range(2): + rgb = ( + cv2.cvtColor(img_arr, cv2.COLOR_RGBA2RGB) + if img_arr.shape[-1] == 4 + else img_arr + ) + rgb = cv2.bilateralFilter(rgb.copy(), 7, 50, 50) + centroids = ( + get_initial_centroids(rgb, color_count, auto_method) + if auto_method is not None + else get_initial_centroids(rgb, color_count) + ) + labels, colors = kmeans(rgb, centroids) + labels = labels.reshape(rgb.shape[:2]) + if attempt or not recover_background_slots or auto_method is not None: + break + recovered = _recover_background_slots(img_arr, labels, len(colors), background) + if recovered is None: + break + # Keep the original numeric seeding and defer cleanup until recovery ends. + img_arr = recovered 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 30f2c89..beb7bc5 100644 --- a/vectorizing/solvers/color/quantize.py +++ b/vectorizing/solvers/color/quantize.py @@ -92,6 +92,34 @@ def auto_color_count( return max(1, best_count), representative, chosen_method +def _recover_background_slots( + img_arr: np.ndarray, + labels: np.ndarray, + cluster_count: int, + background: np.ndarray | None, +) -> np.ndarray | None: + """Replace hidden RGB only for occupied, background-only raw clusters.""" + # Inspect labels before masking or cleanup; losing a tiny visible component + # to cleanup is not evidence that hidden RGB consumed a palette slot. + if background is None: + return None + visible = background == 0 + if not visible.any(): + return None + visible_counts = np.bincount(labels[visible], minlength=cluster_count) + if (visible_counts > 0).all(): + return None + counts = np.bincount(labels.ravel(), minlength=cluster_count) + if not np.any((counts > 0) & (visible_counts == 0)): + return None + _, representative, _ = auto_color_count(img_arr, background) + if representative is None: + return None + recovered = img_arr.copy() + recovered[~visible, :3] = representative + return recovered + + 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) @@ -221,17 +249,26 @@ def quantize( color_count: int, *, auto_method: Image.Quantize | None = None, + recover_background_slots: bool = False, ) -> tuple[np.ndarray, np.ndarray, bool]: - """Return area-cleaned labels, an RGB-ordered palette and background status.""" + """Return cleaned labels/palette, optionally recovering background-only slots.""" background = get_background_cluster(img_arr) if img_arr.shape[-1] == 4 else None - rgb = bilateral_filter(img_arr[:, :, :3].copy()) - centroids = ( - get_initial_centroids(rgb, color_count, auto_method) - if auto_method is not None - else get_initial_centroids(rgb, color_count) - ) - labels, colors = kmeans(rgb, centroids) - labels = labels.reshape(rgb.shape[:2]) + for attempt in range(2): + rgb = bilateral_filter(img_arr[:, :, :3].copy()) + centroids = ( + get_initial_centroids(rgb, color_count, auto_method) + if auto_method is not None + else get_initial_centroids(rgb, color_count) + ) + labels, colors = kmeans(rgb, centroids) + labels = labels.reshape(rgb.shape[:2]) + if attempt or not recover_background_slots or auto_method is not None: + break + recovered = _recover_background_slots(img_arr, labels, len(colors), background) + if recovered is None: + break + # Retry the same numeric count and seed method, then clean only once. + img_arr = recovered # Paint order determines which neighboring colors may receive a bounded rim. order = np.lexsort(colors.T[::-1]) diff --git a/vectorizing/tests/test_color_configurations.py b/vectorizing/tests/test_color_configurations.py index f90e078..3835ddf 100644 --- a/vectorizing/tests/test_color_configurations.py +++ b/vectorizing/tests/test_color_configurations.py @@ -6,6 +6,7 @@ from unittest.mock import Mock import numpy as np +import potrace import pytest from flask.testing import FlaskClient from PIL import Image @@ -13,8 +14,9 @@ import vectorizing from vectorizing.server.timer import Timer from vectorizing.solvers.color import legacy +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.svg.markup import generate_original_SVG_markup, generate_SVG_markup from vectorizing.tests.color_reference import original_area_cleanup from vectorizing.util.limit_size import limit_size from vectorizing.util.read import convert_RGB_A @@ -153,11 +155,29 @@ def test_original_opaque_svg_matches_reference( ) +def test_original_transparent_serialization_matches_reference() -> None: + """Pin the original stages independently of numeric palette-slot recovery.""" + image = transparent_artwork() + labels, colors, has_background = legacy.quantize(np.asarray(image), 4) + bitmaps, colors = create_bitmaps(labels, colors, has_background) + paths = legacy.remove_layering( + [potrace.Bitmap(bitmap).trace() for bitmap in bitmaps], + image.width, + image.height, + has_background, + ) + markup = generate_original_SVG_markup(paths, colors, image.width, image.height) + # Captured from the original stages, not regenerated to match recovery. + assert hashlib.sha256(markup.encode()).hexdigest() == ( + "901127e424e2944c199af568da7bf136373b825a8f2939b1df109ed0c1269632" + ) + + def test_original_profile_is_http_default_and_experimental_is_independent( client: FlaskClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Pin original raw SVG bytes while keeping the newer pipeline selectable.""" + """Keep the original serializer as HTTP default and profiles selectable.""" image = transparent_artwork() monkeypatch.setattr( vectorizing, @@ -170,9 +190,8 @@ def test_original_profile_is_http_default_and_experimental_is_independent( experimental = client.post("/", json={**base, "configuration": "experimental"}) assert all(r.status_code == 200 for r in (default, original, experimental)) assert default.data == original.data - # Captured from the pre-change solver and SVG serializer, not this implementation. - assert hashlib.sha256(default.data).hexdigest() == ( - "901127e424e2944c199af568da7bf136373b825a8f2939b1df109ed0c1269632" + assert default.data.decode() == generate_original_SVG_markup( + *ColorSolver(image, 4, Timer(), "current").solve(), ) assert experimental.data != default.data assert experimental.data.decode() == generate_SVG_markup( diff --git a/vectorizing/tests/test_color_numeric.py b/vectorizing/tests/test_color_numeric.py new file mode 100644 index 0000000..8f19caa --- /dev/null +++ b/vectorizing/tests/test_color_numeric.py @@ -0,0 +1,154 @@ +"""Keep numeric palette slots available for visible colors on transparent images.""" + +from io import BytesIO +from unittest.mock import Mock + +import numpy as np +import pytest +from cairosvg import svg2png +from PIL import Image + +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.svg.markup import generate_original_SVG_markup, generate_SVG_markup + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +@pytest.mark.parametrize("hidden_rgb", [(0, 0, 0), (255, 255, 0)]) +def test_numeric_transparent_canvas_does_not_take_a_color_slot( + configuration: str, + hidden_rgb: tuple[int, int, int], +) -> None: + """Retain three visible colors regardless of RGB hidden beneath zero alpha.""" + pixels = np.zeros((128, 128, 4), dtype=np.uint8) + pixels[:, :, :3] = hidden_rgb + pixels[8:56, 8:56] = (255, 128, 128, 255) + pixels[8:56, 72:120] = (128, 255, 128, 255) + pixels[72:120, 8:56] = (128, 128, 255, 255) + image = Image.fromarray(pixels) + solver = ColorSolver(image, 3, Timer(), configuration) + paths, colors, width, height = solver.solve() + + assert solver.color_count == len(paths) == len(colors) == 3 + actual = np.array(sorted(tuple(color[:3]) for color in colors)) + expected = np.array([(128, 128, 255), (128, 255, 128), (255, 128, 128)]) + np.testing.assert_allclose(actual, expected, atol=1) + np.testing.assert_array_equal(np.asarray(image), pixels) + + 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") + assert raster.getpixel((0, 0))[3] == 0 + assert raster.getpixel((32, 32))[3] == 255 + assert serialize(*solver.solve()) == serialize(paths, colors, width, height) + np.testing.assert_array_equal(solver.img_arr, 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.""" + pixels = np.zeros((128, 128, 4), dtype=np.uint8) + pixels[8:56, 8:56] = (255, 128, 128, 255) + pixels[8:56, 72:120] = (128, 255, 128, 255) + pixels[72:120, 8:56] = (128, 128, 255, 255) + solver = ColorSolver(Image.fromarray(pixels), 2, Timer(), configuration) + paths, colors, _, _ = solver.solve() + assert solver.color_count == len(paths) == len(colors) == 2 + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_fully_transparent_numeric_image_stays_empty(configuration: str) -> None: + """No visible representative means no artwork, rather than a binary fallback.""" + solver = ColorSolver(Image.new("RGBA", (8, 8)), 3, Timer(), configuration) + paths, colors, _, _ = solver.solve() + assert solver.color_count == 3 + assert paths == colors == [] + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_numeric_palette_keeps_clusters_shared_with_background( + configuration: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not retry when all palette colors already have visible pixels.""" + pixels = np.zeros((128, 128, 4), dtype=np.uint8) + pixels[8:56, 8:56] = (0, 0, 0, 255) + pixels[8:56, 72:120] = (128, 255, 128, 255) + pixels[72:120, 8:56] = (255, 128, 128, 255) + estimator = Mock(side_effect=AssertionError("Full numeric palettes must not retry")) + monkeypatch.setattr( + "vectorizing.solvers.color.quantize.auto_color_count", + estimator, + ) + solver = ColorSolver(Image.fromarray(pixels), 3, Timer(), configuration) + paths, colors, _, _ = solver.solve() + assert len(paths) == len(colors) == 3 + estimator.assert_not_called() + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_numeric_component_cleanup_does_not_trigger_recovery( + configuration: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep original colors when cleanup, not transparency, removes a cluster.""" + pixels = np.zeros((128, 128, 4), dtype=np.uint8) + pixels[:, :, :3] = (20, 40, 60) + pixels[8:56, 8:56] = (20, 40, 60, 255) + pixels[8:120, 72:120] = (250, 250, 250, 255) + pixels[80, 20] = (255, 0, 0, 255) + quantizer = legacy.quantize if configuration == "current" else quantize + module = quantizer.__module__ + clustering = [] + original_kmeans = quantizer.__globals__["kmeans"] + + def capture_clusters( + rgb: np.ndarray, + centroids: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray]: + labels, colors = original_kmeans(rgb, centroids) + visible = labels.reshape(pixels.shape[:2])[pixels[:, :, 3] > 0] + clustering.append(np.bincount(visible, minlength=len(colors))) + return labels, colors + + monkeypatch.setattr(f"{module}.kmeans", capture_clusters) + labels, palette, _ = quantizer(pixels, 3) + assert (clustering[0] > 0).all() + surviving = np.unique(labels[labels != 0]) + assert len(surviving) == 2 + clustering.clear() + + _, colors, _, _ = ColorSolver( + Image.fromarray(pixels), + 3, + Timer(), + configuration, + ).solve() + assert len(clustering) == 1 + np.testing.assert_array_equal(colors, palette[surviving]) + + +@pytest.mark.parametrize("visible_alpha", [0, 255]) +def test_unused_palette_entry_does_not_trigger_recovery( + visible_alpha: int, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unused centroid or an empty image is not a background-only color loss.""" + pixels = np.zeros((8, 8, 4), dtype=np.uint8) + pixels[2:6, 2:6, 3] = visible_alpha + background = (pixels[:, :, 3] == 0).astype(np.uint8) + labels = np.zeros((8, 8), dtype=np.uint8) + estimator = Mock(side_effect=AssertionError("No occupied slot was lost")) + monkeypatch.setattr( + "vectorizing.solvers.color.quantize.auto_color_count", + estimator, + ) + assert _recover_background_slots(pixels, labels, 2, background) is None + estimator.assert_not_called()