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 ADDED
@@ -0,0 +1,5 @@
1
+ from metalgrow.device import get_device
2
+ from metalgrow.upscaler import Upscaler
3
+
4
+ __all__ = ["Upscaler", "get_device"]
5
+ __version__ = "0.0.1"
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ metalgrow = metalgrow.cli:app
@@ -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.