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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ The request format is the following:
{
url: string, // Image URL
solver: number, // Solver. 0 -> Binary, 1 -> Color
color_count: number, // Number of colors (if applicable)
color_count: number | "auto", // Number of colors, or choose 1–16 automatically (color solver only)
configuration: string, // Optional color profile: "current" (default) or "experimental"
raw: boolean // If true, plain return plain SVG markup
}
Expand Down Expand Up @@ -127,6 +127,8 @@ 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.

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 All @@ -136,7 +138,7 @@ the most common surviving perimeter color is also protected to retain small
background-colored letter counters. This can retain extra small specks of that
color. Tracing retains small paths and uses a curve optimization tolerance of 0.5.
The existing 1,048,576-pixel area cap, default of six
colors and supported range of 2–64 colors are unchanged. Color paths touching the
colors and numeric range of 2–64 colors are unchanged. Color paths touching the
canvas are extended before tracing and clipped back to the image so corners remain
covered and returned bounds do not include the padding. If Skia rejects a canvas
clip, that layer is retraced without padding and a warning is logged. Recovery
Expand Down
7 changes: 5 additions & 2 deletions vectorizing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def process_binary(img: Image.Image) -> tuple[list[Path], list[list[int]], int,

def process_color(
img: Image.Image,
color_count: int | None,
color_count: int | str | None,
timer: Timer,
configuration: str = DEFAULT_CONFIGURATION,
) -> list[list[Path] | list[np.ndarray] | int]:
Expand All @@ -69,6 +69,9 @@ def validate_args(args: dict[str, object]) -> SimpleNamespace | Literal[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 == 1 and isinstance(color_count, str) and color_count not in ("auto", ""):
return False

box = args.get("crop_box")
if box:
Expand All @@ -84,7 +87,7 @@ def validate_args(args: dict[str, object]) -> SimpleNamespace | Literal[False]:
solver=solver,
url=args.get("url"),
raw=args.get("raw"),
color_count=args.get("color_count"),
color_count=color_count,
configuration=configuration,
)

Expand Down
40 changes: 32 additions & 8 deletions vectorizing/solvers/color/ColorSolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
create_background_bitmap,
create_bitmaps,
)
from vectorizing.solvers.color.quantize import quantize
from vectorizing.solvers.color.quantize import (
auto_color_count,
get_background_cluster,
quantize,
)
from vectorizing.util.limit_size import limit_size


Expand Down Expand Up @@ -97,20 +101,36 @@ class ColorSolver:
def __init__(
self,
img: Image.Image,
color_count: int | None,
color_count: int | str | None,
timer: Timer,
configuration: str = DEFAULT_CONFIGURATION,
) -> None:
color_count = color_count or ColorSolver.DEFAULT_COLOR_COUNT
color_count = max(color_count, ColorSolver.MIN_COLOR_COUNT)
color_count = min(color_count, ColorSolver.MAX_COLOR_COUNT)
self.color_count = color_count

self.img = limit_size(img)

# Init image array
self.img_arr = np.asarray(self.img).astype(np.uint8)

self.auto_method = None
if color_count == "auto":
background = (
get_background_cluster(self.img_arr)
if self.img_arr.shape[-1] == 4
else None
)
self.color_count, representative, self.auto_method = auto_color_count(
self.img_arr,
background,
)
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.
self.img_arr = self.img_arr.copy()
self.img_arr[background > 0, :3] = representative
else:
color_count = color_count or ColorSolver.DEFAULT_COLOR_COUNT
color_count = max(color_count, ColorSolver.MIN_COLOR_COUNT)
self.color_count = min(color_count, ColorSolver.MAX_COLOR_COUNT)

self.timer = timer
self.configuration = CONFIGURATIONS[configuration]

Expand All @@ -120,7 +140,11 @@ def solve(self) -> list[list[Path] | list[np.ndarray] | int]:
quantizer = (
legacy.quantize if self.configuration.original_quantization else quantize
)
labels, colors, has_background = quantizer(self.img_arr, self.color_count)
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)
)
self.timer.end_timer()

self.timer.start_timer("Bitmap Creation")
Expand Down
10 changes: 9 additions & 1 deletion vectorizing/solvers/color/legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import potrace
import scipy.ndimage as ndi
from pathops import Path, PathOp, op
from PIL import Image
from skimage.measure import label

from vectorizing.geometry.potrace import potrace_path_to_compound_path
Expand All @@ -18,14 +19,21 @@
def quantize(
img_arr: np.ndarray,
color_count: int,
*,
auto_method: Image.Quantize | None = None,
) -> tuple[np.ndarray, np.ndarray, bool]:
"""Run the original strong-filter and overlap-based component cleanup."""
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)
labels, colors = kmeans(rgb, get_initial_centroids(rgb, color_count))
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 background is not None:
labels = np.where(background > 0, 0, labels + 1)
Expand Down
99 changes: 96 additions & 3 deletions vectorizing/solvers/color/quantize.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,88 @@
MIN_COMPONENT_AREA = 8


