Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
23 changes: 14 additions & 9 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 @@ -96,6 +99,7 @@ The request format is the following:
url: string, // Image URL
solver: number, // Solver. 0 -> Binary, 1 -> Color
color_count: number, // Number of colors (if applicable)
configuration: string, // Optional color profile: "current" (default) or "experimental"
raw: boolean // If true, plain return plain SVG markup
}
```
Expand Down Expand Up @@ -123,7 +127,9 @@ A typical response would be

Or, if `raw = true` was supplied, just plain SVG markup

Color processing uses a gentle 3-pixel bilateral filter and removes only connected
Color processing can be selected per request with the optional `configuration` JSON field. The default `current` profile uses the original color quantization and layer clipping behavior. Set `configuration` to `experimental` to use the newer quantization, overlap-based layers, and opaque-background detection/isolation. The profiles are assembled from independently switchable solver stages, so behavior can be compared without removing either implementation. Unknown profile names are rejected with `INVALID_PARAMETERS`.

The `experimental` profile's color processing uses a gentle 3-pixel bilateral filter and removes only connected
components smaller than eight processed-image pixels, rather than rejecting thin
regions based on their shape. Transparent pixels are protected; on opaque images,
the most common surviving perimeter color is also protected to retain small
Expand All @@ -138,15 +144,14 @@ constrains any outlying curve control points to the canvas. It keeps smooth edit
vectors instead of failing the request, but can adjust curves near the canvas and
round corners inward, reducing edge coverage on the affected layer.

SVGs use compact absolute/relative commands on an integer hundredth-pixel grid,
inside a `scale(.01)` group. This preserves the previous two-decimal coordinate
rounding, viewport, paint order and opacity without raster images or SVG strokes.
Binary tracing is unchanged, but both solvers use the compact serializer. Equivalent
geometry can produce small renderer-specific antialiasing differences; exact pixel
identity across viewers and zoom levels is not guaranteed. Potrace and pypotrace
remain dependencies under their existing GPL licenses.
The default color profile uses the original two-decimal SVG serialization.
Binary and `experimental` color responses use compact absolute/relative commands
on an integer hundredth-pixel grid inside a `scale(.01)` group. All profiles emit
editable vectors without raster images or strokes. Equivalent geometry can produce
small renderer-specific antialiasing differences across viewers and zoom levels.
Potrace and pypotrace remain dependencies under their existing GPL licenses.

Color layers have cutouts with a small overlap along shared edges to hide seams.
The `experimental` color layers have cutouts with a small overlap along shared edges to hide seams.
The overlap is two pixels in the resized image before tracing, not screen pixels.
Hiding a color can expose this rim; very thin features may remain covered, and
zooming in makes the rim larger. No SVG strokes are added.
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