Skip to content

Commit 4581c3f

Browse files
realproject7claude
andcommitted
[#76] Line-quality Phase 0: axis-snap + smooth-gate fix + gated dark-outline snap
Three fidelity-safe line-quality wins, loop-validated on a 23-image harness. - smooth.py: _axis_snap post-pass — straight runs already classified by the fitter, if within ~10deg of an exact 0/45/90/135 axis, snap to exact (length-preserving, endpoint propagated as the pen so contours stay seamless, per-snap drift guard). - pipeline.py: wobble-relief escape on the all-or-nothing smooth gate — keep the smoothed output when it cuts contour wobble >=25% within a 0.10 SSIM hard floor, even when the plain 0.06 tolerance would veto the visibly-sharper result (SSIM rewards the antialiased staircase). The 0.06 safety net is unchanged. - preprocess.py/postprocess.py/pipeline.py: gated dark-outline palette-snap — on the coverage path, collapse scattered near-black outline tints to one clean #000000 layer (luma/DeltaE snap + palette exclusion + output-side fill snap), NO morph erosion. Class-gated (near-black-mass pre-gate) and SSIM-guarded. Harness medians baseline->after: axis_purity 0.199->0.233, jitter 0.427->0.387, dark_colors 33->22, SSIM 0.908->0.907 (flat). c_17 dark 47->1 SSIM +0.034; c_15 dark 125->26 paths 311->192 (cleaner outlines, visually confirmed); c_41 axis +0.10. Only c_15 crosses -0.02 SSIM (the known SSIM metric inversion on the staircase). No class leak; corpus goldens + full suite green; ruff clean. Tests added for _axis_snap and snap_dark_fills. Version 0.5.0 -> 0.5.1. Fixes #76 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 86c7c57 commit 4581c3f

7 files changed

Lines changed: 366 additions & 11 deletions

File tree

src/svgsmith/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""svgsmith — convert raster images into clean, editable SVG."""
22

3-
__version__ = "0.5.0"
3+
__version__ = "0.5.1"

src/svgsmith/pipeline.py

Lines changed: 178 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from svgsmith.classify import Classification, classify
1717
from svgsmith.engines.base import ImageInput, load_image
18-
from svgsmith.postprocess import drop_background_paths, snap_background_layer
18+
from svgsmith.postprocess import drop_background_paths, snap_background_layer, snap_dark_fills
1919
from svgsmith.preprocess import PreprocessOptions, _edge_flood_fill_mask, preprocess
2020
from svgsmith.report import Report, svg_stats
2121
from svgsmith.smooth import smooth_svg
@@ -121,6 +121,51 @@ def _supersample_candidate(image: ImageInput) -> bool:
121121
# False to fall back to pure Tier 1 (narrow full-frame-gradient gate, no region cleanup).
122122
_TIER2_REGION_COVERAGE = True
123123

124+
# Dark-outline black snap (#lever-C). On the coverage path the dark linework of an outlined
125+
# cartoon scatters across many near-black tints (the pigeon JPEG: 186 dark colours), bloating
126+
# paths and giving a ragged variable-width outline. We pull every pixel within this ΔE76 ball of
127+
# pure black out of the palette build and paint it ONE clean #000000 region (NO morphology — that
128+
# was verified to regress outline IoU). ``_COVERAGE_BLACK_SNAP_DE`` is the ball radius;
129+
# ``_COVERAGE_BLACK_SNAP_MIN_AREA`` is the near-black share that must be present before it fires,
130+
# so line-free / no-black art (e.g. c_07) is left byte-identical. SSIM-guarded in ``convert``: the
131+
# snapped trace is kept only when it does not cost more than ``_COVERAGE_BLACK_SNAP_SSIM_DROP``
132+
# SSIM against the no-snap trace. SAFE-REVERT: set the ΔE to 0.0 to disable.
133+
_COVERAGE_BLACK_SNAP_DE = 12.0
134+
_COVERAGE_BLACK_SNAP_MIN_AREA = 0.004
135+
_COVERAGE_BLACK_SNAP_SSIM_DROP = 0.01
136+
137+
138+
def _distinct_dark_fills(svg: str) -> int:
139+
"""Count distinct dark (luma < 60) fill colours in an SVG — the dark-layer cleanliness
140+
proxy. Used to guard the black snap: a sharper black core can make the colour tracer
141+
carve MORE near-black edge tints on some inputs, so we keep the snap only when it does
142+
not inflate this count.
143+
"""
144+
import re
145+
146+
dark = set()
147+
for hexc in re.findall(r'fill="(#[0-9a-fA-F]{6})"', svg):
148+
r, g, b = (int(hexc[i : i + 2], 16) for i in (1, 3, 5))
149+
if 0.299 * r + 0.587 * g + 0.114 * b < 60:
150+
dark.add(hexc.lower())
151+
return len(dark)
152+
153+
154+
def _has_black_outline(image: ImageInput) -> bool:
155+
"""Cheap pre-check: does ``image`` carry enough near-pure-black mass to snap an outline?
156+
157+
Mirrors the per-pixel test inside ``quantize_coverage`` (ΔE76 to the LAB origin) so the
158+
pipeline can decide up front whether the black-snap will fire — and therefore whether the
159+
SSIM-guard's extra no-snap render is even needed. No-black art short-circuits to a single
160+
render with byte-identical output.
161+
"""
162+
from skimage.color import rgb2lab
163+
164+
rgb = np.asarray(load_image(image, "RGB"), dtype=np.float64)
165+
lab = rgb2lab(rgb / 255.0)
166+
near_black = (np.linalg.norm(lab, axis=2) <= _COVERAGE_BLACK_SNAP_DE).mean()
167+
return float(near_black) >= _COVERAGE_BLACK_SNAP_MIN_AREA
168+
124169

125170
def _coverage_candidate(image: ImageInput) -> bool:
126171
"""Whether ``image`` should take the perceptual-coverage colour path.
@@ -144,10 +189,96 @@ def _coverage_candidate(image: ImageInput) -> bool:
144189
# Max SSIM the curve-smoothing pass may cost before we fall back to un-smoothed
145190
# output (smoothing reduces SSIM slightly by design; a big drop = lost feature).
146191
_SMOOTH_SSIM_TOLERANCE = 0.06
192+
# Wobble-relief escape clause for the smooth gate. Raw-SSIM rewards the antialiased
193+
# pixel staircase, so on the wobbliest inputs the visibly-SHARPER smoothed output can
194+
# score LOWER and get vetoed by the plain SSIM gate above — exactly where smoothing is
195+
# most needed (e.g. a jittery vtracer polyline that smoothing straightens). To recover
196+
# those cases we measure path wobble directly off the SVG geometry (angle sign-flips per
197+
# unit length over the polyline approximation — the same structure SSIM is blind to). If
198+
# smoothing cuts wobble by at least _SMOOTH_WOBBLE_RELIEF (relative) AND SSIM stays within
199+
# the wider _SMOOTH_SSIM_HARD_FLOOR, we keep the smoothed output even when the plain SSIM
200+
# tolerance would veto it. The hard floor still guards against a genuine feature loss
201+
# (which would crater SSIM far past the staircase penalty). Inputs that already clear the
202+
# plain tolerance are unaffected, so default behaviour is unchanged for them.
203+
_SMOOTH_WOBBLE_RELIEF = 0.25
204+
_SMOOTH_SSIM_HARD_FLOOR = 0.10
147205
_MODE_PRESET = {"binary": "logo", "color": "illustration", "pixel": "pixel"}
148206
MODES = ("auto", *_MODE_PRESET)
149207

150208

209+
def _path_wobble(svg: str) -> float:
210+
"""Wobble of an SVG's contours: angle sign-flips per unit length over its polylines.
211+
212+
Rasterization-free structural measure of polyline jitter — high when a contour
213+
zig-zags (raw quantized-pixel edges), low when it is a clean curve (after smoothing).
214+
Mirrors the line-quality jitter metric so the smooth gate can reward genuine wobble
215+
removal that raw SSIM (which rewards the antialiased staircase) penalises.
216+
"""
217+
import math
218+
import re
219+
import xml.etree.ElementTree as ET
220+
221+
num = r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?"
222+
try:
223+
root = ET.fromstring(svg)
224+
except ET.ParseError:
225+
return 0.0
226+
flips = 0.0
227+
length = 0.0
228+
for el in root.iter():
229+
if el.tag.rsplit("}", 1)[-1] != "path":
230+
continue
231+
toks = re.findall(r"[MmLlCcQqZzHhVv]|" + num, el.attrib.get("d", ""))
232+
pts: list[list[float]] = []
233+
subs: list[np.ndarray] = []
234+
cur = [0.0, 0.0]
235+
i = 0
236+
while i < len(toks):
237+
t = toks[i]
238+
if t in "Mm":
239+
if pts:
240+
subs.append(np.array(pts))
241+
pts = []
242+
x, y = float(toks[i + 1]), float(toks[i + 2])
243+
cur = [x, y] if t == "M" else [cur[0] + x, cur[1] + y]
244+
pts = [cur[:]]
245+
i += 3
246+
elif t in "Ll":
247+
x, y = float(toks[i + 1]), float(toks[i + 2])
248+
cur = [x, y] if t == "L" else [cur[0] + x, cur[1] + y]
249+
pts.append(cur[:])
250+
i += 3
251+
elif t in "Cc":
252+
c = [float(v) for v in toks[i + 1 : i + 7]]
253+
cur = [c[4], c[5]] if t == "C" else [cur[0] + c[4], cur[1] + c[5]]
254+
pts.append(cur[:])
255+
i += 7
256+
elif t in "Qq":
257+
c = [float(v) for v in toks[i + 1 : i + 5]]
258+
cur = [c[2], c[3]] if t == "Q" else [cur[0] + c[2], cur[1] + c[3]]
259+
pts.append(cur[:])
260+
i += 5
261+
else:
262+
i += 1
263+
if pts:
264+
subs.append(np.array(pts))
265+
for poly in subs:
266+
if len(poly) < 4:
267+
continue
268+
seg = np.diff(poly, axis=0)
269+
seg_len = np.hypot(seg[:, 0], seg[:, 1])
270+
ang = np.arctan2(seg[:, 1], seg[:, 0])
271+
if len(ang) <= 2:
272+
continue
273+
da = np.diff(ang)
274+
da = (da + np.pi) % (2 * np.pi) - np.pi
275+
small = np.abs(da) < math.radians(40)
276+
flip = np.sign(da[:-1]) != np.sign(da[1:])
277+
flips += float(np.sum(small[:-1] & flip))
278+
length += float(seg_len.sum())
279+
return 100.0 * flips / max(length, 1.0)
280+
281+
151282
@dataclass(frozen=True)
152283
class ConvertOptions:
153284
"""Options for :func:`convert` (mirrors the ``svgsmith convert`` flags)."""
@@ -326,7 +457,18 @@ def render(
326457
reference = load_image(image, "RGB")
327458
smoothed = smooth_svg(svg)
328459
smoothed_score = score(reference, rasterize(smoothed, reference.size))
329-
if smoothed_score >= result.best_score - _SMOOTH_SSIM_TOLERANCE:
460+
keep = smoothed_score >= result.best_score - _SMOOTH_SSIM_TOLERANCE
461+
# Wobble-relief escape clause: raw SSIM rewards the antialiased staircase, so a
462+
# visibly-sharper smooth can dip past the plain tolerance on the wobbliest inputs.
463+
# Recover those by measuring path wobble directly (SSIM is blind to it): if
464+
# smoothing genuinely straightens the contours and SSIM stays within the wider
465+
# hard floor, keep it. The floor still vetoes a true feature loss (huge SSIM drop).
466+
if not keep and smoothed_score >= result.best_score - _SMOOTH_SSIM_HARD_FLOOR:
467+
raw_wobble = _path_wobble(svg)
468+
if raw_wobble > 0.0:
469+
relief = (raw_wobble - _path_wobble(smoothed)) / raw_wobble
470+
keep = relief >= _SMOOTH_WOBBLE_RELIEF
471+
if keep:
330472
svg, similarity = smoothed, smoothed_score
331473
return svg, similarity, result.iterations
332474

@@ -370,9 +512,42 @@ def render(
370512
coverage_dark_thin=opts.illustration_dark_thin,
371513
)
372514
cov_class = classification._replace(preset="continuous")
373-
svg, similarity, iterations = render(
515+
# Dark-outline black snap (#lever-C): collapse scattered near-black tints into one clean
516+
# #000000 outline layer. Only engage when there is genuine near-black mass (so no-black art
517+
# is untouched and pays no extra render); then SSIM-guard against the no-snap trace so a
518+
# dark-but-not-black-detail image can never regress.
519+
snap_black = _COVERAGE_BLACK_SNAP_DE > 0.0 and _has_black_outline(image)
520+
base_svg, base_sim, base_iters = render(
374521
cov_pre, cov_class, palette_threshold=0.0, max_iters=1
375522
)
523+
if snap_black:
524+
# Palette-build snap gives the tracer a clean pure-black core; the output-side
525+
# fill snap then collapses the tints the colour tracer re-derives along the
526+
# anti-aliased outline edges into one #000000 layer. SSIM-guarded: keep the
527+
# snapped result only when it does not cost more than the tolerance vs the
528+
# un-snapped trace, so a dark-but-not-black-detail image can never regress.
529+
snap_pre = replace(
530+
cov_pre,
531+
coverage_black_snap=_COVERAGE_BLACK_SNAP_DE,
532+
coverage_black_snap_min_area=_COVERAGE_BLACK_SNAP_MIN_AREA,
533+
)
534+
snap_svg, snap_sim, snap_iters = render(
535+
snap_pre, cov_class, palette_threshold=0.0, max_iters=1
536+
)
537+
snap_svg = snap_dark_fills(snap_svg, _COVERAGE_BLACK_SNAP_DE)
538+
snap_sim = score(
539+
load_image(image, "RGB"), rasterize(snap_svg, load_image(image, "RGB").size)
540+
)
541+
# Keep the snap only when it both holds SSIM AND actually cleans the dark layer
542+
# (never inflates the distinct-dark-fill count) — a sharper black core can make the
543+
# colour tracer carve MORE edge tints on some inputs, the opposite of the goal.
544+
cleaner = _distinct_dark_fills(snap_svg) <= _distinct_dark_fills(base_svg)
545+
if base_sim - snap_sim <= _COVERAGE_BLACK_SNAP_SSIM_DROP and cleaner:
546+
svg, similarity, iterations = snap_svg, snap_sim, snap_iters
547+
else:
548+
svg, similarity, iterations = base_svg, base_sim, base_iters
549+
else:
550+
svg, similarity, iterations = base_svg, base_sim, base_iters
376551
# Path cap = misgated-photo blowup guard. At --detail high the user opted into
377552
# max fidelity, so a high count is INTENDED for legitimately grainy/painterly art
378553
# (the reference traces such inputs into thousands of micro-tiles) — raise the cap

src/svgsmith/postprocess.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,34 @@ def drop_background_paths(svg_str: str, bg_mask: np.ndarray) -> str:
744744
return _build_svg(root, kept, True)
745745

746746

747+
_BLACK = "#000000"
748+
749+
750+
def snap_dark_fills(svg_str: str, de: float) -> str:
751+
"""Collapse every near-pure-black fill in the SVG to a single clean ``#000000``.
752+
753+
The dark-outline black snap (#lever-C) pulls near-black pixels out of the coverage
754+
palette build, but the colour tracer re-derives fills from the image and re-introduces
755+
a spray of near-black tints along the anti-aliased outline edges. This is the matching
756+
output-side step: every fill within ``de`` (CIE76 ΔE) of pure black is rewritten to one
757+
``#000000``, so the linework becomes a single black layer instead of hundreds of tints.
758+
759+
Surgical string rewrite of the ``fill`` attribute only — geometry, ordering and every
760+
non-dark fill are left byte-identical, so the result is a no-op on art with no near-black
761+
fills. ``de <= 0`` disables it.
762+
"""
763+
if de <= 0.0:
764+
return svg_str
765+
766+
def _swap(match: re.Match[str]) -> str:
767+
hex_color = _normalize_hex(match.group(1))
768+
if hex_color and hex_color != _BLACK and _color_distance(hex_color, _BLACK) <= de:
769+
return f'fill="{_BLACK}"'
770+
return match.group(0)
771+
772+
return re.sub(r'fill="(#[0-9a-fA-F]{3,6})"', _swap, svg_str)
773+
774+
747775
def svg_bbox(svg_str: str, samples: int = 18) -> tuple[float, float, float, float] | None:
748776
"""Overall geometry bounding box ``(minx, miny, maxx, maxy)``, or None."""
749777
root = ET.fromstring(svg_str)

src/svgsmith/preprocess.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,17 @@ class PreprocessOptions:
9595
# tone — a target for the daily quality loop to refine.
9696
coverage_dark_thin: int = 0
9797
coverage_dark_protect: float = 0.0006
98+
# Dark-outline black snap (#lever-C; 0 = OFF = unchanged): outlined-cartoon JPEGs scatter the
99+
# dark linework across many near-black tints (the pigeon: 186 dark colours), bloating paths and
100+
# giving a ragged variable-width outline. When >0, every pixel within ``coverage_black_snap``
101+
# (ΔE76) of pure black is pulled OUT of the coverage palette build and painted as ONE clean
102+
# #000000 region, so the outline becomes a single black layer. Bounded by construction (a
103+
# snapped pixel moves at most ``coverage_black_snap`` ΔE). Self-gating: it only fires when the
104+
# near-black mass is at least ``coverage_black_snap_min_area`` of the image, so no-black art
105+
# (e.g. a pure-pastel illustration) is left byte-identical. NO morphology — just a luma/chroma
106+
# threshold snap + palette exclusion (erosion was verified to regress outline IoU).
107+
coverage_black_snap: float = 0.0
108+
coverage_black_snap_min_area: float = 0.004 # min near-black share before the snap activates
98109

99110
solid_background: bool = False # replace the background with one clean solid color
100111
background_tolerance: int = 32 # per-channel tolerance for the edge-flood-fill bg region
@@ -482,6 +493,8 @@ def quantize_coverage(
482493
region_noise_de: float = 0.0,
483494
dark_thin: int = 0,
484495
dark_protect: float = 0.0006,
496+
black_snap: float = 0.0,
497+
black_snap_min_area: float = 0.004,
485498
denoise_lossy: bool = False,
486499
denoise_sigma_color: float = 0.02,
487500
denoise_sigma_spatial: float = 1.5,
@@ -510,6 +523,18 @@ def quantize_coverage(
510523
rgb = rgba[:, :, :3].reshape(-1, 3).astype(np.float64)
511524
lab = rgb2lab(rgba[:, :, :3] / 255.0).reshape(-1, 3)
512525

526+
# Dark-outline black snap (#lever-C): pull every pixel within ``black_snap`` (ΔE76) of pure
527+
# black OUT of the palette build and paint it one clean #000000 region, so scattered near-black
528+
# tints collapse to a single outline layer. ΔE to pure black is just the LAB norm (pure black
529+
# is the LAB origin). Self-gating: only fire when the near-black share clears
530+
# ``black_snap_min_area`` so no-black art is untouched (a snapped pixel moves at most
531+
# ``black_snap`` ΔE).
532+
black_mask = None
533+
if black_snap > 0.0:
534+
cand = np.linalg.norm(lab, axis=1) <= black_snap
535+
if cand.mean() >= black_snap_min_area:
536+
black_mask = cand
537+
513538
# Weighted histogram over 1-unit LAB bins (cheap, resolution-independent work set).
514539
# Encode each (L, a, b) bin into one integer key (L highest-order) so the dedup is a
515540
# 1-D ``np.unique`` instead of ``unique(..., axis=0)``'s lexsort — the dominant cost
@@ -533,16 +558,29 @@ def quantize_coverage(
533558
bin_rgb_sum = np.zeros((len(bins), 3))
534559
np.add.at(bin_rgb_sum, inverse, rgb)
535560

561+
# Exclude the near-black bins from the palette build so the greedy never spends budget
562+
# carving the outline into many tints. A bin counts as black when its center is within
563+
# ``black_snap`` of the LAB origin (matches the per-pixel test). Their pixels are repainted
564+
# pure black at the end via ``black_mask``; here we zero their weight and keep them out of
565+
# the leftover/speckle bookkeeping by pre-assigning a sentinel.
566+
black_bin = (
567+
np.linalg.norm(binf, axis=1) <= black_snap
568+
if black_mask is not None
569+
else np.zeros(len(bins), dtype=bool)
570+
)
571+
536572
assigned = np.full(len(bins), -1, dtype=np.int64)
537573
centers_lab: list[np.ndarray] = []
538574
remaining = counts.astype(np.float64).copy()
575+
remaining[black_bin] = 0.0 # never selected as / absorbed into a coverage anchor
576+
cover_total = float(remaining.sum()) or total # coverage budget over the non-black mass
539577
covered = 0.0
540-
while covered < coverage * total and len(centers_lab) < max_colors:
578+
while covered < coverage * cover_total and len(centers_lab) < max_colors:
541579
idx = int(np.argmax(remaining))
542580
if remaining[idx] <= 0:
543581
break
544582
center = binf[idx]
545-
within = (np.linalg.norm(binf - center, axis=1) <= step) & (assigned < 0)
583+
within = (np.linalg.norm(binf - center, axis=1) <= step) & (assigned == -1) & ~black_bin
546584
ci = len(centers_lab)
547585
assigned[within] = ci
548586
centers_lab.append(center)
@@ -553,10 +591,19 @@ def quantize_coverage(
553591
return img
554592
centers = np.array(centers_lab)
555593
# Tail colours beyond the coverage budget snap to their nearest palette entry.
556-
leftover = np.where(assigned < 0)[0]
594+
leftover = np.where((assigned == -1) & ~black_bin)[0]
557595
for bi in leftover:
558596
assigned[bi] = int(np.argmin(np.linalg.norm(centers - binf[bi], axis=1)))
559597

598+
# The near-black bins become ONE dedicated palette entry, kept out of the coverage build
599+
# above so the outline is a single clean layer instead of many scattered tints. Its
600+
# representative is forced to pure black at the end (rep-mean would drift to dark grey).
601+
black_entry = -1
602+
if black_bin.any():
603+
black_entry = len(centers)
604+
centers = np.vstack([centers, np.zeros((1, 3))])
605+
assigned[black_bin] = black_entry
606+
560607
# Representative RGB per palette entry = mean RGB of its pixels.
561608
n = len(centers)
562609
rep_sum = np.zeros((n, 3))
@@ -580,6 +627,11 @@ def quantize_coverage(
580627
np.add.at(rep_cnt, assigned, counts)
581628
rep_rgb = np.divide(rep_sum, np.maximum(rep_cnt[:, None], 1.0))
582629

630+
# Force the dedicated outline entry to pure #000000 (the rep-mean of near-black pixels
631+
# would otherwise land on a dark grey, defeating the single-clean-black-layer goal).
632+
if black_entry >= 0:
633+
rep_rgb[black_entry] = 0.0
634+
583635
pixel_palette = assigned[inverse] # palette index per pixel (flat)
584636

585637
# Tier 2 (#67): merge by connected-component *area*, not global colour mass. This
@@ -782,6 +834,8 @@ def preprocess(image: ImageInput, opts: PreprocessOptions | None = None) -> Imag
782834
region_noise_de=opts.coverage_region_noise_de,
783835
dark_thin=opts.coverage_dark_thin,
784836
dark_protect=opts.coverage_dark_protect,
837+
black_snap=opts.coverage_black_snap,
838+
black_snap_min_area=opts.coverage_black_snap_min_area,
785839
denoise_lossy=lossy_coverage,
786840
denoise_sigma_color=opts.coverage_denoise_sigma_color,
787841
denoise_sigma_spatial=opts.coverage_denoise_sigma_spatial,

0 commit comments

Comments
 (0)