diff --git a/README.md b/README.md index efa95ea..6533550 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ The request format is the following: { url: string, // Image URL solver: number, // Solver. 0 -> Binary, 1 -> Color - color_count: number, // Number of colors (if applicable) + color_count: number | "auto", // Number of colors, or choose 1–16 automatically (color solver only) configuration: string, // Optional color profile: "current" (default) or "experimental" raw: boolean // If true, plain return plain SVG markup } @@ -127,6 +127,8 @@ 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. + Color processing can be selected per request with the optional `configuration` JSON field. The default `current` profile uses the original color quantization and layer clipping behavior. Set `configuration` to `experimental` to use the newer quantization, overlap-based layers, and opaque-background detection/isolation. The profiles are assembled from independently switchable solver stages, so behavior can be compared without removing either implementation. Unknown profile names are rejected with `INVALID_PARAMETERS`. The `experimental` profile's color processing uses a gentle 3-pixel bilateral filter and removes only connected @@ -136,7 +138,7 @@ the most common surviving perimeter color is also protected to retain small background-colored letter counters. This can retain extra small specks of that color. Tracing retains small paths and uses a curve optimization tolerance of 0.5. The existing 1,048,576-pixel area cap, default of six -colors and supported range of 2–64 colors are unchanged. Color paths touching the +colors and numeric range of 2–64 colors are unchanged. Color paths touching the canvas are extended before tracing and clipped back to the image so corners remain covered and returned bounds do not include the padding. If Skia rejects a canvas clip, that layer is retraced without padding and a warning is logged. Recovery diff --git a/vectorizing/__init__.py b/vectorizing/__init__.py index 1195a05..d33a25f 100644 --- a/vectorizing/__init__.py +++ b/vectorizing/__init__.py @@ -48,7 +48,7 @@ def process_binary(img: Image.Image) -> tuple[list[Path], list[list[int]], int, def process_color( img: Image.Image, - color_count: int | None, + color_count: int | str | None, timer: Timer, configuration: str = DEFAULT_CONFIGURATION, ) -> list[list[Path] | list[np.ndarray] | int]: @@ -69,6 +69,9 @@ def validate_args(args: dict[str, object]) -> SimpleNamespace | Literal[False]: configuration = args.get("configuration", DEFAULT_CONFIGURATION) if not isinstance(configuration, str) or configuration not in CONFIGURATIONS: return False + color_count = args.get("color_count") + if solver == 1 and isinstance(color_count, str) and color_count not in ("auto", ""): + return False box = args.get("crop_box") if box: @@ -84,7 +87,7 @@ def validate_args(args: dict[str, object]) -> SimpleNamespace | Literal[False]: solver=solver, url=args.get("url"), raw=args.get("raw"), - color_count=args.get("color_count"), + color_count=color_count, configuration=configuration, ) diff --git a/vectorizing/solvers/color/ColorSolver.py b/vectorizing/solvers/color/ColorSolver.py index b2920d9..9907b53 100644 --- a/vectorizing/solvers/color/ColorSolver.py +++ b/vectorizing/solvers/color/ColorSolver.py @@ -16,7 +16,11 @@ create_background_bitmap, create_bitmaps, ) -from vectorizing.solvers.color.quantize import quantize +from vectorizing.solvers.color.quantize import ( + auto_color_count, + get_background_cluster, + quantize, +) from vectorizing.util.limit_size import limit_size @@ -97,20 +101,36 @@ class ColorSolver: def __init__( self, img: Image.Image, - color_count: int | None, + color_count: int | str | None, timer: Timer, configuration: str = DEFAULT_CONFIGURATION, ) -> None: - color_count = color_count or ColorSolver.DEFAULT_COLOR_COUNT - color_count = max(color_count, ColorSolver.MIN_COLOR_COUNT) - color_count = min(color_count, ColorSolver.MAX_COLOR_COUNT) - self.color_count = color_count - self.img = limit_size(img) # Init image array self.img_arr = np.asarray(self.img).astype(np.uint8) + self.auto_method = None + if color_count == "auto": + background = ( + get_background_cluster(self.img_arr) + if self.img_arr.shape[-1] == 4 + else None + ) + self.color_count, representative, self.auto_method = auto_color_count( + self.img_arr, + background, + ) + if background is not None and representative is not None: + # Both quantizers cluster background RGB before masking it; + # give it a visible color so it cannot consume a palette slot. + self.img_arr = self.img_arr.copy() + self.img_arr[background > 0, :3] = representative + else: + color_count = color_count or ColorSolver.DEFAULT_COLOR_COUNT + color_count = max(color_count, ColorSolver.MIN_COLOR_COUNT) + self.color_count = min(color_count, ColorSolver.MAX_COLOR_COUNT) + self.timer = timer self.configuration = CONFIGURATIONS[configuration] @@ -120,7 +140,11 @@ def solve(self) -> list[list[Path] | list[np.ndarray] | int]: quantizer = ( legacy.quantize if self.configuration.original_quantization else quantize ) - labels, colors, has_background = quantizer(self.img_arr, self.color_count) + 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) + ) self.timer.end_timer() self.timer.start_timer("Bitmap Creation") diff --git a/vectorizing/solvers/color/legacy.py b/vectorizing/solvers/color/legacy.py index 0abea64..de694a9 100644 --- a/vectorizing/solvers/color/legacy.py +++ b/vectorizing/solvers/color/legacy.py @@ -5,6 +5,7 @@ import potrace import scipy.ndimage as ndi from pathops import Path, PathOp, op +from PIL import Image from skimage.measure import label from vectorizing.geometry.potrace import potrace_path_to_compound_path @@ -18,6 +19,8 @@ def quantize( img_arr: np.ndarray, color_count: int, + *, + auto_method: Image.Quantize | None = None, ) -> tuple[np.ndarray, np.ndarray, bool]: """Run the original strong-filter and overlap-based component cleanup.""" background = get_background_cluster(img_arr) if img_arr.shape[-1] == 4 else None @@ -25,7 +28,12 @@ def quantize( cv2.cvtColor(img_arr, cv2.COLOR_RGBA2RGB) if img_arr.shape[-1] == 4 else img_arr ) rgb = cv2.bilateralFilter(rgb.copy(), 7, 50, 50) - labels, colors = kmeans(rgb, get_initial_centroids(rgb, color_count)) + 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 background is not None: labels = np.where(background > 0, 0, labels + 1) diff --git a/vectorizing/solvers/color/quantize.py b/vectorizing/solvers/color/quantize.py index d38c1fb..30f2c89 100644 --- a/vectorizing/solvers/color/quantize.py +++ b/vectorizing/solvers/color/quantize.py @@ -10,6 +10,88 @@ MIN_COMPONENT_AREA = 8 +def auto_color_count( + img_arr: np.ndarray, + background: np.ndarray | None, +) -> tuple[int, np.ndarray | None, Image.Quantize]: + """Return color count, representative visible RGB and chosen seed method. + + Small sample palettes choose K and the K-means initialization method; the + selected profile still quantizes the full processed image with its own filter. + + Parameters + ---------- + img_arr : numpy.ndarray + Processed RGB or RGBA pixels. + background : numpy.ndarray or None + Nonzero at pixels that the quantizer treats as transparent background. + + Returns + ------- + tuple + Color count, representative RGB (or None), and seed method. + """ + pixels = ( + img_arr[background == 0, :3] + if background is not None + else img_arr[:, :, :3].reshape(-1, 3) + ) + if not len(pixels): + return 1, None, Image.Quantize.FASTOCTREE + if len(pixels) > 65536: + # Fixed-seed sampling avoids row-aligned strides hiding whole features. + indices = np.random.default_rng(0).choice(len(pixels), 65536, replace=False) + pixels = pixels[indices] + sample = Image.fromarray(pixels.reshape(1, -1, 3)) + best_count = 0 + representative = None + chosen_method = Image.Quantize.FASTOCTREE + # Octree keeps small vivid accents; median cut separates large close tones. + for method in (Image.Quantize.FASTOCTREE, Image.Quantize.MEDIANCUT): + palette_image = sample.quantize(16, method=method) + palette = np.asarray(palette_image.getpalette("RGB"), dtype=np.uint8).reshape( + -1, + 3, + ) + entries = sorted(palette_image.getcolors(), reverse=True) + dominant: list[np.ndarray] = [] + for size, index in entries: + share = size / len(pixels) + if share < 0.005: + continue + color = palette[index].astype(np.float32) + # Large, subtly different regions (e.g. a patterned background) + # matter even when their RGB distance is below the edge-shade cutoff. + separation = 8 if share >= 0.1 else 25 + if any(np.linalg.norm(color - other) < separation for other in dominant): + continue + # Small blended edge colors lie between two stronger colors; large + # intermediate regions can be real artwork and must keep their slot. + if share < 0.03 and any( + np.linalg.norm( + color + - first + - np.clip( + np.dot(color - first, second - first) + / np.dot(second - first, second - first), + 0, + 1, + ) + * (second - first), + ) + < 25 + for i, first in enumerate(dominant) + for second in dominant[i + 1 :] + ): + continue + dominant.append(color) + if len(dominant) > best_count: + best_count = len(dominant) + representative = palette[entries[0][1]] + chosen_method = method + return max(1, best_count), representative, chosen_method + + 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) @@ -105,10 +187,14 @@ def write_background_cluster(labels: np.ndarray, bg_cluster: np.ndarray) -> np.n return labels -def get_initial_centroids(img_arr: np.ndarray, color_count: int) -> np.ndarray: +def get_initial_centroids( + img_arr: np.ndarray, + color_count: int, + method: Image.Quantize = Image.Quantize.FASTOCTREE, +) -> np.ndarray: """Return sorted unique used RGB palette colors for K-means initialization.""" img = Image.fromarray(img_arr) - img = img.quantize(color_count, method=Image.Quantize.FASTOCTREE) + img = img.quantize(color_count, method=method) # Palette padding must not introduce additional clusters. RGB order affects # initialization, so sort the used entries rather than the entire pixel grid. used_indices = [index for _, index in img.getcolors()] @@ -133,11 +219,18 @@ def kmeans( def quantize( img_arr: np.ndarray, color_count: int, + *, + auto_method: Image.Quantize | None = None, ) -> tuple[np.ndarray, np.ndarray, bool]: """Return area-cleaned labels, an RGB-ordered palette and background status.""" background = get_background_cluster(img_arr) if img_arr.shape[-1] == 4 else None rgb = bilateral_filter(img_arr[:, :, :3].copy()) - labels, colors = kmeans(rgb, get_initial_centroids(rgb, color_count)) + 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]) # Paint order determines which neighboring colors may receive a bounded rim. diff --git a/vectorizing/tests/images/test_logo.png b/vectorizing/tests/images/test_logo.png new file mode 100644 index 0000000..7470968 Binary files /dev/null and b/vectorizing/tests/images/test_logo.png differ diff --git a/vectorizing/tests/test_api_contracts.py b/vectorizing/tests/test_api_contracts.py index 6c28e81..dbfec37 100644 --- a/vectorizing/tests/test_api_contracts.py +++ b/vectorizing/tests/test_api_contracts.py @@ -23,6 +23,7 @@ {"url": "image", "configuration": "legacy"}, {"url": "image", "configuration": None}, {"url": "image", "configuration": []}, + {"url": "image", "solver": 1, "color_count": "automatic"}, ], ) def test_invalid_arguments_return_the_same_error( @@ -41,6 +42,9 @@ def test_invalid_arguments_return_the_same_error( [ {"url": "image"}, {"url": "image", "configuration": "experimental"}, + {"url": "image", "color_count": "auto"}, + {"url": "image", "solver": 1, "color_count": ""}, + {"url": "image", "solver": 0, "color_count": "ignored"}, {"url": None}, {"url": "image", "solver": True}, {"url": "image", "crop_box": []}, @@ -58,11 +62,32 @@ def test_argument_validation_preserves_accepted_values( "solver": payload.get("solver", 0), "crop_box": payload.get("crop_box"), "raw": None, - "color_count": None, + "color_count": payload.get("color_count"), "configuration": payload.get("configuration", "current"), } +def test_binary_ignores_string_color_count( + client: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Leave unrelated color fields alone on the binary request path.""" + monkeypatch.setattr( + vectorizing, + "try_read_image_from_url", + Mock(return_value=Image.new("RGB", (8, 8))), + ) + response = client.post( + "/", + json={"url": "image", "solver": 0, "color_count": "ignored", "raw": True}, + ) + assert response.status_code == 200 + assert ( + response.data + == client.post("/", json={"url": "image", "solver": 0, "raw": True}).data + ) + + @pytest.mark.parametrize("solver", [0, 1]) def test_raw_requests_crop_before_vectorizing_and_do_not_upload( solver: int, diff --git a/vectorizing/tests/test_color_auto.py b/vectorizing/tests/test_color_auto.py new file mode 100644 index 0000000..0365d29 --- /dev/null +++ b/vectorizing/tests/test_color_auto.py @@ -0,0 +1,177 @@ +"""Choose automatic palette sizes without changing numeric mode.""" + +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 + +import vectorizing +from vectorizing.server.timer import Timer +from vectorizing.solvers.color.ColorSolver import ColorSolver +from vectorizing.svg.markup import generate_original_SVG_markup, generate_SVG_markup +from vectorizing.util.read import convert_RGB_A + + +@pytest.mark.parametrize( + "name, expected", + [ + # These artwork counts are contracts: never rebaseline them to fit a + # new heuristic. aftermath.png has too many colors to pin this way. + ("geo_logo.png", 3), + ("midnight_strike.png", 3), + ("shapes.png", 4), + ("shapes_2.png", 6), + ("test_logo.png", 3), + ("bubbles.png", 4), + ("black_rectangle.png", 1), + ("empty.png", 1), + ], +) +def test_auto_count_on_gallery(name: str, expected: int) -> None: + """Pin curated palette sizes while ignoring blended edge colors.""" + with Image.open(Path(__file__).parent / "images" / name) as source: + image = convert_RGB_A(source) + solver = ColorSolver(image, "auto", Timer()) + assert solver.color_count == expected + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_auto_traces_three_layers_on_midnight_strike(configuration: str) -> None: + """Pass the selected count through real quantization and layer tracing.""" + with Image.open(Path(__file__).parent / "images" / "midnight_strike.png") as source: + solver = ColorSolver(convert_RGB_A(source), "auto", Timer(), configuration) + paths, colors, _, _ = solver.solve() + assert solver.color_count == len(paths) == len(colors) == 3 + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_transparent_canvas_does_not_take_a_color_slot(configuration: str) -> None: + """Hidden black must not desaturate two opaque colors on a transparent logo.""" + pixels = np.zeros((128, 128, 4), dtype=np.uint8) + pixels[16:64, 16:64] = (255, 0, 0, 255) + pixels[64:112, 64:112] = (0, 0, 255, 255) + pixels[0, 0] = (0, 255, 0, 1) # A rare edge must not dominate hidden RGB. + solver = ColorSolver(Image.fromarray(pixels), "auto", Timer(), configuration) + _, colors, _, _ = solver.solve() + assert solver.color_count == 2 + assert {tuple(color[:3]) for color in colors} == {(255, 0, 0), (0, 0, 255)} + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_thin_strokes_between_grid_points_keep_both_colors(configuration: str) -> None: + """Transparent line art cannot vanish just because its columns miss a grid.""" + pixels = np.zeros((1024, 1024, 4), dtype=np.uint8) + pixels[:, 1:4] = (255, 0, 0, 255) + pixels[:, 5:8] = (0, 0, 255, 255) + solver = ColorSolver(Image.fromarray(pixels), "auto", Timer(), configuration) + _, colors, _, _ = solver.solve() + assert solver.color_count == 2 + assert {tuple(color[:3]) for color in colors} == {(255, 0, 0), (0, 0, 255)} + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_visible_pixel_sampling_cannot_lock_to_background_columns( + configuration: str, +) -> None: + """A single transparent pixel must not phase-lock sampling away from stripes.""" + pixels = np.full((1024, 1024, 4), 255, dtype=np.uint8) + pixels[:, 5::16, :3] = (255, 0, 0) + pixels[:, 6::16, :3] = (255, 0, 0) + pixels[:, 7::16, :3] = (255, 0, 0) + pixels[0, 0] = (0, 0, 0, 0) + solver = ColorSolver(Image.fromarray(pixels), "auto", Timer(), configuration) + _, colors, _, _ = solver.solve() + assert solver.color_count == 2 + expected = {(255, 255, 255), (255, 0, 0)} + assert {tuple(color[:3]) for color in colors} == expected + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_shadow_masked_by_background_does_not_take_a_slot( + configuration: str, +) -> None: + """An alpha-60 shadow becomes background when the image also has alpha 0.""" + pixels = np.zeros((128, 128, 4), dtype=np.uint8) + pixels[8:64, 8:64] = (255, 0, 0, 255) + pixels[64:120, 64:120] = (0, 0, 255, 255) + pixels[8:64, 64:120] = (0, 255, 0, 60) + solver = ColorSolver(Image.fromarray(pixels), "auto", Timer(), configuration) + _, colors, _, _ = solver.solve() + assert solver.color_count == 2 + assert {tuple(color[:3]) for color in colors} == {(255, 0, 0), (0, 0, 255)} + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_geo_logo_pattern_is_a_separate_layer(configuration: str) -> None: + """Keep both large pale regions separate from each other and the dark logo.""" + with Image.open(Path(__file__).parent / "images" / "geo_logo.png") as source: + solver = ColorSolver(convert_RGB_A(source), "auto", Timer(), configuration) + paths, colors, width, height = solver.solve() + assert solver.color_count == len(paths) == len(colors) == 3 + pale = [color[:3] for color in colors if np.min(color[:3]) > 200] + assert len(pale) == 2 + assert np.linalg.norm(pale[0].astype(int) - pale[1].astype(int)) >= 5 + serialize = ( + generate_original_SVG_markup + if configuration == "current" + else generate_SVG_markup + ) + markup = serialize(paths, colors, width, height) + raster = Image.open(BytesIO(svg2png(bytestring=markup.encode()))).convert("RGB") + assert raster.getpixel((0, 0)) != raster.getpixel((150, 0)) + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_kittl_logo_accent_has_its_own_layer(configuration: str) -> None: + """Preserve the small neon mark as well as black lettering and white canvas.""" + with Image.open(Path(__file__).parent / "images" / "test_logo.png") as source: + solver = ColorSolver(convert_RGB_A(source), "auto", Timer(), configuration) + paths, colors, _, _ = solver.solve() + assert solver.color_count == len(paths) == len(colors) == 3 + assert any(160 < color[1] and color[2] < 120 for color in colors) + + +def test_large_intermediate_color_is_not_treated_as_an_edge() -> None: + """An entire gray panel between black and white is a third color.""" + pixels = np.zeros((100, 100, 3), dtype=np.uint8) + pixels[40:75] = (255, 255, 255) + pixels[75:] = (128, 128, 128) + assert ColorSolver(Image.fromarray(pixels), "auto", Timer()).color_count == 3 + + +def test_partially_transparent_pixels_count_as_visible() -> None: + """The solver clusters nonzero-alpha RGB, so auto must count it too.""" + pixels = np.zeros((100, 100, 4), dtype=np.uint8) + pixels[:50] = (255, 0, 0, 64) + pixels[50:] = (0, 0, 255, 255) + assert ColorSolver(Image.fromarray(pixels), "auto", Timer()).color_count == 2 + + +def test_auto_request_and_numeric_default_remain_distinct( + client: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only the explicit auto string opts into estimation; numeric clamps remain.""" + monkeypatch.setattr( + vectorizing, + "try_read_image_from_url", + Mock(return_value=Image.new("RGB", (8, 8))), + ) + process = Mock(return_value=([], [], 8, 8)) + monkeypatch.setattr(vectorizing, "process_color", process) + for count in ["auto", None, "", 1, 65]: + response = client.post( + "/", + json={"url": "image", "solver": 1, "raw": True, "color_count": count}, + ) + assert response.status_code == 200 + assert process.call_args.args[1] == count + assert ColorSolver(Image.new("RGB", (8, 8)), None, Timer()).color_count == 6 + assert ColorSolver(Image.new("RGB", (8, 8)), 1, Timer()).color_count == 2 + assert ColorSolver(Image.new("RGB", (8, 8)), 65, Timer()).color_count == 64 + assert ColorSolver(Image.new("RGB", (8, 8)), "", Timer()).color_count == 6