diff --git a/README.md b/README.md index 79114a1..c5edd60 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,17 @@ svgsmith convert input.png \ | `--quality FLOAT` | `0.9` | Target fidelity in `[0,1]` (SSIM vs the original). Drives the verify loop. | | `--max-iters INT` | `4` | Max verify/refine iterations before returning the best result so far. | | `--editable` / `--no-editable` | on | Editable grouped/simplified SVG, or the raw traced output. | +| `--smooth` / `--no-smooth` | on | Curve-refit color contours into smooth, sparse Béziers (Schneider least-squares). | +| `--solid-background` | off | Isolate the subject and repaint the background as one clean solid color — removes texture/grain/specks while keeping subject detail. | +| `--uniform-outline` | off | Force an even-width outline band (outlined illustrations only; would add a wrong border on line art). | | `--out PATH` | `.svg` | Output SVG path. | | `--report {off,json}` | `off` | Print a JSON report to stdout (the only thing on stdout). | +> **Composable for agents.** svgsmith is meant to be driven by an AI agent that maps a +> user's intent to flags. *"Detailed character on a clean flat background"* → +> `--solid-background`; *"crisp logo"* → `--mode binary`; *"keep the raw look"* → +> `--no-smooth`. The [`vectorize` skill](skills/vectorize/SKILL.md) encodes this mapping. + ### Exit codes | Code | Meaning | diff --git a/skills/vectorize/SKILL.md b/skills/vectorize/SKILL.md index b452485..450de1b 100644 --- a/skills/vectorize/SKILL.md +++ b/skills/vectorize/SKILL.md @@ -26,6 +26,15 @@ Choose `--mode` and `--quality` from what the user asked for. When unsure, use ` | "pixel art", sprite, low-res blocky image | `--mode pixel` | | anything unspecified / "vectorize this" | `--mode auto` | +Then add **refinement flags** based on what the user wants out of it (these compose): + +| User wants | Add flag | +|---|---| +| "clean / flat / solid background", "remove the background texture", "just the subject on a plain color", "isolate the cat/logo/person" | `--solid-background` | +| "keep maximum detail on the subject" | (default already preserves detail; `--solid-background` also helps by not spending the palette on background noise) | +| "even / consistent outline", "uniform line weight" (only for art that already has a dark outline) | `--uniform-outline` | +| "keep the raw / rough / hand-drawn look", "don't smooth it" | `--no-smooth` | + Always pass `--report json` and a sensible `--out` (default: input path with `.svg`). ## Step 2 — Run the CLI diff --git a/src/svgsmith/cli.py b/src/svgsmith/cli.py index b282130..1a71258 100644 --- a/src/svgsmith/cli.py +++ b/src/svgsmith/cli.py @@ -53,6 +53,7 @@ def _convert(args: argparse.Namespace) -> int: editable=args.editable, smooth=args.smooth, uniform_outline=args.uniform_outline, + solid_background=args.solid_background, out=args.out, ) @@ -162,6 +163,15 @@ def build_parser() -> argparse.ArgumentParser: "border on line art." ), ) + convert.add_argument( + "--solid-background", + action="store_true", + default=False, + help=( + "Isolate the subject and repaint the background as one clean solid " + "color, removing texture/grain/specks while keeping subject detail." + ), + ) convert.add_argument( "--out", default=None, diff --git a/src/svgsmith/pipeline.py b/src/svgsmith/pipeline.py index 9d31656..defc7ff 100644 --- a/src/svgsmith/pipeline.py +++ b/src/svgsmith/pipeline.py @@ -36,6 +36,7 @@ class ConvertOptions: editable: bool = True smooth: bool = True # curve-refit color output (Schneider Bezier fit) for smooth contours uniform_outline: bool = False # opt-in: force an even outline band (outlined art only) + solid_background: bool = False # opt-in: isolate subject, repaint background one solid color out: str | None = None def __post_init__(self) -> None: @@ -104,6 +105,8 @@ def convert(input_path: str, opts: ConvertOptions | None = None) -> tuple[str, R pre_opts = _preprocess_opts(classification.mode) if opts.uniform_outline and classification.mode == "color": pre_opts = replace(pre_opts, uniform_outline=True) + if opts.solid_background: + pre_opts = replace(pre_opts, solid_background=True) prepared = preprocess(image, pre_opts) svg, result = run_loop( diff --git a/src/svgsmith/preprocess.py b/src/svgsmith/preprocess.py index 28a04e3..0bdead2 100644 --- a/src/svgsmith/preprocess.py +++ b/src/svgsmith/preprocess.py @@ -33,6 +33,9 @@ class PreprocessOptions: quantize: bool = True palette_size: int = 16 # target palette; T3 preset can inform this + solid_background: bool = False # replace the background with one clean solid color + subject_threshold: int = 60 # per-channel distance from bg color to count as subject + uniform_outline: bool = False # force a constant-width outline band (opt-in) outline_width: int = 8 # band half-width in px, used when uniform_outline is on @@ -103,6 +106,47 @@ def quantize_colors(img: Image.Image, palette_size: int) -> Image.Image: return quantized +def solid_background(img: Image.Image, threshold: int, min_fraction: float = 0.01) -> Image.Image: + """Isolate the subject and repaint everything else as one clean solid color. + + Detects the subject as the significant connected regions that differ from the + dominant corner (background) color, then replaces all non-subject pixels with + the median background color. Removes texture, grain, streaks, and stray specks + so the background becomes a single flat fill — while the subject is untouched, + so its detail is fully preserved. Small specks below ``min_fraction`` of the + image are absorbed into the background rather than kept as noise. + """ + from scipy import ndimage + + rgb = np.array(img.convert("RGB")) + height, width = rgb.shape[:2] + corners = [ + tuple(rgb[0, 0]), + tuple(rgb[0, -1]), + tuple(rgb[-1, 0]), + tuple(rgb[-1, -1]), + ] + background = np.array(max(set(corners), key=corners.count), dtype=int) + far = np.abs(rgb.astype(int) - background).max(axis=2) > threshold + + labels, count = ndimage.label(far) + subject = np.zeros((height, width), dtype=bool) + if count: + min_pixels = min_fraction * height * width + sizes = ndimage.sum(np.ones_like(labels), labels, range(1, count + 1)) + for index, size in enumerate(sizes, start=1): + if size >= min_pixels: + subject |= labels == index + subject = ndimage.binary_fill_holes(subject) + subject = ndimage.binary_closing(subject, iterations=2) + + out = rgb.copy() + bg_pixels = rgb[~subject] + if bg_pixels.size: + out[~subject] = np.median(bg_pixels, axis=0).astype(np.uint8) + return Image.fromarray(out, "RGB").convert(img.mode) + + def uniform_outline(img: Image.Image, width: int, bg_tolerance: int = 18) -> Image.Image: """Paint a constant-width band of the darkest color around the silhouette. @@ -184,12 +228,15 @@ def remove_background(img: Image.Image, tolerance: int) -> Image.Image: def preprocess(image: ImageInput, opts: PreprocessOptions | None = None) -> Image.Image: """Run the enabled preprocessing steps in order and return an RGBA image. - Order: tiny-input upscale → denoise → quantize → background removal. - Background removal runs last so the cleared alpha is not quantized away. + Order: solid background → tiny-input upscale → denoise → flatten → quantize → + outline band → background removal. Solid-background runs first so the subject + is detected from the original colors before any flattening/quantization. """ opts = opts or PreprocessOptions() img = load_image(image, "RGBA") + if opts.solid_background: + img = solid_background(img, opts.subject_threshold) if opts.upscale: img = upscale_tiny(img, opts.min_dimension) if opts.denoise: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 18627f9..3024a16 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -113,3 +113,13 @@ def test_uniform_outline_is_opt_in_and_color_only(): # The flag runs without error and still produces a valid color SVG report. assert on.mode_used == "color" assert on.svg.paths >= 1 + + +def test_solid_background_is_opt_in_and_runs(): + on = convert( + str(FIXTURES / "illustration.png"), + ConvertOptions(max_iters=1, solid_background=True), + )[1] + # Off by default elsewhere; on, it still yields a valid color SVG report. + assert on.mode_used == "color" + assert on.svg.paths >= 1