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. 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.
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. When hidden RGB shares a visible cluster, numeric requests compensate its contribution to the color's mean RGB without reassigning pixels or changing layer masks. 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
5 changes: 4 additions & 1 deletion vectorizing/solvers/color/legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from vectorizing.geometry.potrace import potrace_path_to_compound_path
from vectorizing.solvers.color.quantize import (
_recover_background_slots,
_remove_background_color_bias,
get_background_cluster,
get_initial_centroids,
kmeans,
Expand All @@ -24,7 +25,7 @@ def quantize(
auto_method: Image.Quantize | None = None,
recover_background_slots: bool = False,
) -> tuple[np.ndarray, np.ndarray, bool]:
"""Run original cleanup, optionally recovering background-only palette slots."""
"""Run original cleanup with optional numeric background color recovery."""
background = get_background_cluster(img_arr) if img_arr.shape[-1] == 4 else None
for attempt in range(2):
rgb = (
Expand All @@ -47,6 +48,8 @@ def quantize(
break
# Keep the original numeric seeding and defer cleanup until recovery ends.
img_arr = recovered
if recover_background_slots and auto_method is None:
colors = _remove_background_color_bias(rgb, labels, colors, background)
if background is not None:
labels = np.where(background > 0, 0, labels + 1)
colors = np.vstack(
Expand Down
51 changes: 50 additions & 1 deletion vectorizing/solvers/color/quantize.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,52 @@ def _recover_background_slots(
return recovered


def _remove_background_color_bias(
rgb: np.ndarray,
labels: np.ndarray,
colors: np.ndarray,
background: np.ndarray | None,
) -> np.ndarray:
"""Compensate shared centroids for hidden RGB without changing their labels."""
if background is None:
return colors
visible = (background == 0).ravel()
if not visible.any():
return colors
assigned = labels.ravel()
counts = np.bincount(assigned, minlength=len(colors))
visible_labels = assigned[visible]
visible_counts = np.bincount(visible_labels, minlength=len(colors))
shared = (visible_counts > 0) & (visible_counts < counts)
if not shared.any():
return colors

pixels = rgb.reshape(-1, 3)
visible_pixels = pixels[visible]
offset = np.empty((np.count_nonzero(shared), 3), dtype=np.float64)
for channel in range(3):
total = np.bincount(
assigned,
weights=pixels[:, channel],
minlength=len(colors),
)[shared]
seen = np.bincount(
visible_labels,
weights=visible_pixels[:, channel],
minlength=len(colors),
)[shared]
offset[:, channel] = seen / visible_counts[shared] - total / counts[shared]
# Keep FAISS's sampled centers and fixed assignments; remove only the mean
# contribution of masked pixels. Reassigning pixels would change layer masks.
corrected = colors.copy()
corrected[shared] = np.clip(
np.rint(colors[shared].astype(np.float64) + offset),
0,
255,
).astype(colors.dtype)
return corrected


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 @@ -251,7 +297,7 @@ def quantize(
auto_method: Image.Quantize | None = None,
recover_background_slots: bool = False,
) -> tuple[np.ndarray, np.ndarray, bool]:
"""Return cleaned labels/palette, optionally recovering background-only slots."""
"""Return cleaned labels/palette with optional numeric background recovery."""
background = get_background_cluster(img_arr) if img_arr.shape[-1] == 4 else None
for attempt in range(2):
rgb = bilateral_filter(img_arr[:, :, :3].copy())
Expand All @@ -274,6 +320,9 @@ def quantize(
order = np.lexsort(colors.T[::-1])
labels = np.argsort(order)[labels].astype(np.uint16)
colors = colors[order]
if recover_background_slots and auto_method is None:
# Correct fills after ordering so paint order and mask geometry stay fixed.
colors = _remove_background_color_bias(rgb, labels, colors, background)
if background is not None:
labels = write_background_cluster(labels, background)
colors = np.vstack(
Expand Down
Binary file added vectorizing/tests/images/snail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
110 changes: 109 additions & 1 deletion vectorizing/tests/test_color_numeric.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Keep numeric palette slots available for visible colors on transparent images."""

from io import BytesIO
from pathlib import Path
from unittest.mock import Mock

import numpy as np
Expand All @@ -11,7 +12,11 @@
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.solvers.color.quantize import (
_recover_background_slots,
_remove_background_color_bias,
quantize,
)
from vectorizing.svg.markup import generate_original_SVG_markup, generate_SVG_markup


Expand Down Expand Up @@ -51,6 +56,34 @@ def test_numeric_transparent_canvas_does_not_take_a_color_slot(
np.testing.assert_array_equal(solver.img_arr, pixels)


@pytest.mark.parametrize("configuration", ["current", "experimental"])
def test_snail_numeric_three_preserves_navy(configuration: str) -> None:
"""Hidden black must not darken navy when all three palette slots survive."""
with Image.open(Path(__file__).parent / "images" / "snail.png") as source:
image = source.copy()
pixels = np.asarray(image)
assert np.count_nonzero(pixels[:, :, 3] == 0) == 43_742
np.testing.assert_array_equal(pixels[pixels[:, :, 3] == 0, :3], 0)
paths, colors, width, height = ColorSolver(image, 3, Timer(), configuration).solve()
assert len(paths) == len(colors) == 3
navy = min(colors, key=lambda color: int(np.sum(color[:3])))
# This opaque pixel is well inside the snail, away from blended edges.
expected = pixels[390, 180, :3]
np.testing.assert_allclose(navy[:3], expected, atol=4)
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")
np.testing.assert_allclose(raster.getpixel((180, 390))[:3], expected, atol=4)
assert raster.getpixel((180, 390))[3] == 255
assert raster.getpixel((0, 0))[3] == 0
np.testing.assert_array_equal(np.asarray(image), 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."""
Expand Down Expand Up @@ -93,6 +126,32 @@ def test_numeric_palette_keeps_clusters_shared_with_background(
estimator.assert_not_called()


@pytest.mark.parametrize("configuration", ["current", "experimental"])
@pytest.mark.parametrize("alpha", [0, 60, 127, 128, 255])
def test_numeric_color_correction_keeps_alpha_classification(
configuration: str,
alpha: int,
) -> None:
"""Color correction cannot move masks or reinterpret half-opacity visibility."""
pixels = np.zeros((16, 16, 4), dtype=np.uint8)
pixels[:8, :, 3] = alpha
pixels[0, 0, 3] = 0
pixels[8:12] = (32, 64, 96, 255)
pixels[12:] = (240, 240, 240, 255)
quantizer = legacy.quantize if configuration == "current" else quantize
before_labels, before_colors, before_background = quantizer(pixels, 2)
labels, colors, background = quantizer(pixels, 2, recover_background_slots=True)
assert background is before_background is True
np.testing.assert_array_equal(labels, before_labels)
assert colors.shape == before_colors.shape
assert colors.dtype == before_colors.dtype == np.uint8
if alpha >= 128:
# Non-masked black must still contribute to the shared visible color.
assert np.sum(colors[1, :3]) < 120
else:
np.testing.assert_allclose(colors[1, :3], (32, 64, 96), atol=4)


@pytest.mark.parametrize("configuration", ["current", "experimental"])
def test_numeric_component_cleanup_does_not_trigger_recovery(
configuration: str,
Expand Down Expand Up @@ -152,3 +211,52 @@ def test_unused_palette_entry_does_not_trigger_recovery(
)
assert _recover_background_slots(pixels, labels, 2, background) is None
estimator.assert_not_called()


@pytest.mark.parametrize("count", [2, 8, 64])
@pytest.mark.parametrize("seed", range(4))
def test_background_color_bias_matches_independent_reference(
count: int,
seed: int,
) -> None:
"""Match per-cluster means, including shared, unused and background-only slots."""
rng = np.random.default_rng(seed)
rgb = rng.integers(0, 256, (7, 9, 3), dtype=np.uint8)
labels = rng.integers(0, count, (7, 9), dtype=np.uint16)
colors = rng.integers(0, 256, (count, 3), dtype=np.uint8)
background = (rng.random((7, 9)) < 0.4).astype(np.uint8)
before_rgb, before_labels, before_colors = rgb.copy(), labels.copy(), colors.copy()
expected = colors.copy()
for index, color in enumerate(colors):
cluster = labels == index
all_pixels = rgb[cluster]
visible = rgb[cluster & (background == 0)]
if len(visible) and len(visible) < len(all_pixels):
bias = visible.mean(axis=0) - all_pixels.mean(axis=0)
expected[index] = np.clip(
np.rint(color.astype(np.float64) + bias),
0,
255,
).astype(np.uint8)
actual = _remove_background_color_bias(rgb, labels, colors, background)
np.testing.assert_array_equal(actual, expected)
assert actual.dtype == colors.dtype
np.testing.assert_array_equal(rgb, before_rgb)
np.testing.assert_array_equal(labels, before_labels)
np.testing.assert_array_equal(colors, before_colors)


@pytest.mark.parametrize("background_value", [None, 0, 1])
def test_background_color_correction_without_shared_pixels_is_noop(
background_value: int | None,
) -> None:
"""Do not alter palettes for opaque images, empty images or unused slots."""
rgb = np.full((8, 8, 3), 80, dtype=np.uint8)
labels = np.zeros((8, 8), dtype=np.uint8)
colors = np.array([[79, 80, 79], [255, 255, 255]], dtype=np.uint8)
background = (
None
if background_value is None
else np.full((8, 8), background_value, dtype=np.uint8)
)
assert _remove_background_color_bias(rgb, labels, colors, background) is colors
31 changes: 27 additions & 4 deletions vectorizing/tests/test_color_p3.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,35 @@ def capture_rims(bitmaps: list[np.ndarray]) -> None:
"23d8c97dee23ea3f0b22d164d39f726f36ead7f2ce47048ca73326bf2731c7e8", # ARM64
"1f52fe3d8589574cc561bf5ab0dff6a9ce410292d79b9e05dcf0a781f2d919f3", # x86_64
}
# Numeric fills compensate hidden RGB without reassigning pixels or
# changing the approved layer ordering and geometry.
np.testing.assert_array_equal(
np.asarray(colors)[:, :3],
[
[6, 9, 22],
[19, 60, 148],
[27, 100, 202],
[40, 35, 63],
[193, 149, 161],
[212, 55, 118],
[234, 214, 216],
[248, 57, 61],
],
)
assert (width, height) == (1024, 1024)
svg = generate_SVG_markup(paths, colors, width, height)
# Approved pre-encoding P3: 1,376,657 bytes. Do not discard geometry for size.
assert len(svg.encode()) < 760_000


def test_aftermath_default_quantizer_keeps_original_palette() -> None:
"""Keep the original lower-level palette when numeric recovery is not enabled."""
with Image.open(IMAGES / "aftermath.png") as image:
pixels = ColorSolver(image, 8, Timer(), "experimental").img_arr
_, colors, has_background = quantize(pixels, 8)
assert has_background
np.testing.assert_array_equal(
colors[1:, :3],
[
[2, 3, 8],
[19, 60, 148],
Expand All @@ -74,10 +101,6 @@ def capture_rims(bitmaps: list[np.ndarray]) -> None:
[248, 57, 61],
],
)
assert (width, height) == (1024, 1024)
svg = generate_SVG_markup(paths, colors, width, height)
# Approved pre-encoding P3: 1,376,657 bytes. Do not discard geometry for size.
assert len(svg.encode()) < 760_000


@pytest.fixture(scope="module")
Expand Down
Loading