diff --git a/src/svgsmith/classify.py b/src/svgsmith/classify.py index ee59d97..e040d47 100644 --- a/src/svgsmith/classify.py +++ b/src/svgsmith/classify.py @@ -29,7 +29,9 @@ BINARY_MIN_EDGE_DENSITY = 0.02 # ...with sharp edges EDGE_MAGNITUDE_CUTOFF = 40 # grayscale edge strength counted as a "strong" edge -PHOTO_WARNING = "photographic gradients; vectorization may bloat" +PHOTO_WARNING = ( + "photographic gradients; vectorization may bloat — try --flatten-shading for a cleaner result" +) class Classification(NamedTuple): diff --git a/src/svgsmith/cli.py b/src/svgsmith/cli.py index 7b2df4d..d91e9a4 100644 --- a/src/svgsmith/cli.py +++ b/src/svgsmith/cli.py @@ -56,6 +56,7 @@ def _convert(args: argparse.Namespace) -> int: solid_background=args.solid_background, background=args.background, transparent_background=args.transparent_background, + flatten_shading=args.flatten_shading, detail=args.detail, out=args.out, ) @@ -235,6 +236,17 @@ def build_parser() -> argparse.ArgumentParser: "subject is preserved even where it shares the background color." ), ) + convert.add_argument( + "--flatten-shading", + action="store_true", + default=False, + help=( + "Collapse soft/glossy shading before tracing (color mode). Smooth " + "gradients (e.g. satin sheen) become clean flat regions instead of " + "shattering into tiny 'scratch' facets — trades fine shading for a " + "cleaner graphic look and a much smaller file." + ), + ) convert.add_argument( "--out", default=None, diff --git a/src/svgsmith/pipeline.py b/src/svgsmith/pipeline.py index 772c45c..475e7a9 100644 --- a/src/svgsmith/pipeline.py +++ b/src/svgsmith/pipeline.py @@ -54,6 +54,7 @@ class ConvertOptions: solid_background: bool = False # opt-in: isolate subject, repaint background one solid color background: str | None = None # exact bg color (#RRGGBB/named); "auto" == --solid-background transparent_background: bool = False # opt-in: drop the background → transparent SVG + flatten_shading: bool = False # opt-in: collapse soft/glossy shading before tracing detail: str = "normal" # color detail dial: high | normal | clean | poster out: str | None = None @@ -135,6 +136,11 @@ def convert(input_path: str, opts: ConvertOptions | None = None) -> tuple[str, R 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) + # Flatten soft/glossy shading before tracing so smooth gradients collapse into + # clean flat regions instead of shattering into scratch facets (opt-in; trades + # fine shading for a cleaner graphic look). + if opts.flatten_shading and classification.mode == "color": + pre_opts = replace(pre_opts, flatten=True, flatten_sigma=0.18, flatten_spatial=8) # --solid-background is the auto case of --background; an explicit color (other # than "auto") repaints the detected background to that exact color. if opts.solid_background or opts.background is not None: diff --git a/src/svgsmith/preprocess.py b/src/svgsmith/preprocess.py index 65ceeaf..596d0a8 100644 --- a/src/svgsmith/preprocess.py +++ b/src/svgsmith/preprocess.py @@ -29,6 +29,7 @@ class PreprocessOptions: flatten: bool = False # edge-preserving color flattening (bilateral) flatten_sigma: float = 0.04 # color sigma; higher = flatter regions + flatten_spatial: int = 4 # bilateral spatial sigma; higher = wider smoothing quantize: bool = True palette_size: int = 16 # target palette; T3 preset can inform this @@ -71,7 +72,7 @@ def denoise(img: Image.Image, median_size: int) -> Image.Image: return filtered -def flatten_colors(img: Image.Image, sigma_color: float) -> Image.Image: +def flatten_colors(img: Image.Image, sigma_color: float, sigma_spatial: int = 4) -> Image.Image: """Edge-preserving bilateral smoothing to flatten color variation. Softens gradients and texture *within* regions while keeping edges sharp, so @@ -82,7 +83,9 @@ def flatten_colors(img: Image.Image, sigma_color: float) -> Image.Image: from skimage.restoration import denoise_bilateral rgb = np.asarray(img.convert("RGB"), dtype=np.float64) / 255.0 - smoothed = denoise_bilateral(rgb, sigma_color=sigma_color, sigma_spatial=4, channel_axis=2) + smoothed = denoise_bilateral( + rgb, sigma_color=sigma_color, sigma_spatial=sigma_spatial, channel_axis=2 + ) out = Image.fromarray((smoothed * 255.0).round().astype(np.uint8), "RGB") if img.mode == "RGBA": out = out.convert("RGBA") @@ -254,7 +257,7 @@ def preprocess(image: ImageInput, opts: PreprocessOptions | None = None) -> Imag if opts.denoise: img = denoise(img, opts.median_size) if opts.flatten: - img = flatten_colors(img, opts.flatten_sigma) + img = flatten_colors(img, opts.flatten_sigma, opts.flatten_spatial) if opts.quantize: img = quantize_colors(img, opts.palette_size) if opts.uniform_outline: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 8686db1..e0d6d22 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -24,6 +24,23 @@ def test_convert_options_rejects_out_of_range_values(kwargs): ConvertOptions(**kwargs) +def test_flatten_shading_reduces_facets_on_gradients(tmp_path): + """--flatten-shading collapses smooth shading, so a noisy gradient traces into + fewer paths than the default.""" + import numpy as np + from PIL import Image + + rng = np.random.default_rng(0) + ramp = np.add.outer(np.linspace(0, 200, 96), np.linspace(0, 40, 96)) + base = np.clip(ramp[..., None] + rng.normal(0, 6, (96, 96, 3)), 0, 255) + Image.fromarray(base.astype(np.uint8), "RGB").save(tmp_path / "gradient.png") + src = str(tmp_path / "gradient.png") + + plain = convert(src, ConvertOptions(mode="color", max_iters=2))[0] + flat = convert(src, ConvertOptions(mode="color", max_iters=2, flatten_shading=True))[0] + assert flat.count("