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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 5 additions & 1 deletion vectorizing/solvers/color/ColorSolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
36 changes: 24 additions & 12 deletions vectorizing/solvers/color/legacy.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.solvers.color.quantize import (
_recover_background_slots,
get_background_cluster,
get_initial_centroids,
kmeans,
Expand All @@ -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(
Expand Down
55 changes: 46 additions & 9 deletions vectorizing/solvers/color/quantize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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])
Expand Down
29 changes: 24 additions & 5 deletions vectorizing/tests/test_color_configurations.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
from unittest.mock import Mock

import numpy as np
import potrace
import pytest
from flask.testing import FlaskClient
from PIL import Image

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