diff --git a/src/svgsmith/cli.py b/src/svgsmith/cli.py index fac7be1..12af0a1 100644 --- a/src/svgsmith/cli.py +++ b/src/svgsmith/cli.py @@ -54,6 +54,7 @@ def _convert(args: argparse.Namespace) -> int: smooth=args.smooth, uniform_outline=args.uniform_outline, solid_background=args.solid_background, + background=args.background, detail=args.detail, out=args.out, ) @@ -209,7 +210,18 @@ def build_parser() -> argparse.ArgumentParser: default=False, help=( "Isolate the subject and repaint the background as one clean solid " - "color, removing texture/grain/specks while keeping subject detail." + "color, removing texture/grain/specks while keeping subject detail. " + "Equivalent to --background auto." + ), + ) + convert.add_argument( + "--background", + default=None, + metavar="COLOR", + help=( + "Isolate the subject and repaint the detected background to COLOR — a " + "hex value (#RRGGBB) or named color (e.g. white). Use 'auto' for the " + "median detected color (same as --solid-background)." ), ) convert.add_argument( diff --git a/src/svgsmith/pipeline.py b/src/svgsmith/pipeline.py index 1326f6c..e09bde0 100644 --- a/src/svgsmith/pipeline.py +++ b/src/svgsmith/pipeline.py @@ -47,6 +47,7 @@ class ConvertOptions: smooth: bool = True # curve-refit color output (Schneider Bezier fit) for smooth contours 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 detail: str = "normal" # color detail dial: high | normal | clean | poster out: str | None = None @@ -59,6 +60,11 @@ def __post_init__(self) -> None: raise ValueError( f"detail must be one of {', '.join(DETAIL_LEVELS)}, got {self.detail!r}" ) + if self.background not in (None, "auto"): + # Fail fast on a bad color rather than surfacing it mid-pipeline. + from svgsmith.preprocess import _parse_color + + _parse_color(self.background) def _resolve_classification(image, mode: str) -> Classification: @@ -123,8 +129,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) - if opts.solid_background: - pre_opts = replace(pre_opts, solid_background=True) + # --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: + target = None if opts.background in (None, "auto") else opts.background + pre_opts = replace(pre_opts, solid_background=True, background_color=target) prepared = preprocess(image, pre_opts) svg, result = run_loop( diff --git a/src/svgsmith/preprocess.py b/src/svgsmith/preprocess.py index 0bdead2..65ceeaf 100644 --- a/src/svgsmith/preprocess.py +++ b/src/svgsmith/preprocess.py @@ -12,7 +12,7 @@ from dataclasses import dataclass import numpy as np -from PIL import Image, ImageFilter +from PIL import Image, ImageColor, ImageFilter from svgsmith.engines.base import ImageInput, load_image @@ -34,7 +34,8 @@ class PreprocessOptions: palette_size: int = 16 # target palette; T3 preset can inform this solid_background: bool = False # replace the background with one clean solid color - subject_threshold: int = 60 # per-channel distance from bg color to count as subject + background_tolerance: int = 32 # per-channel tolerance for the edge-flood-fill bg region + background_color: str | None = None # exact bg color (#RRGGBB/named); None = auto median uniform_outline: bool = False # force a constant-width outline band (opt-in) outline_width: int = 8 # band half-width in px, used when uniform_outline is on @@ -106,44 +107,88 @@ def quantize_colors(img: Image.Image, palette_size: int) -> Image.Image: return quantized -def solid_background(img: Image.Image, threshold: int, min_fraction: float = 0.01) -> Image.Image: - """Isolate the subject and repaint everything else as one clean solid color. +def _edge_flood_fill_mask(rgba: np.ndarray, tolerance: int) -> np.ndarray: + """Boolean mask of background pixels reachable by flood-fill from the borders. - Detects the subject as the significant connected regions that differ from the - dominant corner (background) color, then replaces all non-subject pixels with - the median background color. Removes texture, grain, streaks, and stray specks - so the background becomes a single flat fill — while the subject is untouched, - so its detail is fully preserved. Small specks below ``min_fraction`` of the - image are absorbed into the background rather than kept as noise. + The background color is the dominant image corner; a pixel counts as background + when it is within ``tolerance`` (per channel) of that color AND is connected to + the image edge through other such pixels. Interior regions that merely *match* + the background color but are enclosed by the subject's outline are therefore + NOT marked — they stay subject. ``rgba`` is an HxWx(3 or 4) uint8 array; only + the RGB channels are used. Returns an HxW bool mask (True = background). """ - from scipy import ndimage - - rgb = np.array(img.convert("RGB")) - height, width = rgb.shape[:2] + height, width = rgba.shape[:2] + rgb = rgba[:, :, :3].astype(np.int16) corners = [ - tuple(rgb[0, 0]), - tuple(rgb[0, -1]), - tuple(rgb[-1, 0]), - tuple(rgb[-1, -1]), + tuple(rgba[0, 0, :3]), + tuple(rgba[0, width - 1, :3]), + tuple(rgba[height - 1, 0, :3]), + tuple(rgba[height - 1, width - 1, :3]), ] - background = np.array(max(set(corners), key=corners.count), dtype=int) - far = np.abs(rgb.astype(int) - background).max(axis=2) > threshold - - labels, count = ndimage.label(far) - subject = np.zeros((height, width), dtype=bool) - if count: - min_pixels = min_fraction * height * width - sizes = ndimage.sum(np.ones_like(labels), labels, range(1, count + 1)) - for index, size in enumerate(sizes, start=1): - if size >= min_pixels: - subject |= labels == index - subject = ndimage.binary_fill_holes(subject) - subject = ndimage.binary_closing(subject, iterations=2) + background = np.array(max(set(corners), key=corners.count), dtype=np.int16) + close = np.abs(rgb - background).max(axis=2) <= tolerance + + visited = np.zeros((height, width), dtype=bool) + queue: deque[tuple[int, int]] = deque() + for x in range(width): + for y in (0, height - 1): + if close[y, x] and not visited[y, x]: + visited[y, x] = True + queue.append((y, x)) + for y in range(height): + for x in (0, width - 1): + if close[y, x] and not visited[y, x]: + visited[y, x] = True + queue.append((y, x)) + + while queue: + y, x = queue.popleft() + for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): + ny, nx = y + dy, x + dx + if 0 <= ny < height and 0 <= nx < width and close[ny, nx] and not visited[ny, nx]: + visited[ny, nx] = True + queue.append((ny, nx)) + + return visited + + +def _parse_color(color: str) -> tuple[int, int, int]: + """Parse a hex (``#RRGGBB``) or named color into an RGB triple. + + Accepts anything :func:`PIL.ImageColor.getrgb` understands (hex, named colors). + Raises ``ValueError`` with a clear message on an unrecognized color. + """ + try: + return ImageColor.getrgb(color)[:3] + except ValueError as exc: + raise ValueError(f"invalid background color {color!r}: {exc}") from exc + + +def solid_background( + img: Image.Image, tolerance: int, target_color: str | None = None +) -> Image.Image: + """Isolate the subject and repaint everything else as one clean solid color. + + The background is the region reachable by edge flood-fill from the image + borders (see :func:`_edge_flood_fill_mask`); the subject is everything not + edge-connected, so a subject region that happens to share the background color + but is enclosed by an outline (a pink ear on a pink wall) is kept, not punched + into a hole. All background pixels are then flattened to one solid color — + ``target_color`` when given (an exact ``#RRGGBB`` or named color), otherwise the + median background color — removing texture, grain, streaks, and stray specks + while the subject is left untouched. + """ + rgb = np.array(img.convert("RGB")) + background_mask = _edge_flood_fill_mask(rgb, tolerance) out = rgb.copy() - bg_pixels = rgb[~subject] - if bg_pixels.size: - out[~subject] = np.median(bg_pixels, axis=0).astype(np.uint8) + if target_color is not None: + fill = np.array(_parse_color(target_color), dtype=np.uint8) + out[background_mask] = fill + else: + bg_pixels = rgb[background_mask] + if bg_pixels.size: + out[background_mask] = np.median(bg_pixels, axis=0).astype(np.uint8) return Image.fromarray(out, "RGB").convert(img.mode) @@ -187,41 +232,8 @@ def remove_background(img: Image.Image, tolerance: int) -> Image.Image: regions that happen to match the background color are kept opaque. """ rgba = np.array(img.convert("RGBA")) - height, width = rgba.shape[:2] - rgb = rgba[:, :, :3].astype(np.int16) - - corners = [ - tuple(rgba[0, 0, :3]), - tuple(rgba[0, width - 1, :3]), - tuple(rgba[height - 1, 0, :3]), - tuple(rgba[height - 1, width - 1, :3]), - ] - background = np.array(max(set(corners), key=corners.count), dtype=np.int16) - - close = np.abs(rgb - background).max(axis=2) <= tolerance - - visited = np.zeros((height, width), dtype=bool) - queue: deque[tuple[int, int]] = deque() - for x in range(width): - for y in (0, height - 1): - if close[y, x] and not visited[y, x]: - visited[y, x] = True - queue.append((y, x)) - for y in range(height): - for x in (0, width - 1): - if close[y, x] and not visited[y, x]: - visited[y, x] = True - queue.append((y, x)) - - while queue: - y, x = queue.popleft() - for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): - ny, nx = y + dy, x + dx - if 0 <= ny < height and 0 <= nx < width and close[ny, nx] and not visited[ny, nx]: - visited[ny, nx] = True - queue.append((ny, nx)) - - rgba[visited, 3] = 0 + background_mask = _edge_flood_fill_mask(rgba, tolerance) + rgba[background_mask, 3] = 0 return Image.fromarray(rgba, "RGBA") @@ -236,7 +248,7 @@ def preprocess(image: ImageInput, opts: PreprocessOptions | None = None) -> Imag img = load_image(image, "RGBA") if opts.solid_background: - img = solid_background(img, opts.subject_threshold) + img = solid_background(img, opts.background_tolerance, opts.background_color) if opts.upscale: img = upscale_tiny(img, opts.min_dimension) if opts.denoise: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 59f315d..41cf03e 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -125,6 +125,39 @@ def test_solid_background_is_opt_in_and_runs(): assert on.svg.paths >= 1 +def test_background_color_repaints_to_exact_color(): + import numpy as np + + from svgsmith.preprocess import PreprocessOptions, preprocess + + # The CLI/pipeline threads --background "#FFFFFF" into preprocess as an exact + # target color: every edge-connected background pixel becomes pure white while + # the subject is preserved. Assert on the preprocessed raster (the trace is + # lossy) so the exact-color contract is checked deterministically. + prepared = preprocess( + str(FIXTURES / "illustration.png"), + PreprocessOptions(solid_background=True, background_color="#FFFFFF"), + ) + arr = np.asarray(prepared.convert("RGB")) + assert tuple(arr[0, 0]) == (255, 255, 255) + assert tuple(arr[0, -1]) == (255, 255, 255) + # The subject (image center) is not flattened to white. + assert tuple(arr[arr.shape[0] // 2, arr.shape[1] // 2]) != (255, 255, 255) + # The whole pipeline accepts the flag and still produces a valid SVG. + report = convert( + str(FIXTURES / "illustration.png"), + ConvertOptions(max_iters=1, background="#FFFFFF"), + )[1] + assert report.svg.paths >= 1 + + +def test_background_invalid_color_is_rejected(): + import pytest + + with pytest.raises(ValueError): + ConvertOptions(background="not-a-color") + + def test_detail_level_validation_and_spectrum(): import pytest diff --git a/tests/test_preprocess.py b/tests/test_preprocess.py index 36c3781..4b823e3 100644 --- a/tests/test_preprocess.py +++ b/tests/test_preprocess.py @@ -11,6 +11,7 @@ preprocess, quantize_colors, remove_background, + solid_background, upscale_tiny, ) @@ -102,3 +103,37 @@ def test_steps_are_individually_toggleable(): def test_preprocess_returns_rgba(): assert preprocess(FLAT_BG).mode == "RGBA" + + +def _pink_ear_fixture() -> Image.Image: + """Pink wall with a subject blob that matches the wall color but is enclosed + by a darker outline — the #53 "pink ear on a pink wall" case.""" + pink = (235, 170, 200) + outline = (40, 40, 40) + arr = np.full((60, 60, 3), pink, dtype=np.uint8) + # A ring of dark outline (rows/cols 20..40) with a pink-filled interior. + arr[20:41, 20:41] = outline + arr[24:37, 24:37] = pink # enclosed subject region, same color as the background + return Image.fromarray(arr, "RGB") + + +def test_solid_background_keeps_enclosed_same_color_subject(): + img = _pink_ear_fixture() + out = np.asarray(solid_background(img, tolerance=24, target_color="#FFFFFF")) + + # The true edge-connected background flattens to the target white. + assert tuple(out[0, 0]) == (255, 255, 255) + # The enclosed pink region, though it shares the wall color, is NOT + # edge-connected (the dark outline blocks the flood fill) — it survives. + assert tuple(out[30, 30]) == (235, 170, 200) + # The dark outline (subject) is also preserved. + assert tuple(out[20, 30]) == (40, 40, 40) + + +def test_solid_background_auto_uses_median_color(): + img = _pink_ear_fixture() + out = np.asarray(solid_background(img, tolerance=24)) + # Auto mode repaints the bg to the detected (pink) color, enclosed blob kept. + assert tuple(out[0, 0]) == (235, 170, 200) + assert tuple(out[30, 30]) == (235, 170, 200) + assert tuple(out[20, 30]) == (40, 40, 40)