diff --git a/AGENTS.md b/AGENTS.md
index 5b999a6..6e0edcb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,7 +8,8 @@ to S3. Start with `README.md` for setup and usage.
- `vectorizing/__init__.py`: app factory, request validation, cropping and API responses.
- `vectorizing/solvers/binary/`: binary masks and tracing.
-- `vectorizing/solvers/color/`: quantization, cluster cleanup, ordered masks and tracing.
+- `vectorizing/solvers/color/`: original and experimental quantization, cluster cleanup,
+ ordered masks and tracing; `ColorSolver.py` defines named color configurations.
- `vectorizing/geometry/` and `vectorizing/svg/`: path conversion, bounds and SVG serialization.
- `vectorizing/util/`: image loading, color-mode normalization and size limits.
- `vectorizing/server/`: environment settings, S3, logging and timing.
@@ -48,14 +49,22 @@ docker compose --profile testing run --build --rm test \
- Keep smooth anti-aliasing and transparent exteriors. Do not hide seams with SVG
strokes or `shape-rendering="crispEdges"`, or reduce normal tracing quality.
-- Color masks use bounded overlaps, not full underpainting. The current rim is
- two processed-image pixels; zoom enlarges it and very thin features may remain
- covered. Do not promise universally exact holes and seam-free rendering.
-- Preserve palette/centroid ordering, dtypes and overflow behavior, connectivity,
- and independent mask storage when optimizing arrays. Rim conversion runs forward
- because the next mask must still be cumulative.
+- Color requests default to `configuration: "current"` (original quantization,
+ layer clipping and SVG formatting). POST JSON may select `"experimental"` for the
+ newer quantization, bounded two-pixel rims, opaque-background detection/isolation
+ and compact SVG formatting. Both profiles share bitmap creation. Keep binary
+ output unaffected by color configuration changes.
+- Experimental color masks use bounded overlaps, not full underpainting. Zoom
+ enlarges the two-processed-pixel rim, and very thin features may remain covered.
+ Do not promise universally exact holes and seam-free rendering.
+- Preserve each profile's palette/centroid ordering, dtypes and overflow behavior,
+ connectivity, and independent mask storage when optimizing arrays. Original
+ cleanup streams one color at a time to avoid retaining component grids for all
+ colors; compare it with the independent pairwise reference when changing it.
+ Experimental rim conversion runs forward because the next mask must still be
+ cumulative.
- Preserve public defaults, response/error shapes, return containers, SVG paint
- order and numeric formatting during refactors or performance work.
+ order and each profile's numeric formatting during refactors or performance work.
- Image readers currently wrap all loading failures, including interrupts, and URL
downloads have no timeout. These are tested legacy contracts, not recommendations;
change them deliberately with updated tests, never incidentally during lint cleanup.
@@ -69,7 +78,9 @@ docker compose --profile testing run --build --rm test \
**not** prove equivalence; do not delete or replace baselines just to make tests pass.
- For output-preserving changes, capture before editing (or use a trusted baseline
checkout), then compare SVG bytes, RGBA pixels, palettes, dimensions and bounds
- with `scripts/vectorization_outputs.py`. See the README for commands.
+ with `scripts/vectorization_outputs.py`. Its default capture exercises `current`;
+ pass `--configuration experimental` to capture or benchmark the newer pipeline.
+ Benchmarks can select a separate `--baseline-configuration`. See the README.
- For layer/geometry changes, check layer deletion, zero alpha and half opacity,
transparent backgrounds and thin features, including `aftermath.png`. An assembled
render alone cannot establish editability.
diff --git a/README.md b/README.md
index 3b500ad..efa95ea 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,8 @@ Capture each version into a different directory, then compare:
python scripts/vectorization_outputs.py capture .user/before --source-root /path/to/baseline
python scripts/vectorization_outputs.py capture .user/after
python scripts/vectorization_outputs.py compare .user/before .user/after
+python scripts/vectorization_outputs.py capture .user/experimental --configuration experimental
+python scripts/vectorization_outputs.py compare .user/before .user/experimental # for a newer-pipeline baseline
python scripts/benchmark_vectorization.py --output .user/benchmark.json --case aftermath-16
```
@@ -73,6 +75,7 @@ python scripts/benchmark_vectorization.py --output .user/benchmark.json --case a
- Benchmark timing covers the solver, SVG serialization and bounds, not file decoding, HTTP/S3 or rasterization. Each sample uses a fresh process; peak RSS includes imports, input preparation, warmups and output checks. Linux and macOS memory units are normalized to MiB.
- Add `--baseline-root /path/to/baseline` to benchmark two trusted checkouts in alternating order, with exact-output checks. `--repeats` defaults to 3 and `--warmups` to 1 per process. Output mismatches exit `1` but still save the report; timings have no pass/fail threshold.
- Reports include runtime/source fingerprints and raw samples. `--label` can record commit IDs. Checkouts share the installed dependencies; this is not a dependency-isolated or production/concurrency benchmark. Use only trusted source roots.
+- Captures and benchmarks use the source checkout's default color configuration unless `--configuration experimental` is passed. To compare the newer pipeline with a checkout from before configurations existed, pass `--configuration experimental` for the candidate and leave the baseline unconfigured. For two configured checkouts, `--baseline-configuration experimental` selects the baseline independently; unknown configurations fail rather than silently using a default. Binary cases ignore color configuration. Use the same `--case` selections for both captures, particularly when an older checkout lacks newer fixtures.
For Docker, build the current scripts and mount only the output directory:
@@ -96,6 +99,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)
+ configuration: string, // Optional color profile: "current" (default) or "experimental"
raw: boolean // If true, plain return plain SVG markup
}
```
@@ -123,7 +127,9 @@ A typical response would be
Or, if `raw = true` was supplied, just plain SVG markup
-Color processing uses a gentle 3-pixel bilateral filter and removes only connected
+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
components smaller than eight processed-image pixels, rather than rejecting thin
regions based on their shape. Transparent pixels are protected; on opaque images,
the most common surviving perimeter color is also protected to retain small
@@ -138,15 +144,14 @@ constrains any outlying curve control points to the canvas. It keeps smooth edit
vectors instead of failing the request, but can adjust curves near the canvas and
round corners inward, reducing edge coverage on the affected layer.
-SVGs use compact absolute/relative commands on an integer hundredth-pixel grid,
-inside a `scale(.01)` group. This preserves the previous two-decimal coordinate
-rounding, viewport, paint order and opacity without raster images or SVG strokes.
-Binary tracing is unchanged, but both solvers use the compact serializer. Equivalent
-geometry can produce small renderer-specific antialiasing differences; exact pixel
-identity across viewers and zoom levels is not guaranteed. Potrace and pypotrace
-remain dependencies under their existing GPL licenses.
+The default color profile uses the original two-decimal SVG serialization.
+Binary and `experimental` color responses use compact absolute/relative commands
+on an integer hundredth-pixel grid inside a `scale(.01)` group. All profiles emit
+editable vectors without raster images or strokes. Equivalent geometry can produce
+small renderer-specific antialiasing differences across viewers and zoom levels.
+Potrace and pypotrace remain dependencies under their existing GPL licenses.
-Color layers have cutouts with a small overlap along shared edges to hide seams.
+The `experimental` color layers have cutouts with a small overlap along shared edges to hide seams.
The overlap is two pixels in the resized image before tracing, not screen pixels.
Hiding a color can expose this rim; very thin features may remain covered, and
zooming in makes the rim larger. No SVG strokes are added.
diff --git a/scripts/_vectorization.py b/scripts/_vectorization.py
index cd8941b..cedb524 100644
--- a/scripts/_vectorization.py
+++ b/scripts/_vectorization.py
@@ -82,6 +82,7 @@ def environment() -> dict[str, object]:
def prepare_case(
name: str,
source_root: Path,
+ configuration: str | None = None,
) -> Callable[[], tuple[str, dict[str, object]]]:
"""Load a fixture once and return a fresh solver/SVG/bounds run for its checkout."""
source_root = source_root.resolve()
@@ -100,7 +101,7 @@ def prepare_case(
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_SVG_markup
+ from vectorizing.svg import markup
from vectorizing.util.read import convert_RGB_A
if Path(vectorizing.__file__).resolve().parent != source_root / "vectorizing":
@@ -108,6 +109,18 @@ def prepare_case(
"A different checkout is already imported; use a fresh process",
)
filename, solver, count = CASES[name]
+ solver_module = sys.modules[ColorSolver.__module__]
+ profiles = getattr(solver_module, "CONFIGURATIONS", {})
+ selected = (
+ configuration
+ if configuration is not None
+ else getattr(solver_module, "DEFAULT_CONFIGURATION", None)
+ )
+ if configuration is not None and selected not in profiles:
+ raise ValueError(
+ f"Configuration {configuration!r} is not supported by {source_root}",
+ )
+ color_args = (configuration,) if configuration is not None else ()
path = source_root / "vectorizing" / "tests" / "images" / filename
with Image.open(path) as source:
image = convert_RGB_A(source).copy()
@@ -122,7 +135,7 @@ def run() -> tuple[str, dict[str, object]]:
result = (
BinarySolver(image).solve()
if solver == "binary"
- else ColorSolver(image, count, Timer()).solve()
+ else ColorSolver(image, count, Timer(), *color_args).solve()
)
paths, colors, width, height = result
bounds = compound_paths_bounds(paths)
@@ -132,7 +145,14 @@ def run() -> tuple[str, dict[str, object]]:
for key, value in bounds.items()
}
palette = np.asarray(colors)
- return generate_SVG_markup(*result), {
+ serialize = (
+ markup.generate_original_SVG_markup
+ if solver == "color"
+ and selected in profiles
+ and profiles[selected].original_svg_serialization
+ else markup.generate_SVG_markup
+ )
+ return serialize(*result), {
"input": source_info,
"colors": palette.tolist(),
"colors_dtype": str(palette.dtype),
diff --git a/scripts/benchmark_vectorization.py b/scripts/benchmark_vectorization.py
index 00fdd06..7de73bd 100644
--- a/scripts/benchmark_vectorization.py
+++ b/scripts/benchmark_vectorization.py
@@ -20,7 +20,12 @@ def peak_rss_mib() -> float:
return value / (1024 * 1024 if sys.platform == "darwin" else 1024)
-def sample(name: str, source_root: Path, warmups: int) -> dict[str, object]:
+def sample(
+ name: str,
+ source_root: Path,
+ warmups: int,
+ configuration: str | None = None,
+) -> dict[str, object]:
"""Warm one case, time one run and verify stable output in this fresh process."""
from scripts._vectorization import (
environment,
@@ -29,7 +34,7 @@ def sample(name: str, source_root: Path, warmups: int) -> dict[str, object]:
source_hash,
)
- run = prepare_case(name, source_root)
+ run = prepare_case(name, source_root, configuration)
revision = source_hash(source_root)
expected = None
consistent = True
@@ -53,7 +58,12 @@ def sample(name: str, source_root: Path, warmups: int) -> dict[str, object]:
}
-def run_sample(name: str, source_root: Path, warmups: int) -> dict[str, object]:
+def run_sample(
+ name: str,
+ source_root: Path,
+ warmups: int,
+ configuration: str | None = None,
+) -> dict[str, object]:
"""Run this script in a new interpreter, propagating worker failures."""
command = [
sys.executable,
@@ -66,6 +76,8 @@ def run_sample(name: str, source_root: Path, warmups: int) -> dict[str, object]:
"--warmups",
str(warmups),
]
+ if configuration is not None:
+ command.extend(["--configuration", configuration])
# The executable and script are fixed; arguments are passed without a shell.
result = subprocess.run(command, capture_output=True, text=True) # noqa: S603
if result.returncode:
@@ -80,6 +92,7 @@ def benchmark(
roots: dict[str, Path],
repeats: int,
warmups: int,
+ configurations: dict[str, str | None] | None = None,
) -> tuple[dict[str, object], bool]:
"""Alternate variants, retaining raw samples and exact-output equality checks."""
cases = {}
@@ -89,7 +102,14 @@ def benchmark(
for iteration in range(repeats):
order = list(roots) if iteration % 2 == 0 else list(reversed(roots))
for variant in order:
- samples[variant].append(run_sample(name, roots[variant], warmups))
+ samples[variant].append(
+ run_sample(
+ name,
+ roots[variant],
+ warmups,
+ (configurations or {}).get(variant),
+ ),
+ )
reference = next(iter(samples.values()))[0]["output"]
equal = all(
row["consistent"] and row["output"] == reference
@@ -157,6 +177,14 @@ def main(argv: list[str] | None = None) -> int:
choices=CASES,
help="Repeat to select cases; default: all",
)
+ parser.add_argument(
+ "--configuration",
+ help="Candidate color profile; omitted uses its default",
+ )
+ parser.add_argument(
+ "--baseline-configuration",
+ help="Baseline color profile; omitted uses its checkout's default",
+ )
parser.add_argument("--repeats", type=positive, default=3)
parser.add_argument(
"--warmups",
@@ -177,22 +205,40 @@ def main(argv: list[str] | None = None) -> int:
raise ValueError("A worker measures exactly one case")
print(
json.dumps(
- sample(names[0], args.source_root, args.warmups),
+ sample(
+ names[0],
+ args.source_root,
+ args.warmups,
+ args.configuration,
+ ),
allow_nan=False,
),
)
return 0
if args.output is None:
raise ValueError("--output is required")
+ if args.baseline_configuration is not None and args.baseline_root is None:
+ raise ValueError("--baseline-configuration requires --baseline-root")
if args.output.exists():
raise ValueError(f"Refusing to overwrite: {args.output}")
roots = {"baseline": args.baseline_root.resolve()} if args.baseline_root else {}
roots["candidate"] = args.source_root.resolve()
- cases, equal = benchmark(names, roots, args.repeats, args.warmups)
+ configurations = {
+ "baseline": args.baseline_configuration,
+ "candidate": args.configuration,
+ }
+ cases, equal = benchmark(
+ names,
+ roots,
+ args.repeats,
+ args.warmups,
+ configurations,
+ )
report = {
"schema_version": 1,
"label": args.label,
"source_roots": {name: str(path) for name, path in roots.items()},
+ "configurations": {name: configurations[name] for name in roots},
"repeats": args.repeats,
"warmups_per_process": args.warmups,
"timing_scope": "solver, SVG serialization, colors, dimensions and bounds",
diff --git a/scripts/vectorization_outputs.py b/scripts/vectorization_outputs.py
index 1b86e0d..74e7767 100644
--- a/scripts/vectorization_outputs.py
+++ b/scripts/vectorization_outputs.py
@@ -12,6 +12,7 @@ def capture(
names: list[str],
source_root: Path,
label: str | None,
+ configuration: str | None = None,
) -> None:
"""Write a complete manifest last, refusing to replace existing artifacts."""
from cairosvg import svg2png
@@ -28,7 +29,7 @@ def capture(
output.mkdir(parents=True, exist_ok=True)
cases = {}
for name in names:
- svg, details = prepare_case(name, source_root)()
+ svg, details = prepare_case(name, source_root, configuration)()
svg_bytes = svg.encode("utf-8")
for extension, data in {
"svg": svg_bytes,
@@ -46,6 +47,7 @@ def capture(
report = {
"schema_version": 1,
"label": label,
+ "configuration": configuration,
"source_root": str(source_root.resolve()),
"source_sha256": source_hash(source_root),
"environment": environment(),
@@ -155,6 +157,10 @@ def main(argv: list[str] | None = None) -> int:
"--label",
help="Optional revision or description for the manifest",
)
+ save.add_argument(
+ "--configuration",
+ help="Color profile in the source checkout; omitted uses its default",
+ )
diff = commands.add_parser(
"compare",
help="Compare complete captures; exit 1 on changes",
@@ -170,6 +176,7 @@ def main(argv: list[str] | None = None) -> int:
list(dict.fromkeys(args.case or CASES)),
args.source_root,
args.label,
+ args.configuration,
)
return 0
except (OSError, ValueError, ImportError) as error:
diff --git a/vectorizing/__init__.py b/vectorizing/__init__.py
index 26d04e5..1195a05 100644
--- a/vectorizing/__init__.py
+++ b/vectorizing/__init__.py
@@ -16,8 +16,12 @@
from vectorizing.server.s3 import upload_markup
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_SVG_markup
+from vectorizing.solvers.color.ColorSolver import (
+ CONFIGURATIONS,
+ DEFAULT_CONFIGURATION,
+ ColorSolver,
+)
+from vectorizing.svg.markup import generate_original_SVG_markup, generate_SVG_markup
from vectorizing.util.read import try_read_image_from_url
# 0 -> BinarySolver
@@ -46,9 +50,10 @@ def process_color(
img: Image.Image,
color_count: int | None,
timer: Timer,
+ configuration: str = DEFAULT_CONFIGURATION,
) -> list[list[Path] | list[np.ndarray] | int]:
- """Trace color layers with bounded overlaps and return processed dimensions."""
- solver = ColorSolver(img, color_count, timer)
+ """Trace color layers using a named processing configuration."""
+ solver = ColorSolver(img, color_count, timer, configuration)
return solver.solve()
@@ -61,6 +66,10 @@ def validate_args(args: dict[str, object]) -> SimpleNamespace | Literal[False]:
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
+
box = args.get("crop_box")
if box:
if len(box) != 4:
@@ -76,6 +85,7 @@ def validate_args(args: dict[str, object]) -> SimpleNamespace | Literal[False]:
url=args.get("url"),
raw=args.get("raw"),
color_count=args.get("color_count"),
+ configuration=configuration,
)
@@ -100,6 +110,7 @@ def index() -> str | Response | tuple[Response, int]:
url = args.url
solver = args.solver
color_count = args.color_count
+ configuration = args.configuration
raw = args.raw
crop_box = args.crop_box
@@ -120,13 +131,19 @@ def index() -> str | Response | tuple[Response, int]:
else:
timer.start_timer("Color Solver - Total")
- solved = process_color(img, color_count, timer)
+ solved = process_color(img, color_count, timer, configuration)
timer.end_timer()
compound_paths, colors, width, height = solved
timer.start_timer("Markup Creation")
- markup = generate_SVG_markup(compound_paths, colors, width, height)
+ serialize = (
+ generate_original_SVG_markup
+ if solver == 1
+ and CONFIGURATIONS[configuration].original_svg_serialization
+ else generate_SVG_markup
+ )
+ markup = serialize(compound_paths, colors, width, height)
timer.end_timer()
if raw:
diff --git a/vectorizing/solvers/color/ColorSolver.py b/vectorizing/solvers/color/ColorSolver.py
index c91a015..b2920d9 100644
--- a/vectorizing/solvers/color/ColorSolver.py
+++ b/vectorizing/solvers/color/ColorSolver.py
@@ -1,6 +1,7 @@
"""Trace editable color layers with bounded overlap at shared edges."""
import logging
+from dataclasses import dataclass
import numpy as np
import potrace
@@ -9,6 +10,7 @@
from vectorizing.geometry.potrace import potrace_path_to_compound_path
from vectorizing.server.timer import Timer
+from vectorizing.solvers.color import legacy
from vectorizing.solvers.color.bitmaps import (
add_bitmap_rims,
create_background_bitmap,
@@ -18,6 +20,28 @@
from vectorizing.util.limit_size import limit_size
+@dataclass(frozen=True)
+class ColorConfiguration:
+ """Switchable color-processing stages collected into named presets."""
+
+ original_quantization: bool = False
+ original_layer_clipping: bool = False
+ detect_opaque_background: bool = True
+ original_svg_serialization: bool = False
+
+
+CONFIGURATIONS = {
+ "current": ColorConfiguration(
+ original_quantization=True,
+ original_layer_clipping=True,
+ detect_opaque_background=False,
+ original_svg_serialization=True,
+ ),
+ "experimental": ColorConfiguration(),
+}
+DEFAULT_CONFIGURATION = "current"
+
+
def _trace_mask(bitmap: np.ndarray) -> Path:
"""Trace a mask with the same detail settings on normal and recovery paths."""
traced = potrace.Bitmap(bitmap).trace(turdsize=0, opttolerance=0.5, alphamax=1)
@@ -70,7 +94,13 @@ def trace_bitmap(bitmap: np.ndarray, *, recover_clip: bool = True) -> Path:
class ColorSolver:
"""Resize and quantize an image, then trace its ordered color layers."""
- def __init__(self, img: Image.Image, color_count: int | None, timer: Timer) -> None:
+ def __init__(
+ self,
+ img: Image.Image,
+ color_count: int | 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)
@@ -82,21 +112,39 @@ def __init__(self, img: Image.Image, color_count: int | None, timer: Timer) -> N
self.img_arr = np.asarray(self.img).astype(np.uint8)
self.timer = timer
+ self.configuration = CONFIGURATIONS[configuration]
def solve(self) -> list[list[Path] | list[np.ndarray] | int]:
"""Return paths, colors, width and height as a list."""
self.timer.start_timer("Quantization")
- labels, colors, has_background = quantize(self.img_arr, self.color_count)
+ quantizer = (
+ legacy.quantize if self.configuration.original_quantization else quantize
+ )
+ labels, colors, has_background = quantizer(self.img_arr, self.color_count)
self.timer.end_timer()
self.timer.start_timer("Bitmap Creation")
- background_bitmap = create_background_bitmap(labels, colors, has_background)
+ background_bitmap = (
+ create_background_bitmap(labels, colors, has_background)
+ if self.configuration.detect_opaque_background
+ else None
+ )
bitmaps, colors = create_bitmaps(labels, colors, has_background)
- add_bitmap_rims(bitmaps)
+ if not self.configuration.original_layer_clipping:
+ add_bitmap_rims(bitmaps)
self.timer.end_timer()
self.timer.start_timer("Bitmap Tracing")
- compound_paths = [trace_bitmap(bitmap) for bitmap in bitmaps]
+ if self.configuration.original_layer_clipping:
+ traced = [potrace.Bitmap(bitmap).trace() for bitmap in bitmaps]
+ compound_paths = legacy.remove_layering(
+ traced,
+ self.img.size[0],
+ self.img.size[1],
+ has_background,
+ )
+ else:
+ compound_paths = [trace_bitmap(bitmap) for bitmap in bitmaps]
if background_bitmap is not None:
try:
background = trace_bitmap(background_bitmap, recover_clip=False)
diff --git a/vectorizing/solvers/color/legacy.py b/vectorizing/solvers/color/legacy.py
new file mode 100644
index 0000000..0abea64
--- /dev/null
+++ b/vectorizing/solvers/color/legacy.py
@@ -0,0 +1,117 @@
+"""Color-processing operations retained for the pre-layer-seam configuration."""
+
+import cv2
+import numpy as np
+import potrace
+import scipy.ndimage as ndi
+from pathops import Path, PathOp, op
+from skimage.measure import label
+
+from vectorizing.geometry.potrace import potrace_path_to_compound_path
+from vectorizing.solvers.color.quantize import (
+ get_background_cluster,
+ get_initial_centroids,
+ kmeans,
+)
+
+
+def quantize(
+ img_arr: np.ndarray,
+ color_count: int,
+) -> 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))
+ labels = labels.reshape(rgb.shape[:2])
+ if background is not None:
+ labels = np.where(background > 0, 0, labels + 1)
+ colors = np.vstack(
+ (
+ np.zeros((1, 4), dtype=np.uint8),
+ np.column_stack((colors, np.ones(len(colors), dtype=np.uint8))),
+ ),
+ )
+ labels = labels + 1
+ enhanced = np.zeros(labels.shape, dtype=np.uint16)
+ kernel = np.ones((2, 2))
+ for index in range(len(colors)):
+ mask = labels == index + 1
+ if not mask.any():
+ continue
+ components = label(mask) + 1
+ counts = np.bincount(components.ravel())
+ # A pixel is erased from this component when any other color's
+ # two-pixel dilation covers it. The union replaces pairwise overlaps.
+ covered = cv2.dilate((~mask).astype(np.uint8), kernel) > 0
+ remaining = np.bincount(components[mask & ~covered], minlength=len(counts))
+ ratio = np.divide(
+ remaining.astype(np.float32),
+ counts.astype(np.float32),
+ out=np.ones(len(counts), dtype=np.float32),
+ where=counts != 0,
+ )
+ enhanced[mask & (ratio[components] > 0.1)] = index + 1
+ nearest = ndi.distance_transform_edt(
+ enhanced == 0,
+ return_distances=False,
+ return_indices=True,
+ )
+ enhanced = np.where(enhanced != 0, enhanced, enhanced[tuple(nearest)])
+ return enhanced - 1, colors.astype(np.uint8), background is not None
+
+
+def create_background_rect(width: int, height: int, padding: int) -> Path:
+ """Build the padded clipping rectangle used by the original layer solver."""
+ rect = Path()
+ rect.moveTo(-padding, -padding)
+ rect.lineTo(width + padding, -padding)
+ rect.lineTo(width + padding, height + padding)
+ rect.lineTo(-padding, height + padding)
+ rect.close()
+ return rect
+
+
+def remove_layering(
+ traced_bitmaps: list[potrace.Path],
+ width: int,
+ height: int,
+ has_background: bool,
+) -> list[Path]:
+ """Apply the original boolean clipping strategy to traced palette layers."""
+ paths = [potrace_path_to_compound_path(path) for path in traced_bitmaps]
+ if has_background:
+ for i in range(len(paths) - 1):
+ try:
+ paths[i] = op(paths[i], paths[i + 1], PathOp.DIFFERENCE)
+ except Exception:
+ break
+ return paths
+
+ disjoint = []
+ for i in range(len(paths) - 1):
+ subtract = Path()
+ for path in disjoint:
+ subtract.addPath(path)
+ subtract.addPath(paths[i + 1])
+ try:
+ disjoint.append(
+ op(
+ create_background_rect(width, height, (i + 1) * 10),
+ subtract,
+ PathOp.DIFFERENCE,
+ ),
+ )
+ except Exception:
+ break
+ try:
+ disjoint = [
+ op(path, create_background_rect(width, height, 0), PathOp.INTERSECTION)
+ for path in disjoint
+ ]
+ except Exception:
+ return paths
+ return disjoint + paths[len(disjoint) :]
diff --git a/vectorizing/svg/markup.py b/vectorizing/svg/markup.py
index 9dfb8a9..3030af3 100644
--- a/vectorizing/svg/markup.py
+++ b/vectorizing/svg/markup.py
@@ -112,6 +112,43 @@ def _path_data(path: Path, width: int, height: int) -> str:
return "".join(pieces)
+def generate_original_SVG_markup(
+ compound_paths: Iterable[Path],
+ colors: Iterable[Sequence[float] | np.ndarray],
+ width: int,
+ height: int,
+) -> str:
+ """Serialize with the original absolute commands and two-decimal coordinates."""
+ paths_markup = []
+ for compound_path, color in zip(compound_paths, colors):
+ segments = list(compound_path.segments)
+ if not segments:
+ continue
+ data = ""
+ for command, points in segments:
+ if command == "moveTo":
+ data += f"M {truncate(points[0][0])} {truncate(points[0][1])} "
+ if command == "lineTo":
+ data += f"L {truncate(points[0][0])} {truncate(points[0][1])} "
+ if command == "curveTo":
+ data += (
+ "C" + f"{truncate(points[0][0])},{truncate(points[0][1])} "
+ f"{truncate(points[1][0])},{truncate(points[1][1])} "
+ f"{truncate(points[2][0])},{truncate(points[2][1])} "
+ )
+ if command == "closePath":
+ data += "Z"
+ paths_markup.append(
+ f'',
+ )
+ markup = "\n".join(paths_markup)
+ return (
+ f'"
+ )
+
+
def generate_SVG_markup(
compound_paths: Iterable[Path],
colors: Iterable[Sequence[float] | np.ndarray],
diff --git a/vectorizing/tests/color_reference.py b/vectorizing/tests/color_reference.py
index 2b93675..c868dac 100644
--- a/vectorizing/tests/color_reference.py
+++ b/vectorizing/tests/color_reference.py
@@ -5,6 +5,8 @@
import cv2
import numpy as np
from PIL import Image
+from scipy.ndimage import distance_transform_edt
+from skimage.measure import label
def legacy_initial_centroids(img_arr: np.ndarray, color_count: int) -> np.ndarray:
@@ -43,6 +45,46 @@ def legacy_create_bitmaps(
return bitmaps, colors
+def original_area_cleanup(labels: np.ndarray, color_count: int) -> np.ndarray:
+ """Run the original pairwise-dilation component cleanup on a small label grid."""
+ shifted = labels + 1
+ clusters = [
+ np.where(shifted == index + 1, index + 1, 0).astype(np.uint16)
+ for index in range(color_count)
+ ]
+ original = [label(cluster) + 1 for cluster in clusters]
+ counts = [np.bincount(components.ravel()) for components in original]
+ remaining = [components.copy() for components in original]
+ for x, cluster_x in enumerate(clusters):
+ dilated = cv2.dilate(cluster_x, np.ones((2, 2)))
+ for y, cluster_y in enumerate(clusters):
+ if x != y:
+ remaining[y] = np.where(
+ np.logical_and(dilated, cluster_y),
+ 0,
+ remaining[y],
+ )
+ for x, components in enumerate(remaining):
+ current = np.bincount(components.ravel(), minlength=len(counts[x]))
+ ratio = np.divide(
+ current.astype(np.float32),
+ counts[x].astype(np.float32),
+ out=np.ones(len(counts[x]), dtype=np.float32),
+ where=counts[x] != 0,
+ )
+ clusters[x] = np.where(ratio[original[x]] <= 0.1, 0, clusters[x])
+ enhanced = np.zeros(labels.shape, dtype=np.uint8)
+ for cluster in clusters:
+ enhanced = np.where(enhanced == 0, cluster, enhanced)
+ nearest = distance_transform_edt(
+ enhanced == 0,
+ return_distances=False,
+ return_indices=True,
+ )
+ enhanced = np.where(enhanced != 0, enhanced, enhanced[tuple(nearest)])
+ return enhanced - 1
+
+
def reference_clean_components(
labels: np.ndarray,
background: np.ndarray | None,
diff --git a/vectorizing/tests/test_api_contracts.py b/vectorizing/tests/test_api_contracts.py
index c46236a..6c28e81 100644
--- a/vectorizing/tests/test_api_contracts.py
+++ b/vectorizing/tests/test_api_contracts.py
@@ -19,6 +19,10 @@
{"url": "image", "solver": "1"},
{"url": "image", "crop_box": [0, 0, 3]},
{"url": "image", "crop_box": [0, 0, 3.5, 4]},
+ {"url": "image", "configuration": "unknown"},
+ {"url": "image", "configuration": "legacy"},
+ {"url": "image", "configuration": None},
+ {"url": "image", "configuration": []},
],
)
def test_invalid_arguments_return_the_same_error(
@@ -36,6 +40,7 @@ def test_invalid_arguments_return_the_same_error(
"payload",
[
{"url": "image"},
+ {"url": "image", "configuration": "experimental"},
{"url": None},
{"url": "image", "solver": True},
{"url": "image", "crop_box": []},
@@ -54,6 +59,7 @@ def test_argument_validation_preserves_accepted_values(
"crop_box": payload.get("crop_box"),
"raw": None,
"color_count": None,
+ "configuration": payload.get("configuration", "current"),
}
@@ -89,12 +95,39 @@ def test_raw_requests_crop_before_vectorizing_and_do_not_upload(
assert b'width="8" height="8"' in response.data
chosen, unused = (binary, color) if solver == 0 else (color, binary)
chosen.assert_called_once()
+ if solver == 1:
+ assert chosen.call_args.args[3] == "current"
assert chosen.call_args.args[0].size == (4, 4)
unused.assert_not_called()
load.assert_called_once_with("image")
upload.assert_not_called()
+def test_color_configuration_can_be_forced(
+ client: FlaskClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Pass a named request configuration through to color processing."""
+ monkeypatch.setattr(
+ vectorizing,
+ "try_read_image_from_url",
+ Mock(return_value=Image.new("RGB", (8, 8))),
+ )
+ color = Mock(return_value=([], [], 8, 8))
+ monkeypatch.setattr(vectorizing, "process_color", color)
+ response = client.post(
+ "/",
+ json={
+ "url": "image",
+ "solver": 1,
+ "configuration": "experimental",
+ "raw": True,
+ },
+ )
+ assert response.status_code == 200
+ assert color.call_args.args[3] == "experimental"
+
+
def test_processing_failure_keeps_json_error(
client: FlaskClient,
monkeypatch: pytest.MonkeyPatch,
diff --git a/vectorizing/tests/test_color_background.py b/vectorizing/tests/test_color_background.py
index 9209bfa..363cb24 100644
--- a/vectorizing/tests/test_color_background.py
+++ b/vectorizing/tests/test_color_background.py
@@ -129,7 +129,7 @@ def test_partial_alpha_uses_existing_transparency_classification(
labels, palette = background_labels
rgba = np.dstack([palette[labels], np.full(labels.shape, 255, np.uint8)])
rgba[0, 0, 3] = 217
- result = ColorSolver(Image.fromarray(rgba), 3, Timer()).solve()
+ result = ColorSolver(Image.fromarray(rgba), 3, Timer(), "experimental").solve()
assert isinstance(result, list)
np.testing.assert_array_equal(result[1][0], palette[2])
@@ -181,7 +181,15 @@ def controlled_op(*args: object, **kwargs: object) -> object:
monkeypatch.setattr("vectorizing.solvers.color.ColorSolver.op", controlled_op)
upload = Mock(return_value="original-layers")
monkeypatch.setattr("vectorizing.upload_markup", upload)
- response = client.post("/", json={"url": "unused", "solver": 1, "raw": raw})
+ response = client.post(
+ "/",
+ json={
+ "url": "unused",
+ "solver": 1,
+ "configuration": "experimental",
+ "raw": raw,
+ },
+ )
assert response.status_code == 200
if raw:
markup = response.data.decode()
diff --git a/vectorizing/tests/test_color_configurations.py b/vectorizing/tests/test_color_configurations.py
new file mode 100644
index 0000000..f90e078
--- /dev/null
+++ b/vectorizing/tests/test_color_configurations.py
@@ -0,0 +1,181 @@
+"""Keep both selectable color pipelines covered by their independent outputs."""
+
+import hashlib
+import tracemalloc
+from pathlib import Path
+from unittest.mock import Mock
+
+import numpy as np
+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.ColorSolver import ColorSolver
+from vectorizing.svg.markup import 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
+
+
+def transparent_artwork() -> Image.Image:
+ """Build opaque nested colors with transparent exterior and an alpha hole."""
+ pixels = np.zeros((36, 36, 4), dtype=np.uint8)
+ pixels[:] = [30, 100, 220, 0]
+ pixels[3:33, 3:33] = [240, 230, 190, 255]
+ pixels[9:27, 9:27] = [35, 60, 130, 255]
+ pixels[16:20, 16:20] = [220, 10, 40, 255]
+ pixels[13:15, 13:15, 3] = 0
+ return Image.fromarray(pixels)
+
+
+def test_default_quantization_matches_original_rgba_output() -> None:
+ """Pin the original palette, transparent cluster, label dtype and mask bytes."""
+ labels, colors, has_background = legacy.quantize(
+ np.asarray(transparent_artwork()),
+ 4,
+ )
+ assert has_background
+ assert labels.dtype == np.uint16
+ assert hashlib.sha256(labels.tobytes()).hexdigest() == (
+ "e410dd6134fe3fe03bf99b184f1dd4f0bf5a03d007d3c42b6b36e6bd8f9bc226"
+ )
+ np.testing.assert_array_equal(
+ colors,
+ [
+ [0, 0, 0, 0],
+ [30, 100, 220, 1],
+ [35, 59, 130, 1],
+ [220, 10, 40, 1],
+ [240, 230, 190, 1],
+ ],
+ )
+
+
+def test_original_rgb_component_cleanup_matches_reference() -> None:
+ """Check the original connected-component decisions, including tiny clusters."""
+ path = Path(__file__).parent / "images" / "shapes_2.png"
+ with Image.open(path) as image:
+ labels, colors, has_background = legacy.quantize(
+ np.asarray(limit_size(image.convert("RGB"))).astype(np.uint8),
+ 7,
+ )
+ assert not has_background
+ assert hashlib.sha256(labels.tobytes()).hexdigest() == (
+ "a5455fcdb7c920fa369f9ab26304277bde52f3267d195712b1ecf4df132ab831"
+ )
+ np.testing.assert_array_equal(
+ colors,
+ [
+ [22, 22, 22],
+ [31, 172, 153],
+ [101, 205, 35],
+ [169, 20, 20],
+ [201, 20, 20],
+ [225, 21, 178],
+ [227, 230, 39],
+ ],
+ )
+
+
+@pytest.mark.parametrize("count", [2, 6, 16, 64])
+@pytest.mark.parametrize("transparent", [False, True])
+@pytest.mark.parametrize("seed", range(4))
+def test_original_cleanup_matches_pairwise_reference(
+ count: int,
+ transparent: bool,
+ seed: int,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Stream components without changing overlap ratios or filled labels."""
+ rng = np.random.default_rng(seed)
+ assigned = rng.integers(0, count, (11, 13), dtype=np.uint8)
+ colors = rng.integers(0, 256, (count, 3), dtype=np.uint8)
+ image = np.zeros((11, 13, 4 if transparent else 3), dtype=np.uint8)
+ if transparent:
+ image[:, :, 3] = 255
+ image[2:5, 3:6, 3] = 0
+ original_labels = np.where(image[:, :, 3] == 0, 0, assigned + 1)
+ else:
+ original_labels = assigned
+ monkeypatch.setattr(legacy, "get_initial_centroids", lambda *_: colors)
+ monkeypatch.setattr(legacy, "kmeans", lambda *_: (assigned.reshape(-1, 1), colors))
+ actual, palette, has_background = legacy.quantize(image, count)
+ np.testing.assert_array_equal(
+ actual,
+ original_area_cleanup(original_labels, count + int(transparent)),
+ )
+ assert actual.dtype == np.uint16
+ assert has_background is transparent
+ assert len(palette) == count + int(transparent)
+
+
+def test_original_cleanup_keeps_working_memory_bounded(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A 64-color image must not retain a component grid per palette entry."""
+ rng = np.random.default_rng(731)
+ assigned = rng.integers(0, 64, (256, 256), dtype=np.uint8)
+ colors = rng.integers(0, 256, (64, 3), dtype=np.uint8)
+ monkeypatch.setattr(legacy, "get_initial_centroids", lambda *_: colors)
+ monkeypatch.setattr(legacy, "kmeans", lambda *_: (assigned.reshape(-1, 1), colors))
+ tracemalloc.start()
+ try:
+ legacy.quantize(np.zeros((256, 256, 3), dtype=np.uint8), 64)
+ _, peak = tracemalloc.get_traced_memory()
+ finally:
+ tracemalloc.stop()
+ assert peak < 32 * 1024 * 1024
+
+
+def test_original_opaque_svg_matches_reference(
+ client: FlaskClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Pin the opaque API output captured from the original implementation."""
+ path = Path(__file__).parent / "images" / "black_rectangle.png"
+ with Image.open(path) as source:
+ image = convert_RGB_A(source).copy()
+ monkeypatch.setattr(
+ vectorizing,
+ "try_read_image_from_url",
+ Mock(return_value=image),
+ )
+ response = client.post(
+ "/",
+ json={"url": "image", "solver": 1, "color_count": 5, "raw": True},
+ )
+ assert response.status_code == 200
+ assert hashlib.sha256(response.data).hexdigest() == (
+ "6cc923711ade26ecda686ded32401cee8d3d730c4adc9e5a5e85151089d4bea1"
+ )
+
+
+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."""
+ image = transparent_artwork()
+ monkeypatch.setattr(
+ vectorizing,
+ "try_read_image_from_url",
+ Mock(return_value=image),
+ )
+ base = {"url": "image", "solver": 1, "color_count": 4, "raw": True}
+ default = client.post("/", json=base)
+ original = client.post("/", json={**base, "configuration": "current"})
+ 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 experimental.data != default.data
+ assert experimental.data.decode() == generate_SVG_markup(
+ *ColorSolver(image, 4, Timer(), "experimental").solve(),
+ )
+ assert client.post("/", json=base).data == default.data
diff --git a/vectorizing/tests/test_color_equivalence.py b/vectorizing/tests/test_color_equivalence.py
index 99f9d8d..9a41405 100644
--- a/vectorizing/tests/test_color_equivalence.py
+++ b/vectorizing/tests/test_color_equivalence.py
@@ -40,7 +40,7 @@ def test_color_optimizations_preserve_vectorization(
) -> None:
"""Compare SVG, colors, dimensions and bounds with independent operation oracles."""
with Image.open(Path(__file__).parent / "images" / image_name) as image:
- actual = ColorSolver(image, color_count, Timer()).solve()
+ actual = ColorSolver(image, color_count, Timer(), "experimental").solve()
target, legacy = {
"centroids": (
"vectorizing.solvers.color.quantize.get_initial_centroids",
@@ -58,7 +58,7 @@ def test_color_optimizations_preserve_vectorization(
# Patch each lookup site and verify the legacy implementation was used.
reference = Mock(wraps=legacy)
monkeypatch.setattr(target, reference)
- expected = ColorSolver(image, color_count, Timer()).solve()
+ expected = ColorSolver(image, color_count, Timer(), "experimental").solve()
reference.assert_called_once()
actual_paths, actual_colors, actual_width, actual_height = actual
diff --git a/vectorizing/tests/test_color_layer_artwork.py b/vectorizing/tests/test_color_layer_artwork.py
index d30624f..133d189 100644
--- a/vectorizing/tests/test_color_layer_artwork.py
+++ b/vectorizing/tests/test_color_layer_artwork.py
@@ -20,7 +20,7 @@
def test_opaque_artwork_has_no_alpha_seams(filename: str, count: int) -> None:
"""Keep opaque artwork opaque through every shared boundary."""
with Image.open(Path(__file__).parent / "images" / filename) as image:
- result = ColorSolver(image, count, Timer()).solve()
+ result = ColorSolver(image, count, Timer(), "experimental").solve()
markup = generate_SVG_markup(*result)
pixels = np.asarray(Image.open(BytesIO(svg2png(bytestring=markup))).convert("RGBA"))
np.testing.assert_array_equal(pixels[:, :, 3], 255)
@@ -47,7 +47,12 @@ def capture_foreground(masks: list[np.ndarray]) -> None:
rim_builder,
)
with Image.open(Path(__file__).parent / "images" / "aftermath.png") as image:
- paths, colors, width, height = ColorSolver(image, 16, Timer()).solve()
+ paths, colors, width, height = ColorSolver(
+ image,
+ 16,
+ Timer(),
+ "experimental",
+ ).solve()
rim_builder.assert_called_once()
markup = generate_SVG_markup(paths, colors, width, height)
full = np.asarray(Image.open(BytesIO(svg2png(bytestring=markup))).convert("RGBA"))
diff --git a/vectorizing/tests/test_color_layer_ownership.py b/vectorizing/tests/test_color_layer_ownership.py
index d7e6080..52b316b 100644
--- a/vectorizing/tests/test_color_layer_ownership.py
+++ b/vectorizing/tests/test_color_layer_ownership.py
@@ -42,6 +42,7 @@ def customer_svg(request: pytest.FixtureRequest) -> tuple[str, tuple[int, ...]]:
image,
request.param,
Timer(),
+ "experimental",
).solve()
# On this fixture the cream background is the last RGB-ordered color, not
# the first foreground color. It must become the first emitted layer.
diff --git a/vectorizing/tests/test_color_layers.py b/vectorizing/tests/test_color_layers.py
index c14bb05..c86e301 100644
--- a/vectorizing/tests/test_color_layers.py
+++ b/vectorizing/tests/test_color_layers.py
@@ -27,6 +27,7 @@ def trace_layers(
Image.fromarray(palette[labels]),
3,
Timer(),
+ "experimental",
).solve()
quantize.assert_called_once()
return paths, retained
diff --git a/vectorizing/tests/test_color_p3.py b/vectorizing/tests/test_color_p3.py
index 73e3dab..71e4a6d 100644
--- a/vectorizing/tests/test_color_p3.py
+++ b/vectorizing/tests/test_color_p3.py
@@ -40,7 +40,12 @@ def capture_rims(bitmaps: list[np.ndarray]) -> None:
capture_rims,
)
with Image.open(IMAGES / "aftermath.png") as image:
- paths, colors, width, height = ColorSolver(image, 8, Timer()).solve()
+ paths, colors, width, height = ColorSolver(
+ image,
+ 8,
+ Timer(),
+ "experimental",
+ ).solve()
# Ordered masks are identical across ARM64 and x86_64. Native Potrace makes
# different curve-optimization decisions on those identical inputs. Both
# geometry captures were checked for coverage and composited render error;
@@ -79,7 +84,7 @@ def capture_rims(bitmaps: list[np.ndarray]) -> None:
def bubbles_pixels() -> np.ndarray:
"""Render the real 16-color artwork at its original dimensions."""
with Image.open(IMAGES / "bubbles.png") as image:
- result = ColorSolver(image, 16, Timer()).solve()
+ result = ColorSolver(image, 16, Timer(), "experimental").solve()
png = svg2png(
bytestring=generate_SVG_markup(*result),
output_width=image.width,
@@ -178,7 +183,15 @@ def test_clip_failure_retraces_bounded_vectors(
monkeypatch.setattr("vectorizing.solvers.color.ColorSolver.op", clip)
upload = Mock(return_value="fallback-svg")
monkeypatch.setattr("vectorizing.upload_markup", upload)
- response = client.post("/", json={"url": "unused", "solver": 1, "raw": raw})
+ response = client.post(
+ "/",
+ json={
+ "url": "unused",
+ "solver": 1,
+ "configuration": "experimental",
+ "raw": raw,
+ },
+ )
assert response.status_code == 200
clip.assert_called_once()
assert "retracing without padding" in caplog.text
@@ -218,7 +231,7 @@ def test_clip_recovery_constrains_curve_overshoot(
def test_clipped_logo_preserves_opaque_canvas() -> None:
"""Keep coverage on a real clipped logo, including the canvas boundary."""
with Image.open(IMAGES / "geo_logo.png") as image:
- result = ColorSolver(image, 8, Timer()).solve()
+ result = ColorSolver(image, 8, Timer(), "experimental").solve()
markup = generate_SVG_markup(*result)
png = svg2png(bytestring=markup)
pixels = np.asarray(Image.open(BytesIO(png)).convert("RGBA"))
@@ -232,7 +245,7 @@ def test_clipped_consecutive_quadratics_keep_opaque_coverage() -> None:
mask = rng.random((height, width)) < rng.uniform(0.03, 0.98)
pixels = np.full((height, width, 3), 255, dtype=np.uint8)
pixels[mask] = [255, 0, 0]
- result = ColorSolver(Image.fromarray(pixels), 2, Timer()).solve()
+ result = ColorSolver(Image.fromarray(pixels), 2, Timer(), "experimental").solve()
rendered = np.asarray(
Image.open(
BytesIO(svg2png(bytestring=generate_SVG_markup(*result))),
@@ -248,5 +261,5 @@ def test_fully_transparent_image_has_no_foreground() -> None:
assert background
np.testing.assert_array_equal(labels, 0)
assert palette.dtype == np.uint8
- result = ColorSolver(Image.fromarray(pixels), 16, Timer()).solve()
+ result = ColorSolver(Image.fromarray(pixels), 16, Timer(), "experimental").solve()
assert result[0] == []
diff --git a/vectorizing/tests/test_developer_tools.py b/vectorizing/tests/test_developer_tools.py
index 2df8f3a..40ce371 100644
--- a/vectorizing/tests/test_developer_tools.py
+++ b/vectorizing/tests/test_developer_tools.py
@@ -87,6 +87,49 @@ def test_changed_metadata_fails(
assert outputs.main(["compare", str(before), str(after)]) == 1
+def test_capture_selects_both_color_profiles(tmp_path: Path) -> None:
+ """Profile selection changes real solver and SVG bytes, not only metadata."""
+ default = tmp_path / "current"
+ explicit = tmp_path / "explicit-current"
+ experimental = tmp_path / "experimental"
+ options = ["--case", "black_rectangle_color"]
+ assert outputs.main(["capture", str(default), *options]) == 0
+ assert (
+ outputs.main(["capture", str(explicit), *options, "--configuration", "current"])
+ == 0
+ )
+ assert outputs.main(["compare", str(default), str(explicit)]) == 0
+ assert (
+ outputs.main(
+ ["capture", str(experimental), *options, "--configuration", "experimental"],
+ )
+ == 0
+ )
+ assert (
+ json.loads((experimental / "outputs.json").read_text())["configuration"]
+ == "experimental"
+ )
+ assert outputs.main(["compare", str(default), str(experimental)]) == 1
+
+
+@pytest.mark.parametrize("case", ["black_rectangle_color", "1px_binary"])
+def test_invalid_source_configuration_fails(tmp_path: Path, case: str) -> None:
+ """Unknown names cannot silently appear in reports, even for binary cases."""
+ with pytest.raises(SystemExit) as error:
+ outputs.main(
+ [
+ "capture",
+ str(tmp_path),
+ "--case",
+ case,
+ "--configuration",
+ "missing",
+ ],
+ )
+ assert error.value.code == 2
+ assert not (tmp_path / "outputs.json").exists()
+
+
def test_changed_svg_fails(tmp_path: Path, fake_solver: None) -> None:
"""Different SVG bytes fail even when both versions render identically."""
before, after = tmp_path / "before", tmp_path / "after"
@@ -225,7 +268,13 @@ def test_alternation_medians_and_equality(monkeypatch: pytest.MonkeyPatch) -> No
order = []
times = {"baseline": iter([9, 1, 5]), "candidate": iter([3, 1, 2])}
- def measure(name: str, root: Path, warmups: int) -> dict[str, object]:
+ def measure(
+ name: str,
+ root: Path,
+ warmups: int,
+ configuration: str | None,
+ ) -> dict[str, object]:
+ assert configuration is None
order.append(root.name)
assert name == "bubbles" and warmups == 2
return {
@@ -263,7 +312,13 @@ def test_benchmark_mismatches_save_failing_report(
) -> None:
"""Cross-version changes and within-process nondeterminism both fail the command."""
- def measure(name: str, root: Path, warmups: int) -> dict[str, object]:
+ def measure(
+ name: str,
+ root: Path,
+ warmups: int,
+ configuration: str | None,
+ ) -> dict[str, object]:
+ assert configuration is None
return {
"elapsed_ms": 1,
"peak_rss_mib": 2,
@@ -291,6 +346,52 @@ def measure(name: str, root: Path, warmups: int) -> dict[str, object]:
assert json.loads(path.read_text())["outputs_equal"] is False
+def test_benchmark_selects_each_profile_independently(tmp_path: Path) -> None:
+ """Fresh workers use the selected baseline and candidate profiles."""
+ report = tmp_path / "benchmark.json"
+ assert (
+ bench.main(
+ [
+ "--output",
+ str(report),
+ "--baseline-root",
+ str(shared.ROOT),
+ "--baseline-configuration",
+ "experimental",
+ "--configuration",
+ "experimental",
+ "--case",
+ "black_rectangle_color",
+ "--repeats",
+ "1",
+ "--warmups",
+ "0",
+ ],
+ )
+ == 0
+ )
+ data = json.loads(report.read_text())
+ assert data["configurations"] == {
+ "baseline": "experimental",
+ "candidate": "experimental",
+ }
+ assert data["outputs_equal"]
+
+
+def test_baseline_configuration_requires_checkout(tmp_path: Path) -> None:
+ """Do not silently discard a requested baseline profile."""
+ with pytest.raises(SystemExit) as error:
+ bench.main(
+ [
+ "--output",
+ str(tmp_path / "report.json"),
+ "--baseline-configuration",
+ "experimental",
+ ],
+ )
+ assert error.value.code == 2
+
+
def test_warmups_and_instability(
fake_solver: None,
monkeypatch: pytest.MonkeyPatch,