From 4f316f3aefb7eedac00654d68ed6d7dff0c0c01e Mon Sep 17 00:00:00 2001 From: Abraham Date: Fri, 5 Jun 2026 18:22:01 -0600 Subject: [PATCH] Add "fill enclosed areas" (CLI + web GUI); release v0.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blank spaces inside a design — letter interiors, the holes in an outlined mark — that match the background were detected as background and left as empty gaps, so they couldn't be painted. Fill them into the silhouette so they're detected as their own color and become paintable. Background touching the image border (the real outside) is left out. - raster.fill_enclosed() (scipy.ndimage.binary_fill_holes); RasterLoader.load gains a fill_holes flag. - Web: Session keeps the raw silhouette; detect/redetect take fillHoles and recompute the effective silhouette, so everything downstream (preview, meshes, printability) follows. app.jsx adds a "Fill enclosed areas" toggle that re-detects. - CLI/pipeline: --fill-holes flag + PlateConfig.fill_holes. - tests/test_fill_holes.py (68 tests total). Verified on a real outlined logo: white letter interiors go from empty holes to a paintable White region. Release prep: bump to 0.5.0 and finalize the CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 +++++- README.md | 10 ++++- colorplate/cli.py | 4 ++ colorplate/config.py | 4 ++ colorplate/pipeline.py | 4 +- colorplate/raster.py | 16 ++++++- colorplate/web/server.py | 9 ++-- colorplate/web/service.py | 23 +++++++--- colorplate/web/static/app.jsx | 36 +++++++++++++--- colorplate/web/static/index.html | 6 +++ pyproject.toml | 2 +- tests/test_fill_holes.py | 74 ++++++++++++++++++++++++++++++++ 12 files changed, 178 insertions(+), 23 deletions(-) create mode 100644 tests/test_fill_holes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0bd4d..3274570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-06-05 + +### Added +- **Fill enclosed areas** (CLI + web GUI). Blank spaces *inside* a design — letter + interiors, the holes in an outlined mark — that match the background were + detected as background and left as empty gaps. Tick "Fill enclosed areas" in + the GUI (or pass `--fill-holes`) to fold those enclosed regions into the + silhouette so they're detected as their own color and become paintable. + Background that touches the image border (the real outside) is left out. + ## [0.4.0] - 2026-06-05 ### Added @@ -90,7 +100,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Graphical README with brand assets (hero banner, logo, pipeline graphic, GUI screenshots) and an MIT license. -[Unreleased]: https://github.com/kurenn/colorplate/compare/v0.4.0...HEAD +[Unreleased]: https://github.com/kurenn/colorplate/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/kurenn/colorplate/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/kurenn/colorplate/compare/v0.3.1...v0.4.0 [0.3.1]: https://github.com/kurenn/colorplate/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/kurenn/colorplate/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index 1cb8df1..8fee8fe 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ colorplate logo.svg -o out/ --height 180 \ | `--palette` | `name=#hex,...`; omit to auto-detect | auto | | `--colors` | target colors when quantizing a raster | 4 | | `--nozzle` | nozzle line width (mm); warns about thinner features | 0.4 | +| `--fill-holes` | fill enclosed blank areas (e.g. letter interiors) into the design | off | | `--single-extruder` | stack colors by height into one terraced STL + swap schedule | off | | `--base` / `--step` | base-plate / per-color band height for single-extruder (mm) | 0.8 / 0.6 | | `--layer-height` | layer height that filament swaps snap to (mm) | 0.2 | @@ -160,6 +161,9 @@ What it does (all real, no mocks): - **Printability** — flags colors with features thinner than your nozzle line width at the chosen size (set the nozzle, default 0.4 mm), highlights the at-risk areas right on the 2D preview, and offers a one-click "bump size" to fix them. +- **Fill enclosed areas** — for logos where blank spaces inside the design (letter + interiors, the holes in an outlined mark) match the background, tick this to fold + them into the silhouette so they become paintable colors instead of empty gaps. - **Generate** — one watertight STL per distinct assigned filament (regions sharing a filament are merged), plus an optional single-color backing plate, a flat-color preview PNG, and a manifest — bundled into a downloadable `.zip`. @@ -241,8 +245,10 @@ analytics. CI runs it on every push and pull request across Python 3.10 & 3.12. the CLI prints a warning per color (tune the nozzle with `--nozzle`). - The front shell must be opaque enough that the backing color doesn't ghost through; ~1.0 mm (5 layers @ 0.2 mm) is usually fine, bump `--front` if not. -- Source artwork must use **filled** color regions. Pure line-art (strokes only, - colors as background showing through) needs a fill pass first. +- Source artwork should use **filled** color regions. If your design has blank + spaces enclosed by the outline (letter interiors, an outlined mark) that match + the background, turn on **Fill enclosed areas** (`--fill-holes`) to make them + paintable instead of empty gaps. ## 📫 Contact diff --git a/colorplate/cli.py b/colorplate/cli.py index 50fa8fb..26f4bb7 100644 --- a/colorplate/cli.py +++ b/colorplate/cli.py @@ -47,6 +47,9 @@ def build_parser() -> argparse.ArgumentParser: help="Rasterization resolution on the long edge (default: 1600)") p.add_argument("--nozzle", type=float, default=0.4, help="Nozzle line width in mm; warns about thinner features (default: 0.4)") + p.add_argument("--fill-holes", action="store_true", + help="Fill enclosed blank areas (e.g. letter interiors) so they " + "become paintable regions instead of empty gaps.") se = p.add_argument_group("single-extruder (filament swaps, no MMU)") se.add_argument("--single-extruder", action="store_true", @@ -71,6 +74,7 @@ def main(argv: list[str] | None = None) -> int: raster_px=args.raster_px, auto_colors=args.colors, nozzle_mm=args.nozzle, + fill_holes=args.fill_holes, palette=_parse_palette(args.palette) if args.palette else [], ) diff --git a/colorplate/config.py b/colorplate/config.py index fb37a2f..939e5d4 100644 --- a/colorplate/config.py +++ b/colorplate/config.py @@ -49,6 +49,10 @@ class PlateConfig: # that are too thin to print at the chosen size. nozzle_mm: float = 0.4 + # Fill enclosed background ('holes' — e.g. white letter interiors) into the + # silhouette so they become paintable regions instead of empty gaps. + fill_holes: bool = False + # Explicit palette. If empty, the pipeline auto-discovers colors # (from SVG fills/strokes, or by quantization for rasters). palette: list[Color] = field(default_factory=list) diff --git a/colorplate/pipeline.py b/colorplate/pipeline.py index 7d1d03a..63046ff 100644 --- a/colorplate/pipeline.py +++ b/colorplate/pipeline.py @@ -63,7 +63,7 @@ def run(self, input_path: str, out_dir: str) -> PlateResult: stem = os.path.splitext(os.path.basename(input_path))[0] loader = RasterLoader(self.cfg.raster_px) - raster = loader.load(input_path) + raster = loader.load(input_path, fill_holes=self.cfg.fill_holes) palette = self.cfg.palette if not palette and input_path.lower().endswith(".svg"): @@ -112,7 +112,7 @@ def run_stack(self, input_path: str, out_dir: str, *, base_mm: float, stem = os.path.splitext(os.path.basename(input_path))[0] loader = RasterLoader(self.cfg.raster_px) - raster = loader.load(input_path) + raster = loader.load(input_path, fill_holes=self.cfg.fill_holes) palette = self.cfg.palette if not palette and input_path.lower().endswith(".svg"): palette = RasterLoader.palette_from_svg(input_path) diff --git a/colorplate/raster.py b/colorplate/raster.py index b56ee25..2564b80 100644 --- a/colorplate/raster.py +++ b/colorplate/raster.py @@ -12,10 +12,19 @@ import numpy as np from PIL import Image +from scipy.ndimage import binary_fill_holes from .config import Color +def fill_enclosed(silhouette: np.ndarray) -> np.ndarray: + """Fill *enclosed* background ('holes') into the silhouette — e.g. the white + interiors of letters that background detection excluded — so they become + paintable regions. Background that touches the image border (the real + outside) is left out.""" + return binary_fill_holes(silhouette) + + @dataclass class Raster: rgb: np.ndarray # (H, W, 3) int @@ -32,12 +41,15 @@ class RasterLoader: def __init__(self, raster_px: int = 1600): self.raster_px = raster_px - def load(self, path: str) -> Raster: + def load(self, path: str, *, fill_holes: bool = False) -> Raster: if path.lower().endswith(self.SVG_EXT): rgba = self._render_svg(path) else: rgba = self._load_raster(path) - return self._to_raster(rgba) + raster = self._to_raster(rgba) + if fill_holes: + raster.silhouette = fill_enclosed(raster.silhouette) + return raster # -- SVG ------------------------------------------------------------- def _render_svg(self, path: str) -> np.ndarray: diff --git a/colorplate/web/server.py b/colorplate/web/server.py index 7f0a74a..2f80dc0 100644 --- a/colorplate/web/server.py +++ b/colorplate/web/server.py @@ -57,6 +57,7 @@ async def _lifespan(app: FastAPI): class RedetectReq(BaseModel): uploadId: str maxColors: int + fillHoles: bool = False class PreviewReq(BaseModel): @@ -119,7 +120,8 @@ def index() -> HTMLResponse: @app.post("/api/detect") -async def api_detect(request: Request, file: UploadFile, maxColors: int = Form(4)): +async def api_detect(request: Request, file: UploadFile, maxColors: int = Form(4), + fillHoles: bool = Form(False)): name = file.filename or "logo.svg" ext = os.path.splitext(name)[1].lower() if ext not in _ACCEPT: @@ -136,7 +138,7 @@ async def api_detect(request: Request, file: UploadFile, maxColors: int = Form(4 try: session, payload = service.load_session( - sid, name, src_path, out_dir, _clamp_colors(maxColors) + sid, name, src_path, out_dir, _clamp_colors(maxColors), fill_holes=fillHoles ) except Exception as exc: service._cleanup_dir(out_dir) @@ -151,7 +153,8 @@ async def api_detect(request: Request, file: UploadFile, maxColors: int = Form(4 @app.post("/api/redetect") def api_redetect(request: Request, req: RedetectReq): session = _require(req.uploadId) - payload = service.detect_from_path(session, _clamp_colors(req.maxColors)) + payload = service.detect_from_path(session, _clamp_colors(req.maxColors), + fill_holes=req.fillHoles) analytics.record("redetect", request, max_colors=_clamp_colors(req.maxColors), regions=len(payload["regions"])) return payload diff --git a/colorplate/web/service.py b/colorplate/web/service.py index ca3f35b..6cac452 100644 --- a/colorplate/web/service.py +++ b/colorplate/web/service.py @@ -35,7 +35,7 @@ from ..config import PlateConfig from ..mesh import MeshBuilder from ..printability import feature_report -from ..raster import RasterLoader +from ..raster import RasterLoader, fill_enclosed from ..stack import merge_terrace from ..stack import snap as _snap from ..stack import swap_bands as _swap_bands @@ -112,7 +112,9 @@ class Session: src_path: str # saved original upload (for full-res regen) out_dir: str # scratch dir for generated artifacts rgb: np.ndarray # (H, W, 3) int detection-resolution color - sil: np.ndarray # (H, W) bool silhouette mask + sil: np.ndarray # (H, W) bool effective silhouette (used everywhere) + sil_raw: np.ndarray = field(default=None) # (H, W) bool silhouette before hole-fill + fill_holes: bool = False # treat enclosed blank areas as design labels: np.ndarray = field(default=None) # (H, W) int region index, -1 = bg detected: list = field(default_factory=list) # hex per region (dominant-first) weights: list = field(default_factory=list) # area fraction per region @@ -249,8 +251,14 @@ def _regions_payload(session: Session) -> list[dict]: return out -def detect_from_path(session: Session, max_colors: int) -> dict: - """(Re)run detection on an already-loaded session at the given color count.""" +def detect_from_path(session: Session, max_colors: int, + fill_holes: bool | None = None) -> dict: + """(Re)run detection on an already-loaded session at the given color count. + When ``fill_holes`` is set, enclosed blank areas (e.g. letter interiors) are + folded into the silhouette so they become paintable regions.""" + if fill_holes is not None: + session.fill_holes = fill_holes + session.sil = fill_enclosed(session.sil_raw) if session.fill_holes else session.sil_raw labels, detected, weights = _quantize(session.rgb, session.sil, max_colors) session.labels = labels session.detected = detected @@ -263,17 +271,18 @@ def detect_from_path(session: Session, max_colors: int) -> dict: "filename": session.filename, "regions": regions, "preview": preview, + "fillHoles": session.fill_holes, } def load_session(sid: str, filename: str, src_path: str, out_dir: str, - max_colors: int) -> tuple[Session, dict]: + max_colors: int, fill_holes: bool = False) -> tuple[Session, dict]: raster = RasterLoader(DETECT_PX).load(src_path) session = Session( id=sid, filename=filename, src_path=src_path, out_dir=out_dir, - rgb=raster.rgb, sil=raster.silhouette, + rgb=raster.rgb, sil=raster.silhouette, sil_raw=raster.silhouette, ) - payload = detect_from_path(session, max_colors) + payload = detect_from_path(session, max_colors, fill_holes=fill_holes) return session, payload diff --git a/colorplate/web/static/app.jsx b/colorplate/web/static/app.jsx index 316c850..6870fbd 100644 --- a/colorplate/web/static/app.jsx +++ b/colorplate/web/static/app.jsx @@ -9,18 +9,19 @@ async function apiErr(r) { catch (e) { return new Error(r.statusText || "Request failed"); } } const api = { - async detect(file, maxColors) { + async detect(file, maxColors, fillHoles) { const fd = new FormData(); fd.append("file", file); fd.append("maxColors", String(maxColors)); + fd.append("fillHoles", fillHoles ? "true" : "false"); const r = await fetch("/api/detect", { method: "POST", body: fd }); if (!r.ok) throw await apiErr(r); return r.json(); }, - async redetect(uploadId, maxColors) { + async redetect(uploadId, maxColors, fillHoles) { const r = await fetch("/api/redetect", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ uploadId, maxColors }), + body: JSON.stringify({ uploadId, maxColors, fillHoles }), }); if (!r.ok) throw await apiErr(r); return r.json(); @@ -156,6 +157,7 @@ function App() { const [view, setView] = useState("2d"); // 2d recolor | 3d geometry const [tourOpen, setTourOpen] = useState(false); const [maxColors, setMaxColors] = useState(4); + const [fillHoles, setFillHoles] = useState(false); const [size, setSize] = useState(180); const [nozzle, setNozzle] = useState(0.4); const [printable, setPrintable] = useState(null); // printability report @@ -245,7 +247,7 @@ function App() { const loadFile = async (f) => { setBusy(true); setError(null); try { - const resp = await api.detect(f, maxColors); + const resp = await api.detect(f, maxColors, fillHoles); applyDetection(resp); setResult(null); setPhase("loaded"); @@ -256,6 +258,22 @@ function App() { } }; + const toggleFill = async () => { + const next = !fillHoles; + setFillHoles(next); + if (!loaded || !uploadId) return; + setBusy(true); setError(null); + try { + const resp = await api.redetect(uploadId, maxColors, next); + applyDetection(resp); + if (phase === "results") { setResult(null); setPhase("loaded"); } + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + }; + const clearFile = () => { setUploadId(null); setFile(null); setPreview(null); setRegions([]); setBacking(null); setResult(null); @@ -267,7 +285,7 @@ function App() { if (!loaded || !uploadId) return; setBusy(true); setError(null); try { - const resp = await api.redetect(uploadId, n); + const resp = await api.redetect(uploadId, n, fillHoles); applyDetection(resp); if (phase === "results") { setResult(null); setPhase("loaded"); } } catch (e) { @@ -392,6 +410,14 @@ function App() {
Max colors
Most printers handle 4 at once.
+ {/* color config */} diff --git a/colorplate/web/static/index.html b/colorplate/web/static/index.html index 19936a1..8153938 100644 --- a/colorplate/web/static/index.html +++ b/colorplate/web/static/index.html @@ -369,6 +369,12 @@ border-radius:5px; padding:2px 6px; flex:none; } + /* ---------- FILL ENCLOSED TOGGLE ---------- */ + .fill-toggle{ display:flex; align-items:flex-start; gap:8px; margin-top:9px; cursor:pointer; font-size:12.5px; color:var(--text-2); font-weight:500; } + .fill-toggle input{ accent-color:var(--accent); margin-top:1px; flex:none; } + .fill-toggle .hint{ display:block; font-weight:400; margin-top:1px; margin-left:0; } + .fill-toggle.is-busy{ opacity:.55; cursor:wait; } + /* ---------- PRINTABILITY ---------- */ .risk{ display:flex; flex-direction:column; gap:6px; margin-top:4px; } .risk-row{ diff --git a/pyproject.toml b/pyproject.toml index 939da61..bb98757 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "colorplate" -version = "0.4.0" +version = "0.5.0" description = "Turn SVG/PNG artwork into layered, gap-free multicolor STL plates for face-down multi-material 3D printing." readme = "README.md" license = "MIT" diff --git a/tests/test_fill_holes.py b/tests/test_fill_holes.py new file mode 100644 index 0000000..5c83d8e --- /dev/null +++ b/tests/test_fill_holes.py @@ -0,0 +1,74 @@ +"""Fill enclosed areas: hole-filling the silhouette so blank spaces inside the +design (e.g. white letter interiors) become paintable regions. +""" +from __future__ import annotations + +import numpy as np +from PIL import Image + +from colorplate.config import PlateConfig +from colorplate.pipeline import PlatePipeline +from colorplate.raster import fill_enclosed +from colorplate.web import server, service + + +def test_fill_enclosed_fills_holes_not_border(): + sil = np.zeros((50, 50), bool) + sil[10:40, 10:40] = True # solid square + sil[20:30, 20:30] = False # an enclosed hole + out = fill_enclosed(sil) + assert out[25, 25] # enclosed hole filled + assert out[10:40, 10:40].all() + assert not out[0, 0] # outer (border-connected) background stays out + + +def _holed_session(): + """Black square frame around a white interior, on a white background — the + interior is excluded by background detection until holes are filled.""" + h = w = 200 + rgb = np.full((h, w, 3), 255, int) + sil = np.zeros((h, w), bool) + sil[20:180, 20:180] = True + sil[60:140, 60:140] = False # white hole (interior) + rgb[sil] = (10, 10, 10) # black design + rgb[60:140, 60:140] = (250, 250, 250) + return service.Session(id="holed", filename="t.png", src_path="", out_dir="", + rgb=rgb, sil=sil, sil_raw=sil) + + +def _has_near_white(payload): + return any(int(r["detected"][1:3], 16) > 200 for r in payload["regions"]) + + +def test_detect_with_fill_adds_the_interior_region(): + sess = _holed_session() + off = service.detect_from_path(sess, 4, fill_holes=False) + on = service.detect_from_path(sess, 4, fill_holes=True) + assert off["fillHoles"] is False and on["fillHoles"] is True + assert not _has_near_white(off) # interior missing without fill + assert _has_near_white(on) # interior surfaces as a paintable color + + +def test_api_redetect_fill_holes(sample_session): + from fastapi.testclient import TestClient + sess = _holed_session() + server.store.put(sess) + client = TestClient(server.app) + off = client.post("/api/redetect", json={"uploadId": sess.id, "maxColors": 4, "fillHoles": False}) + on = client.post("/api/redetect", json={"uploadId": sess.id, "maxColors": 4, "fillHoles": True}) + assert off.status_code == 200 and on.status_code == 200 + assert len(on.json()["regions"]) > len(off.json()["regions"]) + + +def test_cli_pipeline_fill_holes(tmp_path): + a = np.full((200, 200, 3), 255, np.uint8) + a[40:160, 40:160] = (10, 10, 10) # black square + a[80:120, 80:120] = (255, 255, 255) # enclosed white hole + p = tmp_path / "holed.png" + Image.fromarray(a).save(p) + + off = PlatePipeline(PlateConfig(size_mm=100, raster_px=400, fill_holes=False)).run( + str(p), str(tmp_path / "off")) + on = PlatePipeline(PlateConfig(size_mm=100, raster_px=400, fill_holes=True)).run( + str(p), str(tmp_path / "on")) + assert len(on.files) > len(off.files) # the white interior becomes its own color