diff --git a/src/svgsmith/cli.py b/src/svgsmith/cli.py index 12af0a1..7b2df4d 100644 --- a/src/svgsmith/cli.py +++ b/src/svgsmith/cli.py @@ -55,6 +55,7 @@ def _convert(args: argparse.Namespace) -> int: uniform_outline=args.uniform_outline, solid_background=args.solid_background, background=args.background, + transparent_background=args.transparent_background, detail=args.detail, out=args.out, ) @@ -224,6 +225,16 @@ def build_parser() -> argparse.ArgumentParser: "median detected color (same as --solid-background)." ), ) + convert.add_argument( + "--transparent-background", + action="store_true", + default=False, + help=( + "Remove the background instead of repainting it — the edge-connected " + "background is cut from the result, leaving a transparent SVG. The " + "subject is preserved even where it shares the background color." + ), + ) convert.add_argument( "--out", default=None, diff --git a/src/svgsmith/pipeline.py b/src/svgsmith/pipeline.py index e09bde0..772c45c 100644 --- a/src/svgsmith/pipeline.py +++ b/src/svgsmith/pipeline.py @@ -10,13 +10,18 @@ from dataclasses import dataclass, replace from pathlib import Path +import numpy as np + from svgsmith.classify import Classification, classify from svgsmith.engines.base import ImageInput, load_image -from svgsmith.preprocess import PreprocessOptions, preprocess +from svgsmith.postprocess import drop_background_paths +from svgsmith.preprocess import PreprocessOptions, _edge_flood_fill_mask, preprocess from svgsmith.report import Report, svg_stats from svgsmith.smooth import smooth_svg from svgsmith.verify import rasterize, run_loop, score +_DEFAULT_BG_TOLERANCE = PreprocessOptions().background_tolerance + # Mode → engine label and the preset used when --mode is given explicitly. _ENGINE = {"binary": "potrace", "color": "vtracer", "pixel": "vtracer"} # Detail levels for color mode — the dial between maximum fidelity and a flat @@ -48,6 +53,7 @@ class ConvertOptions: uniform_outline: bool = False # opt-in: force an even outline band (outlined art only) 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 detail: str = "normal" # color detail dial: high | normal | clean | poster out: str | None = None @@ -159,6 +165,17 @@ def convert(input_path: str, opts: ConvertOptions | None = None) -> tuple[str, R if smoothed_score >= result.best_score - _SMOOTH_SSIM_TOLERANCE: svg, similarity = smoothed, smoothed_score + # Transparent background: trace/verify ran normally (with the background), so + # the loop is unaffected; here we cut the edge-connected background paths from + # the final SVG using a region mask (subjects sharing the bg colour are kept). + # Color mode only — its paths live in viewBox space; binary/pixel line-art is + # already foreground-only, so there is no background layer to remove. + if opts.transparent_background and classification.mode == "color": + bg_mask = _edge_flood_fill_mask( + np.array(load_image(input_path, "RGBA")), _DEFAULT_BG_TOLERANCE + ) + svg = drop_background_paths(svg, bg_mask) + output = _output_path(input_path, opts.out) report = Report( output=output, diff --git a/src/svgsmith/postprocess.py b/src/svgsmith/postprocess.py index 7aae5fd..e8a0f82 100644 --- a/src/svgsmith/postprocess.py +++ b/src/svgsmith/postprocess.py @@ -16,6 +16,8 @@ import xml.etree.ElementTree as ET from dataclasses import dataclass +import numpy as np + SVG_NS = "http://www.w3.org/2000/svg" _NUMBER = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?") _TOKEN = re.compile(r"[MmLlHhVvCcSsQqTtZz]|" + _NUMBER.pattern) @@ -568,6 +570,62 @@ def count_path_points(svg_str: str) -> int: ) +_TRANSLATE_RE = re.compile(r"translate\(\s*([-\d.]+)(?:[ ,]+([-\d.]+))?\s*\)") + + +def _translate_of(transform: str) -> Point: + """Sum the translate() offsets in a transform string (tracer output only + uses translate, so other primitives are ignored).""" + tx = ty = 0.0 + for match in _TRANSLATE_RE.finditer(transform or ""): + tx += float(match.group(1)) + ty += float(match.group(2)) if match.group(2) is not None else 0.0 + return tx, ty + + +def drop_background_paths(svg_str: str, bg_mask: np.ndarray) -> str: + """Remove the edge-connected background, leaving a transparent SVG. + + ``bg_mask`` is an HxW bool array (True = background, from the edge flood-fill). + Path coordinates live in the tracer's viewBox space (after applying each path's + translate), mapped onto the mask by fraction so any tracer upscale is handled. + A path is dropped when it covers ~the whole canvas (the tracer's base + rectangle) or when most of its sampled points fall in the background — + region-based, so a subject that shares the background colour is kept. + """ + root = ET.fromstring(svg_str) + geom_w, geom_h = _geometry_size(root) + if not (geom_w and geom_h): + return svg_str + mask_h, mask_w = bg_mask.shape + canvas_area = geom_w * geom_h + + kept: list[dict] = [] + for path in _collect_paths(root): + tx, ty = _translate_of(path["transform"]) + points = [ + (x + tx, y + ty) + for sub in parse_path(path["d"]) + for x, y in _subpath_points(sub, 6) + ] + if not points: + continue + xs = [x for x, _ in points] + ys = [y for _, y in points] + if (max(xs) - min(xs)) * (max(ys) - min(ys)) >= 0.95 * canvas_area: + continue # full-canvas base / background rectangle + background = 0 + for x, y in points: + ix = min(mask_w - 1, max(0, int(x / geom_w * mask_w))) + iy = min(mask_h - 1, max(0, int(y / geom_h * mask_h))) + if bg_mask[iy, ix]: + background += 1 + if background * 2 > len(points): + continue # majority of the outline sits in the background + kept.append(path) + return _build_svg(root, kept, True) + + def svg_bbox(svg_str: str, samples: int = 18) -> tuple[float, float, float, float] | None: """Overall geometry bounding box ``(minx, miny, maxx, maxy)``, or None.""" root = ET.fromstring(svg_str) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 41cf03e..8686db1 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -24,6 +24,33 @@ def test_convert_options_rejects_out_of_range_values(kwargs): ConvertOptions(**kwargs) +def test_transparent_background_removes_bg_keeps_subject(tmp_path): + """--transparent-background drops the edge-connected background, leaving the + subject on transparency (region-based, so subject detail is preserved).""" + import numpy as np + from PIL import Image, ImageDraw + + from svgsmith.render import rasterize + + # A blue subject on a uniform red background. + image = Image.new("RGB", (96, 96), (230, 80, 80)) + ImageDraw.Draw(image).ellipse([28, 28, 68, 68], fill=(40, 60, 200)) + src = tmp_path / "subject.png" + image.save(src) + + opts = ConvertOptions(mode="color", transparent_background=True, max_iters=2) + svg, _ = convert(str(src), opts) + svg_path = tmp_path / "out.svg" + svg_path.write_text(svg, encoding="utf-8") + png_path = tmp_path / "out.png" + rasterize(str(svg_path), str(png_path), width=96) # no background → transparent + + alpha = np.asarray(Image.open(png_path).convert("RGBA"))[:, :, 3] + assert alpha[3, 3] == 0 # corner background removed + assert alpha[-4, -4] == 0 + assert alpha[48, 48] > 0 # centered subject kept + + @pytest.mark.parametrize("name", ALL_FIXTURES) def test_convert_produces_valid_svg_and_consistent_report(name): svg, report = convert(str(FIXTURES / name), ConvertOptions(max_iters=2))