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() {