feat(scripts): Potrace floor-plan vectorization spike (#299) #1663
1 changed files with 321 additions and 0 deletions
321
backend/scripts/spike_plan_vectorize.py
Normal file
321
backend/scripts/spike_plan_vectorize.py
Normal 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())
|
||||
Loading…
Add table
Reference in a new issue