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. 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
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
39 changes: 27 additions & 12 deletions vectorizing/solvers/color/legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

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 @@ -21,20 +23,33 @@ 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 with optional numeric background color recovery."""
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 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
104 changes: 95 additions & 9 deletions vectorizing/solvers/color/quantize.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,80 @@ 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 _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 @@ -221,22 +295,34 @@ 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 with optional numeric background recovery."""
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])
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.
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
Loading
Loading