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 +60 -0
- snipbg/cli.py +290 -0
- snipbg/download.py +184 -0
- snipbg/images.py +148 -0
- snipbg/models.py +157 -0
- snipbg/processing.py +160 -0
- snipbg/server/__init__.py +16 -0
- snipbg/server/app.py +343 -0
- snipbg/server/config.py +114 -0
- snipbg/server/fetch.py +169 -0
- snipbg/session.py +196 -0
- snipbg-2.0.0.dist-info/METADATA +322 -0
- snipbg-2.0.0.dist-info/RECORD +16 -0
- snipbg-2.0.0.dist-info/WHEEL +4 -0
- snipbg-2.0.0.dist-info/entry_points.txt +2 -0
- snipbg-2.0.0.dist-info/licenses/LICENSE +21 -0
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)
|