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 +3 -0
- svgsmith/__main__.py +6 -0
- svgsmith/classify.py +90 -0
- svgsmith/cli.py +266 -0
- svgsmith/engines/__init__.py +28 -0
- svgsmith/engines/base.py +118 -0
- svgsmith/engines/binary.py +71 -0
- svgsmith/engines/color.py +36 -0
- svgsmith/pipeline.py +165 -0
- svgsmith/postprocess.py +583 -0
- svgsmith/preprocess.py +253 -0
- svgsmith/render.py +79 -0
- svgsmith/report.py +69 -0
- svgsmith/smooth.py +389 -0
- svgsmith/verify.py +204 -0
- svgsmith-0.1.0.dist-info/METADATA +214 -0
- svgsmith-0.1.0.dist-info/RECORD +21 -0
- svgsmith-0.1.0.dist-info/WHEEL +5 -0
- svgsmith-0.1.0.dist-info/entry_points.txt +2 -0
- svgsmith-0.1.0.dist-info/licenses/LICENSE +21 -0
- svgsmith-0.1.0.dist-info/top_level.txt +1 -0
svgsmith/__init__.py
ADDED
svgsmith/__main__.py
ADDED
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
|
+
]
|
svgsmith/engines/base.py
ADDED
|
@@ -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
|
+
)
|