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 2d534bb..6b5d2c7 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: @@ -95,7 +98,8 @@ 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 } ``` @@ -123,7 +127,35 @@ A typical response would be Or, if `raw = true` was supplied, just plain SVG markup -Color layers have cutouts with a small overlap along shared edges to hide seams. +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. + +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. + +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 +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 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 +constrains any outlying curve control points to the canvas. It keeps smooth editable +vectors instead of failing the request, but can adjust curves near the canvas and +round corners inward, reducing edge coverage on the affected layer. + +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. + +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. @@ -145,7 +177,9 @@ docker compose --profile testing run --build --rm test ``` Tests are split into `test_*.py` files in `vectorizing/tests`. Compose and CI -run them all. `color_reference.py` keeps the old code for before/after checks. +run them all. `color_reference.py` keeps independent centroid, bitmap and area-cleanup +oracles. The color acceptance tests also check the approved Aftermath geometry, +Bubble Whirl's lettering counters, alpha behavior and compact SVG coordinates. When running pytest outside Compose, configure the AWS variables for an S3-compatible service first. 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..342c889 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 @@ -44,11 +48,12 @@ 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, -) -> list[list[Path] | list[np.ndarray] | int]: - """Trace color layers with bounded overlaps and return processed dimensions.""" - solver = ColorSolver(img, color_count, timer) + configuration: str = DEFAULT_CONFIGURATION, +) -> list[list[Path] | list[np.ndarray] | list[list[int]] | int]: + """Trace color layers using a named processing configuration.""" + solver = ColorSolver(img, color_count, timer, configuration) return solver.solve() @@ -57,10 +62,19 @@ def validate_args(args: dict[str, object]) -> SimpleNamespace | Literal[False]: if "url" not in args: return False - solver = args.get("solver", DEFAULT_SOLVER) + color_count = args.get("color_count") + solver = args.get("solver", 1 if color_count == "auto" else DEFAULT_SOLVER) 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 + if solver == 0 and color_count == "auto": + return False + if solver == 1 and isinstance(color_count, str) and color_count not in ("auto", ""): + return False + box = args.get("crop_box") if box: if len(box) != 4: @@ -75,7 +89,8 @@ 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, ) @@ -100,6 +115,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 +136,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 545c833..24dfa89 100644 --- a/vectorizing/solvers/color/ColorSolver.py +++ b/vectorizing/solvers/color/ColorSolver.py @@ -1,52 +1,199 @@ """Trace editable color layers with bounded overlap at shared edges.""" +import logging +from dataclasses import dataclass + import numpy as np import potrace -from pathops import Path +from pathops import Path, PathOp, PathOpsError, op from PIL import Image from vectorizing.geometry.potrace import potrace_path_to_compound_path from vectorizing.server.timer import Timer -from vectorizing.solvers.color.bitmaps import add_bitmap_rims, create_bitmaps -from vectorizing.solvers.color.quantize import quantize +from vectorizing.solvers.binary.BinarySolver import BinarySolver +from vectorizing.solvers.color import legacy +from vectorizing.solvers.color.bitmaps import ( + add_bitmap_rims, + create_background_bitmap, + create_bitmaps, +) +from vectorizing.solvers.color.quantize import ( + auto_color_count, + get_background_cluster, + quantize, +) 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) + return potrace_path_to_compound_path(traced) + + +def trace_bitmap(bitmap: np.ndarray, *, recover_clip: bool = True) -> Path: + """Trace bounded detail, optionally leaving clip recovery to the caller.""" + height, width = bitmap.shape + # Potrace rounds exposed mask corners. Extending edge labels moves that + # rounding outside the viewport instead of leaving transparent canvas corners. + mask = bitmap.astype(np.uint8) + padded = np.pad(mask, 2, mode="edge") + path = _trace_mask(padded).transform(1, 0, 0, 1, -2, -2) + left, top, right, bottom = path.bounds + if left < 0 or top < 0 or right > width or bottom > height: + # Keep returned geometry/bounds inside the image, not just the SVG's + # viewport. This also makes layer exports safe without the original clip. + canvas = Path() + canvas.moveTo(0, 0) + canvas.lineTo(width, 0) + canvas.lineTo(width, height) + canvas.lineTo(0, height) + canvas.close() + try: + path = op(path, canvas, PathOp.INTERSECTION) + except PathOpsError: + if not recover_clip: + raise + # Recovery keeps smooth, bounded vectors and does not repeat the + # failing boolean operation. Unpadded tracing can round canvas + # corners inward; only this exceptional layer loses edge coverage. + logging.getLogger(__name__).warning( + "Canvas clipping failed; retracing without padding", + exc_info=True, + ) + traced = _trace_mask(mask) + path = Path(fillType=traced.fillType) + pen = path.getPen() + # Even unpadded fitted curves can overshoot the canvas. Constrain + # their control hull on recovery, without another boolean operation. + for command, points in traced.segments: + bounded = tuple( + (min(width, max(0, x)), min(height, max(0, y))) for x, y in points + ) + getattr(pen, command)(*bounded) + return 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: - 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 - + def __init__( + self, + img: Image.Image, + color_count: int | str | None, + timer: Timer, + configuration: str = DEFAULT_CONFIGURATION, + ) -> None: self.img = limit_size(img) # Init image array self.img_arr = np.asarray(self.img).astype(np.uint8) + self.auto_method = None + self.binary_fallback = False + 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, + ) + self.binary_fallback = self.color_count == 1 and representative is not None + 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] - def solve(self) -> list[list[Path] | list[np.ndarray] | int]: + def solve(self) -> list[list[Path] | list[np.ndarray] | list[list[int]] | int]: """Return paths, colors, width and height as a list.""" + if self.binary_fallback: + return list(BinarySolver(self.img).solve()) + 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, 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") + 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") - traced_bitmaps = [potrace.Bitmap(bitmap).trace() 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) + foreground = [ + op(path, compound_paths[-1], PathOp.DIFFERENCE) + for path in compound_paths[:-1] + ] + except PathOpsError: + # Keep the complete original output if any isolation step fails; + # never publish only the foreground colors processed so far. + logging.getLogger(__name__).warning( + "Background isolation failed; retaining original layers", + exc_info=True, + ) + else: + compound_paths = [background, *foreground] + colors = [colors[-1], *colors[:-1]] self.timer.end_timer() - compound_paths = [ - potrace_path_to_compound_path(traced) for traced in traced_bitmaps - ] - return [compound_paths, colors, self.img.size[0], self.img.size[1]] diff --git a/vectorizing/solvers/color/bitmaps.py b/vectorizing/solvers/color/bitmaps.py index a099366..4a730bd 100644 --- a/vectorizing/solvers/color/bitmaps.py +++ b/vectorizing/solvers/color/bitmaps.py @@ -3,6 +3,40 @@ import cv2 import numpy as np +from vectorizing.solvers.color.quantize import _perimeter + + +def create_background_bitmap( + labels: np.ndarray, + colors: np.ndarray, + has_background: bool, +) -> np.ndarray | None: + """Find a frontmost opaque background and give it a bounded two-pixel rim.""" + # The quantizer's background flag means detected transparency. Preserve that + # classification, including its existing handling of partial input alpha. + if has_background: + return None + border = _perimeter(labels) + background = int(border[0]) + if not np.all(border == background): + return None + used = np.unique(labels) + # Intermediate backgrounds have hidden rims beneath later foreground colors; + # cutting those rims can break coverage. Already-first backgrounds need no fix. + if len(used) < 2 or background != used[-1]: + return None + if ( + np.count_nonzero(np.all(colors[used, :3] == colors[background, :3], axis=1)) + != 1 + ): + return None + return cv2.dilate( + (labels == background).astype(np.uint8), + np.ones((5, 5), dtype=np.uint8), + borderType=cv2.BORDER_CONSTANT, + borderValue=0, + ) + def create_bitmaps( labels: np.ndarray, diff --git a/vectorizing/solvers/color/legacy.py b/vectorizing/solvers/color/legacy.py new file mode 100644 index 0000000..de694a9 --- /dev/null +++ b/vectorizing/solvers/color/legacy.py @@ -0,0 +1,125 @@ +"""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 PIL import Image +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, + *, + 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) + 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) + 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/solvers/color/quantize.py b/vectorizing/solvers/color/quantize.py index 109210a..30f2c89 100644 --- a/vectorizing/solvers/color/quantize.py +++ b/vectorizing/solvers/color/quantize.py @@ -1,92 +1,173 @@ -"""Quantize RGB colors while keeping transparent background pixels separate.""" +"""Quantize colors without discarding connected thin features or alpha holes.""" import cv2 import numpy as np -import scipy.ndimage as ndi from faiss import Kmeans from PIL import Image +from scipy.ndimage import distance_transform_edt from skimage.measure import label -BILATERAL_FILTER_DIAMETER = 7 -BILATERAL_FILTER_SIGMA = 50 +MIN_COMPONENT_AREA = 8 -def bilateral_filter( +def auto_color_count( img_arr: np.ndarray, - d: int = BILATERAL_FILTER_DIAMETER, - s: float = BILATERAL_FILTER_SIGMA, -) -> np.ndarray: - """Smooth clip-art colors while preserving edges; photos may lose detail.""" - return cv2.bilateralFilter(img_arr, d, s, s) - - -def fill_holes(matrix: np.ndarray) -> np.ndarray: - """Replace zero cells with their nearest nonzero value via a distance transform.""" - closest = ndi.distance_transform_edt( - np.logical_not(matrix), - return_distances=False, - return_indices=True, + 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) ) - matrix = np.where(matrix != 0, matrix, matrix[closest[0], closest[1]]) - return matrix - - -def enhance( - img_arr: np.ndarray, - labels: np.ndarray, - colors: np.ndarray, -) -> tuple[np.ndarray, np.ndarray]: - """Remove boundary-dominated components with O(K*N) work and O(N) image storage.""" - # Reserve zero for holes, preserving the existing input-dtype shift behavior. - labels = labels + 1 - valid = (labels > 0) & (labels <= len(colors)) - - # OpenCV's 2x2 dilation (default anchor/border) reaches a pixel from above, - # left, and upper-left. Mark overlap from any OTHER palette color once, - # without treating missing/out-of-palette neighbors as a dilated cluster. - boundary = np.zeros(labels.shape, dtype=bool) - boundary[1:, :] |= valid[:-1, :] & (labels[1:, :] != labels[:-1, :]) - boundary[:, 1:] |= valid[:, :-1] & (labels[:, 1:] != labels[:, :-1]) - boundary[1:, 1:] |= valid[:-1, :-1] & (labels[1:, 1:] != labels[:-1, :-1]) - - # The boundary is fixed; reuse its complement for every color's area count. - interior = ~boundary - del boundary, valid - cleaned = np.zeros(img_arr.shape[:2], dtype=np.uint16 if len(colors) else np.uint8) - # One scan finds every color's box, including all disconnected components. - # max_label excludes invalid labels; zero would instead infer the maximum. - regions = ndi.find_objects(labels, max_label=len(colors)) if len(colors) else [] - for index, region in enumerate(regions): - if region is None: - continue - cluster = labels[region] == index + 1 - # Keep full (8-neighbor) connectivity, including diagonal contacts. - components = label(cluster, connectivity=2) - original_counts = np.bincount(components.ravel()) - interior_counts = np.bincount( - # Slice the global mask: other colors outside the box still influence it. - components[interior[region]], - minlength=len(original_counts), + 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, ) - areas_ratio = np.divide( - interior_counts.astype(np.float32), - original_counts.astype(np.float32), - out=np.ones(len(original_counts), dtype=np.float32), - where=original_counts != 0, + 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) + + +def _perimeter(array: np.ndarray) -> np.ndarray: + """Read each boundary pixel once, including single-row or single-column images.""" + if min(array.shape) == 1: + return array.ravel() + return np.concatenate([array[0], array[-1], array[1:-1, 0], array[1:-1, -1]]) + + +def clean_components( + labels: np.ndarray, + background: np.ndarray | None, +) -> np.ndarray: + """Refill tiny components, protecting transparency and background-color counters. + + Area, not the proportion of interior pixels, determines removal, retaining + thin components above the cutoff. Connectivity includes diagonal contacts. + The labeled chamfer transform assigns removed pixels to surviving neighbors; + when nothing survives, retain the input instead of inventing a replacement. + For opaque images, protect the most common surviving perimeter color so small + enclosed letter counters are not filled. Ties use the lowest palette index. + + Parameters + ---------- + labels : numpy.ndarray + Two-dimensional palette indices, including zero for any background. + background : numpy.ndarray or None + Nonzero at protected transparent pixels, if present. + + Returns + ------- + numpy.ndarray + Cleaned labels with the input dtype; the input is never mutated. + """ + components = label(labels.astype(np.uint16) + 1, background=0, connectivity=2) + counts = np.bincount(components.ravel()) + holes = counts[components] < MIN_COMPONENT_AREA + if background is not None: + holes &= background == 0 + elif holes.any() and not holes.all(): + perimeter = _perimeter(labels)[~_perimeter(holes)] + if perimeter.size: + background_color = np.bincount(perimeter).argmax() + holes &= labels != background_color + if not holes.any() or holes.all(): + return labels + + _, nearest = cv2.distanceTransformWithLabels( + holes.astype(np.uint8), + cv2.DIST_L2, + 5, + labelType=cv2.DIST_LABEL_PIXEL, + ) + lookup = np.zeros(int(nearest.max()) + 1, dtype=labels.dtype) + lookup[nearest[~holes]] = labels[~holes] + cleaned = labels.copy() + cleaned[holes] = lookup[nearest[holes]] + unassigned = holes & (nearest == 0) + if unassigned.any(): + # OpenCV can leave zero labels beyond its distance propagation limit + # on very wide/tall images. Zero is not a seed: use an exact fallback + # only there, preserving normal chamfer assignments and tie breaking. + indices = distance_transform_edt( + holes, + return_distances=False, + return_indices=True, ) - # Retain whole components, using the same float32, inclusive <=0.1 cutoff. - cleaned[region][cluster & (areas_ratio[components] > 0.1)] = index + 1 - # Release this map before allocating the next color's component map. - del components - - return fill_holes(cleaned) - 1, colors + cleaned[unassigned] = labels[tuple(indices[:, unassigned])] + return cleaned -# Try to get the cluster of pixels that represent a transparent background -# If there is no transparent background, return None -# This is needed because RGBA quantization is very volatile, and sometimes -# opaque colors get assigned to the same clusters as highly transparent ones. -# So we focus on the solid image + transparent background case for now. +# Keep transparent pixels separate from RGB clustering: otherwise opaque and +# transparent pixels with similar RGB values can be assigned to the same color. def get_background_cluster(img_arr: np.ndarray, t: float = 0.5) -> np.ndarray | None: """Return an alpha mask if any raw alpha is below t, otherwise return None.""" r, g, b, a = cv2.split(img_arr) @@ -99,8 +180,6 @@ def get_background_cluster(img_arr: np.ndarray, t: float = 0.5) -> np.ndarray | return None -# Write the background cluster on top of a matrix of clusters (labels) -# The background cluster has priority def write_background_cluster(labels: np.ndarray, bg_cluster: np.ndarray) -> np.ndarray: """Shift labels by one, reserving zero for the overriding background mask.""" labels = labels + 1 @@ -108,16 +187,16 @@ 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) - - # Ignore unused entries: palette padding must not add extra K-means clusters. - # Keep np.unique's RGB order and deduplication, but sort only the used palette - # rather than every pixel. Centroid order affects K-means results. - # quantize() returns mode P: at most 256 entries, within getcolors()'s cap. + 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()] palette = np.asarray(img.getpalette("RGB"), dtype=np.uint8).reshape(-1, 3) return np.unique(palette[used_indices], axis=0) @@ -140,29 +219,33 @@ def kmeans( def quantize( img_arr: np.ndarray, color_count: int, + *, + auto_method: Image.Quantize | None = None, ) -> tuple[np.ndarray, np.ndarray, bool]: - """Return cleaned labels, palette colors and whether a background was isolated.""" - channel_count = img_arr.shape[-1] - - background_cluster = None - if channel_count == 4: - background_cluster = get_background_cluster(img_arr) - img = Image.fromarray(img_arr) - img = img.convert("RGB") - img_arr = np.asarray(img) - - img_arr = bilateral_filter(img_arr) - - labels, colors = kmeans(img_arr, get_initial_centroids(img_arr, color_count)) - - labels = np.reshape(labels, img_arr.shape[:-1]) - - has_background = background_cluster is not None - if has_background: - labels = write_background_cluster(labels, background_cluster) - colors = [[0, 0, 0, 0]] + [[r, g, b, 1] for r, g, b in colors] - colors = np.array(colors) - - colors = colors.astype(np.uint8) - labels, colors = enhance(img_arr, labels, colors) - return labels, colors, has_background + """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()) + 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. + order = np.lexsort(colors.T[::-1]) + labels = np.argsort(order)[labels].astype(np.uint16) + colors = colors[order] + if background is not None: + labels = write_background_cluster(labels, background) + colors = np.vstack( + [ + np.zeros((1, 4), dtype=np.uint8), + np.column_stack([colors, np.ones(len(colors), dtype=np.uint8)]), + ], + ) + labels = clean_components(labels, background) + if background is not None: + labels = np.where(background > 0, 0, labels) + return labels, colors, background is not None diff --git a/vectorizing/svg/markup.py b/vectorizing/svg/markup.py index 643e7b6..3030af3 100644 --- a/vectorizing/svg/markup.py +++ b/vectorizing/svg/markup.py @@ -1,9 +1,12 @@ -"""Serialize traced paths without strokes or changes to their paint order.""" +"""Serialize editable filled paths on an exact hundredth-pixel coordinate grid.""" from collections.abc import Iterable, Sequence import numpy as np -from pathops import Path +from pathops import Path, PathVerb + +Point = tuple[int, int] +Command = tuple[str, tuple[int, ...]] def truncate(number: float) -> str: @@ -14,56 +17,174 @@ def truncate(number: float) -> str: def to_SVG_color_string(color: Sequence[float] | np.ndarray) -> str: """Format RGB or RGBA components without scaling or rounding their values.""" func = "rgb" if len(color) == 3 else "rgba" - tuple = "".join( - [ - f"{item}," if i != len(color) - 1 else f"{item}" - for i, item in enumerate(color) - ], - ) - return f"{func}({tuple})" + return f"{func}({','.join(str(item) for item in color)})" -def generate_SVG_markup( +def _color_string(color: Sequence[float] | np.ndarray) -> str: + """Use short hex for opaque integral RGB, preserving other color/alpha values.""" + opaque = len(color) == 3 or color[3] == 1 + integral = all(0 <= value <= 255 and value == int(value) for value in color[:3]) + if opaque and integral: + rgb = "".join(f"{int(value):02x}" for value in color[:3]) + return "#" + (rgb[::2] if rgb[::2] == rgb[1::2] else rgb) + return to_SVG_color_string(color) + + +def _point(point: tuple[float, float]) -> Point: + """Keep decimal-format rounding, including half ties, before changing units.""" + # Multiplying a float by 100 and rounding can disagree with formatting it + # first (e.g. 2.675). No additional precision is lost by the integer encoding. + return tuple(int(truncate(value).replace(".", "")) for value in point) + + +def _variants( + command: str, + points: tuple[Point, ...], + current: Point, + control: Point | None, +) -> list[Command]: + """Offer equivalent absolute, relative, axial and reflected cubic commands.""" + absolute = tuple(value for point in points for value in point) + relative = tuple(value - current[i % 2] for i, value in enumerate(absolute)) + variants = [(command, absolute), (command.lower(), relative)] + if command == "L": + if points[0][1] == current[1]: + variants.extend([("H", (absolute[0],)), ("h", (relative[0],))]) + if points[0][0] == current[0]: + variants.extend([("V", (absolute[1],)), ("v", (relative[1],))]) + if command == "C" and control is not None: + reflected = (2 * current[0] - control[0], 2 * current[1] - control[1]) + if points[0] == reflected: + variants.extend([("S", absolute[2:]), ("s", relative[2:])]) + return variants + + +def _shortest(variants: list[Command], previous: str) -> tuple[str, str]: + """Choose the shortest legal spelling, eliding repeated commands when allowed.""" + candidates = [] + for command, values in variants: + numbers = " ".join(str(value) for value in values).replace(" -", "-") + candidates.append((command + numbers, command)) + # Extra moveto pairs are lines, not new subpaths: never omit an M/m. + implicit_line = (previous, command) in [("M", "L"), ("m", "l")] + if command.upper() != "M" and (previous == command or implicit_line): + separator = "" if numbers.startswith("-") else " " + candidates.append((separator + numbers, command)) + return min(candidates, key=lambda candidate: len(candidate[0])) + + +def _path_data(path: Path, width: int, height: int) -> str: + """Encode filled geometry without changing contour order or winding.""" + pieces = [] + current = start = (0, 0) + control = None + previous = "" + # The segment-pen interface coalesces adjacent quadratics with implied + # endpoints. Raw verbs expose each control/end pair required by SVG Q/q. + for kind, coordinates in path: + if kind == PathVerb.CLOSE: + pieces.append("z") + current = start + control = None + previous = "z" + continue + # Canvas clipping can reduce a cubic to a quadratic; retain those too. + command = { + PathVerb.MOVE: "M", + PathVerb.LINE: "L", + PathVerb.CUBIC: "C", + PathVerb.QUAD: "Q", + }[kind] + points = tuple(_point(point) for point in coordinates) + variants = _variants(command, points, current, control) + x, y = points[-1] + if x in (0, width * 100) or y in (0, height * 100): + # Some renderers accumulate subpixel error in relative commands. + # Anchor canvas boundaries explicitly (not H/V, which retain one + # current coordinate) to keep opaque edge pixels fully covered. + variants = variants[:1] + text, previous = _shortest(variants, previous) + pieces.append(text) + current = points[-1] + if command == "M": + start = current + control = points[1] if command == "C" else None + return "".join(pieces) + + +def generate_original_SVG_markup( compound_paths: Iterable[Path], colors: Iterable[Sequence[float] | np.ndarray], width: int, height: int, ) -> str: - """Serialize line/cubic paths in paint order, skipping empty geometry.""" + """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 len(segments): + if not segments: continue - - d = "" - for segment in segments: - command = segment[0] + data = "" + for command, points in segments: if command == "moveTo": - x = segment[1][0][0] - y = segment[1][0][1] - d += f"M {truncate(x)} {truncate(y)} " + data += f"M {truncate(points[0][0])} {truncate(points[0][1])} " if command == "lineTo": - x = segment[1][0][0] - y = segment[1][0][1] - d += f"L {truncate(x)} {truncate(y)} " + data += f"L {truncate(points[0][0])} {truncate(points[0][1])} " if command == "curveTo": - d += ( - "C" + f"{truncate(segment[1][0][0])},{truncate(segment[1][0][1])} " - f"{truncate(segment[1][1][0])},{truncate(segment[1][1][1])} " - f"{truncate(segment[1][2][0])},{truncate(segment[1][2][1])} " + 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": - d += "Z" + data += "Z" + paths_markup.append( + f'', + ) + markup = "\n".join(paths_markup) + return ( + f'\n' + f"\n{markup}\n\n" + ) - path_markup = f'' - paths_markup.append(path_markup) - paths_markup = "\n".join(paths_markup) +def generate_SVG_markup( + compound_paths: Iterable[Path], + colors: Iterable[Sequence[float] | np.ndarray], + width: int, + height: int, +) -> str: + """Emit compact vectors with unchanged viewport, paint order and alpha values. + + Integer coordinates with a 0.01 scale represent the same two-decimal geometry + as absolute pixel coordinates. Relative commands and transforms can still + cause small renderer-specific antialiasing differences; this is not a promise + of pixel-identical rendering at every zoom level. + + Parameters + ---------- + compound_paths : Iterable[Path] + Line/quadratic/cubic compound paths in paint order. + colors : Iterable[Sequence[float] | numpy.ndarray] + Corresponding RGB or RGBA colors; alpha is not rescaled. + width : int + Viewport width in pixels. + height : int + Viewport height in pixels. + + Returns + ------- + str + SVG markup with real filled paths and no strokes. + """ + paths_markup = [] + for path, color in zip(compound_paths, colors): + data = _path_data(path, width, height) + if data: + paths_markup.append(f'') return ( f'\n' - f"\n{paths_markup}\n\n" - f"" + f'viewBox="0 0 {width} {height}">' + '' + "".join(paths_markup) + "" ) diff --git a/vectorizing/tests/color_reference.py b/vectorizing/tests/color_reference.py index c67b9e8..c868dac 100644 --- a/vectorizing/tests/color_reference.py +++ b/vectorizing/tests/color_reference.py @@ -1,18 +1,13 @@ -"""Frozen pre-optimization algorithms used as independent regression oracles. +"""Independent, intentionally slow oracles for optimized color operations.""" -Keep these intentionally slow implementations separate from production helpers: -sharing optimized code would let a regression affect both sides of a comparison. -Cleanup shares only the unchanged fill_holes step so tests can bypass it in both -paths to inspect component removal before nearest-neighbor filling hides holes. -""" +from collections import Counter import cv2 import numpy as np from PIL import Image +from scipy.ndimage import distance_transform_edt from skimage.measure import label -from vectorizing.solvers.color import quantize as color_quantize - def legacy_initial_centroids(img_arr: np.ndarray, color_count: int) -> np.ndarray: """Preserve the RGB-pixel centroid algorithm from before PR 71.""" @@ -50,56 +45,76 @@ def legacy_create_bitmaps( return bitmaps, colors -def legacy_enhance( - img_arr: np.ndarray, - labels: np.ndarray, - colors: np.ndarray, -) -> tuple[np.ndarray, np.ndarray]: - """Preserve the pairwise cleanup from main at f69c69b, before PR 74.""" - dims = img_arr.shape[:2] - labels = labels + 1 +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(labels == idx + 1, idx + 1, 0).astype(np.uint16) - for idx, _ in enumerate(colors) + np.where(shifted == index + 1, index + 1, 0).astype(np.uint16) + for index in range(color_count) ] - # Preserve integer labeling, full connectivity and shifted component IDs. - # The optimized path must match this, not redefine the expected behavior. - original_connected_components_list = [label(cluster) + 1 for cluster in clusters] - connected_components_bincounts = [ - np.bincount(connected_components.flatten()) - for connected_components in original_connected_components_list - ] - connected_components_list = [ - np.array(item, copy=True) for item in original_connected_components_list - ] - # Keep the expensive pairwise dilation scans: independence is the point. + 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_cluster_x = cv2.dilate(cluster_x, np.ones((2, 2))) + dilated = cv2.dilate(cluster_x, np.ones((2, 2))) for y, cluster_y in enumerate(clusters): - if x == y: - continue - overlap = np.logical_and(dilated_cluster_x, cluster_y) - connected_components_list[y] = np.where( - overlap, - 0, - connected_components_list[y], - ) - for x, connected_components in enumerate(connected_components_list): - original_bincount = connected_components_bincounts[x] - count = original_bincount.shape[0] - new_bincount = np.bincount(connected_components.flatten(), minlength=count) - areas_ratio = np.divide( - new_bincount.astype(np.float32), - original_bincount.astype(np.float32), - out=np.ones((count,), dtype=np.float32), - where=original_bincount != 0, - ) - clusters[x] = np.where( - areas_ratio[original_connected_components_list[x]] <= 0.1, - 0, - clusters[x], + 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, ) - labels = np.zeros(dims).astype(np.uint8) + clusters[x] = np.where(ratio[original[x]] <= 0.1, 0, clusters[x]) + enhanced = np.zeros(labels.shape, dtype=np.uint8) for cluster in clusters: - labels = np.where(labels == 0, cluster, labels) - return color_quantize.fill_holes(labels) - 1, colors + 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, +) -> np.ndarray: + """Find small regions one color at a time, then use labeled chamfer refill.""" + remove = np.zeros(labels.shape, dtype=bool) + for color in np.unique(labels): + mask = labels == color + _, components = cv2.connectedComponents(mask.astype(np.uint8), connectivity=8) + sizes = np.bincount(components.ravel()) + remove |= mask & (sizes[components] < 8) + if background is not None: + remove &= background == 0 + else: + height, width = labels.shape + edge = {(y, x) for y in (0, height - 1) for x in range(width)} + edge |= {(y, x) for x in (0, width - 1) for y in range(height)} + counts = Counter(int(labels[y, x]) for y, x in edge if not remove[y, x]) + if counts: + color = min(counts, key=lambda value: (-counts[value], value)) + remove &= labels != color + result = labels.copy() + if remove.any() and not remove.all(): + _, nearest = cv2.distanceTransformWithLabels( + remove.astype(np.uint8), + cv2.DIST_L2, + 5, + labelType=cv2.DIST_LABEL_PIXEL, + ) + # OpenCV numbers each zero pixel in scan order, starting from one. + survivors = labels[~remove] + result[remove] = survivors[nearest[remove] - 1] + return result diff --git a/vectorizing/tests/images/midnight_strike.png b/vectorizing/tests/images/midnight_strike.png new file mode 100644 index 0000000..8e3f0ad Binary files /dev/null and b/vectorizing/tests/images/midnight_strike.png differ diff --git a/vectorizing/tests/images/test_logo.png b/vectorizing/tests/images/test_logo.png new file mode 100644 index 0000000..7470968 Binary files /dev/null and b/vectorizing/tests/images/test_logo.png differ diff --git a/vectorizing/tests/test_api_contracts.py b/vectorizing/tests/test_api_contracts.py index c46236a..e8e980a 100644 --- a/vectorizing/tests/test_api_contracts.py +++ b/vectorizing/tests/test_api_contracts.py @@ -19,6 +19,12 @@ {"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": []}, + {"url": "image", "solver": 1, "color_count": "automatic"}, + {"url": "image", "solver": 0, "color_count": "auto"}, ], ) def test_invalid_arguments_return_the_same_error( @@ -36,6 +42,10 @@ def test_invalid_arguments_return_the_same_error( "payload", [ {"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": []}, @@ -50,13 +60,45 @@ def test_argument_validation_preserves_accepted_values( assert isinstance(result, SimpleNamespace) assert vars(result) == { "url": payload["url"], - "solver": payload.get("solver", 0), + "solver": payload.get( + "solver", + 1 if payload.get("color_count") == "auto" else 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_omitted_solver_with_auto_selects_color() -> None: + """Do not silently treat an auto-only request as a binary request.""" + args = vectorizing.validate_args({"url": "image", "color_count": "auto"}) + assert isinstance(args, SimpleNamespace) + assert args.solver == 1 + + +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, @@ -89,12 +131,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_auto.py b/vectorizing/tests/test_color_auto.py new file mode 100644 index 0000000..3380e34 --- /dev/null +++ b/vectorizing/tests/test_color_auto.py @@ -0,0 +1,224 @@ +"""Choose automatic palette sizes without changing numeric mode.""" + +from io import BytesIO +from pathlib import Path +from unittest.mock import Mock + +import numpy as np +import pytest +from cairosvg import svg2png +from flask.testing import FlaskClient +from PIL import Image + +import vectorizing +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_original_SVG_markup, generate_SVG_markup +from vectorizing.util.read import convert_RGB_A + + +@pytest.mark.parametrize( + "name, expected", + [ + # These artwork counts are contracts: never rebaseline them to fit a + # new heuristic. aftermath.png has too many colors to pin this way. + ("geo_logo.png", 3), + ("midnight_strike.png", 3), + ("shapes.png", 4), + ("shapes_2.png", 6), + ("test_logo.png", 3), + ("bubbles.png", 4), + ("black_rectangle.png", 1), + ("empty.png", 1), + ], +) +def test_auto_count_on_gallery(name: str, expected: int) -> None: + """Pin curated palette sizes while ignoring blended edge colors.""" + with Image.open(Path(__file__).parent / "images" / name) as source: + image = convert_RGB_A(source) + solver = ColorSolver(image, "auto", Timer()) + assert solver.color_count == expected + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_auto_traces_three_layers_on_midnight_strike(configuration: str) -> None: + """Pass the selected count through real quantization and layer tracing.""" + with Image.open(Path(__file__).parent / "images" / "midnight_strike.png") as source: + solver = ColorSolver(convert_RGB_A(source), "auto", Timer(), configuration) + paths, colors, _, _ = solver.solve() + assert solver.color_count == len(paths) == len(colors) == 3 + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_transparent_canvas_does_not_take_a_color_slot(configuration: str) -> None: + """Hidden black must not desaturate two opaque colors on a transparent logo.""" + pixels = np.zeros((128, 128, 4), dtype=np.uint8) + pixels[16:64, 16:64] = (255, 0, 0, 255) + pixels[64:112, 64:112] = (0, 0, 255, 255) + pixels[0, 0] = (0, 255, 0, 1) # A rare edge must not dominate hidden RGB. + solver = ColorSolver(Image.fromarray(pixels), "auto", Timer(), configuration) + _, colors, _, _ = solver.solve() + assert solver.color_count == 2 + assert {tuple(color[:3]) for color in colors} == {(255, 0, 0), (0, 0, 255)} + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_thin_strokes_between_grid_points_keep_both_colors(configuration: str) -> None: + """Transparent line art cannot vanish just because its columns miss a grid.""" + pixels = np.zeros((1024, 1024, 4), dtype=np.uint8) + pixels[:, 1:4] = (255, 0, 0, 255) + pixels[:, 5:8] = (0, 0, 255, 255) + solver = ColorSolver(Image.fromarray(pixels), "auto", Timer(), configuration) + _, colors, _, _ = solver.solve() + assert solver.color_count == 2 + assert {tuple(color[:3]) for color in colors} == {(255, 0, 0), (0, 0, 255)} + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_visible_pixel_sampling_cannot_lock_to_background_columns( + configuration: str, +) -> None: + """A single transparent pixel must not phase-lock sampling away from stripes.""" + pixels = np.full((1024, 1024, 4), 255, dtype=np.uint8) + pixels[:, 5::16, :3] = (255, 0, 0) + pixels[:, 6::16, :3] = (255, 0, 0) + pixels[:, 7::16, :3] = (255, 0, 0) + pixels[0, 0] = (0, 0, 0, 0) + solver = ColorSolver(Image.fromarray(pixels), "auto", Timer(), configuration) + _, colors, _, _ = solver.solve() + assert solver.color_count == 2 + expected = {(255, 255, 255), (255, 0, 0)} + assert {tuple(color[:3]) for color in colors} == expected + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_shadow_masked_by_background_does_not_take_a_slot( + configuration: str, +) -> None: + """An alpha-60 shadow becomes background when the image also has alpha 0.""" + pixels = np.zeros((128, 128, 4), dtype=np.uint8) + pixels[8:64, 8:64] = (255, 0, 0, 255) + pixels[64:120, 64:120] = (0, 0, 255, 255) + pixels[8:64, 64:120] = (0, 255, 0, 60) + solver = ColorSolver(Image.fromarray(pixels), "auto", Timer(), configuration) + _, colors, _, _ = solver.solve() + assert solver.color_count == 2 + assert {tuple(color[:3]) for color in colors} == {(255, 0, 0), (0, 0, 255)} + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_geo_logo_pattern_is_a_separate_layer(configuration: str) -> None: + """Keep both large pale regions separate from each other and the dark logo.""" + with Image.open(Path(__file__).parent / "images" / "geo_logo.png") as source: + solver = ColorSolver(convert_RGB_A(source), "auto", Timer(), configuration) + paths, colors, width, height = solver.solve() + assert solver.color_count == len(paths) == len(colors) == 3 + pale = [color[:3] for color in colors if np.min(color[:3]) > 200] + assert len(pale) == 2 + assert np.linalg.norm(pale[0].astype(int) - pale[1].astype(int)) >= 5 + serialize = ( + generate_original_SVG_markup + if configuration == "current" + else generate_SVG_markup + ) + markup = serialize(paths, colors, width, height) + raster = Image.open(BytesIO(svg2png(bytestring=markup.encode()))).convert("RGB") + assert raster.getpixel((0, 0)) != raster.getpixel((150, 0)) + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_kittl_logo_accent_has_its_own_layer(configuration: str) -> None: + """Preserve the small neon mark as well as black lettering and white canvas.""" + with Image.open(Path(__file__).parent / "images" / "test_logo.png") as source: + solver = ColorSolver(convert_RGB_A(source), "auto", Timer(), configuration) + paths, colors, _, _ = solver.solve() + assert solver.color_count == len(paths) == len(colors) == 3 + assert any(160 < color[1] and color[2] < 120 for color in colors) + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_visible_single_color_auto_traces_with_binary_solver( + configuration: str, +) -> None: + """Turn white text on transparent canvas into a black transparent SVG.""" + with Image.open(Path(__file__).parent / "images" / "white_text.png") as source: + image = convert_RGB_A(source) + solver = ColorSolver(image, "auto", Timer(), configuration) + actual = solver.solve() + expected = BinarySolver(image).solve() + serialize = ( + generate_original_SVG_markup + if configuration == "current" + else generate_SVG_markup + ) + assert isinstance(actual, list) + assert solver.color_count == 1 + assert actual[1] == [[0, 0, 0, 1]] + assert serialize(*actual) == serialize(*expected) + raster = Image.open( + BytesIO(svg2png(bytestring=serialize(*actual).encode())), + ).convert("RGBA") + assert raster.getchannel("A").getextrema() == (0, 255) + + +@pytest.mark.parametrize("configuration", ["current", "experimental"]) +def test_fully_transparent_auto_does_not_trace_a_black_rectangle( + configuration: str, +) -> None: + """No visible color means there is no artwork for the binary fallback.""" + with Image.open(Path(__file__).parent / "images" / "empty.png") as source: + solver = ColorSolver(convert_RGB_A(source), "auto", Timer(), configuration) + paths, colors, width, height = solver.solve() + assert solver.color_count == 1 + assert paths == colors == [] + serialize = ( + generate_original_SVG_markup + if configuration == "current" + else generate_SVG_markup + ) + raster = Image.open( + BytesIO(svg2png(bytestring=serialize(paths, colors, width, height).encode())), + ).convert("RGBA") + assert raster.getchannel("A").getextrema() == (0, 0) + + +def test_large_intermediate_color_is_not_treated_as_an_edge() -> None: + """An entire gray panel between black and white is a third color.""" + pixels = np.zeros((100, 100, 3), dtype=np.uint8) + pixels[40:75] = (255, 255, 255) + pixels[75:] = (128, 128, 128) + assert ColorSolver(Image.fromarray(pixels), "auto", Timer()).color_count == 3 + + +def test_partially_transparent_pixels_count_as_visible() -> None: + """The solver clusters nonzero-alpha RGB, so auto must count it too.""" + pixels = np.zeros((100, 100, 4), dtype=np.uint8) + pixels[:50] = (255, 0, 0, 64) + pixels[50:] = (0, 0, 255, 255) + assert ColorSolver(Image.fromarray(pixels), "auto", Timer()).color_count == 2 + + +def test_auto_request_and_numeric_default_remain_distinct( + client: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only the explicit auto string opts into estimation; numeric clamps remain.""" + monkeypatch.setattr( + vectorizing, + "try_read_image_from_url", + Mock(return_value=Image.new("RGB", (8, 8))), + ) + process = Mock(return_value=([], [], 8, 8)) + monkeypatch.setattr(vectorizing, "process_color", process) + for count in ["auto", None, "", 1, 65]: + response = client.post( + "/", + json={"url": "image", "solver": 1, "raw": True, "color_count": count}, + ) + assert response.status_code == 200 + assert process.call_args.args[1] == count + assert ColorSolver(Image.new("RGB", (8, 8)), None, Timer()).color_count == 6 + assert ColorSolver(Image.new("RGB", (8, 8)), 1, Timer()).color_count == 2 + assert ColorSolver(Image.new("RGB", (8, 8)), 65, Timer()).color_count == 64 + assert ColorSolver(Image.new("RGB", (8, 8)), "", Timer()).color_count == 6 diff --git a/vectorizing/tests/test_color_background.py b/vectorizing/tests/test_color_background.py new file mode 100644 index 0000000..363cb24 --- /dev/null +++ b/vectorizing/tests/test_color_background.py @@ -0,0 +1,205 @@ +"""Conservative background detection and all-or-nothing geometry isolation.""" + +from unittest.mock import Mock + +import numpy as np +import pytest +from flask.testing import FlaskClient +from pathops import PathOp, PathOpsError, op +from PIL import Image + +from vectorizing.server.timer import Timer +from vectorizing.solvers.color.bitmaps import ( + add_bitmap_rims, + create_background_bitmap, + create_bitmaps, +) +from vectorizing.solvers.color.ColorSolver import ColorSolver, trace_bitmap +from vectorizing.svg.markup import generate_SVG_markup +from vectorizing.tests.test_color_layers import render_layers, trace_layers + + +@pytest.fixture() +def background_labels() -> tuple[np.ndarray, np.ndarray]: + """Provide two foreground colors surrounded by a frontmost white background.""" + labels = np.full((40, 40), 2, dtype=np.uint16) + labels[5:35, 8:14] = 0 + labels[8:32, 20:36] = 1 + palette = np.array([[20, 80, 200], [230, 130, 20], [255, 255, 255]], np.uint8) + return labels, palette + + +def test_background_mask_is_bounded_and_does_not_mutate_inputs( + background_labels: tuple[np.ndarray, np.ndarray], +) -> None: + """Match an independent square-neighborhood expansion, not full underpainting.""" + labels, palette = background_labels + original_labels, original_palette = labels.copy(), palette.copy() + expected = np.zeros(labels.shape, dtype=np.uint8) + for y, x in np.argwhere(labels == 2): + expected[max(0, y - 2) : y + 3, max(0, x - 2) : x + 3] = 1 + actual = create_background_bitmap(labels, palette, False) + np.testing.assert_array_equal(actual, expected) + assert actual.dtype == np.uint8 + assert actual[20, 28] == 0 + np.testing.assert_array_equal(labels, original_labels) + np.testing.assert_array_equal(palette, original_palette) + + +@pytest.mark.parametrize( + "case", + ["transparent", "mixed-border", "first", "middle", "duplicate", "single"], +) +def test_unsupported_backgrounds_keep_the_original_svg( + background_labels: tuple[np.ndarray, np.ndarray], + monkeypatch: pytest.MonkeyPatch, + case: str, +) -> None: + """Use the pre-isolation tracing sequence as the unchanged-output oracle.""" + labels, palette = background_labels + transparent = case == "transparent" + if case == "mixed-border": + labels[0, 10] = 0 + elif case == "first": + order = np.array([2, 0, 1]) + labels, palette = np.argsort(order)[labels], palette[order] + elif case == "middle": + order = np.array([0, 2, 1]) + labels, palette = np.argsort(order)[labels], palette[order] + elif case == "duplicate": + palette[0] = palette[2] + elif case == "single": + labels.fill(2) + assert create_background_bitmap(labels, palette, transparent) is None + masks, retained = create_bitmaps(labels, palette, transparent) + add_bitmap_rims(masks) + expected = generate_SVG_markup( + [trace_bitmap(mask) for mask in masks], + retained, + 40, + 40, + ) + paths, colors = trace_layers(monkeypatch, labels, palette, transparent) + assert generate_SVG_markup(paths, colors, 40, 40) == expected + + +def test_detection_uses_only_retained_palette_entries( + background_labels: tuple[np.ndarray, np.ndarray], +) -> None: + """Ignore unused higher colors, and recognize an already-first retained color.""" + labels, palette = background_labels + palette = np.vstack([palette, [255, 255, 255]]).astype(np.uint8) + assert create_background_bitmap(labels, palette, False) is not None + labels[labels != 2] = 3 + assert create_background_bitmap(labels, palette, False) is None + + +def test_detection_supports_the_full_palette_range() -> None: + """Keep 64-color labels intact while finding the frontmost border color.""" + palette = np.array([[i, i * 3 % 256, i * 17 % 256] for i in range(64)], np.uint8) + labels = np.full((12, 12), 63, dtype=np.uint16) + labels[2:10, 2:10] = np.arange(64).reshape(8, 8) + mask = create_background_bitmap(labels, palette, False) + assert mask is not None + assert labels.dtype == np.uint16 + assert len(np.unique(labels)) == 64 + + +@pytest.mark.parametrize("size", [1, 2, 3, 5]) +def test_isolated_background_preserves_counters_and_boundedness( + background_labels: tuple[np.ndarray, np.ndarray], + monkeypatch: pytest.MonkeyPatch, + size: int, +) -> None: + """Keep small background-colored counters open without a full background fill.""" + labels, palette = background_labels + labels[12 : 12 + size, 24 : 24 + size] = 2 + paths, colors = trace_layers(monkeypatch, labels, palette, False) + np.testing.assert_array_equal(colors, palette[[2, 0, 1]]) + assert not paths[0].contains((28, 27)) + assert all(not path.contains((24 + size / 2, 12 + size / 2)) for path in paths[1:]) + assert all(0 <= v <= 40 for path in paths for v in path.bounds) + assert render_layers(paths, colors)[:, :, 3].min() >= 240 + + +def test_partial_alpha_uses_existing_transparency_classification( + background_labels: tuple[np.ndarray, np.ndarray], +) -> None: + """Do not introduce a stricter input-alpha rule than the current quantizer.""" + 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(), "experimental").solve() + assert isinstance(result, list) + np.testing.assert_array_equal(result[1][0], palette[2]) + + +@pytest.mark.parametrize("failure", ["background-clip", "second-difference"]) +@pytest.mark.parametrize("raw", [False, True]) +def test_isolation_failure_retains_complete_original_output( + background_labels: tuple[np.ndarray, np.ndarray], + client: FlaskClient, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + failure: str, + raw: bool, +) -> None: + """Recover the full original layer set and API response, never partial isolation.""" + labels, palette = background_labels + masks, retained = create_bitmaps(labels, palette, False) + add_bitmap_rims(masks) + expected = generate_SVG_markup( + [trace_bitmap(mask) for mask in masks], + retained, + 40, + 40, + ) + monkeypatch.setattr( + "vectorizing.try_read_image_from_url", + lambda url: Image.fromarray(palette[labels]), + ) + monkeypatch.setattr( + "vectorizing.solvers.color.ColorSolver.quantize", + Mock(return_value=(labels, palette, False)), + ) + # Of the original layers, only the background touches the canvas. Its clip + # succeeds; fail either the new background's clip or a later subtraction. + intersections = [] + differences = [] + + def controlled_op(*args: object, **kwargs: object) -> object: + if args[2] == PathOp.INTERSECTION: + intersections.append(1) + if failure == "background-clip" and len(intersections) == 2: + raise PathOpsError("injected background clip failure") + if args[2] == PathOp.DIFFERENCE: + differences.append(1) + if failure == "second-difference" and len(differences) == 2: + raise PathOpsError("injected subtraction failure") + return op(*args, **kwargs) + + 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, + "configuration": "experimental", + "raw": raw, + }, + ) + assert response.status_code == 200 + if raw: + markup = response.data.decode() + upload.assert_not_called() + else: + upload.assert_called_once() + markup = upload.call_args.args[0] + assert response.get_json()["objectId"] == "original-layers" + assert markup == expected + assert "Background isolation failed; retaining original layers" in caplog.text + assert "retracing without padding" not in caplog.text + assert len(intersections) == 2 + assert len(differences) == (2 if failure == "second-difference" else 0) diff --git a/vectorizing/tests/test_color_cleanup.py b/vectorizing/tests/test_color_cleanup.py index adc6576..6ad3452 100644 --- a/vectorizing/tests/test_color_cleanup.py +++ b/vectorizing/tests/test_color_cleanup.py @@ -1,224 +1,113 @@ -"""Cluster cleanup parity, morphology edge cases and component-map lifetime. - -Unfilled comparisons expose the actual keep/remove decisions: hole filling can -otherwise repaint a wrongly removed component and hide the regression. -""" - -import weakref +"""Area-only cleanup preserves thin connected regions and transparent counters.""" import numpy as np import pytest -from vectorizing.solvers.color import quantize as color_quantize -from vectorizing.tests.color_reference import legacy_enhance - -# After the final label shift, an unfilled zero wraps to uint16's maximum. -# This sentinel is used only by tests bypassing fill_holes with a nonempty palette. -UNFILLED_LABEL = np.iinfo(np.uint16).max - - -@pytest.mark.parametrize("before_fill", [False, True], ids=["filled", "unfilled"]) -@pytest.mark.parametrize( - "variant", - [ - "noise", - "strided", - "one-pixel", - "one-row", - "one-column", - "solid", - "sparse", - "checkerboard", - ], -) -# Transparent artwork can add a background entry to the 64 requested colors. -@pytest.mark.parametrize("color_count", [1, 2, 6, 16, 64, 65]) +from vectorizing.solvers.color.quantize import clean_components +from vectorizing.tests.color_reference import reference_clean_components + + +@pytest.mark.parametrize("count", [1, 2, 6, 16, 64, 65]) +@pytest.mark.parametrize("transparent", [False, True]) +@pytest.mark.parametrize("strided", [False, True]) @pytest.mark.parametrize("seed", range(3)) -def test_cluster_cleanup_matches_original( - before_fill: bool, - variant: str, - color_count: int, +def test_cleanup_matches_per_color_reference( + count: int, + transparent: bool, + strided: bool, seed: int, - monkeypatch: pytest.MonkeyPatch, ) -> None: - """Preserve component decisions and filled labels without mutating inputs.""" + """Match a separate CCL implementation without changing input storage/dtype.""" rng = np.random.default_rng(seed) - noise = rng.integers(0, color_count, (19, 23), dtype=np.uint8) - labels = { - "noise": noise, - "strided": noise[::2, ::2], - "one-pixel": noise[:1, :1], - "one-row": noise[:1, :], - "one-column": noise[:, :1], - "solid": np.full_like(noise, color_count - 1), - "sparse": noise % 2, - "checkerboard": ( - np.indices(noise.shape).sum(axis=0) % min(color_count, 2) - ).astype(np.uint8), - }[variant] - colors = rng.integers(0, 256, (color_count, 4), dtype=np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) + labels = rng.integers(0, count, (39, 47), dtype=np.uint16) + labels[:5] = 0 # Ensure a survivor even in high-color random cases. + if strided: + labels = labels[::2, ::2] + background = (labels == 0).astype(np.uint8) if transparent else None + original = labels.copy() labels.setflags(write=False) - colors.setflags(write=False) - pixels.setflags(write=False) - original_labels = labels.copy() - if before_fill: - # Compare removal decisions, not just the colors painted over their holes. - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - expected, expected_colors = legacy_enhance(pixels, labels, colors) - actual, actual_colors = color_quantize.enhance(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - np.testing.assert_equal(actual.dtype, expected.dtype) - np.testing.assert_array_equal(actual_colors, expected_colors) - np.testing.assert_equal(actual_colors is colors, True) - np.testing.assert_array_equal(labels, original_labels) - - -@pytest.mark.parametrize("area", [9, 10, 11]) -def test_cluster_cleanup_preserves_inclusive_area_threshold( - area: int, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Keep a component with 1/9 interior area, but remove 1/10 and 1/11.""" - labels = np.zeros((8, 16), dtype=np.uint8) - # A 2x2 core has one interior pixel at (3, 3). Extending only its top row - # changes the component's total area without adding any interior pixels. - labels[2, 2:area] = 1 - labels[3, 2:4] = 1 - colors = np.zeros((2, 3), dtype=np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - expected = labels.astype(np.uint16) - if area >= 10: - expected[labels == 1] = UNFILLED_LABEL - for cleaner in (legacy_enhance, color_quantize.enhance): - actual, _ = cleaner(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - - -def test_cluster_cleanup_preserves_diagonal_connectivity( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Treat a 2x2 core and its diagonal thin tail as one removable component.""" - labels = np.zeros((8, 16), dtype=np.uint8) - # Seven tail pixels touch the 4-pixel core only at a corner. With full - # connectivity the combined 11-pixel component has 1/11 interior area; - # with 4-neighbor connectivity the core would incorrectly survive at 1/4. - labels[2:4, 2:4] = 1 - labels[4, 4:11] = 1 - colors = np.zeros((2, 3), dtype=np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - expected = labels.astype(np.uint16) - expected[labels == 1] = UNFILLED_LABEL - for cleaner in (legacy_enhance, color_quantize.enhance): - actual, _ = cleaner(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - - -def test_cluster_cleanup_preserves_dilation_anchor_and_border( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The 2x2 default anchor spares only the isolated top-left corner pixel.""" - labels = np.zeros((5, 5), dtype=np.uint8) - labels[::4, ::4] = 1 - colors = np.zeros((2, 3), dtype=np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - expected = labels.astype(np.uint16) - expected[labels == 1] = UNFILLED_LABEL - # No upper/left source exists at (0, 0); outside the image is not a color. - expected[0, 0] = 1 - for cleaner in (legacy_enhance, color_quantize.enhance): - actual, _ = cleaner(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - - -@pytest.mark.parametrize("before_fill", [False, True], ids=["filled", "unfilled"]) -@pytest.mark.parametrize("color_count", [0, 1, 3, 256]) -@pytest.mark.parametrize("dtype", [np.uint8, np.uint16, np.int16]) -def test_cluster_cleanup_preserves_unassigned_labels_and_dtype( - color_count: int, - dtype: type[np.generic], - before_fill: bool, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Ignore unassigned neighbors and retain existing label-shift overflow behavior.""" - rng = np.random.default_rng(42) - # 255 + 1 wraps only for uint8; do not silently fix that old behavior here. - # Small palettes also leave labels whose pixels must not dilate into others. - labels = rng.choice([0, 1, 2, 3, 254, 255], size=(7, 9)).astype(dtype) - if dtype == np.int16: - labels[1, 1:3] = [-1, -2] - if before_fill: - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - colors = np.zeros((color_count, 3), dtype=np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - expected, _ = legacy_enhance(pixels, labels, colors) - actual, actual_colors = color_quantize.enhance(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - np.testing.assert_equal(actual.dtype, expected.dtype) - np.testing.assert_equal(actual_colors is colors, True) - - -@pytest.mark.parametrize("before_fill", [False, True], ids=["filled", "unfilled"]) -@pytest.mark.parametrize("transpose", [False, True]) -def test_cluster_cleanup_crops_labeling_without_losing_neighbor_influence( - before_fill: bool, - transpose: bool, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Crop sparse colors while preserving disjoint islands and outside influences.""" - labels = np.zeros((13, 17), dtype=np.uint8) - labels[2:4, 3:5] = 1 - labels[8, 11] = 1 # A disconnected pixel of the same color must be removed. - labels[0, 14:17] = 2 # A single-row crop touching the image border. - labels[9, 2] = labels[10, 3] = 3 # Diagonal contact within a tight crop. - if transpose: - labels = labels.T - colors = np.zeros((6, 3), dtype=np.uint8) # Two palette entries are unused. - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - if before_fill: - monkeypatch.setattr(color_quantize, "fill_holes", lambda matrix: matrix) - expected, _ = legacy_enhance(pixels, labels, colors) - original_label = color_quantize.label - shapes = [] - - def record_label(cluster: np.ndarray, connectivity: int) -> np.ndarray: - shapes.append(cluster.shape) - return original_label(cluster, connectivity=connectivity) - - monkeypatch.setattr(color_quantize, "label", record_label) - actual, _ = color_quantize.enhance(pixels, labels, colors) - np.testing.assert_array_equal(actual, expected) - # The background spans the image; each other color gets one minimal box, - # including all of its disconnected pieces, and unused entries get no pass. - expected_shapes = [(13, 17), (7, 9), (1, 3), (2, 2)] - if transpose: - expected_shapes = [(width, height) for height, width in expected_shapes] - np.testing.assert_array_equal(shapes, expected_shapes) - - -def test_cluster_cleanup_releases_each_component_map( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Release the old map before labeling the next color, not only on return.""" - original_label = color_quantize.label - maps: list[weakref.ReferenceType[np.ndarray]] = [] - - def record_label(cluster: np.ndarray, connectivity: int) -> np.ndarray: - # Weak references observe lifetime without keeping the maps alive. - # Checking before allocation catches even a temporary two-map overlap. - np.testing.assert_equal(all(ref() is None for ref in maps), True) - components = original_label(cluster, connectivity=connectivity) - maps.append(weakref.ref(components)) - return components - - monkeypatch.setattr(color_quantize, "label", record_label) - labels = (np.indices((8, 9)).sum(axis=0) % 4).astype(np.uint8) - pixels = np.zeros((*labels.shape, 3), dtype=np.uint8) - colors = np.zeros((4, 3), dtype=np.uint8) - color_quantize.enhance(pixels, labels, colors) - # All colors are present: a skipped labeler must not make this pass vacuously. - np.testing.assert_equal(len(maps), 4) - np.testing.assert_equal(all(ref() is None for ref in maps), True) + actual = clean_components(labels, background) + np.testing.assert_array_equal( + actual, + reference_clean_components(labels, background), + ) + np.testing.assert_array_equal(labels, original) + assert actual.dtype == labels.dtype + + +@pytest.mark.parametrize("area", [1, 7, 8, 9, 20]) +def test_thin_components_use_area_not_interior_ratio(area: int) -> None: + """Keep one-pixel-wide features of at least eight pixels, including the cutoff.""" + labels = np.zeros((25, 25), dtype=np.uint16) + labels[10, 2 : 2 + area] = 1 + expected = labels if area >= 8 else np.zeros_like(labels) + np.testing.assert_array_equal(clean_components(labels, None), expected) + + +def test_diagonals_form_one_component() -> None: + """Eight corner-connected pixels survive even though none has an interior.""" + labels = np.zeros((16, 16), dtype=np.uint16) + indices = np.arange(3, 11) + labels[indices, indices] = 1 + np.testing.assert_array_equal(clean_components(labels, None), labels) + + +def test_small_transparent_counter_is_protected() -> None: + """Never remove a background hole, even when it is smaller than the area cutoff.""" + labels = np.ones((16, 16), dtype=np.uint16) + labels[8, 8] = 0 + background = (labels == 0).astype(np.uint8) + np.testing.assert_array_equal(clean_components(labels, background), labels) + + +@pytest.mark.parametrize("area", [1, 4, 7, 8]) +def test_enclosed_opaque_counter_survives(area: int) -> None: + """Protect background-colored counters even below the usual area cutoff.""" + labels = np.zeros((16, 16), dtype=np.uint16) + labels[3:13, 3:13] = 1 + labels[7, 4 : 4 + area] = 0 + np.testing.assert_array_equal(clean_components(labels, None), labels) + + +@pytest.mark.parametrize("shape", [(1, 1), (1, 7), (7, 1), (4, 4)]) +def test_no_survivors_retains_original_labels(shape: tuple[int, int]) -> None: + """Do not feed an all-foreground distance transform when every region is tiny.""" + labels = np.arange(np.prod(shape), dtype=np.uint16).reshape(shape) + np.testing.assert_array_equal(clean_components(labels, None), labels) + + +def test_background_selection_ignores_tiny_perimeter_components() -> None: + """Discard border noise rather than mistaking its frequent color for background.""" + labels = np.ones((20, 20), dtype=np.uint16) + labels[0, ::2] = labels[-1, ::2] = 0 + labels[::2, 0] = labels[::2, -1] = 0 + np.testing.assert_array_equal(clean_components(labels, None), 1) + + +@pytest.mark.parametrize("shape", [(1, 20), (20, 1)]) +def test_background_selection_on_narrow_images(shape: tuple[int, int]) -> None: + """Count each perimeter pixel once on a single row or column.""" + labels = np.array([1] + [2] * 8 + [1] * 11, dtype=np.uint16).reshape(shape) + np.testing.assert_array_equal( + clean_components(labels, None), + reference_clean_components(labels, None), + ) + + +@pytest.mark.parametrize("shape", [(1, 40_000), (40_000, 1)]) +def test_distant_holes_receive_a_surviving_color(shape: tuple[int, int]) -> None: + """The label propagation limit must not invent palette-zero survivors.""" + labels = (np.arange(40_000) % 2).astype(np.uint16) + labels[-8:] = 2 + labels = labels.reshape(shape) + original = labels.copy() + np.testing.assert_array_equal(clean_components(labels, None), 2) + np.testing.assert_array_equal(labels, original) + + +def test_small_island_can_refill_from_transparent_background() -> None: + """Remove tiny opaque specks without painting over transparent pixels.""" + labels = np.zeros((12, 12), dtype=np.uint16) + labels[5, 5] = 1 + background = (labels == 0).astype(np.uint8) + np.testing.assert_array_equal(clean_components(labels, background), 0) 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 2be8255..9a41405 100644 --- a/vectorizing/tests/test_color_equivalence.py +++ b/vectorizing/tests/test_color_equivalence.py @@ -1,8 +1,4 @@ -"""Full solver regressions comparing exact SVG bytes, colors and geometry. - -Replace one optimization at a time with its frozen reference while retaining the -others. This catches interactions that isolated mask/centroid/cleanup tests miss. -""" +"""Compare optimized P3 operations with independent references end to end.""" from pathlib import Path from unittest.mock import Mock @@ -17,8 +13,8 @@ from vectorizing.svg.markup import generate_SVG_markup from vectorizing.tests.color_reference import ( legacy_create_bitmaps, - legacy_enhance, legacy_initial_centroids, + reference_clean_components, ) @@ -42,9 +38,9 @@ def test_color_optimizations_preserve_vectorization( optimization: str, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Compare final SVG, colors, dimensions and bounds against the old algorithm.""" + """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", @@ -55,14 +51,14 @@ def test_color_optimizations_preserve_vectorization( legacy_create_bitmaps, ), "cleanup": ( - "vectorizing.solvers.color.quantize.enhance", - legacy_enhance, + "vectorizing.solvers.color.quantize.clean_components", + reference_clean_components, ), }[optimization] # 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 217e070..133d189 100644 --- a/vectorizing/tests/test_color_layer_artwork.py +++ b/vectorizing/tests/test_color_layer_artwork.py @@ -5,13 +5,11 @@ from unittest.mock import Mock import numpy as np -import potrace import pytest from cairosvg import svg2png from PIL import Image from scipy.ndimage import distance_transform_edt -from vectorizing.geometry.potrace import potrace_path_to_compound_path from vectorizing.server.timer import Timer from vectorizing.solvers.color.bitmaps import add_bitmap_rims from vectorizing.solvers.color.ColorSolver import ColorSolver @@ -22,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) @@ -34,9 +32,13 @@ def test_aftermath_retains_holes_when_layers_are_edited( ) -> None: """Keep the exterior transparent and reveal holes, not underpainting, on edits.""" foreground = [] + owned = [] def capture_foreground(masks: list[np.ndarray]) -> None: foreground.append(masks[0].copy()) + for index, mask in enumerate(masks): + above = masks[index + 1] if index + 1 < len(masks) else 0 + owned.append(mask > above) add_bitmap_rims(masks) rim_builder = Mock(side_effect=capture_foreground) @@ -45,13 +47,13 @@ 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() - silhouette = potrace_path_to_compound_path(potrace.Bitmap(foreground[0]).trace()) - silhouette_svg = generate_SVG_markup([silhouette], [[0, 0, 0]], width, height) - silhouette_alpha = np.asarray( - Image.open(BytesIO(svg2png(bytestring=silhouette_svg))).convert("RGBA"), - )[:, :, 3] markup = generate_SVG_markup(paths, colors, width, height) full = np.asarray(Image.open(BytesIO(svg2png(bytestring=markup))).convert("RGBA")) np.testing.assert_equal(full[:, :, 3].min(), 0) @@ -60,10 +62,12 @@ def capture_foreground(masks: list[np.ndarray]) -> None: alpha = full[:, :, 3] # Unlike a total partial-alpha budget, this checks where gaps occur. Allow # a 3-pixel band by the exterior: separately fitted curves can differ there. - interior = distance_transform_edt(silhouette_alpha == 255) > 3 + # Use the actual foreground mask: tracing a reference silhouette with + # Potrace's default speck removal would fill genuine tiny background holes. + interior = distance_transform_edt(foreground[0] > 0) > 3 np.testing.assert_equal(interior.any(), True) np.testing.assert_array_equal(alpha[interior], 255) - exterior = distance_transform_edt(silhouette_alpha == 0) > 3 + exterior = distance_transform_edt(foreground[0] == 0) > 3 np.testing.assert_array_equal(alpha[exterior], 0) np.testing.assert_equal( np.count_nonzero((alpha > 0) & (alpha < 255)) < 10_000, @@ -74,10 +78,12 @@ def capture_foreground(masks: list[np.ndarray]) -> None: solo_alpha = np.asarray( Image.open(BytesIO(svg2png(bytestring=solo))).convert("RGBA"), )[:, :, 3] - # Select a point far inside the color, excluding its intentionally narrow rim. - distances = distance_transform_edt(solo_alpha == 255) - np.testing.assert_equal(distances.max() > 2, True) + # Sample actual ownership, not the solo path: many tiny neighboring + # regions can be covered by bounded rims and appear solid in a solo render. + distances = distance_transform_edt(owned[index]) + np.testing.assert_equal(distances.max() > 4, True) point = np.unravel_index(distances.argmax(), distances.shape) + np.testing.assert_equal(solo_alpha[point], 255) for opacity in [None, 0, 0.5]: edited_paths, edited_colors = list(paths), list(colors) if opacity is None: diff --git a/vectorizing/tests/test_color_layer_ownership.py b/vectorizing/tests/test_color_layer_ownership.py new file mode 100644 index 0000000..52b316b --- /dev/null +++ b/vectorizing/tests/test_color_layer_ownership.py @@ -0,0 +1,118 @@ +"""Background deletion must preserve the visible artwork, not every color's rim.""" + +from io import BytesIO +from pathlib import Path + +import numpy as np +import pytest +from cairosvg import svg2png +from defusedxml import ElementTree +from PIL import Image + +from vectorizing.server.timer import Timer +from vectorizing.solvers.color.ColorSolver import ColorSolver +from vectorizing.svg.markup import generate_SVG_markup +from vectorizing.tests.test_color_layers import trace_layers + + +def edit_background(markup: str, opacity: float | None = None) -> bytes: + """Delete or change the first color node in the emitted SVG itself.""" + document = ElementTree.fromstring(markup) + container = document.find("{http://www.w3.org/2000/svg}g") + assert container is not None + background = list(container)[0] + if opacity is None: + container.remove(background) + else: + background.set("opacity", str(opacity)) + return ElementTree.tostring(document) + + +def raster(markup: str | bytes, scale: float = 1) -> Image.Image: + """Render the SVG on a transparent surface at the requested scale.""" + return Image.open(BytesIO(svg2png(bytestring=markup, scale=scale))).convert("RGBA") + + +@pytest.fixture(scope="module", params=[3, 4, 6, 8, 16]) +def customer_svg(request: pytest.FixtureRequest) -> tuple[str, tuple[int, ...]]: + """Solve the customer image once per palette with real quantization and tracing.""" + filename = Path(__file__).parent / "images" / "midnight_strike.png" + with Image.open(filename) as image: + paths, colors, width, height = ColorSolver( + 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. + background = tuple(int(value) for value in colors[0]) + assert background == max(tuple(int(value) for value in color) for color in colors) + assert len(colors) <= request.param + assert (width, height) == (1024, 1024) + return generate_SVG_markup(paths, colors, width, height), (*background, 255) + + +@pytest.mark.parametrize("scale", [1, 2]) +def test_customer_background_deletion_preserves_artwork( + customer_svg: tuple[str, tuple[int, ...]], + scale: int, +) -> None: + """Preserve appearance on the same matte, with opaque assembly and no new seams.""" + markup, color = customer_svg + full = raster(markup, scale) + removed = raster(edit_background(markup), scale) + zero = raster(edit_background(markup, 0), scale) + assert np.asarray(full)[:, :, 3].min() >= 240 + np.testing.assert_array_equal(np.asarray(removed), np.asarray(zero)) + before = Image.new("RGBA", full.size, color) + before.alpha_composite(full) + after = Image.new("RGBA", full.size, color) + after.alpha_composite(removed) + # Source-over rounding is not pixel-exact. A large hidden foreground lip + # produces differences of hundreds, not these few channel levels. + difference = np.abs(np.asarray(before).astype(np.int16) - np.asarray(after)) + assert difference.max() <= 4 + assert "stroke=" not in markup + assert 'shape-rendering="crispEdges"' not in markup + + +@pytest.mark.parametrize("scale", [1, 1.25, 2]) +def test_thin_line_keeps_its_width_after_background_deletion( + monkeypatch: pytest.MonkeyPatch, + scale: float, +) -> None: + """A six-pixel line stays six pixels wide rather than exposing a four-pixel lip.""" + labels = np.full((40, 40), 2, dtype=np.uint16) + labels[5:35, 8:14] = 0 + labels[8:32, 22:36] = 1 + palette = np.array([[20, 80, 200], [230, 130, 20], [255, 255, 255]], np.uint8) + paths, colors = trace_layers(monkeypatch, labels, palette, False) + np.testing.assert_array_equal(colors, palette[[2, 0, 1]]) + markup = generate_SVG_markup(paths, colors, 40, 40) + full = raster(markup, scale) + removed = raster(edit_background(markup), scale) + assert np.asarray(full)[:, :, 3].min() >= 240 + row = np.asarray(removed)[int(20 * scale), : int(20 * scale), 3] + if scale == 1: + assert np.flatnonzero(row > 128).tolist() == list(range(8, 14)) + expected = Image.new("RGBA", full.size, (255, 255, 255, 255)) + expected.alpha_composite(removed) + difference = np.abs(np.asarray(full).astype(np.int16) - np.asarray(expected)) + assert difference.max() <= 4 + + +def test_background_half_opacity_does_not_fade_foreground( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep a single editable background color and normal path-opacity behavior.""" + labels = np.full((40, 40), 2, dtype=np.uint16) + labels[10:30, 10:30] = 0 + labels[15:25, 25:35] = 1 + palette = np.array([[20, 80, 200], [230, 130, 20], [255, 255, 255]], np.uint8) + paths, colors = trace_layers(monkeypatch, labels, palette, False) + markup = generate_SVG_markup(paths, colors, 40, 40) + pixels = np.asarray(raster(edit_background(markup, 0.5))) + assert pixels[2, 2, 3] == 128 + np.testing.assert_array_equal(pixels[20, 15], [20, 80, 200, 255]) + np.testing.assert_array_equal(pixels[20, 30], [230, 130, 20, 255]) 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 new file mode 100644 index 0000000..71e4a6d --- /dev/null +++ b/vectorizing/tests/test_color_p3.py @@ -0,0 +1,265 @@ +"""Acceptance checks for the approved detail-preserving color pipeline.""" + +import hashlib +import json +from io import BytesIO +from pathlib import Path +from unittest.mock import Mock + +import cv2 +import numpy as np +import pytest +from cairosvg import svg2png +from flask.testing import FlaskClient +from pathops import PathOpsError +from PIL import Image + +from vectorizing.geometry.bounds import compound_paths_bounds +from vectorizing.server.timer import Timer +from vectorizing.solvers.color.bitmaps import add_bitmap_rims +from vectorizing.solvers.color.ColorSolver import ColorSolver, trace_bitmap +from vectorizing.solvers.color.quantize import quantize +from vectorizing.svg.markup import generate_SVG_markup +from vectorizing.tests.test_svg_geometry import expected_commands + +IMAGES = Path(__file__).parent / "images" + + +def test_aftermath_matches_approved_p3_geometry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep approved masks, validated native geometry variants, palette and size.""" + masks = [] + + def capture_rims(bitmaps: list[np.ndarray]) -> None: + add_bitmap_rims(bitmaps) + masks.extend(bitmap.astype(np.uint8).tobytes() for bitmap in bitmaps) + + monkeypatch.setattr( + "vectorizing.solvers.color.ColorSolver.add_bitmap_rims", + capture_rims, + ) + with Image.open(IMAGES / "aftermath.png") as image: + 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; + # accept only those validated variants, not an arbitrary CI replacement. + assert hashlib.sha256(b"".join(masks)).hexdigest() == ( + "e70cbf1c9d62e8ba853f6fa547975aac8311d9c44ac71decf14e9a6b2bf502bf" + ) + commands = [expected_commands(path) for path in paths] + digest = hashlib.sha256( + json.dumps(commands, separators=(",", ":")).encode(), + ).hexdigest() + assert digest in { + "23d8c97dee23ea3f0b22d164d39f726f36ead7f2ce47048ca73326bf2731c7e8", # ARM64 + "1f52fe3d8589574cc561bf5ab0dff6a9ce410292d79b9e05dcf0a781f2d919f3", # x86_64 + } + np.testing.assert_array_equal( + np.asarray(colors)[:, :3], + [ + [2, 3, 8], + [19, 60, 148], + [28, 100, 202], + [41, 36, 63], + [192, 149, 160], + [213, 54, 118], + [235, 215, 217], + [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") +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(), "experimental").solve() + png = svg2png( + bytestring=generate_SVG_markup(*result), + output_width=image.width, + output_height=image.height, + ) + return np.asarray(Image.open(BytesIO(png)).convert("RGBA")) + + +@pytest.mark.parametrize("threshold", [15, 30, 45]) +@pytest.mark.parametrize( + "box, center, minimum_area", + [((697, 891, 736, 933), (19, 21), 15), ((752, 868, 786, 899), (17, 15), 5)], + ids=["a", "e"], +) +def test_laundry_care_counters_remain_open( + bubbles_pixels: np.ndarray, + threshold: int, + box: tuple[int, int, int, int], + center: tuple[int, int], + minimum_area: int, +) -> None: + """Retain both enclosed light counters, including the smaller 'e'.""" + x0, y0, x1, y1 = box + roi = bubbles_pixels[y0:y1, x0:x1, :3].astype(np.int16) + pink = (roi[:, :, 0] - roi[:, :, 1] > threshold) & ( + roi[:, :, 0] - roi[:, :, 2] > threshold / 2 + ) + _, components, stats, centers = cv2.connectedComponentsWithStats( + (~pink).astype(np.uint8), + connectivity=8, + ) + border = np.unique( + np.concatenate( + [ + components[0], + components[-1], + components[:, 0], + components[:, -1], + ], + ), + ) + holes = [ + index + for index in range(1, len(stats)) + if index not in border + and stats[index, cv2.CC_STAT_AREA] >= minimum_area + and np.linalg.norm(centers[index] - center) <= 6 + ] + assert holes, f"The Laundry Care counter at {box} was filled" + + +@pytest.mark.parametrize( + "count, expected", + [(None, 6), (0, 6), (1, 2), (8, 8), (100, 64)], +) +def test_public_limits_remain_unchanged(count: int | None, expected: int) -> None: + """Keep caller color counts/defaults and the existing pixel-area cap.""" + solver = ColorSolver(Image.new("RGB", (1200, 1200)), count, Timer()) + assert solver.color_count == expected + assert solver.img.size == (1024, 1024) + + +def test_canvas_padding_is_clipped_from_returned_paths() -> None: + """Fill canvas corners without exposing padding in exported geometry or bounds.""" + path = trace_bitmap(np.ones((12, 20), dtype=np.uint8)) + assert compound_paths_bounds([path]) == { + "left": 0, + "top": 0, + "right": 20, + "bottom": 12, + "width": 20, + "height": 12, + } + png = svg2png(bytestring=generate_SVG_markup([path], [[0, 0, 0]], 20, 12)) + np.testing.assert_array_equal( + np.asarray(Image.open(BytesIO(png)).convert("RGBA"))[:, :, 3], + 255, + ) + + +@pytest.mark.parametrize("raw", [False, True]) +def test_clip_failure_retraces_bounded_vectors( + client: FlaskClient, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + raw: bool, +) -> None: + """An exceptional clip failure still yields smooth vectors, holes and bounds.""" + pixels = np.full((20, 32, 4), 255, dtype=np.uint8) + pixels[8:12, 14:18, 3] = 0 + monkeypatch.setattr( + "vectorizing.try_read_image_from_url", + lambda url: Image.fromarray(pixels), + ) + clip = Mock(side_effect=PathOpsError("injected failure")) + 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, + "configuration": "experimental", + "raw": raw, + }, + ) + assert response.status_code == 200 + clip.assert_called_once() + assert "retracing without padding" in caplog.text + if raw: + markup = response.data + upload.assert_not_called() + else: + upload.assert_called_once() + markup = upload.call_args.args[0] + bounds = response.get_json()["info"]["bounds"] + assert 0 <= bounds["left"] <= bounds["right"] <= 32 + assert 0 <= bounds["top"] <= bounds["bottom"] <= 20 + rendered = np.asarray( + Image.open(BytesIO(svg2png(bytestring=markup))).convert("RGBA"), + ) + assert rendered[10, 16, 3] == 0 + assert rendered[5, 5, 3] == 255 + + +def test_clip_recovery_constrains_curve_overshoot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unpadded curve fitting can still extend beyond the bitmap's control hull.""" + rng = np.random.default_rng(6) + height, width = rng.integers(1, 45, 2) + mask = (rng.random((height, width)) < rng.uniform(0.03, 0.98)).astype(np.uint8) + clip = Mock(side_effect=PathOpsError("injected failure")) + monkeypatch.setattr("vectorizing.solvers.color.ColorSolver.op", clip) + path = trace_bitmap(mask) + clip.assert_called_once() + for _, points in path: + for x, y in points: + assert 0 <= x <= width + assert 0 <= y <= height + + +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(), "experimental").solve() + markup = generate_SVG_markup(*result) + png = svg2png(bytestring=markup) + pixels = np.asarray(Image.open(BytesIO(png)).convert("RGBA")) + np.testing.assert_array_equal(pixels[:, :, 3], 255) + + +def test_clipped_consecutive_quadratics_keep_opaque_coverage() -> None: + """Real clipping must not emit malformed Q commands that expose transparent gaps.""" + rng = np.random.default_rng(513) + height, width = rng.integers(1, 45, 2) + 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(), "experimental").solve() + rendered = np.asarray( + Image.open( + BytesIO(svg2png(bytestring=generate_SVG_markup(*result))), + ).convert("RGBA"), + ) + assert rendered[:, :, 3].min() >= 240 + + +def test_fully_transparent_image_has_no_foreground() -> None: + """Area cleanup must not invent opaque pixels in an entirely transparent input.""" + pixels = np.zeros((8, 8, 4), dtype=np.uint8) + labels, palette, background = quantize(pixels, 16) + assert background + np.testing.assert_array_equal(labels, 0) + assert palette.dtype == np.uint8 + 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, diff --git a/vectorizing/tests/test_integration.py b/vectorizing/tests/test_integration.py index 8cb8231..3f74b3f 100644 --- a/vectorizing/tests/test_integration.py +++ b/vectorizing/tests/test_integration.py @@ -107,19 +107,24 @@ def test_color_solver_preserves_empty_transparent_image() -> None: assert rendered.getchannel("A").getextrema() == (0, 0) -def test_uploads_markup_to_s3(client: FlaskClient) -> None: +@pytest.mark.parametrize("solver", [0, 1]) +def test_uploads_markup_to_s3(client: FlaskClient, solver: int) -> None: """Store the generated SVG with its media type and return the object's key.""" response = client.post( "/", json={ "url": get_image_url("black_rectangle.png"), - "solver": 0, + "solver": solver, "raw": False, }, ) assert response.status_code == 200 payload = response.get_json() + info = payload["info"] + bounds = info["bounds"] + assert 0 <= bounds["left"] <= bounds["right"] <= info["image_width"] + assert 0 <= bounds["top"] <= bounds["bottom"] <= info["image_height"] test_bucket = os.environ["S3_TEST_BUCKET"] s3 = get_s3_client() try: diff --git a/vectorizing/tests/test_svg_geometry.py b/vectorizing/tests/test_svg_geometry.py new file mode 100644 index 0000000..c4f913d --- /dev/null +++ b/vectorizing/tests/test_svg_geometry.py @@ -0,0 +1,196 @@ +"""Decode compact paths independently to verify exact two-decimal geometry.""" + +import re +from decimal import Decimal +from io import BytesIO + +import numpy as np +import pytest +from cairosvg import svg2png +from defusedxml import ElementTree as ET +from pathops import Path +from PIL import Image + +from vectorizing.svg.markup import generate_SVG_markup + +SVG = "{http://www.w3.org/2000/svg}" + + +def decode_path(data: str) -> list[tuple[str, tuple[int, ...]]]: + """Expand SVG's integer path grammar to absolute M/L/Q/C/Z commands.""" + tokens = re.findall(r"[A-Za-z]|-?\d+", data) + result = [] + cursor = np.array([0, 0], dtype=np.int64) + origin = cursor.copy() + control = None + command = "" + i = 0 + while i < len(tokens): + if tokens[i].isalpha(): + command = tokens[i] + i += 1 + kind = command.upper() + if kind == "Z": + result.append(("Z", ())) + cursor = origin.copy() + control = None + command = "" + continue + count = {"M": 2, "L": 2, "H": 1, "V": 1, "Q": 4, "C": 6, "S": 4}[kind] + values = np.array(tokens[i : i + count], dtype=np.int64) + i += count + if kind in ("H", "V"): + axis = int(kind == "V") + point = cursor.copy() + point[axis] = values[0] + (cursor[axis] if command.islower() else 0) + points = point.reshape(1, 2) + kind = "L" + else: + points = values.reshape(-1, 2) + if command.islower(): + points = points + cursor + if kind == "S": + reflected = cursor if control is None else 2 * cursor - control + points = np.vstack([reflected, points]) + kind = "C" + result.append((kind, tuple(int(n) for n in points.ravel()))) + cursor = points[-1].copy() + control = points[-2].copy() if kind == "C" else None + if kind == "M": + origin = cursor.copy() + command = "l" if command.islower() else "L" + return result + + +def expected_commands(path: Path) -> list[tuple[str, tuple[int, ...]]]: + """Use decimal arithmetic on the original segments, not encoder helpers.""" + expanded = [] + for kind, points in path.segments: + if kind == "qCurveTo": + # Independently expand the segment-pen's implied on-curve midpoints; + # production uses raw PathVerb pairs instead of this interface. + for index, control in enumerate(points[:-1]): + endpoint = points[-1] + if index < len(points) - 2: + endpoint = tuple( + (a + b) / 2 for a, b in zip(control, points[index + 1]) + ) + expanded.append(("Q", (control, endpoint))) + else: + command = { + "moveTo": "M", + "lineTo": "L", + "curveTo": "C", + "closePath": "Z", + }[kind] + expanded.append((command, points)) + return [ + ( + command, + tuple(int(Decimal(f"{n:.2f}") * 100) for point in points for n in point), + ) + for command, points in expanded + ] + + +@pytest.mark.parametrize("seed", range(30)) +def test_random_compound_paths_round_trip(seed: int) -> None: + """Preserve every point and closed subpath through mixed relative commands.""" + rng = np.random.default_rng(seed) + path = Path() + for _ in range(5): + path.moveTo(*rng.uniform(-2000, 2000, 2)) + for index in range(20): + if index % 3 == 1: + path.cubicTo(*rng.uniform(-2000, 2000, 6)) + elif index % 3 == 2: + path.quadTo(*rng.uniform(-2000, 2000, 4)) + else: + path.lineTo(*rng.uniform(-2000, 2000, 2)) + path.close() + root = ET.fromstring(generate_SVG_markup([path], [[12, 34, 56]], 1024, 900)) + assert root.attrib == {"width": "1024", "height": "900", "viewBox": "0 0 1024 900"} + assert root.find(f"{SVG}g").get("transform") == "scale(.01)" + assert decode_path(root.find(f".//{SVG}path").get("d")) == expected_commands(path) + + +def test_axes_reflections_and_close_reset() -> None: + """Cover H/V/S, implicit repeats and relative moves after a closed subpath.""" + path = Path() + path.moveTo(100, 100) + path.lineTo(101, 100) + path.lineTo(101, 102) + path.cubicTo(101, 103, 102, 104, 103, 104) + path.cubicTo(104, 104, 105, 103, 105, 102) + path.cubicTo(105, 101, 104, 100, 103, 100) + path.close() + path.moveTo(101, 101) + path.lineTo(101.25, 101.25) + path.lineTo(102.25, 102.25) + path.close() + markup = generate_SVG_markup([path], [[0, 0, 0]], 200, 200) + data = ET.fromstring(markup).find(f".//{SVG}path").get("d") + assert all(command in data.lower() for command in "hvsmz") + assert decode_path(data) == expected_commands(path) + + +def test_consecutive_quadratics_expand_implied_endpoints() -> None: + """Skia's segment pen can combine two quadratics into three coordinate pairs.""" + path = Path() + path.moveTo(1, 1) + path.quadTo(2, 3, 4, 4) + path.quadTo(6, 5, 7, 7) + path.close() + assert len(list(path.segments)[1][1]) == 3 + markup = generate_SVG_markup([path], [[0, 0, 0]], 10, 10) + data = ET.fromstring(markup).find(f".//{SVG}path").get("d") + assert decode_path(data) == [ + ("M", (100, 100)), + ("Q", (200, 300, 400, 400)), + ("Q", (600, 500, 700, 700)), + ("Z", ()), + ] + + +def test_rounding_near_half_ties_and_negative_zero() -> None: + """Retain legacy decimal rounding instead of rounding multiplied binary floats.""" + path = Path() + path.moveTo(-0.0, 2.675) + path.cubicTo(-2.675, 1.005, 9.995, -0.005, 1000.125, 1.235) + path.close() + markup = generate_SVG_markup([path], [[255, 0, 0]], 1200, 1200) + data = ET.fromstring(markup).find(f".//{SVG}path").get("d") + assert decode_path(data) == expected_commands(path) + assert 'fill="#f00"' in markup + + +@pytest.mark.parametrize("size", [40, 101, 160]) +def test_compound_hole_rendering(size: int) -> None: + """Keep holes, transparent exterior and half opacity after transformed encoding.""" + path = Path() + path.moveTo(4, 4) + path.lineTo(36, 4) + path.lineTo(36, 36) + path.lineTo(4, 36) + path.close() + # Opposite winding carves a hole in the same compound path. + path.moveTo(12, 12) + path.lineTo(12, 28) + path.lineTo(28, 28) + path.lineTo(28, 12) + path.close() + svg = generate_SVG_markup([path], [[20, 30, 40, 0.5]], 40, 40) + pixels = np.asarray( + Image.open( + BytesIO( + svg2png( + bytestring=svg, + output_width=size, + output_height=size, + ), + ), + ).convert("RGBA"), + ) + assert pixels[size // 2, size // 2, 3] == 0 + assert pixels[0, 0, 3] == 0 + assert pixels[size // 5, size // 5, 3] == 128 diff --git a/vectorizing/tests/test_svg_markup.py b/vectorizing/tests/test_svg_markup.py index 31e2e80..55c4624 100644 --- a/vectorizing/tests/test_svg_markup.py +++ b/vectorizing/tests/test_svg_markup.py @@ -1,4 +1,4 @@ -"""Freeze externally visible SVG formatting, including whitespace and alpha.""" +"""Freeze compact SVG spelling while preserving coordinate rounding and alpha.""" from collections.abc import Sequence @@ -42,7 +42,7 @@ def test_color_formatting(color: Sequence[float] | np.ndarray, expected: str) -> def test_svg_serialization_preserves_exact_markup() -> None: - """Keep paint order, empty-path filtering, cubic syntax and document whitespace.""" + """Keep paint order, empty-path filtering, viewport and fractional alpha.""" curve = Path() curve.moveTo(0, 0) curve.lineTo(10, 0) @@ -60,17 +60,16 @@ def test_svg_serialization_preserves_exact_markup() -> None: ) assert actual == ( '\n' - '\n\n' - '\n' - "\n" + 'viewBox="0 0 40 20">' + '' + '' + "" ) def test_empty_svg_serialization() -> None: - """Keep the empty document's dimensions and blank group unchanged.""" + """Keep the empty document's dimensions and omit all paint.""" assert generate_SVG_markup([], [], 8, 9) == ( '\n\n\n\n' + 'viewBox="0 0 8 9">' )