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
11 changes: 11 additions & 0 deletions src/svgsmith/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 18 additions & 1 deletion src/svgsmith/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions src/svgsmith/postprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading