diff --git a/README.md b/README.md index c5edd60..fce0d23 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ svgsmith convert input.png \ | `--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). | +| `--detail {high,normal,clean,poster}` | `normal` | Color detail dial. `high` = maximum detail; `clean` = edge-preserving cleanup (less noise/grain); `poster` = bold flat graphic with few colors. | | `--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. | diff --git a/skills/vectorize/SKILL.md b/skills/vectorize/SKILL.md index 450de1b..1832498 100644 --- a/skills/vectorize/SKILL.md +++ b/skills/vectorize/SKILL.md @@ -31,10 +31,15 @@ Then add **refinement flags** based on what the user wants out of it (these comp | 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) | +| "maximum detail", "keep every detail / texture / shading" | `--detail high` | +| "cleaner / tidier", "less noise / grain", "smooth it out a bit" | `--detail clean` | +| "poster / flat / bold graphic", "simple flat colors", "minimalist" | `--detail poster` | | "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` | +`--detail` is the dial between fidelity and a clean/flat look; default `normal` is balanced. +Flags compose, e.g. *"a detailed cat on a clean solid background"* → `--detail high --solid-background`. + 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 1a71258..4974c6e 100644 --- a/src/svgsmith/cli.py +++ b/src/svgsmith/cli.py @@ -54,6 +54,7 @@ def _convert(args: argparse.Namespace) -> int: smooth=args.smooth, uniform_outline=args.uniform_outline, solid_background=args.solid_background, + detail=args.detail, out=args.out, ) @@ -163,6 +164,16 @@ def build_parser() -> argparse.ArgumentParser: "border on line art." ), ) + convert.add_argument( + "--detail", + choices=["high", "normal", "clean", "poster"], + default="normal", + help=( + "Color detail dial (default: normal). high = maximum detail; " + "clean = edge-preserving cleanup, less noise; poster = bold flat graphic, " + "few colors." + ), + ) convert.add_argument( "--solid-background", action="store_true", diff --git a/src/svgsmith/pipeline.py b/src/svgsmith/pipeline.py index defc7ff..1326f6c 100644 --- a/src/svgsmith/pipeline.py +++ b/src/svgsmith/pipeline.py @@ -19,6 +19,16 @@ # Mode → engine label and the preset used when --mode is given explicitly. _ENGINE = {"binary": "potrace", "color": "vtracer", "pixel": "vtracer"} +# Detail levels for color mode — the dial between maximum fidelity and a flat +# poster look. Each maps to (edge-preserving flatten sigma, pre-trace palette size, +# perceptual LAB ΔE merge threshold). "normal" is the default and reproduces the +# prior behavior exactly. Higher levels flatten more and keep fewer colors. +DETAIL_LEVELS = { + "high": (0.02, 64, 10.0), # maximum detail: least flattening, richest palette + "normal": (0.04, 48, 14.0), # balanced (default) + "clean": (0.10, 48, 18.0), # tidied: edge-preserving cleanup, noise reduced + "poster": (0.13, 28, 30.0), # bold flat graphic: few colors, strong flattening +} # Max SSIM the curve-smoothing pass may cost before we fall back to un-smoothed # output (smoothing reduces SSIM slightly by design; a big drop = lost feature). _SMOOTH_SSIM_TOLERANCE = 0.06 @@ -37,6 +47,7 @@ class ConvertOptions: 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 + detail: str = "normal" # color detail dial: high | normal | clean | poster out: str | None = None def __post_init__(self) -> None: @@ -44,6 +55,10 @@ def __post_init__(self) -> None: raise ValueError(f"quality must be in [0, 1], got {self.quality}") if self.max_iters < 1: raise ValueError(f"max_iters must be >= 1, got {self.max_iters}") + if self.detail not in DETAIL_LEVELS: + raise ValueError( + f"detail must be one of {', '.join(DETAIL_LEVELS)}, got {self.detail!r}" + ) def _resolve_classification(image, mode: str) -> Classification: @@ -102,7 +117,10 @@ def convert(input_path: str, opts: ConvertOptions | None = None) -> tuple[str, R image: ImageInput = load_image(input_path, "RGBA") classification = _resolve_classification(image, opts.mode) + flatten_sigma, palette_size, palette_threshold = DETAIL_LEVELS[opts.detail] pre_opts = _preprocess_opts(classification.mode) + if classification.mode == "color": + pre_opts = replace(pre_opts, flatten_sigma=flatten_sigma, palette_size=palette_size) if opts.uniform_outline and classification.mode == "color": pre_opts = replace(pre_opts, uniform_outline=True) if opts.solid_background: @@ -116,6 +134,7 @@ def convert(input_path: str, opts: ConvertOptions | None = None) -> tuple[str, R max_iters=opts.max_iters, editable=opts.editable, reference=image, # score against the true original, not the preprocessed image + palette_threshold=palette_threshold if classification.mode == "color" else None, ) # Curve-refit color output so contours are smooth (the verify loop traces diff --git a/src/svgsmith/verify.py b/src/svgsmith/verify.py index 6e7c102..c97086a 100644 --- a/src/svgsmith/verify.py +++ b/src/svgsmith/verify.py @@ -104,12 +104,16 @@ def _trace_and_post( preset: Preset, simplify_level: float, editable: bool = True, + palette_threshold: float | None = None, ) -> str: engine = BinaryTracer() if mode == "binary" else ColorTracer() raw = engine.trace(image, preset) if not editable: return raw # --no-editable: emit the raw traced SVG, skip postprocess - return postprocess(raw, PostprocessOptions(simplify_level=simplify_level)) + opts = PostprocessOptions(simplify_level=simplify_level) + if palette_threshold is not None: + opts = replace(opts, palette_threshold=palette_threshold) + return postprocess(raw, opts) def run_loop( @@ -120,6 +124,7 @@ def run_loop( renderer: str | None = None, editable: bool = True, reference: ImageInput | None = None, + palette_threshold: float | None = None, ) -> tuple[str, VerifyResult]: """Trace+postprocess, score, and re-tune up to ``max_iters``; return the best. @@ -165,7 +170,9 @@ def run_loop( simplify_level += 1.0 preset = _tune_preset(base, color_level) - svg = _trace_and_post(trace_image, mode, preset, simplify_level, editable) + svg = _trace_and_post( + trace_image, mode, preset, simplify_level, editable, palette_threshold + ) current = score(original, rasterize(svg, original.size, renderer)) scores.append(current) params = { diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 3024a16..59f315d 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -123,3 +123,19 @@ def test_solid_background_is_opt_in_and_runs(): # Off by default elsewhere; on, it still yields a valid color SVG report. assert on.mode_used == "color" assert on.svg.paths >= 1 + + +def test_detail_level_validation_and_spectrum(): + import pytest + + with pytest.raises(ValueError): + ConvertOptions(detail="ultra") + # The dial trades detail for flatness: higher levels keep fewer colors. + counts = {} + for level in ("high", "normal", "clean", "poster"): + _svg, rep = convert( + str(FIXTURES / "illustration.png"), + ConvertOptions(detail=level, max_iters=1), + ) + counts[level] = rep.svg.colors + assert counts["high"] >= counts["normal"] >= counts["clean"] >= counts["poster"]