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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,29 @@ inspired by SAMVG, not an installation of the unreleased research code. Use
`--no-samvg-seed` to skip it; the feature is currently available for SVG output
only.

SAM inputs default to a 1024px maximum side, the model's native encoder size;
the returned masks are restored to the target's original canvas before tracing.
Set `VECTRIFY_SAMVG_MAX_SIDE` to choose another cap, or pass `max_side=None` to
the Python API to opt out explicitly.

Automatic SAM masks retain the dissertation's 32×32 prompt grid but decode 64
prompts per CUDA batch in FP16 by default. Set `VECTRIFY_SAMVG_POINTS_PER_BATCH`
for a larger-memory GPU; full-resolution mask filtering remains on CPU so the
batch does not consume the renderer's CUDA memory.

The default candidate is deliberately the segmentation-and-tracing seed only;
it does not run the path optimiser. This keeps seed quality measurable without
mixing in local refinement.

For the dissertation-style two-phase measurement (initial fit, residual prompts,
and recovery fit), build with the optional native CUDA renderer and run:

```sh
VECTRIFY_BUILD_SAMVG_CUDA=1 uv build --wheel --no-build-isolation
uv pip install --force-reinstall --no-deps dist/vectrify-*.whl
.venv/bin/python scripts/bench_samvg_two_phase.py --cat
```

