Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
140f928
feat: use analytic fills in local path fitting
rasros Aug 24, 2026
53fce3f
feat: emit strokes for thin SAMVG masks
rasros Aug 24, 2026
1e121c4
feat: add Torch OCR to SAMVG seeds
rasros Aug 24, 2026
6afcb0d
feat: use Qwen for SAMVG text recognition
rasros Aug 24, 2026
8045a7c
feat: verify OCR text against seed pixels
rasros Aug 24, 2026
cddc252
feat: tolerate small OCR text mismatches
rasros Aug 24, 2026
503020e
refactor: remove scientific stack from SAMVG
rasros Aug 24, 2026
b6a9892
feat: fit mixed SVG primitives locally
rasros Aug 24, 2026
ff523c0
feat: accelerate SAMVG refinement
rasros Aug 24, 2026
bc58e22
perf: reduce SAMVG mask transfer overhead
rasros Aug 24, 2026
c01b2e7
chore: ignore generated benchmark results
rasros Aug 25, 2026
02934ff
perf: accelerate SAMVG impact filtering
rasros Aug 25, 2026
142397d
docs: add SAMVG algorithm reference
rasros Aug 25, 2026
81f8b42
fix: align SAMVG mask recovery stages
rasros Aug 25, 2026
7998c2e
fix: preserve SAMVG contour topology
rasros Aug 25, 2026
b694ce0
fix: recover all SAMVG prompt components
rasros Aug 25, 2026
1912d5f
tune: calibrate SAMVG residual kernel
rasros Aug 25, 2026
41a1d93
feat: add SAMVG learnable fill alpha
rasros Aug 25, 2026
c050c9b
feat: add SAMVG variable segment tracing
rasros Aug 25, 2026
8f9d8f3
feat: render long SAMVG contours on CUDA
rasros Aug 25, 2026
d8ac89f
fix: select local SAMVG variable corners
rasros Aug 25, 2026
23eb68b
fix: reuse SAM runtime in two-phase benchmark
rasros Aug 25, 2026
e99ef14
feat: add sparse SAMVG global replay
rasros Aug 25, 2026
d0b27f0
perf: use analytic coverage for compound fills
rasros Aug 25, 2026
33889d1
perf: batch sparse fill replay gradients
rasros Aug 25, 2026
cdf0c27
perf: batch SAMVG replay tiles
rasros Aug 25, 2026
8bdf9db
fix: honor disabled torch compilation
rasros Aug 25, 2026
f766dd5
fix: preserve SAM logits through mask resizing
rasros Aug 25, 2026
6c85834
fix: preserve SAM full-resolution mask filtering
rasros Aug 25, 2026
fbde45b
refactor: report SAMVG Cairo MSE
rasros Aug 25, 2026
a75c2a4
fix: refresh moved SAMVG fill tiles
rasros Aug 25, 2026
8008675
tune: densify SAMVG residual recovery
rasros Aug 25, 2026
65fb390
fix: apply global SAM crop NMS
rasros Aug 25, 2026
ccb50c8
fix: match SAM automatic crop suppression
rasros Aug 25, 2026
84fdc54
fix: preserve SAM crop traversal order
rasros Aug 25, 2026
9486d75
docs: record SAM crop suppression order
rasros Aug 25, 2026
33f6f5b
fix: retain SAMVG variable trace extrema
rasros Aug 25, 2026
edc765e
fix: store variable SAMVG contour lengths
rasros Aug 25, 2026
1374818
fix: preserve SAM mask compound paths
rasros Aug 25, 2026
65ed147
revert: preserve SAM mask compound paths
rasros Aug 25, 2026
5035ee3
merge: resolve SAMVG conflicts
rasros Aug 25, 2026
9be1635
fix: align SAMVG coverage recovery
rasros Aug 25, 2026
33ca1c9
fix: separate SAMVG benchmark targets
rasros Aug 25, 2026
6838148
fix: defer SAMVG mask gates to impact filtering
rasros Aug 25, 2026
66e03e8
feat: report SAMVG mask canvas error
rasros Aug 25, 2026
45b2f46
fix: retain accepted SAMVG first fit
rasros Aug 25, 2026
19acdba
fix: restore SAMVG coverage mean shift
rasros Aug 25, 2026
3a474f5
feat: expose SAMVG representation variations
rasros Aug 25, 2026
56de1ba
perf: batch sparse fill replay
rasros Aug 25, 2026
18df891
fix: bound sparse replay batches
rasros Aug 25, 2026
399962f
fix: normalize SAMVG Xing loss
rasros Aug 26, 2026
98d3e89
fix: bound sparse replay compiler memory
rasros Aug 26, 2026
d7c4a6c
fix: refit visible SAMVG seed colours
rasros Aug 26, 2026
6360f46
fix: avoid duplicate SAMVG trace samples
rasros Aug 26, 2026
a866a33
fix: disable SAMVG seeds by default
rasros Aug 26, 2026
77f201d
feat: group SAMVG seed help
rasros Aug 26, 2026
946204d
feat: expose SAMVG seed controls
rasros Aug 26, 2026
7ec2617
style: format SAMVG tooling
rasros Aug 26, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
.idea/
*.egg-info
/build/
/bench/results/
/output
/models
# Run output: vectrify writes <output-stem>/runs/ next to the output file
Expand Down
17 changes: 8 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ vectrify photo.jpg -o sketch.svg --seeds 10 --epochs 4 \
--max-wall-seconds 1800
vectrify mascot.png -o mascot.svg --segment-count 12 # tiles/local elites (default: 8)

