All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m55s
CI / openapi-codegen-check (pull_request) Successful in 1m50s
CI / backend-tests (push) Successful in 8m54s
CI / backend-tests (pull_request) Successful in 9m3s
Exploration spike (#299 Phase 1): reusable PNG/JPG -> grayscale -> threshold -> PBM -> `potrace -s` -> SVG pipeline with optional rsvg-convert render-back for visual QA. CLI via argparse, ruff-clean. Not production wiring. Spike verdict: Phase 1 production-ready for clean line-art (gated by an input-quality filter); no Phase 2 ML needed yet. Median compression 1.29x, readability 87% (100% on genuine line-art). Full doc → vault code/patterns. Caveat: real DOM.RF validation pending the 22d SSR scraper.
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""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)
|
|
print(f"aggregate : {total_raster}B raster -> {total_svg}B svg "
|
|
f"({total_raster / total_svg:.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())
|