diff --git a/README.md b/README.md index 6533550..6b5d2c7 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ 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. +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. + 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 diff --git a/vectorizing/__init__.py b/vectorizing/__init__.py index d33a25f..342c889 100644 --- a/vectorizing/__init__.py +++ b/vectorizing/__init__.py @@ -51,7 +51,7 @@ def process_color( color_count: int | str | None, timer: Timer, configuration: str = DEFAULT_CONFIGURATION, -) -> list[list[Path] | list[np.ndarray] | int]: +) -> list[list[Path] | list[np.ndarray] | list[list[int]] | int]: """Trace color layers using a named processing configuration.""" solver = ColorSolver(img, color_count, timer, configuration) return solver.solve() @@ -62,14 +62,16 @@ def validate_args(args: dict[str, object]) -> SimpleNamespace | Literal[False]: if "url" not in args: return False - solver = args.get("solver", DEFAULT_SOLVER) + color_count = args.get("color_count") + solver = args.get("solver", 1 if color_count == "auto" else DEFAULT_SOLVER) if solver not in SOLVERS: return 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 == 0 and color_count == "auto": + return False if solver == 1 and isinstance(color_count, str) and color_count not in ("auto", ""): return False diff --git a/vectorizing/solvers/color/ColorSolver.py b/vectorizing/solvers/color/ColorSolver.py index 9907b53..24dfa89 100644 --- a/vectorizing/solvers/color/ColorSolver.py +++ b/vectorizing/solvers/color/ColorSolver.py @@ -10,6 +10,7 @@ from vectorizing.geometry.potrace import potrace_path_to_compound_path from vectorizing.server.timer import Timer +from vectorizing.solvers.binary.BinarySolver import BinarySolver from vectorizing.solvers.color import legacy from vectorizing.solvers.color.bitmaps import ( add_bitmap_rims, @@ -111,6 +112,7 @@ def __init__( self.img_arr = np.asarray(self.img).astype(np.uint8) self.auto_method = None + self.binary_fallback = False if color_count == "auto": background = ( get_background_cluster(self.img_arr) @@ -121,6 +123,7 @@ def __init__( self.img_arr, background, ) + self.binary_fallback = self.color_count == 1 and representative is not None 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. @@ -134,8 +137,11 @@ def __init__( self.timer = timer self.configuration = CONFIGURATIONS[configuration] - def solve(self) -> list[list[Path] | list[np.ndarray] | int]: + def solve(self) -> list[list[Path] | list[np.ndarray] | list[list[int]] | int]: """Return paths, colors, width and height as a list.""" + if self.binary_fallback: + return list(BinarySolver(self.img).solve()) + self.timer.start_timer("Quantization") quantizer = ( legacy.quantize if self.configuration.original_quantization else quantize diff --git a/vectorizing/tests/test_api_contracts.py b/vectorizing/tests/test_api_contracts.py index dbfec37..e8e980a 100644 --- a/vectorizing/tests/test_api_contracts.py +++ b/vectorizing/tests/test_api_contracts.py @@ -24,6 +24,7 @@ {"url": "image", "configuration": None}, {"url": "image", "configuration": []}, {"url": "image", "solver": 1, "color_count": "automatic"}, + {"url": "image", "solver": 0, "color_count": "auto"}, ], ) def test_invalid_arguments_return_the_same_error( @@ -59,7 +60,10 @@ def test_argument_validation_preserves_accepted_values( assert isinstance(result, SimpleNamespace) assert vars(result) == { "url": payload["url"], - "solver": payload.get("solver", 0), + "solver": payload.get( + "solver", + 1 if payload.get("color_count") == "auto" else 0, + ), "crop_box": payload.get("crop_box"), "raw": None, "color_count": payload.get("color_count"), @@ -67,6 +71,13 @@ def test_argument_validation_preserves_accepted_values( } +def test_omitted_solver_with_auto_selects_color() -> None: + """Do not silently treat an auto-only request as a binary request.""" + args = vectorizing.validate_args({"url": "image", "color_count": "auto"}) + assert isinstance(args, SimpleNamespace) + assert args.solver == 1 + + def test_binary_ignores_string_color_count( client: FlaskClient, monkeypatch: pytest.MonkeyPatch, diff --git a/vectorizing/tests/test_color_auto.py b/vectorizing/tests/test_color_auto.py index 0365d29..3380e34 100644 --- a/vectorizing/tests/test_color_auto.py +++ b/vectorizing/tests/test_color_auto.py @@ -12,6 +12,7 @@ import vectorizing from vectorizing.server.timer import Timer +from vectorizing.solvers.binary.BinarySolver import BinarySolver 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 @@ -136,6 +137,52 @@ def test_kittl_logo_accent_has_its_own_layer(configuration: str) -> None: assert any(160 < color[1] and color[2] < 120 for color in colors) +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_visible_single_color_auto_traces_with_binary_solver( + configuration: str, +) -> None: + """Turn white text on transparent canvas into a black transparent SVG.""" + with Image.open(Path(__file__).parent / "images" / "white_text.png") as source: + image = convert_RGB_A(source) + solver = ColorSolver(image, "auto", Timer(), configuration) + actual = solver.solve() + expected = BinarySolver(image).solve() + serialize = ( + generate_original_SVG_markup + if configuration == "current" + else generate_SVG_markup + ) + assert isinstance(actual, list) + assert solver.color_count == 1 + assert actual[1] == [[0, 0, 0, 1]] + assert serialize(*actual) == serialize(*expected) + raster = Image.open( + BytesIO(svg2png(bytestring=serialize(*actual).encode())), + ).convert("RGBA") + assert raster.getchannel("A").getextrema() == (0, 255) + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_fully_transparent_auto_does_not_trace_a_black_rectangle( + configuration: str, +) -> None: + """No visible color means there is no artwork for the binary fallback.""" + with Image.open(Path(__file__).parent / "images" / "empty.png") as source: + solver = ColorSolver(convert_RGB_A(source), "auto", Timer(), configuration) + paths, colors, width, height = solver.solve() + assert solver.color_count == 1 + assert paths == colors == [] + 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.getchannel("A").getextrema() == (0, 0) + + 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)