# Skip the default segmentation-derived SVG seed (SVG only)
vectrify artwork.png -o artwork.svg --no-samvg-seed
# Add the optional segmentation-derived SVG seed (SVG only)
vectrify artwork.png -o artwork.svg --samvg-seed

# Choose a provider, model, or scorer explicitly
vectrify input.png --provider anthropic --model MODEL_NAME
Expand Down Expand Up @@ -109,14 +109,13 @@ by default; add `--save-heatmap` for perceptual difference maps.

## SAMVG-inspired seed

One native, segmentation-first SVG candidate is added to every SVG run without
reducing the configured LLM seed count. It uses SAM ViT-H by default, retains
masks only when they materially improve a flat-colour reconstruction of the
target, and traces the retained masks into editable layered SVG paths. Set
With `--samvg-seed`, Vectrify adds one native, segmentation-first SVG candidate
without reducing the configured LLM seed count. It uses SAM ViT-H by default,
retains masks only when they materially improve a flat-colour reconstruction of
the target, and traces the retained masks into editable layered SVG paths. Set
`VECTRIFY_SAMVG_MODEL=facebook/sam-vit-base` for the smaller checkpoint. It is
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.
inspired by SAMVG, not an installation of the unreleased research code and is
off by default.

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.
Expand Down
117 changes: 100 additions & 17 deletions scripts/bench_samvg_two_phase.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
_mse,
_render_layers,
_render_svg,
_sam_runtime,
filter_by_impact,
prompted_masks,
residual_prompt_points,
Expand All @@ -43,13 +44,11 @@ def _path_count(svg: str) -> int:
)


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 _result_name(target_path: Path) -> str:
"""Give standard bench targets stable, non-colliding output directories."""
if target_path.name == "target.png":
return target_path.parent.name
return target_path.stem


