feat(scripts): Potrace floor-plan vectorization spike (#299) #1663

Merged
lekss361 merged 2 commits from feat/plan-vectorization-spike-299 into main 2026-06-17 17:12:49 +00:00

View file

@ -0,0 +1,321 @@
"""Spike — contour-vectorize floor-plan rasters (PNG/JPG → SVG) via Potrace.
Forgejo issue #299 (Phase 1 exploration SPIKE). **This is a throwaway probe, NOT
production wiring**: it answers one question is Potrace contour tracing good
enough to turn floor-plan rasters into compact, readable SVG for the planning UI
catalog and PDF reports? No ML, no room semantics, no DOM.RF coupling.
WHAT IT MEASURES
----------------
For each input raster the pipeline runs:
1. load (Pillow) grayscale (``L``)
2. binarise: pixels below ``--threshold`` (0-255) become ink (black), the rest
become paper (white). Optional ``--invert`` for light-on-dark plans.
3. write a 1-bit PBM bitmap (Potrace's native input) to the output dir
4. ``potrace -s`` SVG (centerline-free, filled-contour tracing)
5. record size metrics: raster bytes, svg bytes, ratio (raster / svg)
It also (optionally, ``--render-back``) rasterises the produced SVG back to PNG
via ``rsvg-convert`` so a human can eyeball SVG-vs-original side by side and
judge whether wall/room contours survived the round trip.
WHY POTRACE IS SOURCE-AGNOSTIC
------------------------------
Potrace traces the boundary between ink and paper regions of a bitmap. It does
not care where the raster came from a Wikimedia line-drawing plan, a scanned
blueprint, or a DOM.RF UI tile all reduce to "dark strokes on a light field"
after thresholding. So the Phase 1 question ("are the contours traceable and how
much do they compress?") is validly answered on any representative floor-plan
rasters; see the spike doc for the explicit DOM.RF caveat.
USAGE
-----
# vectorise every raster in a folder, collect a metrics table
uv run python backend/scripts/spike_plan_vectorize.py \
--in-dir /tmp/plan-spike/in --out-dir /tmp/plan-spike/out
# also render the SVGs back to PNG for visual QA
uv run python backend/scripts/spike_plan_vectorize.py \
--in-dir /tmp/plan-spike/in --out-dir /tmp/plan-spike/out --render-back
Requires the ``potrace`` binary on PATH (``brew install potrace``) and, for
``--render-back``, ``rsvg-convert`` (``brew install librsvg``). Pillow ships with
the backend deps.
"""
from __future__ import annotations
import argparse
import logging
import shutil
import statistics
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from PIL import Image
logger = logging.getLogger("spike_plan_vectorize")
_RASTER_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff"}
_POTRACE_TIMEOUT_S = 120
_RSVG_TIMEOUT_S = 120
@dataclass(frozen=True)
class VectorizeResult:
"""One raster's trip through the pipeline."""
name: str
raster_bytes: int
svg_bytes: int
svg_path: Path
rendered_path: Path | None
@property
def ratio(self) -> float:
"""Compression ratio raster / svg (>1 means SVG is smaller)."""
return self.raster_bytes / self.svg_bytes if self.svg_bytes else 0.0
def metrics_line(self) -> str:
return (
f"{self.name:<44} "
f"raster={self.raster_bytes:>9}B "
f"svg={self.svg_bytes:>8}B "
f"ratio={self.ratio:>6.2f}x"
)
def _require_binary(name: str) -> str:
"""Resolve an external binary on PATH or exit with a clear message."""
path = shutil.which(name)
if path is None:
logger.error("required binary %r not found on PATH", name)
raise SystemExit(f"{name!r} not found — install it (e.g. `brew install {name}`) and retry")
return path
def raster_to_pbm(src: Path, pbm_path: Path, *, threshold: int, invert: bool) -> None:
"""Load a raster, grayscale + threshold it, and write a 1-bit PBM bitmap.
Potrace consumes 1-bit bitmaps (PBM/PGM/BMP). We binarise with a fixed
threshold so the spike's behaviour is deterministic and explainable — black
(ink) is what Potrace traces, white is background. ``invert`` flips the test
for light-stroke-on-dark plans.
"""
with Image.open(src) as im:
gray = im.convert("L")
# point(): pixel < threshold → ink (0), else paper (255). Pillow's "1" mode
# then packs to a true 1-bit bitmap that Potrace reads natively.
cutoff = threshold
if invert:
bitmap = gray.point(lambda px: 0 if px >= cutoff else 255).convert("1")
else:
bitmap = gray.point(lambda px: 0 if px < cutoff else 255).convert("1")
bitmap.save(pbm_path)
def pbm_to_svg(potrace_bin: str, pbm_path: Path, svg_path: Path) -> None:
"""Trace a PBM bitmap to SVG with ``potrace -s`` (SVG backend)."""
try:
subprocess.run(
[potrace_bin, "-s", "-o", str(svg_path), str(pbm_path)],
check=True,
capture_output=True,
timeout=_POTRACE_TIMEOUT_S,
)
except subprocess.CalledProcessError as exc:
stderr = exc.stderr.decode("utf-8", "replace").strip()
logger.error("potrace failed for %s: %s", pbm_path.name, stderr)
raise
def svg_to_png(rsvg_bin: str, svg_path: Path, png_path: Path, *, width: int) -> None:
"""Render an SVG back to PNG via ``rsvg-convert`` for visual QA.
We composite onto an explicit white background. Potrace's SVG is black
filled paths on a *transparent* canvas; without ``--background-color=white``
rsvg renders the black fill onto transparency and a flattening viewer shows
a solid black tile. White matches the real catalog/PDF use case anyway.
"""
try:
subprocess.run(
[
rsvg_bin,
"-w",
str(width),
"--background-color=white",
"-o",
str(png_path),
str(svg_path),
],
check=True,
capture_output=True,
timeout=_RSVG_TIMEOUT_S,
)
except subprocess.CalledProcessError as exc:
stderr = exc.stderr.decode("utf-8", "replace").strip()
logger.error("rsvg-convert failed for %s: %s", svg_path.name, stderr)
raise
def vectorize_one(
src: Path,
out_dir: Path,
*,
potrace_bin: str,
rsvg_bin: str | None,
threshold: int,
invert: bool,
render_width: int,
) -> VectorizeResult:
"""Run the full PNG/JPG → PBM → SVG (→ PNG) pipeline for a single raster."""
stem = src.stem
pbm_path = out_dir / f"{stem}.pbm"
svg_path = out_dir / f"{stem}.svg"
raster_to_pbm(src, pbm_path, threshold=threshold, invert=invert)
pbm_to_svg(potrace_bin, pbm_path, svg_path)
rendered_path: Path | None = None
if rsvg_bin is not None:
rendered_path = out_dir / f"{stem}.rendered.png"
svg_to_png(rsvg_bin, svg_path, rendered_path, width=render_width)
return VectorizeResult(
name=src.name,
raster_bytes=src.stat().st_size,
svg_bytes=svg_path.stat().st_size,
svg_path=svg_path,
rendered_path=rendered_path,
)
def discover_rasters(in_dir: Path) -> list[Path]:
"""Return sorted raster files in ``in_dir`` (non-recursive)."""
return sorted(
p for p in in_dir.iterdir() if p.is_file() and p.suffix.lower() in _RASTER_SUFFIXES
)
def summarise(results: list[VectorizeResult]) -> None:
"""Print a per-file metrics table plus min/median/max compression."""
if not results:
logger.warning("no results to summarise")
return
print("\n=== per-raster metrics ===")
for r in results:
print(r.metrics_line())
ratios = sorted(r.ratio for r in results)
print("\n=== compression summary ===")
print(f"samples : {len(ratios)}")
print(f"min ratio : {min(ratios):.2f}x")
print(f"median ratio : {statistics.median(ratios):.2f}x")
print(f"max ratio : {max(ratios):.2f}x")
total_raster = sum(r.raster_bytes for r in results)
total_svg = sum(r.svg_bytes for r in results)
agg_ratio = total_raster / total_svg if total_svg else float("inf")
print(
f"aggregate : {total_raster}B raster -> {total_svg}B svg " f"({agg_ratio:.2f}x overall)"
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Spike: contour-vectorize floor-plan rasters (PNG/JPG → SVG) via Potrace.",
)
parser.add_argument(
"--in-dir",
type=Path,
required=True,
help="folder containing input rasters (PNG/JPG/...)",
)
parser.add_argument(
"--out-dir",
type=Path,
required=True,
help="folder for output .pbm/.svg (and .rendered.png with --render-back)",
)
parser.add_argument(
"--threshold",
type=int,
default=128,
help="grayscale ink/paper cutoff 0-255 (default 128)",
)
parser.add_argument(
"--invert",
action="store_true",
help="treat light strokes on a dark field as ink",
)
parser.add_argument(
"--render-back",
action="store_true",
help="rasterise each SVG back to PNG via rsvg-convert for visual QA",
)
parser.add_argument(
"--render-width",
type=int,
default=900,
help="width in px for --render-back output (default 900)",
)
return parser
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
args = build_parser().parse_args(argv)
in_dir: Path = args.in_dir
out_dir: Path = args.out_dir
if not in_dir.is_dir():
logger.error("input dir does not exist: %s", in_dir)
return 2
if not 0 <= args.threshold <= 255:
logger.error("--threshold must be 0-255, got %d", args.threshold)
return 2
out_dir.mkdir(parents=True, exist_ok=True)
potrace_bin = _require_binary("potrace")
rsvg_bin = _require_binary("rsvg-convert") if args.render_back else None
rasters = discover_rasters(in_dir)
if not rasters:
logger.error("no raster files found in %s", in_dir)
return 1
logger.info("found %d raster(s) in %s", len(rasters), in_dir)
results: list[VectorizeResult] = []
failed = 0
for src in rasters:
try:
results.append(
vectorize_one(
src,
out_dir,
potrace_bin=potrace_bin,
rsvg_bin=rsvg_bin,
threshold=args.threshold,
invert=args.invert,
render_width=args.render_width,
)
)
logger.info("vectorized %s", src.name)
except Exception:
# Log + continue: one bad raster must not abort the batch, but we
# never swallow silently — the traceback is recorded and the file
# is counted as a failure in the final tally.
failed += 1
logger.exception("failed to vectorize %s", src.name)
summarise(results)
if failed:
logger.warning("%d/%d raster(s) failed", failed, len(rasters))
return 0 if results else 1
if __name__ == "__main__":
sys.exit(main())