1515
1616from svgsmith .classify import Classification , classify
1717from 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
1919from svgsmith .preprocess import PreprocessOptions , _edge_flood_fill_mask , preprocess
2020from svgsmith .report import Report , svg_stats
2121from 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
125170def _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" }
148206MODES = ("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 )
152283class 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
0 commit comments