`--all` also evaluates every benchmark target and the connect-the-dots duck.
Each target directory contains the five stage rasters, SVGs, a gallery,
pixel-error table, and per-bounded-group CUDA memory/timing data.
6 changes: 2 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,15 @@ vision = [
"scikit-learn>=1.3.0",
"torch>=2.0.0",
"torchvision>=0.28.0",
"transformers>=4.40.0",
"transformers>=4.49.0",
]
# The CUDA extension is shipped in platform-specific wheels. Installing this
# extra is deliberately sufficient for the SAMVG seed even on machines that
# use the portable Torch renderer fallback.
samvg = [
"scipy>=1.11.0",
"scikit-learn>=1.3.0",
"torch>=2.0.0",
"torchvision>=0.28.0",
"transformers>=4.40.0",
"transformers>=4.49.0",
]
graphviz = [
"graphviz>=0.21",
Expand Down
221 changes: 221 additions & 0 deletions scripts/bench_samvg_two_phase.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
"""Run and record SAMVG's two-phase segmentation, fit, and recovery process.

Examples:
uv run python scripts/bench_samvg_two_phase.py --cat
uv run python scripts/bench_samvg_two_phase.py --all

The native CUDA extension must be available for the 1024px cat workload. The
script deliberately uses the regular SAMVG masks and fixed-16-segment tracer;
it only bounds the differentiable fit to one spatial fill group at a time.
"""

from __future__ import annotations

import argparse
import csv
import json
import xml.etree.ElementTree as ET
from pathlib import Path
from time import perf_counter

import numpy as np
from PIL import Image, ImageDraw

from vectrify.formats.svg.plugin import SvgPlugin
from vectrify.refine.paths import fit_filled_svg_bounded
from vectrify.refine.samvg import (
_append_layers,
_mse,
_render_layers,
_render_svg,
filter_by_impact,
prompted_masks,
residual_prompt_points,
retrieve_layers,
)

ROOT = Path(__file__).resolve().parents[1]


def _path_count(svg: str) -> int:
return sum(
element.tag.split("}")[-1] == "path" for element in ET.fromstring(svg).iter()
)


def _l1(target: Image.Image, rendered: Image.Image) -> float:
return float(
np.abs(
np.asarray(target.convert("RGB"), dtype=np.float32) / 255.0
- np.asarray(rendered.convert("RGB"), dtype=np.float32) / 255.0
).mean()
)


def _write_gallery(images: list[tuple[str, Image.Image]], destination: Path) -> None:
width = max(image.width for _name, image in images)
height = max(image.height for _name, image in images)
gallery = Image.new("RGB", (width * len(images), height + 28), "white")
labels = ImageDraw.Draw(gallery)
for index, (name, image) in enumerate(images):
gallery.paste(image.convert("RGB"), (index * width, 28))
labels.text((index * width + 4, 6), name, fill="black")
gallery.save(destination)


def _fit_if_improved(
svg: str,
target: Image.Image,
plugin: SvgPlugin,
steps: int,
) -> tuple[str, Image.Image, list[dict[str, int | float]], bool]:
before = _render_svg(svg, target, plugin.rasterize)
measurements: list[dict[str, int | float]] = []
candidate = fit_filled_svg_bounded(
svg,
target,
rasterize=plugin.rasterize,
steps=steps,
measurements=measurements,
)
after = _render_svg(candidate, target, plugin.rasterize)
if _mse(target, after) <= _mse(target, before):
return candidate, after, measurements, True
return svg, before, measurements, False


def run_target(
target_path: Path,
output: Path,
*,
steps: int,
reference_svg: Path | None = None,
) -> None:
target = Image.open(target_path).convert("RGB")
plugin = SvgPlugin()
destination = output / target_path.stem
destination.mkdir(parents=True, exist_ok=True)
started = perf_counter()
layers = retrieve_layers(target)
initial = _append_layers(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{target.width}" '
f'height="{target.height}" viewBox="0 0 {target.width} {target.height}"></svg>',
layers,
16,
hybrid_strokes=False,
)
first, first_render, first_measurements, first_accepted = _fit_if_improved(
initial, target, plugin, steps
)
points = residual_prompt_points(target, first_render)
_canvas, coverage = _render_layers((target.height, target.width), layers)
added = filter_by_impact(
target,
prompted_masks(target, points),
existing=layers,
initial_canvas=np.asarray(first_render, dtype=np.uint8),
initial_coverage=coverage,
)[len(layers) :]
recovery = _append_layers(first, added, 16, hybrid_strokes=False)
final, final_render, final_measurements, final_accepted = _fit_if_improved(
recovery, target, plugin, steps
)
stages = [
("target", target, None),
("first-seed", _render_svg(initial, target, plugin.rasterize), initial),
("first-fit", first_render, first),
(
"residual-recovery",
_render_svg(recovery, target, plugin.rasterize),
recovery,
),
("final-fit", final_render, final),
]
if reference_svg is not None:
reference = _render_svg(reference_svg.read_text(), target, plugin.rasterize)
stages.append(("reference-svg", reference, reference_svg.read_text()))
rows = []
for name, rendered, svg in stages:
rendered.save(destination / f"{name}.png")
if svg is not None:
(destination / f"{name}.svg").write_text(svg)
rows.append(
{
"stage": name,
"l1": _l1(target, rendered),
"mse": _mse(target, rendered),
"paths": _path_count(svg) if svg is not None else 0,
}
)
_write_gallery(
[(name, image) for name, image, _svg in stages], destination / "gallery.png"
)
with (destination / "stages.csv").open("w", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=["stage", "l1", "mse", "paths"])
writer.writeheader()
writer.writerows(rows)
measurements = [
{"phase": "first-fit", **measurement} for measurement in first_measurements
] + [{"phase": "final-fit", **measurement} for measurement in final_measurements]
(destination / "fit-groups.json").write_text(json.dumps(measurements, indent=2))
(destination / "summary.json").write_text(
json.dumps(
{
"target": str(target_path),
"first_fit_accepted": first_accepted,
"final_fit_accepted": final_accepted,
"initial_layers": len(layers),
"residual_layers": len(added),
"wall_seconds": perf_counter() - started,
"stages": rows,
},
indent=2,
)
)


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--target", type=Path, action="append", default=[])
parser.add_argument(
"--reference-svg",
type=Path,
help="Reference SVG to Cairo-rasterize alongside a single target.",
)
parser.add_argument("--cat", action="store_true")
parser.add_argument(
"--all", action="store_true", help="Run cat, duck, and all bench targets."
)
parser.add_argument(
"--output", type=Path, default=ROOT / "bench/results/samvg-two-phase"
)
parser.add_argument("--steps", type=int, default=500)
args = parser.parse_args()
targets = list(args.target)
if args.cat or args.all:
targets.append(Path("/tmp/SAMVG_thesis/cat1024.jpg"))
if args.all:
targets.extend(sorted((ROOT / "bench/cases").glob("*/target.png")))
targets.append(ROOT / "connect-the-dots-little-duck.png")
if not targets:
parser.error("give --target, --cat, or --all")
if args.steps < 1:
parser.error("--steps must be positive")
if args.reference_svg is not None and len(targets) != 1:
parser.error("--reference-svg requires exactly one target")
for target in targets:
reference_svg = args.reference_svg
if target == Path("/tmp/SAMVG_thesis/cat1024.jpg"):
candidate = target.with_suffix(".svg")
if candidate.exists():
reference_svg = candidate
run_target(
target,
args.output,
steps=args.steps,
reference_svg=reference_svg,
)


if __name__ == "__main__":
main()
3 changes: 2 additions & 1 deletion src/vectrify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
action=argparse.BooleanOptionalAction,
default=True,
help="Add one SAMVG-inspired SVG seed made from automatic SAM masks, "
"impact filtering, and contour tracing. Requires vectrify[vision] and "
"impact filtering, contour tracing, and Torch OCR. Requires "
"vectrify[samvg] and "
"is available for SVG output only. Default: on",
)

Expand Down
26 changes: 15 additions & 11 deletions src/vectrify/formats/svg/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
PATH_FIT,
UnsupportedPathError,
fit_available,
fit_random_group,
fit_svg_primitives_locally,
fittable_opaque_fills,
fittable_strokes,
)

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -157,16 +159,18 @@ def mutate(
if reference_png is None or not fit_available():
return content, PATH_FIT
try:
return (
fit_random_group(
content,
reference_png,
rasterize=lambda svg, w, h: self.rasterize(svg, w, h),
weights=targets,
gpu_gate=self.gpu_gate,
),
PATH_FIT,
)
if fittable_opaque_fills(content) or fittable_strokes(content):
return (
fit_svg_primitives_locally(
content,
reference_png,
rasterize=lambda svg, w, h: self.rasterize(svg, w, h),
weights=targets,
gpu_gate=self.gpu_gate,
),
PATH_FIT,
)
raise UnsupportedPathError("no supported SVG primitive to fit")
except UnsupportedPathError as exc:
log.debug(f"Nothing to fit: {exc}")
return content, PATH_FIT
Expand Down
Loading
Loading