diff --git a/README.md b/README.md index 86359a2..ad15fda 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,11 @@ svgsmith convert path/to/image.png --out out.svg --report json with a pure-black outline anchor — clean flat colors and smooth outlines instead of a scratchy, near-duplicate-color trace. Applied only to that input class (with an output-complexity fallback), so other art is unaffected. +- **Faithful, economical color** — the default color engine keeps the *deliberate* detail + an artist drew (dots, stipple, fine texture) while collapsing only anti-alias/compression + fringes, so illustrations come out clean and editable without losing their character or + ballooning in size. `--detail high` additionally preserves subtle painterly brush-grain + for heavily textured art. - **Editable output** — instead of one monolithic ``, output is grouped into `` layers with simplified paths and a consolidated color palette. - **Self-verifying** — converts, re-rasterizes, diffs against the original (SSIM), and @@ -119,7 +124,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. | +| `--detail {high,normal,clean,poster}` | `normal` | Color detail dial. `high` = maximum detail, preserving fine texture / painterly brush-grain; `normal` = faithful but economical (keeps deliberate detail, drops anti-alias fringes); `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. | | `--background COLOR` | off | Like `--solid-background`, but repaint the detected background to a **specific** color (`#RRGGBB` or named, e.g. `white`). `auto` = the detected median. | | `--transparent-background` | off | **Remove** the background instead of repainting it — the edge-connected background is cut, leaving a **transparent** SVG. The subject is kept even where it shares the background color (color mode). | diff --git a/src/svgsmith/__init__.py b/src/svgsmith/__init__.py index 41be4f7..93b5561 100644 --- a/src/svgsmith/__init__.py +++ b/src/svgsmith/__init__.py @@ -1,3 +1,3 @@ """svgsmith — convert raster images into clean, editable SVG.""" -__version__ = "0.4.2" +__version__ = "0.5.0" diff --git a/src/svgsmith/pipeline.py b/src/svgsmith/pipeline.py index 7d49844..a6e260d 100644 --- a/src/svgsmith/pipeline.py +++ b/src/svgsmith/pipeline.py @@ -35,6 +35,13 @@ "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 } +# Detail-aware region-merge ΔE76 threshold per detail level for the coverage path: a small +# region merges into its neighbour only when the neighbour is closer than this (AA/noise); +# distinct marks farther than it are KEPT. LOWER = keep more subtle texture (painterly grain, +# fine stipple) = faithful but heavier; HIGHER = collapse more = economical/flat. "normal" is +# the loop-validated economical-fidelity default; "high" preserves painterly brush-grain +# (reference-grade on textured art, at more paths/bytes — fidelity over economy, on demand). +COVERAGE_NOISE_DE = {"high": 5.0, "normal": 13.0, "clean": 18.0, "poster": 26.0} # Pre-trace supersampling target (#60) and per-detail fixed-K palette size (#41) # for color mode. Upscaling smooths contours; the small k-means palette + black # anchor snaps each region to one clean color and keeps the outline pure black. @@ -103,6 +110,9 @@ def _supersample_candidate(image: ImageInput) -> bool: _COVERAGE_MAX_EDGE_DENSITY = 0.008 _COVERAGE_MIN_DISTINCT = 256 # must be genuinely continuous-tone, not a low-edge flat _COVERAGE_MAX_PATHS = 4000 # safety cap; over this, fall back to the baseline path +# At --detail high (max fidelity) legitimately grainy art needs far more tiles; raise the +# cap so grain survives, while still catching a misgated photo (which explodes far past it). +_COVERAGE_MAX_PATHS_HIGH = 14000 # Tier 2 (#67): perceptual-coverage + connected-component min-AREA cleanup as the default # colour engine. The area filter makes coverage economical and clean on *any* rich-colour @@ -333,7 +343,21 @@ def render( resolve_pre_opts(False), coverage_palette=True, coverage_region_cleanup=_TIER2_REGION_COVERAGE, + # Economical-fidelity operating point (loop-validated): a fine perceptual step with + # almost no global speckle drop preserves subtle colour detail, while the detail-aware + # region merge (region_noise_de) collapses low-ΔE anti-alias fringes for economy/speed + # yet KEEPS high-ΔE deliberate marks (dots/stipple/texture). Reaches reference-grade + # fidelity at ~1.4-3.6x reference bytes and 1-8 s (was 8-13x / 40-218 s without it). + coverage_step=5.0, + coverage_min_area=0.00003, + coverage_region_min_area=0.0006, + coverage_region_noise_de=COVERAGE_NOISE_DE[opts.detail], ) + # --detail high = max fidelity: skip the pre-trace flatten so painterly brush-grain + # / fine texture survives to the tracer (flatten, even light, smooths grain away). + # Other levels keep the flatten (the loop-validated economical default). + if opts.detail == "high": + cov_pre = replace(cov_pre, flatten=False) # Illustration-geometry knobs (experimental, loop-tuned): supersample the flat-colour # mask for round/uniform scallop boundaries, and/or thin the dark linework into crescents. # Applied ONLY to the outlined low-res illustration class and ONLY when requested, so the @@ -349,7 +373,14 @@ def render( svg, similarity, iterations = render( cov_pre, cov_class, palette_threshold=0.0, max_iters=1 ) - if svg.count(" _COVERAGE_MAX_PATHS: + # Path cap = misgated-photo blowup guard. At --detail high the user opted into + # max fidelity, so a high count is INTENDED for legitimately grainy/painterly art + # (the reference traces such inputs into thousands of micro-tiles) — raise the cap + # so the grain-rich coverage output is kept instead of being thrown away and + # re-traced on the baseline path (which both loses the grain AND doubles the time). + # A truly misgated photo still explodes far past the high cap and falls back. + cov_cap = _COVERAGE_MAX_PATHS_HIGH if opts.detail == "high" else _COVERAGE_MAX_PATHS + if svg.count(" cov_cap: coverage = False # not actually a clean gradient — use the proven path if not coverage: diff --git a/src/svgsmith/postprocess.py b/src/svgsmith/postprocess.py index 7bcc6cc..98af26c 100644 --- a/src/svgsmith/postprocess.py +++ b/src/svgsmith/postprocess.py @@ -15,6 +15,7 @@ import re import xml.etree.ElementTree as ET from dataclasses import dataclass +from functools import lru_cache import numpy as np @@ -327,8 +328,14 @@ def _rgb(hex_color: str) -> tuple[int, int, int]: return tuple(int(hex_color[i : i + 2], 16) for i in (1, 3, 5)) # type: ignore[return-value] +@lru_cache(maxsize=4096) def _rgb_to_lab(hex_color: str) -> tuple[float, float, float]: - """sRGB hex -> CIE L*a*b* (D65). Perceptual space so 'close' means look-alike.""" + """sRGB hex -> CIE L*a*b* (D65). Perceptual space so 'close' means look-alike. + + Cached: a palette consolidation compares the same handful of colours many times, so + converting each unique hex once (not per comparison) is the difference between a few + ms and tens of seconds on a colour-rich trace. + """ def _lin(c: float) -> float: c /= 255.0 @@ -361,6 +368,12 @@ def _color_distance(a: str, b: str) -> float: def _consolidate(colors: list[str], threshold: float) -> dict[str, str]: """Map each color to a representative; near-identical colors share one.""" + # threshold <= 0 merges nothing (only an exact-LAB match would qualify, and the + # caller already de-duplicates by hex) — so every colour maps to itself. Short-circuit + # to identity: skips an O(n^2) pairwise ΔE scan that is pure waste on the coverage path + # (palette_threshold=0), where a grain-rich trace can carry thousands of unique fills. + if threshold <= 0: + return {color: color for color in colors} representatives: list[str] = [] mapping: dict[str, str] = {} for color in colors: diff --git a/src/svgsmith/preprocess.py b/src/svgsmith/preprocess.py index 8a2a957..baac3d3 100644 --- a/src/svgsmith/preprocess.py +++ b/src/svgsmith/preprocess.py @@ -73,6 +73,11 @@ class PreprocessOptions: coverage_region_cleanup: bool = False coverage_region_min_area: float = 0.0004 # merge components below this share of pixels coverage_region_max_px: int = 2000 # px ceiling on the threshold (protect small marks) + # Detail-aware merge (#economical-fidelity): keep a small region whose nearest neighbour is + # ≥ this ΔE76 away — a *distinct-colour* mark (dot/stipple/texture) drawn on purpose, not + # AA/compression noise. Collapses low-ΔE fringes (economy + speed) while preserving the + # high-ΔE detail that makes output designer-usable. 0.0 = merge-everything (legacy). + coverage_region_noise_de: float = 0.0 # Lossy-input denoise (#71): JPEG/MPO compression noise inflates the CIELAB volume so # coverage emits near-duplicate colours (the pigeon JPEG: 19 vs the reference's ~10). A # light edge-preserving bilateral pre-filter, GATED to lossy sources only, removes those @@ -331,7 +336,10 @@ def _adjacent_region_pairs(region_label: np.ndarray) -> np.ndarray: def _merge_small_regions( - region_label: np.ndarray, region_lab: np.ndarray, min_area_px: float + region_label: np.ndarray, + region_lab: np.ndarray, + min_area_px: float, + noise_de: float = 0.0, ) -> np.ndarray: """Absorb every region below ``min_area_px`` into its lowest-ΔE live neighbour. @@ -339,6 +347,13 @@ def _merge_small_regions( the surviving region id for each original region id (apply as ``roots[region_label]``). The survivor keeps its own colour — merging only *repaints* the small region, so no new colours are introduced. + + When ``noise_de`` > 0, a small region whose nearest neighbour is ≥ ``noise_de`` (ΔE76) + away is KEPT, not merged: it is a *distinct-colour* mark (a dot, stipple speck, fine + texture) the artist drew on purpose, not anti-alias/compression noise. This is the + economical-fidelity lever — collapse low-ΔE AA fringes (economy + speed) while keeping + high-ΔE deliberate detail (designer-usable). ``noise_de`` == 0 keeps the original + merge-everything behaviour. """ import heapq @@ -371,6 +386,8 @@ def find(x: int) -> int: if not neighbours: continue best = min(neighbours, key=lambda nb: float(np.linalg.norm(lab[root] - lab[nb]))) + if noise_de > 0.0 and float(np.linalg.norm(lab[root] - lab[best])) >= noise_de: + continue # distinct-colour mark (dot/stipple/texture) — keep, do not merge parent[root] = best wa, wb = sizes[best], sizes[root] lab[best] = (lab[best] * wa + lab[root] * wb) / (wa + wb) @@ -462,6 +479,7 @@ def quantize_coverage( region_cleanup: bool = False, region_min_area: float = 0.0004, region_max_px: int = 2000, + region_noise_de: float = 0.0, dark_thin: int = 0, dark_protect: float = 0.0006, denoise_lossy: bool = False, @@ -576,7 +594,9 @@ def quantize_coverage( region_lab = centers[region_pal] # ΔE merge distance uses the palette colour min_area_px = min(float(region_min_area) * total, float(region_max_px)) min_area_px = max(min_area_px, 1.0) - roots = _merge_small_regions(region_label, region_lab, min_area_px) + roots = _merge_small_regions( + region_label, region_lab, min_area_px, region_noise_de + ) pixel_palette = region_pal[roots[region_label]].ravel() out_rgb = rep_rgb[pixel_palette].round().clip(0, 255).astype(np.uint8).reshape(height, width, 3) @@ -759,6 +779,7 @@ def preprocess(image: ImageInput, opts: PreprocessOptions | None = None) -> Imag region_cleanup=opts.coverage_region_cleanup, region_min_area=opts.coverage_region_min_area, region_max_px=opts.coverage_region_max_px, + region_noise_de=opts.coverage_region_noise_de, dark_thin=opts.coverage_dark_thin, dark_protect=opts.coverage_dark_protect, denoise_lossy=lossy_coverage, diff --git a/tests/test_postprocess.py b/tests/test_postprocess.py index f6cbd04..d210801 100644 --- a/tests/test_postprocess.py +++ b/tests/test_postprocess.py @@ -139,6 +139,19 @@ def test_consolidation_can_be_disabled(): assert len(_fill_colors(out)) == 2 +def test_palette_threshold_zero_merges_nothing(): + """palette_threshold <= 0 is an identity (no merge): near-identical fills both survive. + The short-circuit also skips the O(n^2) pairwise ΔE scan (the coverage path uses 0).""" + svg = ( + f'' + '' + '' # near-identical, but not merged at 0 + "" + ) + out = postprocess(svg, PostprocessOptions(palette_threshold=0.0, simplify_level=0)) + assert len(_fill_colors(out)) == 2 + + def test_merge_fill_runs_collapses_consecutive_translate_paths(): # Two consecutive same-fill translate-only paths collapse into ONE # (subpaths combined, translate baked in); a different fill stays separate. diff --git a/tests/test_preprocess.py b/tests/test_preprocess.py index ee7a213..d98273a 100644 --- a/tests/test_preprocess.py +++ b/tests/test_preprocess.py @@ -186,3 +186,45 @@ def test_solid_background_auto_uses_median_color(): assert tuple(out[0, 0]) == (235, 170, 200) assert tuple(out[30, 30]) == (235, 170, 200) assert tuple(out[20, 30]) == (40, 40, 40) + + +def test_merge_small_regions_noise_de_keeps_distinct_marks(): + """The detail-aware merge (#economical-fidelity): a small region merges into its + neighbour only when that neighbour is closer than ``noise_de`` (ΔE76). A distinct- + colour small mark (a dot/stipple speck, large ΔE) is KEPT; a near-colour speck + (anti-alias/noise, small ΔE) is absorbed. ``noise_de`` == 0 merges everything.""" + from svgsmith.preprocess import _merge_small_regions + + # 6x6 grid: region 0 = background; region 1 = small DISTINCT-colour strip (top-left); + # region 2 = small NEAR-colour strip (bottom-right). Both are below min_area_px. + labels = np.zeros((6, 6), dtype=np.int64) + labels[0, 0:2] = 1 # 2 px, distinct + labels[5, 4:6] = 2 # 2 px, near-bg + region_lab = np.array( + [[50.0, 0.0, 0.0], [50.0, 60.0, 60.0], [52.0, 2.0, 2.0]] # bg # far # near + ) + min_area_px = 5.0 + + # Legacy behaviour (noise_de=0): both small regions merge into the background. + roots0 = _merge_small_regions(labels, region_lab, min_area_px, 0.0) + assert roots0[1] == 0 + assert roots0[2] == 0 + + # Detail-aware (noise_de=13): the distinct mark is kept (root is itself); the near + # speckle still merges into the background. + roots = _merge_small_regions(labels, region_lab, min_area_px, 13.0) + assert roots[1] == 1 # distinct-colour mark preserved + assert roots[2] == 0 # near-colour noise absorbed + + +def test_coverage_noise_de_dial_orders_detail_levels(): + """The coverage detail-aware merge threshold rises as the detail dial flattens: + 'high' keeps the most texture (lowest ΔE), 'poster' collapses the most (highest).""" + from svgsmith.pipeline import COVERAGE_NOISE_DE + + assert ( + COVERAGE_NOISE_DE["high"] + < COVERAGE_NOISE_DE["normal"] + < COVERAGE_NOISE_DE["clean"] + < COVERAGE_NOISE_DE["poster"] + )