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

Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
152 changes: 82 additions & 70 deletions src/svgsmith/preprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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")


Expand All @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 35 additions & 0 deletions tests/test_preprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
preprocess,
quantize_colors,
remove_background,
solid_background,
upscale_tiny,
)

Expand Down Expand Up @@ -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)
Loading