Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
640ce45
perf(color): extract centroids from the used palette
phil-r Sep 17, 2026
095da23
style(tests): satisfy trailing-comma hook
phil-r Sep 17, 2026
1c0eeda
Merge pull request #71 from Kittl/perf/used-palette-centroids
phil-r Sep 17, 2026
f69c69b
Merge pull request #73 from Kittl/perf/cumulative-bitmap-layering
phil-r Sep 17, 2026
f074fa5
revert(color): restore pre-69 layer clipping (#75)
phil-r Sep 18, 2026
0683f7f
fix(ci): avoid duplicate checks on release PRs (#78)
phil-r Sep 18, 2026
3ad2d29
fix(color): preserve holes with narrow overlaps at shared edges (#76)
phil-r Sep 18, 2026
4762b9a
chore(lint): restore passing repository checks (#79)
phil-r Sep 18, 2026
279960d
chore(dev): add output comparison and benchmark tools (#80)
phil-r Sep 18, 2026
351fa34
docs: add repository agent guidance (#81)
phil-r Sep 18, 2026
8287c3b
fix(api): return raw SVG with correct MIME type (#83)
phil-r Sep 21, 2026
420d2c3
perf(color): make cluster cleanup linear and memory-bounded (#74)
phil-r Sep 22, 2026
0897d1e
chore: reconcile production ancestry after squash promotion
phil-r Sep 24, 2026
087df6c
Merge remote-tracking branch 'origin/production'
phil-r Sep 24, 2026
8c513f5
feat: preserve color detail with compact SVG output (#85)
phil-r Sep 25, 2026
31e9568
fix: preserve line weight when deleting detected backgrounds (#86)
phil-r Sep 28, 2026
2c6b730
fix(color): restore original vectorization as default (#87)
phil-r Sep 28, 2026
cc81a34
feat(color): select color count automatically (#88)
phil-r Sep 29, 2026
716a59d
feat(color): trace single-color auto artwork as binary (#89)
phil-r Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
40 changes: 37 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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:

Expand All @@ -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
}
```
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand Down
26 changes: 23 additions & 3 deletions scripts/_vectorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -100,14 +101,26 @@ 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":
raise ValueError(
"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()
Expand All @@ -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)
Expand All @@ -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),
Expand Down
58 changes: 52 additions & 6 deletions scripts/benchmark_vectorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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 = {}
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion scripts/vectorization_outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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",
Expand All @@ -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:
Expand Down
Loading
Loading