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