Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<path>`, output is grouped into
`<g>` layers with simplified paths and a consolidated color palette.
- **Self-verifying** — converts, re-rasterizes, diffs against the original (SSIM), and
Expand Down Expand Up @@ -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). |
Expand Down
2 changes: 1 addition & 1 deletion src/svgsmith/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""svgsmith — convert raster images into clean, editable SVG."""

__version__ = "0.4.2"
__version__ = "0.5.0"
33 changes: 32 additions & 1 deletion src/svgsmith/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -349,7 +373,14 @@ def render(
svg, similarity, iterations = render(
cov_pre, cov_class, palette_threshold=0.0, max_iters=1
)
if svg.count("<path") > _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("<path") > cov_cap:
coverage = False # not actually a clean gradient — use the proven path

if not coverage:
Expand Down
15 changes: 14 additions & 1 deletion src/svgsmith/postprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 23 additions & 2 deletions src/svgsmith/preprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -331,14 +336,24 @@ 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.

Smallest-first (a region that grows past the threshold is kept). Returns ``roots``:
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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions tests/test_postprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<svg xmlns="{SVG_NS}" width="10" height="10">'
'<path d="M0 0 L1 0 L1 1 Z" fill="#101010"/>'
'<path d="M2 2 L3 2 L3 3 Z" fill="#121212"/>' # near-identical, but not merged at 0
"</svg>"
)
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 <path>
# (subpaths combined, translate baked in); a different fill stays separate.
Expand Down
42 changes: 42 additions & 0 deletions tests/test_preprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
)
Loading