snipbg 2.0.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.
snipbg/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """snipbg - remove image backgrounds with ONNX segmentation models.
2
+
3
+ from snipbg import remove
4
+ png_bytes = remove(open("photo.jpg", "rb").read())
5
+
6
+ Attributes are resolved lazily so that ``snipbg.models`` and
7
+ ``snipbg.download`` stay importable without numpy, Pillow or onnxruntime
8
+ installed (the Docker model stage relies on this).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ __version__ = "2.0.0"
16
+
17
+ __all__ = [
18
+ "DEFAULT_MODEL",
19
+ "MODELS",
20
+ "DownloadError",
21
+ "ModelSpec",
22
+ "Remover",
23
+ "__version__",
24
+ "cache_dir",
25
+ "ensure_model",
26
+ "get_remover",
27
+ "list_models",
28
+ "remove",
29
+ ]
30
+
31
+ if TYPE_CHECKING:
32
+ from .download import DownloadError, cache_dir, ensure_model
33
+ from .models import DEFAULT_MODEL, MODELS, ModelSpec, list_models
34
+ from .session import Remover, get_remover, remove
35
+
36
+ _LAZY: dict[str, str] = {
37
+ "DEFAULT_MODEL": "snipbg.models",
38
+ "MODELS": "snipbg.models",
39
+ "ModelSpec": "snipbg.models",
40
+ "list_models": "snipbg.models",
41
+ "DownloadError": "snipbg.download",
42
+ "cache_dir": "snipbg.download",
43
+ "ensure_model": "snipbg.download",
44
+ "Remover": "snipbg.session",
45
+ "get_remover": "snipbg.session",
46
+ "remove": "snipbg.session",
47
+ }
48
+
49
+
50
+ def __getattr__(name: str) -> Any:
51
+ module_name = _LAZY.get(name)
52
+ if module_name is None:
53
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
54
+ import importlib
55
+
56
+ return getattr(importlib.import_module(module_name), name)
57
+
58
+
59
+ def __dir__() -> list[str]:
60
+ return sorted(__all__)
snipbg/cli.py ADDED
@@ -0,0 +1,290 @@
1
+ """Command line interface: ``snipbg remove|download|models|serve``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .models import DEFAULT_MODEL, MODELS, get_spec
11
+
12
+ _EPILOG = """\
13
+ examples:
14
+ snipbg remove photo.jpg
15
+ snipbg remove photo.jpg -o out.png --refine --trim
16
+ snipbg remove *.jpg -o cutouts/ -m isnet-general-use
17
+ cat photo.jpg | snipbg remove - > out.png
18
+ snipbg remove photo.jpg --bg white -o flat.jpg
19
+ snipbg download u2net isnet-general-use
20
+ snipbg serve --port 3001
21
+ """
22
+
23
+
24
+ def _build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="snipbg",
27
+ description="Remove image backgrounds with ONNX segmentation models.",
28
+ epilog=_EPILOG,
29
+ formatter_class=argparse.RawDescriptionHelpFormatter,
30
+ )
31
+ parser.add_argument("--version", action="version", version=f"snipbg {__version__}")
32
+ subparsers = parser.add_subparsers(dest="command", required=True)
33
+
34
+ # remove ----------------------------------------------------------------
35
+ remove_parser = subparsers.add_parser(
36
+ "remove",
37
+ help="remove the background from one or more images",
38
+ description="Remove the background from one or more images.",
39
+ )
40
+ remove_parser.add_argument("inputs", nargs="+", help="image files, or - for stdin")
41
+ remove_parser.add_argument(
42
+ "-o",
43
+ "--output",
44
+ help="output file, output directory, or - for stdout "
45
+ "(default: <name>_nobg.png next to each input)",
46
+ )
47
+ remove_parser.add_argument(
48
+ "-m", "--model", default=DEFAULT_MODEL, help=f"model to use (default: {DEFAULT_MODEL})"
49
+ )
50
+ remove_parser.add_argument(
51
+ "--only-mask", action="store_true", help="write the grayscale mask instead of the cutout"
52
+ )
53
+ remove_parser.add_argument(
54
+ "--refine", action="store_true", help="harden and feather the mask edges"
55
+ )
56
+ remove_parser.add_argument("--foreground-threshold", type=int, default=240, metavar="N")
57
+ remove_parser.add_argument("--background-threshold", type=int, default=15, metavar="N")
58
+ remove_parser.add_argument(
59
+ "--erode", type=int, default=0, metavar="N", help="shrink the mask edge by N pixels"
60
+ )
61
+ remove_parser.add_argument(
62
+ "--blur", type=float, default=1.0, metavar="R", help="feather radius used with --refine"
63
+ )
64
+ remove_parser.add_argument(
65
+ "--bg",
66
+ "--background",
67
+ dest="background",
68
+ metavar="COLOR",
69
+ help="composite onto a colour (name, #rrggbb) instead of transparency",
70
+ )
71
+ remove_parser.add_argument(
72
+ "--trim", action="store_true", help="crop fully transparent borders away"
73
+ )
74
+ remove_parser.add_argument(
75
+ "-f", "--format", dest="image_format", help="output format (default: from the suffix, PNG)"
76
+ )
77
+ remove_parser.add_argument("-q", "--quality", type=int, help="JPEG/WEBP quality")
78
+ remove_parser.set_defaults(func=_cmd_remove)
79
+
80
+ # download --------------------------------------------------------------
81
+ download_parser = subparsers.add_parser(
82
+ "download", help="pre-fetch model weights into the cache"
83
+ )
84
+ download_parser.add_argument("models", nargs="*", help="model names (default: the default one)")
85
+ download_parser.add_argument("--all", action="store_true", help="download every known model")
86
+ download_parser.add_argument(
87
+ "--check", action="store_true", help="re-hash cached files and report mismatches"
88
+ )
89
+ download_parser.set_defaults(func=_cmd_download)
90
+
91
+ # models ----------------------------------------------------------------
92
+ models_parser = subparsers.add_parser("models", help="list the available models")
93
+ models_parser.add_argument("--json", action="store_true", help="machine readable output")
94
+ models_parser.set_defaults(func=_cmd_models)
95
+
96
+ # serve -----------------------------------------------------------------
97
+ serve_parser = subparsers.add_parser("serve", help="run the HTTP API")
98
+ serve_parser.add_argument("--host", default="0.0.0.0", help="bind address (default: 0.0.0.0)")
99
+ serve_parser.add_argument("--port", type=int, default=3001, help="port (default: 3001)")
100
+ serve_parser.add_argument("--reload", action="store_true", help="reload on code changes")
101
+ serve_parser.add_argument("--workers", type=int, default=1, help="worker processes")
102
+ serve_parser.set_defaults(func=_cmd_serve)
103
+
104
+ return parser
105
+
106
+
107
+ def _output_path(source: Path, output: str | None, suffix: str, many: bool) -> Path:
108
+ if output is None:
109
+ return source.with_name(f"{source.stem}_nobg{suffix}")
110
+ target = Path(output)
111
+ if many or target.is_dir() or output.endswith(("/", "\\")):
112
+ target.mkdir(parents=True, exist_ok=True)
113
+ return target / f"{source.stem}_nobg{suffix}"
114
+ if target.parent != Path(""):
115
+ target.parent.mkdir(parents=True, exist_ok=True)
116
+ return target
117
+
118
+
119
+ def _cmd_remove(args: argparse.Namespace) -> int:
120
+ from .images import encode_image, format_for_path, load_image
121
+ from .session import get_remover
122
+
123
+ remover = get_remover(args.model)
124
+ options = dict(
125
+ only_mask=args.only_mask,
126
+ refine=args.refine,
127
+ foreground_threshold=args.foreground_threshold,
128
+ background_threshold=args.background_threshold,
129
+ erode_size=args.erode,
130
+ blur_radius=args.blur,
131
+ background=args.background,
132
+ trim_border=args.trim,
133
+ )
134
+
135
+ inputs = list(args.inputs)
136
+ many = len(inputs) > 1
137
+ to_stdout = args.output == "-"
138
+ failures = 0
139
+
140
+ for name in inputs:
141
+ try:
142
+ if name == "-":
143
+ data = sys.stdin.buffer.read()
144
+ image = load_image(data)
145
+ source = Path("stdin.png")
146
+ else:
147
+ source = Path(name)
148
+ image = load_image(source)
149
+
150
+ result = remover.remove(image, **options)
151
+
152
+ if name == "-" and args.output is None:
153
+ to_stdout = True
154
+ image_format = (args.image_format or "PNG").upper()
155
+ if not to_stdout:
156
+ suffix = f".{(args.image_format or 'png').lower().replace('jpeg', 'jpg')}"
157
+ destination = _output_path(source, args.output, suffix, many)
158
+ if args.image_format is None:
159
+ image_format = format_for_path(destination)
160
+
161
+ payload = encode_image(result, image_format, quality=args.quality)
162
+ if to_stdout:
163
+ sys.stdout.buffer.write(payload)
164
+ else:
165
+ destination.write_bytes(payload)
166
+ print(f"{name} -> {destination}", file=sys.stderr)
167
+ except (ValueError, OSError) as exc:
168
+ print(f"error: {name}: {exc}", file=sys.stderr)
169
+ failures += 1
170
+
171
+ return 1 if failures else 0
172
+
173
+
174
+ def _cmd_download(args: argparse.Namespace) -> int:
175
+ from .download import DownloadError, download_model, is_cached, model_path, verify_model
176
+
177
+ names = list(MODELS) if args.all else (args.models or [DEFAULT_MODEL])
178
+ failures = 0
179
+
180
+ for name in names:
181
+ try:
182
+ spec = get_spec(name)
183
+ except ValueError as exc:
184
+ print(f"error: {exc}", file=sys.stderr)
185
+ failures += 1
186
+ continue
187
+
188
+ if args.check:
189
+ if not model_path(spec).is_file():
190
+ print(f"{spec.name}: not cached")
191
+ continue
192
+ ok = verify_model(spec)
193
+ print(f"{spec.name}: {'ok' if ok else 'CORRUPT - re-download with snipbg download'}")
194
+ failures += 0 if ok else 1
195
+ continue
196
+
197
+ if is_cached(spec):
198
+ print(f"{spec.name}: already cached at {model_path(spec)}")
199
+ continue
200
+
201
+ print(f"{spec.name}: downloading {spec.size_mb:.1f} MB from {spec.url}", file=sys.stderr)
202
+ try:
203
+ path = download_model(spec, progress=True, force=True)
204
+ print(f"{spec.name}: saved to {path}")
205
+ except DownloadError as exc:
206
+ print(f"error: {exc}", file=sys.stderr)
207
+ failures += 1
208
+
209
+ return 1 if failures else 0
210
+
211
+
212
+ def _cmd_models(args: argparse.Namespace) -> int:
213
+ from .download import is_cached, model_path
214
+
215
+ specs = list(MODELS.values())
216
+ if args.json:
217
+ import json
218
+
219
+ print(
220
+ json.dumps(
221
+ [
222
+ {
223
+ "name": s.name,
224
+ "filename": s.filename,
225
+ "url": s.url,
226
+ "sha256": s.sha256,
227
+ "size_bytes": s.size_bytes,
228
+ "input_size": list(s.input_size),
229
+ "license": s.license,
230
+ "description": s.description,
231
+ "cached": is_cached(s),
232
+ "path": str(model_path(s)),
233
+ }
234
+ for s in specs
235
+ ],
236
+ indent=2,
237
+ )
238
+ )
239
+ return 0
240
+
241
+ width = max(len(s.name) for s in specs)
242
+ print(f"{'MODEL'.ljust(width)} {'SIZE':>8} {'INPUT':>9} {'CACHED':>6} DESCRIPTION")
243
+ for spec in specs:
244
+ size = f"{spec.size_mb:.0f} MB"
245
+ shape = f"{spec.input_size[0]}x{spec.input_size[1]}"
246
+ cached = "yes" if is_cached(spec) else "no"
247
+ default = " (default)" if spec.name == DEFAULT_MODEL else ""
248
+ print(
249
+ f"{spec.name.ljust(width)} {size:>8} {shape:>9} {cached:>6} "
250
+ f"{spec.description}{default}"
251
+ )
252
+ return 0
253
+
254
+
255
+ def _cmd_serve(args: argparse.Namespace) -> int:
256
+ try:
257
+ import uvicorn
258
+ except ImportError:
259
+ print(
260
+ 'error: the API needs the extra dependencies: pip install "snipbg[api]"',
261
+ file=sys.stderr,
262
+ )
263
+ return 1
264
+
265
+ uvicorn.run(
266
+ "snipbg.server.app:app",
267
+ host=args.host,
268
+ port=args.port,
269
+ reload=args.reload,
270
+ workers=args.workers if not args.reload else None,
271
+ )
272
+ return 0
273
+
274
+
275
+ def main(argv: list[str] | None = None) -> int:
276
+ """Entry point. Returns a process exit code."""
277
+ parser = _build_parser()
278
+ args = parser.parse_args(argv)
279
+ try:
280
+ return int(args.func(args))
281
+ except KeyboardInterrupt:
282
+ print("interrupted", file=sys.stderr)
283
+ return 130
284
+ except ValueError as exc:
285
+ print(f"error: {exc}", file=sys.stderr)
286
+ return 1
287
+
288
+
289
+ if __name__ == "__main__":
290
+ raise SystemExit(main())
snipbg/download.py ADDED
@@ -0,0 +1,184 @@
1
+ """Model cache: where weights live, and how they get there.
2
+
3
+ Only the standard library may be imported here: the Docker model stage installs
4
+ no third-party packages and runs this module together with :mod:`models`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import shutil
12
+ import sys
13
+ import urllib.request
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+
17
+ from .models import ModelSpec, get_spec
18
+
19
+ _CHUNK = 1024 * 256
20
+ _USER_AGENT = "snipbg (+https://github.com/asimmakhmudov/snipbg)"
21
+
22
+ ProgressFn = Callable[[int, int], None]
23
+
24
+
25
+ class DownloadError(RuntimeError):
26
+ """A model could not be downloaded, or arrived corrupted."""
27
+
28
+
29
+ def cache_dir() -> Path:
30
+ """Directory holding downloaded weights.
31
+
32
+ ``SNIPBG_HOME`` wins; otherwise ``$XDG_CACHE_HOME/snipbg``, falling
33
+ back to ``~/.cache/snipbg``.
34
+ """
35
+ env = os.environ.get("SNIPBG_HOME")
36
+ if env:
37
+ return Path(env).expanduser()
38
+ xdg = os.environ.get("XDG_CACHE_HOME")
39
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".cache"
40
+ return base / "snipbg"
41
+
42
+
43
+ def model_path(spec: ModelSpec | str) -> Path:
44
+ """Path the given model's weights are cached at (may not exist yet)."""
45
+ if isinstance(spec, str):
46
+ spec = get_spec(spec)
47
+ return cache_dir() / spec.filename
48
+
49
+
50
+ def sha256_file(path: Path) -> str:
51
+ """SHA-256 of a file, read in chunks."""
52
+ digest = hashlib.sha256()
53
+ with open(path, "rb") as fh:
54
+ for chunk in iter(lambda: fh.read(_CHUNK), b""):
55
+ digest.update(chunk)
56
+ return digest.hexdigest()
57
+
58
+
59
+ def is_cached(spec: ModelSpec | str) -> bool:
60
+ """True if the weights are already on disk with the expected size."""
61
+ if isinstance(spec, str):
62
+ spec = get_spec(spec)
63
+ path = model_path(spec)
64
+ if not path.is_file():
65
+ return False
66
+ if spec.size_bytes and path.stat().st_size != spec.size_bytes:
67
+ return False
68
+ return True
69
+
70
+
71
+ def _stderr_progress(done: int, total: int) -> None:
72
+ if total <= 0:
73
+ sys.stderr.write(f"\r {done / 1048576:.1f} MB")
74
+ else:
75
+ pct = 100.0 * done / total
76
+ sys.stderr.write(f"\r {pct:5.1f}% {done / 1048576:7.1f} / {total / 1048576:.1f} MB")
77
+ sys.stderr.flush()
78
+
79
+
80
+ def download_model(
81
+ spec: ModelSpec | str,
82
+ *,
83
+ progress: ProgressFn | bool = False,
84
+ force: bool = False,
85
+ ) -> Path:
86
+ """Download a model's weights into the cache and return the path.
87
+
88
+ The body is streamed to a temporary file next to the destination, hashed
89
+ while it is written, and only renamed into place once the digest matches —
90
+ so a cached path is never a half-written file.
91
+
92
+ Raises:
93
+ DownloadError: on a transport error or a digest mismatch.
94
+ """
95
+ if isinstance(spec, str):
96
+ spec = get_spec(spec)
97
+
98
+ dest = model_path(spec)
99
+ if dest.is_file() and not force:
100
+ return dest
101
+
102
+ on_progress: ProgressFn | None
103
+ if progress is True:
104
+ on_progress = _stderr_progress
105
+ elif progress is False:
106
+ on_progress = None
107
+ else:
108
+ on_progress = progress
109
+
110
+ dest.parent.mkdir(parents=True, exist_ok=True)
111
+ tmp = dest.with_suffix(dest.suffix + ".part")
112
+ digest = hashlib.sha256()
113
+ request = urllib.request.Request(spec.url, headers={"User-Agent": _USER_AGENT})
114
+
115
+ try:
116
+ with urllib.request.urlopen(request) as response: # noqa: S310 - fixed https base
117
+ total = int(response.headers.get("Content-Length") or spec.size_bytes or 0)
118
+ done = 0
119
+ with open(tmp, "wb") as fh:
120
+ while True:
121
+ chunk = response.read(_CHUNK)
122
+ if not chunk:
123
+ break
124
+ fh.write(chunk)
125
+ digest.update(chunk)
126
+ done += len(chunk)
127
+ if on_progress is not None:
128
+ on_progress(done, total)
129
+ except OSError as exc: # URLError and friends are OSError subclasses
130
+ tmp.unlink(missing_ok=True)
131
+ raise DownloadError(f"could not download {spec.name} from {spec.url}: {exc}") from exc
132
+ finally:
133
+ if on_progress is _stderr_progress:
134
+ sys.stderr.write("\n")
135
+
136
+ actual = digest.hexdigest()
137
+ if spec.sha256 and actual != spec.sha256:
138
+ tmp.unlink(missing_ok=True)
139
+ raise DownloadError(
140
+ f"{spec.name}: SHA-256 mismatch (expected {spec.sha256}, got {actual}). "
141
+ "The download was discarded."
142
+ )
143
+
144
+ os.replace(tmp, dest)
145
+ return dest
146
+
147
+
148
+ def ensure_model(spec: ModelSpec | str, *, progress: ProgressFn | bool = False) -> Path:
149
+ """Return the cached path for a model, downloading it if it is missing."""
150
+ if isinstance(spec, str):
151
+ spec = get_spec(spec)
152
+ if is_cached(spec):
153
+ return model_path(spec)
154
+ return download_model(spec, progress=progress, force=True)
155
+
156
+
157
+ def verify_model(spec: ModelSpec | str) -> bool:
158
+ """Hash a cached file and compare it with the expected digest.
159
+
160
+ Returns ``False`` if the file is missing or corrupt, ``True`` if it matches
161
+ (or if the registry carries no digest for it).
162
+ """
163
+ if isinstance(spec, str):
164
+ spec = get_spec(spec)
165
+ path = model_path(spec)
166
+ if not path.is_file():
167
+ return False
168
+ if not spec.sha256:
169
+ return True
170
+ return sha256_file(path) == spec.sha256
171
+
172
+
173
+ def remove_cached(spec: ModelSpec | str) -> bool:
174
+ """Delete a cached model. True if a file was removed."""
175
+ path = model_path(spec)
176
+ if path.is_file():
177
+ path.unlink()
178
+ return True
179
+ return False
180
+
181
+
182
+ def clear_cache() -> None:
183
+ """Delete the whole model cache directory."""
184
+ shutil.rmtree(cache_dir(), ignore_errors=True)
snipbg/images.py ADDED
@@ -0,0 +1,148 @@
1
+ """Getting images in and out: decoding inputs, encoding results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from io import BytesIO
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ import numpy as np
11
+ from PIL import Image, ImageOps
12
+
13
+ #: Anything :func:`load_image` accepts.
14
+ ImageInput = bytes | bytearray | memoryview | str | os.PathLike | Image.Image | np.ndarray
15
+
16
+ InputKind = Literal["bytes", "path", "pil", "numpy"]
17
+
18
+ #: Formats that cannot carry an alpha channel, so a cutout has to be flattened.
19
+ _OPAQUE_FORMATS = frozenset({"JPEG", "JPG", "BMP", "PCX", "PPM", "EPS"})
20
+
21
+ _SUFFIX_FORMATS = {
22
+ ".png": "PNG",
23
+ ".jpg": "JPEG",
24
+ ".jpeg": "JPEG",
25
+ ".jpe": "JPEG",
26
+ ".webp": "WEBP",
27
+ ".tif": "TIFF",
28
+ ".tiff": "TIFF",
29
+ ".bmp": "BMP",
30
+ ".gif": "GIF",
31
+ }
32
+
33
+
34
+ def input_kind(data: ImageInput) -> InputKind:
35
+ """Classify an input so the caller's result can match its type."""
36
+ if isinstance(data, Image.Image):
37
+ return "pil"
38
+ if isinstance(data, np.ndarray):
39
+ return "numpy"
40
+ if isinstance(data, bytes | bytearray | memoryview):
41
+ return "bytes"
42
+ if isinstance(data, str | os.PathLike):
43
+ return "path"
44
+ raise TypeError(f"unsupported image input type: {type(data).__name__}")
45
+
46
+
47
+ def _from_numpy(array: np.ndarray) -> Image.Image:
48
+ if array.ndim not in (2, 3):
49
+ raise ValueError(f"expected a 2-D or 3-D array, got shape {array.shape}")
50
+ if array.dtype != np.uint8:
51
+ working = array.astype(np.float32)
52
+ # Floating point arrays are taken as 0..1 unless they clearly are not.
53
+ if working.size and working.max() <= 1.0:
54
+ working = working * 255.0
55
+ array = np.clip(working, 0, 255).astype(np.uint8)
56
+ if array.ndim == 3 and array.shape[2] == 1:
57
+ array = array[:, :, 0]
58
+ if array.ndim == 3 and array.shape[2] not in (3, 4):
59
+ raise ValueError(f"expected 1, 3 or 4 channels, got {array.shape[2]}")
60
+ return Image.fromarray(array)
61
+
62
+
63
+ def load_image(data: ImageInput) -> Image.Image:
64
+ """Decode any supported input into a PIL image with EXIF rotation applied.
65
+
66
+ Raises:
67
+ ValueError: if the bytes or file are not a readable image.
68
+ TypeError: if the input type is not supported.
69
+ """
70
+ kind = input_kind(data)
71
+
72
+ if kind == "pil":
73
+ assert isinstance(data, Image.Image)
74
+ image = data
75
+ elif kind == "numpy":
76
+ assert isinstance(data, np.ndarray)
77
+ image = _from_numpy(data)
78
+ else:
79
+ source: BytesIO | Path
80
+ if kind == "bytes":
81
+ source = BytesIO(bytes(data)) # type: ignore[arg-type]
82
+ else:
83
+ source = Path(os.fspath(data)) # type: ignore[arg-type]
84
+ if not source.is_file():
85
+ raise ValueError(f"no such image file: {source}")
86
+ try:
87
+ image = Image.open(source)
88
+ image.load()
89
+ except OSError as exc:
90
+ # PIL's message for a buffer embeds an object repr (and its memory
91
+ # address), which would end up in an HTTP error body.
92
+ if kind == "bytes":
93
+ raise ValueError(
94
+ "could not decode an image from the given bytes: "
95
+ "the data is not a supported image format"
96
+ ) from exc
97
+ raise ValueError(f"could not decode an image from {source}: {exc}") from exc
98
+
99
+ rotated = ImageOps.exif_transpose(image)
100
+ if rotated is None:
101
+ return image
102
+ # exif_transpose hands back a copy, which does not carry .format.
103
+ rotated.format = rotated.format or image.format
104
+ return rotated
105
+
106
+
107
+ def format_for_path(path: str | os.PathLike, default: str = "PNG") -> str:
108
+ """Pick an output format from a filename suffix."""
109
+ return _SUFFIX_FORMATS.get(Path(os.fspath(path)).suffix.lower(), default)
110
+
111
+
112
+ def flatten_if_opaque(image: Image.Image, image_format: str, color: str = "white") -> Image.Image:
113
+ """Composite away the alpha channel for formats that cannot store it."""
114
+ if image_format.upper() not in _OPAQUE_FORMATS or image.mode not in ("RGBA", "LA", "P"):
115
+ return image
116
+ rgba = image.convert("RGBA")
117
+ background = Image.new("RGBA", rgba.size, color)
118
+ return Image.alpha_composite(background, rgba).convert("RGB")
119
+
120
+
121
+ def encode_image(
122
+ image: Image.Image,
123
+ image_format: str = "PNG",
124
+ *,
125
+ quality: int | None = None,
126
+ ) -> bytes:
127
+ """Serialise an image to bytes.
128
+
129
+ Alpha is flattened onto white for formats that cannot carry it, so asking
130
+ for a JPEG never fails on an RGBA cutout.
131
+ """
132
+ image_format = image_format.upper()
133
+ prepared = flatten_if_opaque(image, image_format)
134
+
135
+ options: dict[str, object] = {}
136
+ if quality is not None and image_format in ("JPEG", "WEBP"):
137
+ options["quality"] = int(quality)
138
+ if image_format == "PNG":
139
+ options["optimize"] = True
140
+
141
+ buffer = BytesIO()
142
+ prepared.save(buffer, format=image_format, **options)
143
+ return buffer.getvalue()
144
+
145
+
146
+ def to_numpy(image: Image.Image) -> np.ndarray:
147
+ """RGBA/RGB image as a uint8 array."""
148
+ return np.asarray(image)