def auto_color_count(
img_arr: np.ndarray,
background: np.ndarray | None,
) -> tuple[int, np.ndarray | None, Image.Quantize]:
"""Return color count, representative visible RGB and chosen seed method.

Small sample palettes choose K and the K-means initialization method; the
selected profile still quantizes the full processed image with its own filter.

Parameters
----------
img_arr : numpy.ndarray
Processed RGB or RGBA pixels.
background : numpy.ndarray or None
Nonzero at pixels that the quantizer treats as transparent background.

Returns
-------
tuple
Color count, representative RGB (or None), and seed method.
"""
pixels = (
img_arr[background == 0, :3]
if background is not None
else img_arr[:, :, :3].reshape(-1, 3)
)
if not len(pixels):
return 1, None, Image.Quantize.FASTOCTREE
if len(pixels) > 65536:
# Fixed-seed sampling avoids row-aligned strides hiding whole features.
indices = np.random.default_rng(0).choice(len(pixels), 65536, replace=False)
pixels = pixels[indices]
sample = Image.fromarray(pixels.reshape(1, -1, 3))
best_count = 0
representative = None
chosen_method = Image.Quantize.FASTOCTREE
# Octree keeps small vivid accents; median cut separates large close tones.
for method in (Image.Quantize.FASTOCTREE, Image.Quantize.MEDIANCUT):
palette_image = sample.quantize(16, method=method)
palette = np.asarray(palette_image.getpalette("RGB"), dtype=np.uint8).reshape(
-1,
3,
)
entries = sorted(palette_image.getcolors(), reverse=True)
dominant: list[np.ndarray] = []
for size, index in entries:
share = size / len(pixels)
if share < 0.005:
continue
color = palette[index].astype(np.float32)
# Large, subtly different regions (e.g. a patterned background)
# matter even when their RGB distance is below the edge-shade cutoff.
separation = 8 if share >= 0.1 else 25
if any(np.linalg.norm(color - other) < separation for other in dominant):
continue
# Small blended edge colors lie between two stronger colors; large
# intermediate regions can be real artwork and must keep their slot.
if share < 0.03 and any(
np.linalg.norm(
color
- first
- np.clip(
np.dot(color - first, second - first)
/ np.dot(second - first, second - first),
0,
1,
)
* (second - first),
)
< 25
for i, first in enumerate(dominant)
for second in dominant[i + 1 :]
):
continue
dominant.append(color)
if len(dominant) > best_count:
best_count = len(dominant)
representative = palette[entries[0][1]]
chosen_method = method
return max(1, best_count), representative, chosen_method


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 @@ -105,10 +187,14 @@ def write_background_cluster(labels: np.ndarray, bg_cluster: np.ndarray) -> np.n
return labels


def get_initial_centroids(img_arr: np.ndarray, color_count: int) -> np.ndarray:
def get_initial_centroids(
img_arr: np.ndarray,
color_count: int,
method: Image.Quantize = Image.Quantize.FASTOCTREE,
) -> np.ndarray:
"""Return sorted unique used RGB palette colors for K-means initialization."""
img = Image.fromarray(img_arr)
img = img.quantize(color_count, method=Image.Quantize.FASTOCTREE)
img = img.quantize(color_count, method=method)
# Palette padding must not introduce additional clusters. RGB order affects
# initialization, so sort the used entries rather than the entire pixel grid.
used_indices = [index for _, index in img.getcolors()]
Expand All @@ -133,11 +219,18 @@ def kmeans(
def quantize(
img_arr: np.ndarray,
color_count: int,
*,
auto_method: Image.Quantize | None = None,
) -> tuple[np.ndarray, np.ndarray, bool]:
"""Return area-cleaned labels, an RGB-ordered palette and background status."""
background = get_background_cluster(img_arr) if img_arr.shape[-1] == 4 else None
rgb = bilateral_filter(img_arr[:, :, :3].copy())
labels, colors = kmeans(rgb, get_initial_centroids(rgb, color_count))
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])

# Paint order determines which neighboring colors may receive a bounded rim.
Expand Down
Binary file added vectorizing/tests/images/test_logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 26 additions & 1 deletion vectorizing/tests/test_api_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
{"url": "image", "configuration": "legacy"},
{"url": "image", "configuration": None},
{"url": "image", "configuration": []},
{"url": "image", "solver": 1, "color_count": "automatic"},
],
)
def test_invalid_arguments_return_the_same_error(
Expand All @@ -41,6 +42,9 @@ def test_invalid_arguments_return_the_same_error(
[
{"url": "image"},
{"url": "image", "configuration": "experimental"},
{"url": "image", "color_count": "auto"},
{"url": "image", "solver": 1, "color_count": ""},
{"url": "image", "solver": 0, "color_count": "ignored"},
{"url": None},
{"url": "image", "solver": True},
{"url": "image", "crop_box": []},
Expand All @@ -58,11 +62,32 @@ def test_argument_validation_preserves_accepted_values(
"solver": payload.get("solver", 0),
"crop_box": payload.get("crop_box"),
"raw": None,
"color_count": None,
"color_count": payload.get("color_count"),
"configuration": payload.get("configuration", "current"),
}


def test_binary_ignores_string_color_count(
client: FlaskClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Leave unrelated color fields alone on the binary request path."""
monkeypatch.setattr(
vectorizing,
"try_read_image_from_url",
Mock(return_value=Image.new("RGB", (8, 8))),
)
response = client.post(
"/",
json={"url": "image", "solver": 0, "color_count": "ignored", "raw": True},
)
assert response.status_code == 200
assert (
response.data
== client.post("/", json={"url": "image", "solver": 0, "raw": True}).data
)


@pytest.mark.parametrize("solver", [0, 1])
def test_raw_requests_crop_before_vectorizing_and_do_not_upload(
solver: int,
Expand Down
Loading
Loading