def _write_gallery(images: list[tuple[str, Image.Image]], destination: Path) -> None:
Expand All @@ -68,6 +67,7 @@ def _fit_if_improved(
target: Image.Image,
plugin: SvgPlugin,
steps: int,
learn_alpha: bool,
) -> tuple[str, Image.Image, list[dict[str, int | float]], bool]:
before = _render_svg(svg, target, plugin.rasterize)
measurements: list[dict[str, int | float]] = []
Expand All @@ -77,6 +77,7 @@ def _fit_if_improved(
rasterize=plugin.rasterize,
steps=steps,
measurements=measurements,
learn_alpha=learn_alpha,
)
after = _render_svg(candidate, target, plugin.rasterize)
if _mse(target, after) <= _mse(target, before):
Expand All @@ -90,36 +91,101 @@ def run_target(
*,
steps: int,
reference_svg: Path | None = None,
learn_alpha: bool = False,
curvature_threshold: float | None = None,
seed_only: bool = False,
) -> None:
target = Image.open(target_path).convert("RGB")
plugin = SvgPlugin()
destination = output / target_path.stem
destination = output / _result_name(target_path)
destination.mkdir(parents=True, exist_ok=True)
started = perf_counter()
layers = retrieve_layers(target)
runtime = _sam_runtime()
layers = retrieve_layers(target, _runtime=runtime)
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,
curvature_threshold=curvature_threshold,
)
_render_svg(initial, target, plugin.rasterize).save(destination / "first-seed.png")
(destination / "first-seed.svg").write_text(initial)
if seed_only:
seed_render = _render_svg(initial, target, plugin.rasterize)
mask_canvas, _coverage = _render_layers((target.height, target.width), layers)
stages = [
("target", target, None),
("mask-canvas", Image.fromarray(mask_canvas), None),
("first-seed", seed_render, initial),
]
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 = [
{
"stage": name,
"mse": _mse(target, rendered),
"paths": _path_count(svg) if svg is not None else 0,
}
for name, rendered, svg in stages
]
_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", "mse", "paths"])
writer.writeheader()
writer.writerows(rows)
(destination / "fit-groups.json").write_text("[]\n")
(destination / "summary.json").write_text(
json.dumps(
{
"target": str(target_path),
"seed_only": True,
"initial_layers": len(layers),
"wall_seconds": perf_counter() - started,
"stages": rows,
},
indent=2,
)
)
return
first, first_render, first_measurements, first_accepted = _fit_if_improved(
initial, target, plugin, steps
initial, target, plugin, steps, learn_alpha
)
first_render.save(destination / "first-fit.png")
(destination / "first-fit.svg").write_text(first)
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),
prompted_masks(target, points, _runtime=runtime),
existing=layers,
initial_canvas=np.asarray(first_render, dtype=np.uint8),
initial_coverage=coverage,
# The residual pass starts from the first fitted raster. It is not an
# uncovered-mask pass, so all pixels must use their actual raster MSE.
initial_coverage=np.ones((target.height, target.width), dtype=bool),
)[len(layers) :]
recovery = _append_layers(first, added, 16, hybrid_strokes=False)
recovery = _append_layers(
first,
added,
16,
hybrid_strokes=False,
curvature_threshold=curvature_threshold,
)
_render_svg(recovery, target, plugin.rasterize).save(
destination / "residual-recovery.png"
)
(destination / "residual-recovery.svg").write_text(recovery)
final, final_render, final_measurements, final_accepted = _fit_if_improved(
recovery, target, plugin, steps
recovery, target, plugin, steps, learn_alpha
)
if _mse(target, final_render) > _mse(target, first_render):
# Phase-two fitting is accepted relative to the recovered document,
# but the benchmark's final result must retain the already accepted
# first fit when residual additions regress the exported Cairo raster.
final, final_render, final_accepted = first, first_render, False
stages = [
("target", target, None),
("first-seed", _render_svg(initial, target, plugin.rasterize), initial),
Expand All @@ -142,7 +208,6 @@ def run_target(
rows.append(
{
"stage": name,
"l1": _l1(target, rendered),
"mse": _mse(target, rendered),
"paths": _path_count(svg) if svg is not None else 0,
}
Expand All @@ -151,7 +216,7 @@ def run_target(
[(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 = csv.DictWriter(handle, fieldnames=["stage", "mse", "paths"])
writer.writeheader()
writer.writerows(rows)
measurements = [
Expand Down Expand Up @@ -182,6 +247,11 @@ def main() -> None:
type=Path,
help="Reference SVG to Cairo-rasterize alongside a single target.",
)
parser.add_argument(
"--curvature-threshold",
type=float,
help="Use the dissertation's variable-segment tracing variation.",
)
parser.add_argument("--cat", action="store_true")
parser.add_argument(
"--all", action="store_true", help="Run cat, duck, and all bench targets."
Expand All @@ -190,6 +260,16 @@ def main() -> None:
"--output", type=Path, default=ROOT / "bench/results/samvg-two-phase"
)
parser.add_argument("--steps", type=int, default=500)
parser.add_argument(
"--seed-only",
action="store_true",
help="Benchmark automatic masks and coverage recovery without fitting.",
)
parser.add_argument(
"--learn-alpha",
action="store_true",
help="Use the dissertation's SAMVG+alpha fitter variation.",
)
args = parser.parse_args()
targets = list(args.target)
if args.cat or args.all:
Expand All @@ -214,6 +294,9 @@ def main() -> None:
args.output,
steps=args.steps,
reference_svg=reference_svg,
learn_alpha=args.learn_alpha,
curvature_threshold=args.curvature_threshold,
seed_only=args.seed_only,
)


Expand Down
94 changes: 91 additions & 3 deletions src/vectrify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
from importlib.metadata import version as _pkg_version

from vectrify.formats import FORMAT_NAMES
from vectrify.refine.samvg import (
SAMVG_MAX_SIDE,
SAMVG_MODEL,
SAMVG_POINTS_PER_BATCH,
)
from vectrify.score import ScorerType
from vectrify.score.vision import DEFAULT_VISION_MODEL

Expand Down Expand Up @@ -196,14 +201,87 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
metavar="N",
help="Edge-aware Voronoi masks retained as local elites. Default: 8",
)
g_search.add_argument(
g_samvg = parser.add_argument_group("SAMVG seed")
g_samvg.add_argument(
"--samvg-seed",
action=argparse.BooleanOptionalAction,
default=True,
default=False,
help="Add one SAMVG-inspired SVG seed made from automatic SAM masks, "
"impact filtering, contour tracing, and Torch OCR. Requires "
"vectrify[samvg] and "
"is available for SVG output only. Default: on",
"is available for SVG output only. Default: off",
)
g_samvg.add_argument(
"--samvg-model",
default=SAMVG_MODEL,
metavar="HF_REPO",
help=f"HuggingFace SAM checkpoint used for the seed. Default: {SAMVG_MODEL}",
)
g_samvg.add_argument(
"--samvg-max-side",
type=int,
default=SAMVG_MAX_SIDE,
metavar="PX",
help="Maximum long side passed to SAM before masks are restored to "
"the output canvas. "
f"Default: {SAMVG_MAX_SIDE}",
)
g_samvg.add_argument(
"--samvg-points-per-batch",
type=int,
default=SAMVG_POINTS_PER_BATCH,
metavar="N",
help="SAM decoder prompts per GPU batch; this does not change the "
"32x32 automatic prompt grid. "
f"Default: {SAMVG_POINTS_PER_BATCH}",
)
g_samvg.add_argument(
"--samvg-min-pixels",
type=int,
default=32,
metavar="N",
help="Discard connected mask components smaller than N pixels before "
"tracing. Default: 32",
)
g_samvg.add_argument(
"--samvg-min-impact",
type=float,
default=3e-6,
metavar="MSE",
help="Minimum whole-mask reconstruction improvement required for "
"retention. Default: 3e-6",
)
g_samvg.add_argument(
"--samvg-max-layers",
type=int,
default=512,
metavar="N",
help="Maximum retained SAM masks in each seed stage. Default: 512",
)
g_samvg.add_argument(
"--samvg-segments",
type=int,
default=16,
metavar="N",
help="Fixed cubic Bézier segments per traced contour. Default: 16",
)
g_samvg.add_argument(
"--samvg-fill-holes",
action=argparse.BooleanOptionalAction,
default=True,
help="Fill only sub-threshold enclosed mask holes before tracing. Default: on",
)
g_samvg.add_argument(
"--samvg-hybrid-strokes",
action=argparse.BooleanOptionalAction,
default=True,
help="Emit conservative centreline strokes for thin seed masks. Default: on",
)
g_samvg.add_argument(
"--samvg-ocr",
action=argparse.BooleanOptionalAction,
default=True,
help="Run optional OCR and retain pixel-verified editable text. Default: on",
)

g_epoch = parser.add_argument_group(
Expand Down Expand Up @@ -484,6 +562,16 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
raise SystemExit("Error: --workers and --pool-size must be > 0")
if ns.segment_count <= 0:
raise SystemExit("Error: --segment-count must be > 0")
if (
ns.samvg_max_side <= 0
or ns.samvg_points_per_batch <= 0
or ns.samvg_min_pixels <= 0
or ns.samvg_max_layers <= 0
or ns.samvg_segments <= 0
):
raise SystemExit("Error: SAMVG integer controls must be > 0")
if ns.samvg_min_impact < 0:
raise SystemExit("Error: --samvg-min-impact must be >= 0")
if ns.resolution <= 0:
raise SystemExit("Error: --resolution must be > 0")
if ns.resolution_llm <= 0:
Expand Down
10 changes: 10 additions & 0 deletions src/vectrify/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ def main():
vision_model=args.vision_model,
segment_count=args.segment_count,
samvg_seed=args.samvg_seed,
samvg_model=args.samvg_model,
samvg_max_side=args.samvg_max_side,
samvg_points_per_batch=args.samvg_points_per_batch,
samvg_min_pixels=args.samvg_min_pixels,
samvg_min_impact=args.samvg_min_impact,
samvg_max_layers=args.samvg_max_layers,
samvg_segments=args.samvg_segments,
samvg_fill_holes=args.samvg_fill_holes,
samvg_hybrid_strokes=args.samvg_hybrid_strokes,
samvg_ocr=args.samvg_ocr,
auto_crop=args.auto_crop,
dry_run=args.dry_run,
dry_run_parameters=vars(args),
Expand Down
Loading
Loading