svgsmith 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
svgsmith/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """svgsmith — convert raster images into clean, editable SVG."""
2
+
3
+ __version__ = "0.1.0"
svgsmith/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m svgsmith`` to invoke the CLI."""
2
+
3
+ from svgsmith.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
svgsmith/classify.py ADDED
@@ -0,0 +1,90 @@
1
+ """Input classification for ``--mode auto``.
2
+
3
+ Deterministic, heuristic-only (no ML) selection of an engine *mode* and a
4
+ canonical *preset* for an input image. Downstream the pipeline maps:
5
+
6
+ - ``binary`` → Potrace (``logo`` / ``icon`` preset)
7
+ - ``color`` → VTracer (``illustration`` preset)
8
+ - ``pixel`` → VTracer (``pixel`` preset)
9
+
10
+ Photographic inputs classify as ``color`` and carry a raw warning string; this
11
+ module emits the string only — #8 assembles the canonical report, so the report
12
+ schema is intentionally not imported here.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import NamedTuple
18
+
19
+ from PIL import Image, ImageFilter, ImageOps
20
+
21
+ from svgsmith.engines.base import ImageInput, load_image
22
+
23
+ # Heuristic thresholds. The fixture feature values sit well clear of every
24
+ # boundary, so classification stays stable across Pillow versions.
25
+ PIXEL_MAX_DIM = 64 # pixel art lives on a tiny canvas
26
+ PIXEL_MAX_PALETTE = 32 # ...with a small palette
27
+ PHOTO_MIN_PALETTE = 64 # rich palette / gradients read as photographic
28
+ BINARY_MAX_PALETTE = 3 # monochrome-ish ink-on-paper
29
+ BINARY_MIN_EDGE_DENSITY = 0.02 # ...with sharp edges
30
+ EDGE_MAGNITUDE_CUTOFF = 40 # grayscale edge strength counted as a "strong" edge
31
+
32
+ PHOTO_WARNING = "photographic gradients; vectorization may bloat"
33
+
34
+
35
+ class Classification(NamedTuple):
36
+ """Result of :func:`classify`.
37
+
38
+ Unpacks as ``(mode, preset, warnings)``; callers that only need the engine
39
+ selection can read ``.mode`` / ``.preset``.
40
+ """
41
+
42
+ mode: str # "binary" | "color" | "pixel"
43
+ preset: str # canonical preset name from engines.PRESETS
44
+ warnings: tuple[str, ...]
45
+
46
+
47
+ def _palette_size(img: Image.Image) -> int:
48
+ """Distinct colors after a coarse posterize that suppresses codec noise."""
49
+ posterized = ImageOps.posterize(img, 4)
50
+ colors = posterized.getcolors(maxcolors=1 << 16)
51
+ return len(colors) if colors is not None else (1 << 16)
52
+
53
+
54
+ def _edge_density(img: Image.Image) -> float:
55
+ """Fraction of pixels whose grayscale edge magnitude exceeds the cutoff."""
56
+ edges = img.convert("L").filter(ImageFilter.FIND_EDGES)
57
+ histogram = edges.histogram()
58
+ strong = sum(histogram[EDGE_MAGNITUDE_CUTOFF:])
59
+ total = img.width * img.height
60
+ return strong / total if total else 0.0
61
+
62
+
63
+ def classify(image: ImageInput) -> Classification:
64
+ """Classify ``image`` into an engine mode + canonical preset.
65
+
66
+ Returns a :class:`Classification`. Photographic inputs classify as ``color``
67
+ with :data:`PHOTO_WARNING` attached.
68
+ """
69
+ img = load_image(image, "RGB")
70
+ max_dim = max(img.size)
71
+ palette = _palette_size(img)
72
+
73
+ # Monochrome-ish + sharp edges → line art. Checked before the pixel-art
74
+ # branch so a tiny 2-3 color icon reaches `binary`/`icon` instead of being
75
+ # captured as pixel art (pixel art carries more than a couple of hues).
76
+ if palette <= BINARY_MAX_PALETTE and _edge_density(img) >= BINARY_MIN_EDGE_DENSITY:
77
+ preset = "icon" if max_dim <= PIXEL_MAX_DIM else "logo"
78
+ return Classification("binary", preset, ())
79
+
80
+ # Tiny canvas + small (but non-monochrome) palette → pixel art.
81
+ if max_dim <= PIXEL_MAX_DIM and palette <= PIXEL_MAX_PALETTE:
82
+ return Classification("pixel", "pixel", ())
83
+
84
+ # Rich palette / gradients → photographic. Still vectorizable as color, but
85
+ # flag the likely bloat for the report to surface.
86
+ if palette >= PHOTO_MIN_PALETTE:
87
+ return Classification("color", "illustration", (PHOTO_WARNING,))
88
+
89
+ # Everything else: flat multi-color illustration.
90
+ return Classification("color", "illustration", ())
svgsmith/cli.py ADDED
@@ -0,0 +1,266 @@
1
+ """Command-line interface for svgsmith.
2
+
3
+ This module wires up the ``svgsmith`` console entrypoint and its ``convert``
4
+ subcommand. ``convert`` runs the full pipeline (classify → preprocess → trace →
5
+ postprocess → verify) and, with ``--report json``, prints the canonical JSON
6
+ report to stdout — and nothing else: all logs and errors go to stderr.
7
+
8
+ Exit codes:
9
+ 0 success (similarity met the --quality threshold)
10
+ 2 an SVG was produced but its similarity is below --quality
11
+ 1 hard error (could not produce output)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from collections.abc import Sequence
19
+
20
+ from svgsmith import __version__
21
+ from svgsmith.pipeline import MODES, ConvertOptions, convert
22
+
23
+ # Process exit codes (documented in the module docstring and --help epilog).
24
+ EXIT_OK = 0
25
+ EXIT_ERROR = 1
26
+ EXIT_BELOW_THRESHOLD = 2
27
+
28
+
29
+ def _log(message: str) -> None:
30
+ """Write a human-facing line to stderr (stdout is reserved for the report)."""
31
+ print(message, file=sys.stderr)
32
+
33
+
34
+ def _convert(args: argparse.Namespace) -> int:
35
+ """Handle ``svgsmith convert`` once arguments have been parsed."""
36
+ if not args.input:
37
+ _log("error: an input image path is required")
38
+ return EXIT_ERROR
39
+
40
+ # Validate bounds before any conversion work so invalid input fails fast
41
+ # with a clear message instead of an opaque downstream error.
42
+ if not 0.0 <= args.quality <= 1.0:
43
+ _log(f"error: --quality must be between 0 and 1 (got {args.quality})")
44
+ return EXIT_ERROR
45
+ if args.max_iters < 1:
46
+ _log(f"error: --max-iters must be at least 1 (got {args.max_iters})")
47
+ return EXIT_ERROR
48
+
49
+ opts = ConvertOptions(
50
+ mode=args.mode,
51
+ quality=args.quality,
52
+ max_iters=args.max_iters,
53
+ editable=args.editable,
54
+ smooth=args.smooth,
55
+ uniform_outline=args.uniform_outline,
56
+ solid_background=args.solid_background,
57
+ detail=args.detail,
58
+ out=args.out,
59
+ )
60
+
61
+ try:
62
+ svg, report = convert(args.input, opts)
63
+ except FileNotFoundError:
64
+ _log(f"error: input not found: {args.input}")
65
+ return EXIT_ERROR
66
+ except Exception as exc: # noqa: BLE001 - surface any failure as a hard error
67
+ _log(f"error: conversion failed: {exc}")
68
+ return EXIT_ERROR
69
+
70
+ try:
71
+ with open(report.output, "w", encoding="utf-8") as handle:
72
+ handle.write(svg)
73
+ except OSError as exc:
74
+ _log(f"error: could not write output {report.output!r}: {exc}")
75
+ return EXIT_ERROR
76
+
77
+ if args.report == "json":
78
+ # stdout carries the report and nothing else.
79
+ print(report.to_json())
80
+ else:
81
+ _log(
82
+ f"wrote {report.output} "
83
+ f"(mode={report.mode_used}, similarity={report.similarity:.3f}, "
84
+ f"passed={report.passed_threshold})"
85
+ )
86
+
87
+ for warning in report.warnings:
88
+ _log(f"warning: {warning}")
89
+
90
+ return EXIT_OK if report.passed_threshold else EXIT_BELOW_THRESHOLD
91
+
92
+
93
+ def _rasterize(args: argparse.Namespace) -> int:
94
+ """Handle ``svgsmith rasterize`` — render an SVG to PNG."""
95
+ if not args.input:
96
+ _log("error: an input SVG path is required")
97
+ return EXIT_ERROR
98
+ from pathlib import Path
99
+
100
+ from svgsmith.render import rasterize as render_png
101
+
102
+ out = args.out or str(Path(args.input).with_suffix(".png"))
103
+ try:
104
+ render_png(
105
+ args.input,
106
+ out,
107
+ width=args.width,
108
+ height=args.height,
109
+ scale=args.scale,
110
+ background=args.background,
111
+ )
112
+ except FileNotFoundError:
113
+ _log(f"error: input not found: {args.input}")
114
+ return EXIT_ERROR
115
+ except Exception as exc: # noqa: BLE001 - surface any failure as a hard error
116
+ _log(f"error: rasterize failed: {exc}")
117
+ return EXIT_ERROR
118
+ _log(f"wrote {out}")
119
+ return EXIT_OK
120
+
121
+
122
+ def build_parser() -> argparse.ArgumentParser:
123
+ """Build the top-level argument parser and its subcommands."""
124
+ parser = argparse.ArgumentParser(
125
+ prog="svgsmith",
126
+ description="Convert raster images into clean, editable SVG.",
127
+ )
128
+ parser.add_argument(
129
+ "--version",
130
+ action="version",
131
+ version=f"%(prog)s {__version__}",
132
+ )
133
+
134
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
135
+
136
+ convert = subparsers.add_parser(
137
+ "convert",
138
+ help="Convert a raster image into SVG.",
139
+ description="Convert a raster image into clean, editable SVG.",
140
+ epilog=(
141
+ "exit codes: 0 success (similarity >= --quality); "
142
+ "2 SVG produced but below --quality; 1 hard error."
143
+ ),
144
+ )
145
+ convert.add_argument(
146
+ "input",
147
+ nargs="?",
148
+ help="Path to the input raster image (PNG, JPEG, …).",
149
+ )
150
+ convert.add_argument(
151
+ "--mode",
152
+ choices=list(MODES),
153
+ default="auto",
154
+ help="Conversion mode: auto|binary|color|pixel (default: auto).",
155
+ )
156
+ convert.add_argument(
157
+ "--quality",
158
+ type=float,
159
+ default=0.9,
160
+ help="Target fidelity in [0, 1] (default: 0.9).",
161
+ )
162
+ convert.add_argument(
163
+ "--max-iters",
164
+ type=int,
165
+ default=4,
166
+ help="Maximum verify/refine iterations (default: 4).",
167
+ )
168
+ convert.add_argument(
169
+ "--editable",
170
+ action=argparse.BooleanOptionalAction,
171
+ default=True,
172
+ help=(
173
+ "Emit editable, grouped/simplified SVG (default: on). "
174
+ "Use --no-editable to skip postprocessing and emit the raw traced SVG."
175
+ ),
176
+ )
177
+ convert.add_argument(
178
+ "--smooth",
179
+ action=argparse.BooleanOptionalAction,
180
+ default=True,
181
+ help=(
182
+ "Curve-refit color output into smooth, sparse Bezier contours "
183
+ "(default: on). Use --no-smooth to keep the raw traced geometry."
184
+ ),
185
+ )
186
+ convert.add_argument(
187
+ "--uniform-outline",
188
+ action="store_true",
189
+ default=False,
190
+ help=(
191
+ "Force an even-width outline band (color mode). Opt-in: only for "
192
+ "illustrations that already have a dark outline; would add a wrong "
193
+ "border on line art."
194
+ ),
195
+ )
196
+ convert.add_argument(
197
+ "--detail",
198
+ choices=["high", "normal", "clean", "poster"],
199
+ default="normal",
200
+ help=(
201
+ "Color detail dial (default: normal). high = maximum detail; "
202
+ "clean = edge-preserving cleanup, less noise; poster = bold flat graphic, "
203
+ "few colors."
204
+ ),
205
+ )
206
+ convert.add_argument(
207
+ "--solid-background",
208
+ action="store_true",
209
+ default=False,
210
+ help=(
211
+ "Isolate the subject and repaint the background as one clean solid "
212
+ "color, removing texture/grain/specks while keeping subject detail."
213
+ ),
214
+ )
215
+ convert.add_argument(
216
+ "--out",
217
+ default=None,
218
+ help="Output SVG path (default: input path with a .svg extension).",
219
+ )
220
+ convert.add_argument(
221
+ "--report",
222
+ choices=["off", "json"],
223
+ default="off",
224
+ help="Emit a JSON report alongside the SVG (default: off).",
225
+ )
226
+ convert.set_defaults(func=_convert)
227
+
228
+ rasterize = subparsers.add_parser(
229
+ "rasterize",
230
+ help="Render an SVG back to a PNG bitmap.",
231
+ description="Rasterize an SVG to PNG (preview, thumbnail, round-trip).",
232
+ )
233
+ rasterize.add_argument("input", nargs="?", help="Path to the input SVG file.")
234
+ rasterize.add_argument(
235
+ "--out", default=None, help="Output PNG path (default: input with a .png extension)."
236
+ )
237
+ rasterize.add_argument("--width", type=int, default=None, help="Output width in px.")
238
+ rasterize.add_argument("--height", type=int, default=None, help="Output height in px.")
239
+ rasterize.add_argument(
240
+ "--scale", type=float, default=None, help="Scale factor over the intrinsic size."
241
+ )
242
+ rasterize.add_argument(
243
+ "--background",
244
+ default=None,
245
+ help="Background color (e.g. white, #ffffff). Default: transparent.",
246
+ )
247
+ rasterize.set_defaults(func=_rasterize)
248
+
249
+ return parser
250
+
251
+
252
+ def main(argv: Sequence[str] | None = None) -> int:
253
+ """Entry point for the ``svgsmith`` console script."""
254
+ parser = build_parser()
255
+ args = parser.parse_args(argv)
256
+
257
+ handler = getattr(args, "func", None)
258
+ if handler is None:
259
+ parser.print_help()
260
+ return EXIT_OK
261
+
262
+ return handler(args)
263
+
264
+
265
+ if __name__ == "__main__":
266
+ raise SystemExit(main())
@@ -0,0 +1,28 @@
1
+ """Uniform tracing interface over the bundled engines.
2
+
3
+ Import the adapters and preset helpers from here; downstream code (T3 routing,
4
+ T5 postprocess, T6 verify) depends only on ``trace(image, preset) -> str``.
5
+ """
6
+
7
+ from .base import (
8
+ PRESETS,
9
+ ImageInput,
10
+ Preset,
11
+ Tracer,
12
+ get_preset,
13
+ load_image,
14
+ )
15
+ from .binary import BinaryTracer, PotraceNotFoundError
16
+ from .color import ColorTracer
17
+
18
+ __all__ = [
19
+ "PRESETS",
20
+ "BinaryTracer",
21
+ "ColorTracer",
22
+ "ImageInput",
23
+ "PotraceNotFoundError",
24
+ "Preset",
25
+ "Tracer",
26
+ "get_preset",
27
+ "load_image",
28
+ ]
@@ -0,0 +1,118 @@
1
+ """Shared types for the tracing engines.
2
+
3
+ Defines the :class:`Preset` parameter bundle, the canonical named presets, and
4
+ the :class:`Tracer` protocol that every engine adapter implements. The rest of
5
+ the pipeline depends only on ``trace(image, preset) -> str`` and never calls an
6
+ engine library directly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Protocol, runtime_checkable
14
+
15
+ from PIL import Image
16
+
17
+ ImageInput = str | Path | Image.Image
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Preset:
22
+ """Engine parameters for one tracing profile.
23
+
24
+ A single bundle carries both color (VTracer) and binary (Potrace) knobs so
25
+ a caller can hand the same preset to either adapter; each adapter reads only
26
+ the fields it understands.
27
+ """
28
+
29
+ name: str
30
+
31
+ # Color (VTracer) parameters.
32
+ color_mode: str = "color" # "color" | "binary"
33
+ color_precision: int = 6
34
+ layer_difference: int = 16
35
+ filter_speckle: int = 4
36
+ corner_threshold: int = 60
37
+ length_threshold: float = 4.0
38
+ splice_threshold: int = 45
39
+ path_precision: int = 8
40
+ curve_mode: str = "spline" # "spline" | "polygon" | "none"
41
+ hierarchical: str = "stacked" # "stacked" | "cutout"
42
+
43
+ # Binary (Potrace) parameters.
44
+ turdsize: int = 2 # speckle suppression: drop specks up to N pixels
45
+ alphamax: float = 1.0 # corner threshold
46
+ opttolerance: float = 0.2 # curve-fitting tolerance
47
+ threshold: float = 0.5 # luminance cut for binarization, in [0, 1]
48
+
49
+
50
+ # Canonical preset names. #4 (classifier) and #8 (report `preset`) reference
51
+ # exactly this list — do not add variants without updating those tickets.
52
+ PRESETS: dict[str, Preset] = {
53
+ "logo": Preset(
54
+ name="logo",
55
+ color_precision=6,
56
+ filter_speckle=4,
57
+ corner_threshold=60,
58
+ turdsize=2,
59
+ alphamax=1.0,
60
+ opttolerance=0.2,
61
+ ),
62
+ "icon": Preset(
63
+ name="icon",
64
+ color_precision=8,
65
+ filter_speckle=2,
66
+ corner_threshold=40,
67
+ turdsize=1,
68
+ alphamax=1.0,
69
+ opttolerance=0.2,
70
+ ),
71
+ "illustration": Preset(
72
+ name="illustration",
73
+ # 6 (not the max 8): fewer, flatter color regions up front — perceptual
74
+ # LAB merge in postprocess then collapses near-duplicate tints. Together
75
+ # they drive the palette toward a small flat set without crushing rich art.
76
+ color_precision=6,
77
+ layer_difference=8,
78
+ filter_speckle=4,
79
+ corner_threshold=60,
80
+ turdsize=2,
81
+ alphamax=1.0,
82
+ opttolerance=0.4,
83
+ ),
84
+ "pixel": Preset(
85
+ name="pixel",
86
+ color_precision=8,
87
+ filter_speckle=0,
88
+ curve_mode="none",
89
+ corner_threshold=180,
90
+ turdsize=0,
91
+ alphamax=0.0,
92
+ opttolerance=0.0,
93
+ ),
94
+ }
95
+
96
+
97
+ def get_preset(name: str) -> Preset:
98
+ """Return the named canonical preset, or raise ``ValueError``."""
99
+ try:
100
+ return PRESETS[name]
101
+ except KeyError:
102
+ known = ", ".join(sorted(PRESETS))
103
+ raise ValueError(f"unknown preset {name!r}; choose one of: {known}") from None
104
+
105
+
106
+ def load_image(image: ImageInput, mode: str = "RGBA") -> Image.Image:
107
+ """Open ``image`` (path or PIL image) and convert it to ``mode``."""
108
+ img = image if isinstance(image, Image.Image) else Image.open(image)
109
+ return img.convert(mode)
110
+
111
+
112
+ @runtime_checkable
113
+ class Tracer(Protocol):
114
+ """Uniform tracing interface implemented by every engine adapter."""
115
+
116
+ def trace(self, image: ImageInput, preset: Preset) -> str:
117
+ """Trace ``image`` with ``preset`` and return an SVG document string."""
118
+ ...
@@ -0,0 +1,71 @@
1
+ """Potrace-backed adapter for line-art / monochrome images.
2
+
3
+ Shells out to the system ``potrace`` binary (installed via apt in CI) rather
4
+ than binding to pypotrace, and exposes the uniform ``trace(image, preset) -> str``
5
+ interface. The image is binarized to 1-bit, fed to ``potrace`` as a PBM bitmap
6
+ over stdin, and the SVG is read back from stdout. See README for the system
7
+ dependency.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import shutil
14
+ import subprocess
15
+
16
+ from .base import ImageInput, Preset, load_image
17
+
18
+ POTRACE_BINARY = "potrace"
19
+
20
+
21
+ class PotraceNotFoundError(RuntimeError):
22
+ """Raised when the system ``potrace`` binary cannot be located."""
23
+
24
+
25
+ class BinaryTracer:
26
+ """Trace monochrome / line-art images into SVG via the potrace binary."""
27
+
28
+ def __init__(self, potrace_path: str | None = None) -> None:
29
+ self._potrace = potrace_path or shutil.which(POTRACE_BINARY)
30
+
31
+ def _binarize(self, image: ImageInput, threshold: float):
32
+ gray = load_image(image, "L")
33
+ cut = round(max(0.0, min(1.0, threshold)) * 255)
34
+ # Pixels brighter than the cut become white (255); darker pixels become
35
+ # black (0), which is what potrace traces.
36
+ return gray.point(lambda p, c=cut: 255 if p > c else 0, mode="1")
37
+
38
+ def trace(self, image: ImageInput, preset: Preset) -> str:
39
+ if self._potrace is None:
40
+ raise PotraceNotFoundError(
41
+ f"the {POTRACE_BINARY!r} binary was not found on PATH; install it "
42
+ "(e.g. `apt-get install potrace`)"
43
+ )
44
+
45
+ bitmap = self._binarize(image, preset.threshold)
46
+ buffer = io.BytesIO()
47
+ bitmap.save(buffer, format="PPM") # 1-bit mode is written as binary PBM
48
+
49
+ command = [
50
+ self._potrace,
51
+ "--svg",
52
+ "--output",
53
+ "-",
54
+ "--turdsize",
55
+ str(preset.turdsize),
56
+ "--alphamax",
57
+ str(preset.alphamax),
58
+ "--opttolerance",
59
+ str(preset.opttolerance),
60
+ "-",
61
+ ]
62
+ result = subprocess.run(
63
+ command,
64
+ input=buffer.getvalue(),
65
+ capture_output=True,
66
+ check=False,
67
+ )
68
+ if result.returncode != 0:
69
+ detail = result.stderr.decode(errors="replace").strip()
70
+ raise RuntimeError(f"potrace failed (exit {result.returncode}): {detail}")
71
+ return result.stdout.decode("utf-8")
@@ -0,0 +1,36 @@
1
+ """VTracer-backed adapter for full-color raster images.
2
+
3
+ Wraps the ``vtracer`` PyPI package and exposes the uniform
4
+ ``trace(image, preset) -> str`` interface. Produces layered, multi-color SVG.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import vtracer
10
+
11
+ from .base import ImageInput, Preset, load_image
12
+
13
+
14
+ class ColorTracer:
15
+ """Trace color images into multi-color SVG via VTracer."""
16
+
17
+ def trace(self, image: ImageInput, preset: Preset) -> str:
18
+ img = load_image(image, "RGBA")
19
+ # get_flattened_data() is the forward-compatible accessor; fall back to
20
+ # getdata() on older Pillow releases.
21
+ accessor = getattr(img, "get_flattened_data", img.getdata)
22
+ pixels = list(accessor())
23
+ return vtracer.convert_pixels_to_svg(
24
+ pixels,
25
+ img.size,
26
+ colormode=preset.color_mode,
27
+ hierarchical=preset.hierarchical,
28
+ mode=preset.curve_mode,
29
+ filter_speckle=preset.filter_speckle,
30
+ color_precision=preset.color_precision,
31
+ layer_difference=preset.layer_difference,
32
+ corner_threshold=preset.corner_threshold,
33
+ length_threshold=preset.length_threshold,
34
+ splice_threshold=preset.splice_threshold,
35
+ path_precision=preset.path_precision,
36
+ )