Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions vectorizing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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

Expand Down
8 changes: 7 additions & 1 deletion vectorizing/solvers/color/ColorSolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion vectorizing/tests/test_api_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -59,14 +60,24 @@ 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"),
"configuration": payload.get("configuration", "current"),
}


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,
Expand Down
47 changes: 47 additions & 0 deletions vectorizing/tests/test_color_auto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading