metalgrow 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.
- metalgrow/__init__.py +5 -0
- metalgrow/backbones/__init__.py +8 -0
- metalgrow/backbones/base.py +47 -0
- metalgrow/backbones/bicubic.py +27 -0
- metalgrow/backbones/realesrgan.py +54 -0
- metalgrow/backbones/registry.py +30 -0
- metalgrow/backbones/swinir.py +58 -0
- metalgrow/batch.py +163 -0
- metalgrow/cli.py +137 -0
- metalgrow/device.py +30 -0
- metalgrow/upscaler.py +177 -0
- metalgrow/weights.py +152 -0
- metalgrow-0.1.0.dist-info/METADATA +16 -0
- metalgrow-0.1.0.dist-info/RECORD +17 -0
- metalgrow-0.1.0.dist-info/WHEEL +4 -0
- metalgrow-0.1.0.dist-info/entry_points.txt +2 -0
- metalgrow-0.1.0.dist-info/licenses/LICENSE +21 -0
metalgrow/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Register built-in backbones by importing their modules for side effects.
|
|
2
|
+
from metalgrow.backbones import bicubic as _bicubic # noqa: F401
|
|
3
|
+
from metalgrow.backbones import realesrgan as _realesrgan # noqa: F401
|
|
4
|
+
from metalgrow.backbones import swinir as _swinir # noqa: F401
|
|
5
|
+
from metalgrow.backbones.base import Backbone
|
|
6
|
+
from metalgrow.backbones.registry import get_backbone, list_backbones, register
|
|
7
|
+
|
|
8
|
+
__all__ = ["Backbone", "get_backbone", "list_backbones", "register"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Backbone(ABC):
|
|
10
|
+
"""Pluggable super-resolution backbone.
|
|
11
|
+
|
|
12
|
+
Implementations accept a float tensor in [0, 1] of shape [N, C, H, W] on
|
|
13
|
+
``self.device`` and return an upscaled tensor of the same dtype/device.
|
|
14
|
+
Preprocessing, model inference, and postprocessing all live inside
|
|
15
|
+
:meth:`upscale` so that the shared :class:`~metalgrow.upscaler.Upscaler`
|
|
16
|
+
stays agnostic to backbone internals.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name: ClassVar[str]
|
|
20
|
+
# None means any scale > 1.0 is accepted (e.g. analytical resamplers).
|
|
21
|
+
supported_scales: ClassVar[tuple[float, ...] | None] = None
|
|
22
|
+
# None means any channel count is supported. 3 means RGB-only — the
|
|
23
|
+
# Upscaler will handle alpha separately for such backbones.
|
|
24
|
+
input_channels: ClassVar[int | None] = None
|
|
25
|
+
# Default tile size for the shared Upscaler-level tiler. None disables
|
|
26
|
+
# tiling by default (analytical backbones have no VRAM ceiling); learned
|
|
27
|
+
# backbones should set a size that keeps a single tile's activations in
|
|
28
|
+
# VRAM on the target device.
|
|
29
|
+
default_tile: ClassVar[int | None] = None
|
|
30
|
+
# Pixels of input context padded around each tile. Needs to cover the
|
|
31
|
+
# backbone's receptive field so the blended seam is free of artefacts.
|
|
32
|
+
default_tile_pad: ClassVar[int] = 16
|
|
33
|
+
|
|
34
|
+
def __init__(self, device: torch.device, dtype: torch.dtype = torch.float32):
|
|
35
|
+
self.device = device
|
|
36
|
+
self.dtype = dtype
|
|
37
|
+
|
|
38
|
+
def validate_scale(self, scale: float) -> None:
|
|
39
|
+
if scale <= 1.0:
|
|
40
|
+
raise ValueError("scale must be > 1.0")
|
|
41
|
+
if self.supported_scales is not None and scale not in self.supported_scales:
|
|
42
|
+
allowed = ", ".join(f"{s:g}" for s in self.supported_scales)
|
|
43
|
+
raise ValueError(f"backbone {self.name!r} supports scales [{allowed}], got {scale:g}")
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def upscale(self, tensor: torch.Tensor, scale: float) -> torch.Tensor:
|
|
47
|
+
"""Return an upscaled copy of ``tensor``."""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn.functional as F
|
|
5
|
+
|
|
6
|
+
from metalgrow.backbones.base import Backbone
|
|
7
|
+
from metalgrow.backbones.registry import register
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BicubicBackbone(Backbone):
|
|
11
|
+
"""Analytical bicubic resampler. Always available; no weights required."""
|
|
12
|
+
|
|
13
|
+
name = "bicubic"
|
|
14
|
+
supported_scales = None # any scale > 1.0
|
|
15
|
+
|
|
16
|
+
def upscale(self, tensor: torch.Tensor, scale: float) -> torch.Tensor:
|
|
17
|
+
self.validate_scale(scale)
|
|
18
|
+
_, _, h, w = tensor.shape
|
|
19
|
+
return F.interpolate(
|
|
20
|
+
tensor,
|
|
21
|
+
size=(int(h * scale), int(w * scale)),
|
|
22
|
+
mode="bicubic",
|
|
23
|
+
align_corners=False,
|
|
24
|
+
).clamp(0.0, 1.0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
register("bicubic", lambda device, dtype: BicubicBackbone(device, dtype))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
|
|
5
|
+
from metalgrow.backbones.base import Backbone
|
|
6
|
+
from metalgrow.backbones.registry import register
|
|
7
|
+
from metalgrow.weights import ensure_weight
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RealESRGANBackbone(Backbone):
|
|
11
|
+
"""Real-ESRGAN RRDBNet backbone loaded via :mod:`spandrel`."""
|
|
12
|
+
|
|
13
|
+
name = "realesrgan"
|
|
14
|
+
supported_scales = (2.0, 4.0)
|
|
15
|
+
input_channels = 3
|
|
16
|
+
# Real-ESRGAN is convolutional; the Upscaler-level tiler wraps the forward
|
|
17
|
+
# call with overlap padding large enough to cover the receptive field.
|
|
18
|
+
default_tile = 256
|
|
19
|
+
default_tile_pad = 16
|
|
20
|
+
|
|
21
|
+
def __init__(self, device: torch.device, dtype: torch.dtype, registry_key: str):
|
|
22
|
+
super().__init__(device, dtype)
|
|
23
|
+
self._registry_key = registry_key
|
|
24
|
+
self._model: torch.nn.Module | None = None
|
|
25
|
+
|
|
26
|
+
def _load(self) -> torch.nn.Module:
|
|
27
|
+
if self._model is not None:
|
|
28
|
+
return self._model
|
|
29
|
+
from spandrel import ModelLoader
|
|
30
|
+
|
|
31
|
+
weights_path = ensure_weight(self._registry_key)
|
|
32
|
+
descriptor = ModelLoader().load_from_file(str(weights_path))
|
|
33
|
+
model = descriptor.model.to(self.device, self.dtype)
|
|
34
|
+
model.eval()
|
|
35
|
+
self._model = model
|
|
36
|
+
return model
|
|
37
|
+
|
|
38
|
+
def upscale(self, tensor: torch.Tensor, scale: float) -> torch.Tensor:
|
|
39
|
+
self.validate_scale(scale)
|
|
40
|
+
model = self._load()
|
|
41
|
+
tensor = tensor.to(self.device, self.dtype)
|
|
42
|
+
with torch.no_grad():
|
|
43
|
+
return model(tensor).clamp(0.0, 1.0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _factory(registry_key: str):
|
|
47
|
+
def make(device: torch.device, dtype: torch.dtype) -> RealESRGANBackbone:
|
|
48
|
+
return RealESRGANBackbone(device, dtype, registry_key=registry_key)
|
|
49
|
+
|
|
50
|
+
return make
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
register("realesrgan-x2", _factory("realesrgan-x2"))
|
|
54
|
+
register("realesrgan-x4", _factory("realesrgan-x4"))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
from metalgrow.backbones.base import Backbone
|
|
8
|
+
|
|
9
|
+
BackboneFactory = Callable[[torch.device, torch.dtype], Backbone]
|
|
10
|
+
|
|
11
|
+
_REGISTRY: dict[str, BackboneFactory] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(name: str, factory: BackboneFactory) -> None:
|
|
15
|
+
if name in _REGISTRY:
|
|
16
|
+
raise ValueError(f"backbone {name!r} already registered")
|
|
17
|
+
_REGISTRY[name] = factory
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_backbone(name: str, device: torch.device, dtype: torch.dtype) -> Backbone:
|
|
21
|
+
try:
|
|
22
|
+
factory = _REGISTRY[name]
|
|
23
|
+
except KeyError:
|
|
24
|
+
available = ", ".join(sorted(_REGISTRY)) or "<none>"
|
|
25
|
+
raise ValueError(f"unknown backbone {name!r}; available: {available}") from None
|
|
26
|
+
return factory(device, dtype)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def list_backbones() -> list[str]:
|
|
30
|
+
return sorted(_REGISTRY)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
|
|
5
|
+
from metalgrow.backbones.base import Backbone
|
|
6
|
+
from metalgrow.backbones.registry import register
|
|
7
|
+
from metalgrow.weights import ensure_weight
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SwinIRBackbone(Backbone):
|
|
11
|
+
"""SwinIR (Swin Transformer) backbone loaded via :mod:`spandrel`.
|
|
12
|
+
|
|
13
|
+
SwinIR trades more memory and latency for generally sharper outputs than
|
|
14
|
+
Real-ESRGAN on clean, photographic content. Attention-heavy, so we tile
|
|
15
|
+
at a smaller default than Real-ESRGAN to keep per-tile activations in
|
|
16
|
+
VRAM on the target device.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name = "swinir"
|
|
20
|
+
supported_scales = (2.0, 4.0)
|
|
21
|
+
input_channels = 3
|
|
22
|
+
default_tile = 128
|
|
23
|
+
default_tile_pad = 16
|
|
24
|
+
|
|
25
|
+
def __init__(self, device: torch.device, dtype: torch.dtype, registry_key: str):
|
|
26
|
+
super().__init__(device, dtype)
|
|
27
|
+
self._registry_key = registry_key
|
|
28
|
+
self._model: torch.nn.Module | None = None
|
|
29
|
+
|
|
30
|
+
def _load(self) -> torch.nn.Module:
|
|
31
|
+
if self._model is not None:
|
|
32
|
+
return self._model
|
|
33
|
+
from spandrel import ModelLoader
|
|
34
|
+
|
|
35
|
+
weights_path = ensure_weight(self._registry_key)
|
|
36
|
+
descriptor = ModelLoader().load_from_file(str(weights_path))
|
|
37
|
+
model = descriptor.model.to(self.device, self.dtype)
|
|
38
|
+
model.eval()
|
|
39
|
+
self._model = model
|
|
40
|
+
return model
|
|
41
|
+
|
|
42
|
+
def upscale(self, tensor: torch.Tensor, scale: float) -> torch.Tensor:
|
|
43
|
+
self.validate_scale(scale)
|
|
44
|
+
model = self._load()
|
|
45
|
+
tensor = tensor.to(self.device, self.dtype)
|
|
46
|
+
with torch.no_grad():
|
|
47
|
+
return model(tensor).clamp(0.0, 1.0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _factory(registry_key: str):
|
|
51
|
+
def make(device: torch.device, dtype: torch.dtype) -> SwinIRBackbone:
|
|
52
|
+
return SwinIRBackbone(device, dtype, registry_key=registry_key)
|
|
53
|
+
|
|
54
|
+
return make
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
register("swinir-x2", _factory("swinir-x2"))
|
|
58
|
+
register("swinir-x4", _factory("swinir-x4"))
|
metalgrow/batch.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Batch / directory upscaling.
|
|
2
|
+
|
|
3
|
+
Reads and writes happen on a thread pool so I/O can overlap with model
|
|
4
|
+
inference, but the inference call itself is serialized on the main thread:
|
|
5
|
+
running multiple GPU forwards concurrently from Python threads doesn't speed
|
|
6
|
+
anything up and risks contention on MPS, so we prefetch reads and queue
|
|
7
|
+
writes around a single in-flight forward.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import glob as glob_module
|
|
13
|
+
from collections import deque
|
|
14
|
+
from collections.abc import Iterable, Iterator
|
|
15
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from PIL import Image
|
|
20
|
+
from rich.progress import (
|
|
21
|
+
BarColumn,
|
|
22
|
+
Progress,
|
|
23
|
+
SpinnerColumn,
|
|
24
|
+
TextColumn,
|
|
25
|
+
TimeElapsedColumn,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from metalgrow.upscaler import Upscaler
|
|
29
|
+
|
|
30
|
+
IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class BatchResult:
|
|
35
|
+
processed: int
|
|
36
|
+
skipped: int
|
|
37
|
+
failed: int
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def total(self) -> int:
|
|
41
|
+
return self.processed + self.skipped + self.failed
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def discover_inputs(src: str) -> list[Path]:
|
|
45
|
+
"""Resolve ``src`` to a sorted list of input image paths.
|
|
46
|
+
|
|
47
|
+
Accepts a single file path, a directory (non-recursive walk), or a glob
|
|
48
|
+
pattern containing wildcards. Directories filter to known image
|
|
49
|
+
extensions; globs trust the user.
|
|
50
|
+
"""
|
|
51
|
+
if any(ch in src for ch in "*?["):
|
|
52
|
+
matches = sorted(Path(p) for p in glob_module.glob(src))
|
|
53
|
+
return [p for p in matches if p.is_file()]
|
|
54
|
+
path = Path(src)
|
|
55
|
+
if path.is_file():
|
|
56
|
+
return [path]
|
|
57
|
+
if path.is_dir():
|
|
58
|
+
return sorted(p for p in path.iterdir() if p.is_file() and p.suffix.lower() in IMAGE_EXTS)
|
|
59
|
+
raise FileNotFoundError(src)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def plan_outputs(inputs: Iterable[Path], dst_dir: Path) -> list[tuple[Path, Path]]:
|
|
63
|
+
"""Map each input to ``dst_dir / input.name``."""
|
|
64
|
+
return [(src, dst_dir / src.name) for src in inputs]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load(src: Path) -> tuple[Image.Image, str]:
|
|
68
|
+
image = Image.open(src)
|
|
69
|
+
has_alpha = image.mode in ("RGBA", "LA") or "transparency" in image.info
|
|
70
|
+
mode = "RGBA" if has_alpha else "RGB"
|
|
71
|
+
return image.convert(mode), mode
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _save(image: Image.Image, dst: Path) -> None:
|
|
75
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
image.save(dst)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _prefetch(
|
|
80
|
+
executor: ThreadPoolExecutor,
|
|
81
|
+
items: Iterable[tuple[Path, Path]],
|
|
82
|
+
depth: int,
|
|
83
|
+
) -> Iterator[tuple[Path, Path, Future]]:
|
|
84
|
+
"""Stream items keeping at most ``depth`` reads in flight at once."""
|
|
85
|
+
pending: deque[tuple[Path, Path, Future]] = deque()
|
|
86
|
+
iterator = iter(items)
|
|
87
|
+
|
|
88
|
+
def submit_next() -> bool:
|
|
89
|
+
try:
|
|
90
|
+
src, dst = next(iterator)
|
|
91
|
+
except StopIteration:
|
|
92
|
+
return False
|
|
93
|
+
pending.append((src, dst, executor.submit(_load, src)))
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
for _ in range(max(1, depth)):
|
|
97
|
+
if not submit_next():
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
while pending:
|
|
101
|
+
item = pending.popleft()
|
|
102
|
+
yield item
|
|
103
|
+
submit_next()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_batch(
|
|
107
|
+
upscaler: Upscaler,
|
|
108
|
+
items: list[tuple[Path, Path]],
|
|
109
|
+
*,
|
|
110
|
+
scale: float,
|
|
111
|
+
tile: int | None,
|
|
112
|
+
tile_pad: int | None,
|
|
113
|
+
workers: int = 4,
|
|
114
|
+
skip_existing: bool = False,
|
|
115
|
+
progress: bool = True,
|
|
116
|
+
) -> BatchResult:
|
|
117
|
+
"""Run ``upscaler`` over each (src, dst) pair, returning counts."""
|
|
118
|
+
pending = [(s, d) for s, d in items if not (skip_existing and d.exists())]
|
|
119
|
+
skipped = len(items) - len(pending)
|
|
120
|
+
processed = 0
|
|
121
|
+
failed = 0
|
|
122
|
+
write_futs: list[Future] = []
|
|
123
|
+
|
|
124
|
+
columns = (
|
|
125
|
+
SpinnerColumn(),
|
|
126
|
+
TextColumn("[progress.description]{task.description}"),
|
|
127
|
+
BarColumn(),
|
|
128
|
+
TextColumn("{task.completed}/{task.total}"),
|
|
129
|
+
TimeElapsedColumn(),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
133
|
+
with Progress(*columns, disable=not progress) as bar:
|
|
134
|
+
task = bar.add_task("upscaling", total=len(pending))
|
|
135
|
+
for src, dst, read_fut in _prefetch(pool, pending, depth=workers):
|
|
136
|
+
try:
|
|
137
|
+
image, _mode = read_fut.result()
|
|
138
|
+
except Exception as exc: # noqa: BLE001 — surface but keep batch going
|
|
139
|
+
failed += 1
|
|
140
|
+
bar.console.log(f"[red]read failed[/red] {src}: {exc}")
|
|
141
|
+
bar.advance(task)
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
out = upscaler.upscale(image, scale=scale, tile=tile, tile_pad=tile_pad)
|
|
145
|
+
except Exception as exc: # noqa: BLE001
|
|
146
|
+
failed += 1
|
|
147
|
+
bar.console.log(f"[red]upscale failed[/red] {src}: {exc}")
|
|
148
|
+
bar.advance(task)
|
|
149
|
+
continue
|
|
150
|
+
write_futs.append(pool.submit(_save, out, dst))
|
|
151
|
+
processed += 1
|
|
152
|
+
bar.advance(task)
|
|
153
|
+
|
|
154
|
+
for fut in write_futs:
|
|
155
|
+
try:
|
|
156
|
+
fut.result()
|
|
157
|
+
except Exception as exc: # noqa: BLE001
|
|
158
|
+
failed += 1
|
|
159
|
+
processed -= 1
|
|
160
|
+
# Best-effort surfacing; a dedicated logger would be nicer here.
|
|
161
|
+
print(f"write failed: {exc}")
|
|
162
|
+
|
|
163
|
+
return BatchResult(processed=processed, skipped=skipped, failed=failed)
|
metalgrow/cli.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from metalgrow.backbones import list_backbones
|
|
7
|
+
from metalgrow.batch import discover_inputs, plan_outputs, run_batch
|
|
8
|
+
from metalgrow.upscaler import Upscaler
|
|
9
|
+
from metalgrow.weights import (
|
|
10
|
+
REGISTRY,
|
|
11
|
+
cached_path,
|
|
12
|
+
ensure_weight,
|
|
13
|
+
remove_cached,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_DTYPES = {"fp32": torch.float32, "fp16": torch.float16}
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="metalgrow — AI image upscaler on Apple Metal.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def upscale(
|
|
23
|
+
src: str = typer.Argument(..., help="Image file, directory, or glob (e.g. 'in/*.png')"),
|
|
24
|
+
dst: Path = typer.Argument(..., help="Output file (single src) or directory (batch)"),
|
|
25
|
+
scale: float = typer.Option(2.0, "--scale", "-s", min=1.01, max=8.0),
|
|
26
|
+
device: str = typer.Option("auto", "--device", "-d", help="auto | mps | cuda | cpu"),
|
|
27
|
+
backbone: str = typer.Option(
|
|
28
|
+
"bicubic",
|
|
29
|
+
"--backbone",
|
|
30
|
+
"-b",
|
|
31
|
+
help=f"SR backbone: {', '.join(list_backbones())}",
|
|
32
|
+
),
|
|
33
|
+
dtype: str = typer.Option(
|
|
34
|
+
"fp32", "--dtype", help="Inference dtype: fp32 | fp16 (fp16 MPS-only, noisier)"
|
|
35
|
+
),
|
|
36
|
+
tile: int | None = typer.Option(
|
|
37
|
+
None, "--tile", help="Tile size in input px (0 disables; omit = backbone default)"
|
|
38
|
+
),
|
|
39
|
+
tile_pad: int | None = typer.Option(
|
|
40
|
+
None, "--tile-pad", help="Context padding per tile edge (omit = backbone default)"
|
|
41
|
+
),
|
|
42
|
+
skip_existing: bool = typer.Option(
|
|
43
|
+
False, "--skip-existing", help="Skip outputs that already exist (batch mode)"
|
|
44
|
+
),
|
|
45
|
+
workers: int = typer.Option(
|
|
46
|
+
4, "--workers", "-j", min=1, help="Parallel I/O workers (inference stays serial)"
|
|
47
|
+
),
|
|
48
|
+
):
|
|
49
|
+
if dtype not in _DTYPES:
|
|
50
|
+
raise typer.BadParameter(f"dtype must be one of {list(_DTYPES)}")
|
|
51
|
+
try:
|
|
52
|
+
inputs = discover_inputs(src)
|
|
53
|
+
except FileNotFoundError as exc:
|
|
54
|
+
raise typer.BadParameter(f"source not found: {exc}") from None
|
|
55
|
+
if not inputs:
|
|
56
|
+
raise typer.BadParameter(f"no images found at {src!r}")
|
|
57
|
+
|
|
58
|
+
upscaler = Upscaler(backbone=backbone, device=device, dtype=_DTYPES[dtype])
|
|
59
|
+
typer.echo(f"device: {upscaler.device}")
|
|
60
|
+
typer.echo(f"backbone: {backbone}")
|
|
61
|
+
typer.echo(f"dtype: {dtype}")
|
|
62
|
+
|
|
63
|
+
batch_mode = len(inputs) > 1 or Path(src).is_dir() or any(ch in src for ch in "*?[")
|
|
64
|
+
|
|
65
|
+
if not batch_mode:
|
|
66
|
+
out = upscaler.upscale_file(inputs[0], dst, scale=scale, tile=tile, tile_pad=tile_pad)
|
|
67
|
+
typer.echo(f"wrote: {out}")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if dst.exists() and not dst.is_dir():
|
|
71
|
+
raise typer.BadParameter(f"batch destination must be a directory: {dst}")
|
|
72
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
items = plan_outputs(inputs, dst)
|
|
75
|
+
result = run_batch(
|
|
76
|
+
upscaler,
|
|
77
|
+
items,
|
|
78
|
+
scale=scale,
|
|
79
|
+
tile=tile,
|
|
80
|
+
tile_pad=tile_pad,
|
|
81
|
+
workers=workers,
|
|
82
|
+
skip_existing=skip_existing,
|
|
83
|
+
)
|
|
84
|
+
typer.echo(
|
|
85
|
+
f"done: {result.processed} processed, {result.skipped} skipped, "
|
|
86
|
+
f"{result.failed} failed (of {result.total})"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command()
|
|
91
|
+
def info():
|
|
92
|
+
import torch
|
|
93
|
+
|
|
94
|
+
typer.echo(f"torch: {torch.__version__}")
|
|
95
|
+
typer.echo(f"mps available: {torch.backends.mps.is_available()}")
|
|
96
|
+
typer.echo(f"cuda available: {torch.cuda.is_available()}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
models_app = typer.Typer(help="Manage cached model weights.")
|
|
100
|
+
app.add_typer(models_app, name="models")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@models_app.command("list")
|
|
104
|
+
def models_list():
|
|
105
|
+
"""List registered models with cache status."""
|
|
106
|
+
header = f"{'NAME':<20} {'SIZE':>10} {'SHA256':<16} STATUS"
|
|
107
|
+
typer.echo(header)
|
|
108
|
+
for name, spec in sorted(REGISTRY.items()):
|
|
109
|
+
path = cached_path(name)
|
|
110
|
+
if path.exists():
|
|
111
|
+
size = f"{path.stat().st_size / 1e6:.1f} MB"
|
|
112
|
+
status = "cached"
|
|
113
|
+
else:
|
|
114
|
+
size = "-"
|
|
115
|
+
status = "missing"
|
|
116
|
+
typer.echo(f"{name:<20} {size:>10} {spec.sha256[:16]} {status}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@models_app.command("download")
|
|
120
|
+
def models_download(name: str = typer.Argument(...)):
|
|
121
|
+
"""Fetch weights for NAME and verify the sha256."""
|
|
122
|
+
if name not in REGISTRY:
|
|
123
|
+
raise typer.BadParameter(f"unknown model {name!r}; see `metalgrow models list`")
|
|
124
|
+
path = ensure_weight(name)
|
|
125
|
+
typer.echo(f"ok: {path}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@models_app.command("rm")
|
|
129
|
+
def models_rm(name: str = typer.Argument(...)):
|
|
130
|
+
"""Remove the cached weight for NAME."""
|
|
131
|
+
if name not in REGISTRY:
|
|
132
|
+
raise typer.BadParameter(f"unknown model {name!r}; see `metalgrow models list`")
|
|
133
|
+
typer.echo("removed" if remove_cached(name) else "not cached")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
app()
|
metalgrow/device.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_device(prefer: str = "auto") -> torch.device:
|
|
7
|
+
if prefer == "cpu":
|
|
8
|
+
return torch.device("cpu")
|
|
9
|
+
if prefer in ("mps", "auto") and torch.backends.mps.is_available():
|
|
10
|
+
_enable_mps_fallback()
|
|
11
|
+
return torch.device("mps")
|
|
12
|
+
if prefer in ("cuda", "auto") and torch.cuda.is_available():
|
|
13
|
+
return torch.device("cuda")
|
|
14
|
+
return torch.device("cpu")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _enable_mps_fallback() -> None:
|
|
18
|
+
"""Set PYTORCH_ENABLE_MPS_FALLBACK=1 so ops missing on MPS fall back to CPU.
|
|
19
|
+
|
|
20
|
+
Several torch ops (notably some upsampling and reflection-pad variants)
|
|
21
|
+
still have gaps on the MPS backend. Without the fallback, a single
|
|
22
|
+
unsupported op aborts the forward pass; with it, torch silently executes
|
|
23
|
+
the op on CPU and returns to MPS for the rest.
|
|
24
|
+
|
|
25
|
+
Opt out by setting ``METALGROW_DISABLE_MPS_FALLBACK=1`` — useful when
|
|
26
|
+
debugging which op is falling back.
|
|
27
|
+
"""
|
|
28
|
+
if os.environ.get("METALGROW_DISABLE_MPS_FALLBACK") == "1":
|
|
29
|
+
return
|
|
30
|
+
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
|
metalgrow/upscaler.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import torch
|
|
5
|
+
import torch.nn.functional as F
|
|
6
|
+
from PIL import Image
|
|
7
|
+
from torchvision.transforms.functional import pil_to_tensor, to_pil_image
|
|
8
|
+
|
|
9
|
+
from metalgrow.backbones import get_backbone
|
|
10
|
+
from metalgrow.device import get_device
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Upscaler:
|
|
14
|
+
"""Upscales images using a pluggable backbone on the selected torch device.
|
|
15
|
+
|
|
16
|
+
The ``bicubic`` backbone is always available and requires no weights, so the
|
|
17
|
+
pipeline is runnable end-to-end in CI. Learned backbones (Real-ESRGAN,
|
|
18
|
+
SwinIR, …) are selected by name via the registry in
|
|
19
|
+
:mod:`metalgrow.backbones`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
backbone: str = "bicubic",
|
|
25
|
+
device: str = "auto",
|
|
26
|
+
dtype: torch.dtype = torch.float32,
|
|
27
|
+
):
|
|
28
|
+
self.device = get_device(device)
|
|
29
|
+
self.dtype = dtype
|
|
30
|
+
self.backbone = get_backbone(backbone, self.device, dtype)
|
|
31
|
+
|
|
32
|
+
def upscale(
|
|
33
|
+
self,
|
|
34
|
+
image: Image.Image,
|
|
35
|
+
scale: float = 2.0,
|
|
36
|
+
tile: int | None = None,
|
|
37
|
+
tile_pad: int | None = None,
|
|
38
|
+
) -> Image.Image:
|
|
39
|
+
tensor = pil_to_tensor(image).to(self.device, self.dtype).unsqueeze(0) / 255.0
|
|
40
|
+
_, c, _, _ = tensor.shape
|
|
41
|
+
|
|
42
|
+
if c == 4 and self.backbone.input_channels == 3:
|
|
43
|
+
# Alpha is out-of-distribution for an RGB-trained SR model. Run
|
|
44
|
+
# the backbone on the RGB plane and bicubic-upscale the alpha
|
|
45
|
+
# channel, then recombine. Cheap and preserves transparency.
|
|
46
|
+
rgb = self._run_backbone(tensor[:, :3], scale, tile, tile_pad).clamp(0.0, 1.0)
|
|
47
|
+
alpha = F.interpolate(
|
|
48
|
+
tensor[:, 3:4], size=rgb.shape[-2:], mode="bicubic", align_corners=False
|
|
49
|
+
).clamp(0.0, 1.0)
|
|
50
|
+
out = torch.cat([rgb, alpha], dim=1)
|
|
51
|
+
else:
|
|
52
|
+
out = self._run_backbone(tensor, scale, tile, tile_pad).clamp(0.0, 1.0)
|
|
53
|
+
|
|
54
|
+
mode = {1: "L", 3: "RGB", 4: "RGBA"}.get(out.shape[1])
|
|
55
|
+
return to_pil_image(out.squeeze(0).float().cpu(), mode=mode)
|
|
56
|
+
|
|
57
|
+
def upscale_file(
|
|
58
|
+
self,
|
|
59
|
+
src: Path,
|
|
60
|
+
dst: Path,
|
|
61
|
+
scale: float = 2.0,
|
|
62
|
+
tile: int | None = None,
|
|
63
|
+
tile_pad: int | None = None,
|
|
64
|
+
) -> Path:
|
|
65
|
+
image = Image.open(src)
|
|
66
|
+
has_alpha = image.mode in ("RGBA", "LA") or "transparency" in image.info
|
|
67
|
+
image = image.convert("RGBA" if has_alpha else "RGB")
|
|
68
|
+
result = self.upscale(image, scale=scale, tile=tile, tile_pad=tile_pad)
|
|
69
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
result.save(dst)
|
|
71
|
+
return dst
|
|
72
|
+
|
|
73
|
+
def _run_backbone(
|
|
74
|
+
self,
|
|
75
|
+
tensor: torch.Tensor,
|
|
76
|
+
scale: float,
|
|
77
|
+
tile: int | None,
|
|
78
|
+
tile_pad: int | None,
|
|
79
|
+
) -> torch.Tensor:
|
|
80
|
+
if tile is None:
|
|
81
|
+
tile = self.backbone.default_tile
|
|
82
|
+
if tile_pad is None:
|
|
83
|
+
tile_pad = self.backbone.default_tile_pad
|
|
84
|
+
|
|
85
|
+
_, _, h, w = tensor.shape
|
|
86
|
+
if not tile or tile <= 0 or (h <= tile and w <= tile):
|
|
87
|
+
return self.backbone.upscale(tensor, scale)
|
|
88
|
+
|
|
89
|
+
return _tiled_forward(
|
|
90
|
+
lambda x: self.backbone.upscale(x, scale),
|
|
91
|
+
tensor,
|
|
92
|
+
scale=scale,
|
|
93
|
+
tile=int(tile),
|
|
94
|
+
pad=int(tile_pad),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _tiled_forward(
|
|
99
|
+
fn: Callable[[torch.Tensor], torch.Tensor],
|
|
100
|
+
x: torch.Tensor,
|
|
101
|
+
scale: float,
|
|
102
|
+
tile: int,
|
|
103
|
+
pad: int,
|
|
104
|
+
) -> torch.Tensor:
|
|
105
|
+
"""Run ``fn`` over tiled crops of ``x`` and blend with a feathered overlap.
|
|
106
|
+
|
|
107
|
+
Each tile is padded by ``pad`` pixels of real image context on every edge
|
|
108
|
+
that isn't at the image boundary, giving the backbone enough receptive
|
|
109
|
+
field to produce seam-free interior pixels. Overlapping regions are
|
|
110
|
+
combined with a linear feather weight so any residual edge mismatch fades
|
|
111
|
+
instead of appearing as a hard seam.
|
|
112
|
+
"""
|
|
113
|
+
if tile <= 0:
|
|
114
|
+
raise ValueError("tile must be positive")
|
|
115
|
+
|
|
116
|
+
n, c, h, w = x.shape
|
|
117
|
+
out_h = int(h * scale)
|
|
118
|
+
out_w = int(w * scale)
|
|
119
|
+
out_dtype = x.dtype
|
|
120
|
+
out_device = x.device
|
|
121
|
+
|
|
122
|
+
accum: torch.Tensor | None = None
|
|
123
|
+
norm: torch.Tensor | None = None
|
|
124
|
+
|
|
125
|
+
for y0 in range(0, h, tile):
|
|
126
|
+
for x0 in range(0, w, tile):
|
|
127
|
+
y1 = min(y0 + tile, h)
|
|
128
|
+
x1 = min(x0 + tile, w)
|
|
129
|
+
py0, py1 = max(0, y0 - pad), min(h, y1 + pad)
|
|
130
|
+
px0, px1 = max(0, x0 - pad), min(w, x1 + pad)
|
|
131
|
+
|
|
132
|
+
patch_out = fn(x[:, :, py0:py1, px0:px1])
|
|
133
|
+
|
|
134
|
+
if accum is None:
|
|
135
|
+
accum = torch.zeros(
|
|
136
|
+
n, patch_out.shape[1], out_h, out_w, dtype=out_dtype, device=out_device
|
|
137
|
+
)
|
|
138
|
+
norm = torch.zeros(1, 1, out_h, out_w, dtype=out_dtype, device=out_device)
|
|
139
|
+
|
|
140
|
+
sy = patch_out.shape[-2] / (py1 - py0)
|
|
141
|
+
sx = patch_out.shape[-1] / (px1 - px0)
|
|
142
|
+
oy0, oy1 = round(py0 * sy), round(py1 * sy)
|
|
143
|
+
ox0, ox1 = round(px0 * sx), round(px1 * sx)
|
|
144
|
+
ph = oy1 - oy0
|
|
145
|
+
pw = ox1 - ox0
|
|
146
|
+
if patch_out.shape[-2:] != (ph, pw):
|
|
147
|
+
# Fall back to the patch's actual size if the backbone didn't
|
|
148
|
+
# scale integrally (e.g. non-integer scale factors).
|
|
149
|
+
ph, pw = patch_out.shape[-2:]
|
|
150
|
+
oy1 = oy0 + ph
|
|
151
|
+
ox1 = ox0 + pw
|
|
152
|
+
|
|
153
|
+
left_pad = round((y0 - py0) * sy)
|
|
154
|
+
right_pad = round((py1 - y1) * sy)
|
|
155
|
+
top_pad = round((x0 - px0) * sx)
|
|
156
|
+
bot_pad = round((px1 - x1) * sx)
|
|
157
|
+
|
|
158
|
+
wy = _feather(ph, left_pad, right_pad, out_dtype, out_device)
|
|
159
|
+
wx = _feather(pw, top_pad, bot_pad, out_dtype, out_device)
|
|
160
|
+
weight = wy.view(-1, 1) * wx.view(1, -1)
|
|
161
|
+
|
|
162
|
+
accum[:, :, oy0:oy1, ox0:ox1] += patch_out * weight
|
|
163
|
+
norm[:, :, oy0:oy1, ox0:ox1] += weight
|
|
164
|
+
|
|
165
|
+
assert accum is not None and norm is not None
|
|
166
|
+
return accum / norm.clamp_min(1e-8)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _feather(length: int, left: int, right: int, dtype: torch.dtype, device: torch.device):
|
|
170
|
+
w = torch.ones(length, dtype=dtype, device=device)
|
|
171
|
+
if left > 0:
|
|
172
|
+
ramp = torch.linspace(0.0, 1.0, left + 2, dtype=dtype, device=device)[1:-1]
|
|
173
|
+
w[:left] = ramp
|
|
174
|
+
if right > 0:
|
|
175
|
+
ramp = torch.linspace(1.0, 0.0, right + 2, dtype=dtype, device=device)[1:-1]
|
|
176
|
+
w[length - right :] = ramp
|
|
177
|
+
return w
|
metalgrow/weights.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Weight file registry + download-on-first-use cache for learned backbones."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.request import urlopen
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class WeightSpec:
|
|
16
|
+
name: str
|
|
17
|
+
url: str
|
|
18
|
+
sha256: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
REGISTRY: dict[str, WeightSpec] = {
|
|
22
|
+
"realesrgan-x2": WeightSpec(
|
|
23
|
+
name="RealESRGAN_x2plus.pth",
|
|
24
|
+
url=(
|
|
25
|
+
"https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth"
|
|
26
|
+
),
|
|
27
|
+
sha256="49fafd45f8fd7aa8d31ab2a22d14d91b536c34494a5cfe31eb5d89c2fa266abb",
|
|
28
|
+
),
|
|
29
|
+
"realesrgan-x4": WeightSpec(
|
|
30
|
+
name="RealESRGAN_x4plus.pth",
|
|
31
|
+
url=(
|
|
32
|
+
"https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth"
|
|
33
|
+
),
|
|
34
|
+
sha256="4fa0d38905f75ac06eb49a7951b426670021be3018265fd191d2125df9d682f1",
|
|
35
|
+
),
|
|
36
|
+
"swinir-x2": WeightSpec(
|
|
37
|
+
name="001_classicalSR_DIV2K_s48w8_SwinIR-M_x2.pth",
|
|
38
|
+
url=(
|
|
39
|
+
"https://github.com/JingyunLiang/SwinIR/releases/download/v0.0/"
|
|
40
|
+
"001_classicalSR_DIV2K_s48w8_SwinIR-M_x2.pth"
|
|
41
|
+
),
|
|
42
|
+
sha256="ac6d4895f73aa1b3d563937e29dcedcfdff4d401280e248f23db3c6f68e8343b",
|
|
43
|
+
),
|
|
44
|
+
"swinir-x4": WeightSpec(
|
|
45
|
+
name="001_classicalSR_DIV2K_s48w8_SwinIR-M_x4.pth",
|
|
46
|
+
url=(
|
|
47
|
+
"https://github.com/JingyunLiang/SwinIR/releases/download/v0.0/"
|
|
48
|
+
"001_classicalSR_DIV2K_s48w8_SwinIR-M_x4.pth"
|
|
49
|
+
),
|
|
50
|
+
sha256="129dc773ba2d4c07f3eb0bb116fbe692011b7cc072d9ca12797cd3748198610a",
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cache_dir() -> Path:
|
|
56
|
+
"""Return the weight cache directory, honoring METALGROW_CACHE_DIR."""
|
|
57
|
+
override = os.environ.get("METALGROW_CACHE_DIR")
|
|
58
|
+
root = Path(override) if override else Path.home() / ".cache" / "metalgrow"
|
|
59
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
return root
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sha256_of(path: Path, chunk: int = 1 << 20) -> str:
|
|
64
|
+
h = hashlib.sha256()
|
|
65
|
+
with path.open("rb") as f:
|
|
66
|
+
while True:
|
|
67
|
+
buf = f.read(chunk)
|
|
68
|
+
if not buf:
|
|
69
|
+
break
|
|
70
|
+
h.update(buf)
|
|
71
|
+
return h.hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _download(url: str, dst: Path) -> None:
|
|
75
|
+
"""Stream ``url`` to ``dst`` atomically, printing coarse progress to stderr."""
|
|
76
|
+
tmp = dst.with_suffix(dst.suffix + ".part")
|
|
77
|
+
# `\r` is only meaningful on a TTY. Under captured / piped stderr (CI logs,
|
|
78
|
+
# Typer's CliRunner, `| cat`) every update would concatenate onto one line,
|
|
79
|
+
# so we fall back to periodic line-per-decile output there.
|
|
80
|
+
tty = sys.stderr.isatty()
|
|
81
|
+
with urlopen(url) as resp: # noqa: S310 — URLs come from a checksum-verified registry
|
|
82
|
+
total = int(resp.headers.get("Content-Length", 0) or 0)
|
|
83
|
+
read = 0
|
|
84
|
+
last_decile = -1
|
|
85
|
+
with tmp.open("wb") as out:
|
|
86
|
+
while True:
|
|
87
|
+
chunk = resp.read(1 << 20)
|
|
88
|
+
if not chunk:
|
|
89
|
+
break
|
|
90
|
+
out.write(chunk)
|
|
91
|
+
read += len(chunk)
|
|
92
|
+
if not total:
|
|
93
|
+
continue
|
|
94
|
+
pct = read * 100 // total
|
|
95
|
+
if tty:
|
|
96
|
+
print(f"\rdownloading {dst.name}: {pct}%", end="", file=sys.stderr)
|
|
97
|
+
else:
|
|
98
|
+
decile = pct // 10
|
|
99
|
+
if decile != last_decile:
|
|
100
|
+
print(f"downloading {dst.name}: {decile * 10}%", file=sys.stderr)
|
|
101
|
+
last_decile = decile
|
|
102
|
+
if total and tty:
|
|
103
|
+
print("", file=sys.stderr)
|
|
104
|
+
shutil.move(tmp, dst)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cached_path(backbone: str) -> Path:
|
|
108
|
+
"""Return the on-disk cache path for ``backbone`` (may not exist)."""
|
|
109
|
+
try:
|
|
110
|
+
spec = REGISTRY[backbone]
|
|
111
|
+
except KeyError:
|
|
112
|
+
raise KeyError(f"no weights registered for backbone {backbone!r}") from None
|
|
113
|
+
return cache_dir() / spec.name
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_cached(backbone: str) -> bool:
|
|
117
|
+
return cached_path(backbone).exists()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def remove_cached(backbone: str) -> bool:
|
|
121
|
+
"""Delete the cached weight for ``backbone``; return True if a file was removed."""
|
|
122
|
+
path = cached_path(backbone)
|
|
123
|
+
if path.exists():
|
|
124
|
+
path.unlink()
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def ensure_weight(backbone: str) -> Path:
|
|
130
|
+
"""Return a local path to the verified weight file for ``backbone``.
|
|
131
|
+
|
|
132
|
+
Downloads on first use into :func:`cache_dir`; re-verifies the checksum on
|
|
133
|
+
every call. Raises ``RuntimeError`` if the downloaded file fails the hash
|
|
134
|
+
check (and removes the bad file so a retry can re-download).
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
spec = REGISTRY[backbone]
|
|
138
|
+
except KeyError:
|
|
139
|
+
raise KeyError(f"no weights registered for backbone {backbone!r}") from None
|
|
140
|
+
|
|
141
|
+
dst = cache_dir() / spec.name
|
|
142
|
+
if not dst.exists():
|
|
143
|
+
_download(spec.url, dst)
|
|
144
|
+
|
|
145
|
+
actual = _sha256_of(dst)
|
|
146
|
+
if actual != spec.sha256:
|
|
147
|
+
dst.unlink(missing_ok=True)
|
|
148
|
+
raise RuntimeError(
|
|
149
|
+
f"checksum mismatch for {spec.name}: expected {spec.sha256}, got {actual}. "
|
|
150
|
+
"Removed the corrupt file; retry to re-download."
|
|
151
|
+
)
|
|
152
|
+
return dst
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metalgrow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered image upscaler accelerated on Apple Metal (MPS).
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: numpy>=1.26
|
|
8
|
+
Requires-Dist: pillow>=10.0
|
|
9
|
+
Requires-Dist: spandrel>=0.4
|
|
10
|
+
Requires-Dist: torch>=2.3
|
|
11
|
+
Requires-Dist: torchvision>=0.18
|
|
12
|
+
Requires-Dist: typer>=0.12
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pre-commit>=3.7; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
metalgrow/__init__.py,sha256=EqvSxKYXIx6SmXlRoVkzPxziRFHMrYYq0GgcXk5u9h4,140
|
|
2
|
+
metalgrow/batch.py,sha256=eHcxj9sMWsw4LKj-ftqWiE6ZTbGQa2I94h-Ig6Ri1uE,5239
|
|
3
|
+
metalgrow/cli.py,sha256=tgee9XVCJw3WrazY_dojLuEIfGifOCsn2QjUow0I2SQ,4521
|
|
4
|
+
metalgrow/device.py,sha256=vpCkJoeennXgOdRx7XmyeB5rh4z5bSRxdqgtS65-NP0,1070
|
|
5
|
+
metalgrow/upscaler.py,sha256=XOTe52GF4a2slvESzrEItT_Enb-iRZ0-TCBWT9egBUY,6286
|
|
6
|
+
metalgrow/weights.py,sha256=9HakZjxaWEJVHbqEnsl6yiPrQJud0p07DZtbgtq1naE,5119
|
|
7
|
+
metalgrow/backbones/__init__.py,sha256=wurbYBxxl_m842-qix-feiDZ9nYLzGuV7D8ezWRJc64,473
|
|
8
|
+
metalgrow/backbones/base.py,sha256=bG_MWuHhRpCjKdxT2Ax8sRVyLvfol-BXUte_H91wDAI,2055
|
|
9
|
+
metalgrow/backbones/bicubic.py,sha256=QWPTan6Tf--tAH7dW4DAslUWUFMLu394vtc-DsGm87g,770
|
|
10
|
+
metalgrow/backbones/realesrgan.py,sha256=_DDaIb76qXOk5tBU3gHN0o1bjpgCAZFJZ8VHb8pwn-Q,1791
|
|
11
|
+
metalgrow/backbones/registry.py,sha256=mcxHyj-iJ0kCM2dc0ML4VXYGmlSG5xf0mOwQsmt0XwA,827
|
|
12
|
+
metalgrow/backbones/swinir.py,sha256=Pt5PZ0WkAmyAQeT-yPR1lRSsEacdSdL6y__4fELMYaQ,1875
|
|
13
|
+
metalgrow-0.1.0.dist-info/METADATA,sha256=JA3FxqY7QVrAUVCEZpiTKPaWaJjGz4hfmfPpHNRhpdE,489
|
|
14
|
+
metalgrow-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
metalgrow-0.1.0.dist-info/entry_points.txt,sha256=gMCvW17BMAsgCD1uzRJ_Cle3iRGiIHHpFKp1KY2AFmY,48
|
|
16
|
+
metalgrow-0.1.0.dist-info/licenses/LICENSE,sha256=WiZDw4NgLukfYYsGRSR4UG0ppehO0W8U_PiD-m69SDA,1068
|
|
17
|
+
metalgrow-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 joaodotwork
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|