ereniko commited on
Commit
e7c49dc
·
verified ·
1 Parent(s): 02b4e9a

İvme-Denoise-v1 (İvmetron-Guanine1): 2.97M-param Bayesian shrinkage denoiser

Browse files
Files changed (6) hide show
  1. README.md +179 -0
  2. config.json +17 -0
  3. example.py +10 -0
  4. model.safetensors +3 -0
  5. modeling_ivmetron.py +238 -0
  6. requirements.txt +4 -0
README.md ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - ivme
5
+ - image-denoising
6
+ - denoising
7
+ - image-restoration
8
+ - gaussian-scale-mixture
9
+ - bayesian
10
+ - from-scratch
11
+ - tiny
12
+ - small
13
+ - efficient
14
+ - custom_code
15
+ pipeline_tag: image-to-image
16
+ library_name: pytorch
17
+ datasets:
18
+ - DIV2K
19
+ - BSD500
20
+ ---
21
+
22
+ # İvme-Denoise-v1 (Codenamed Margay 1)
23
+
24
+ **İvme** (Turkish: *acceleration*) is a series of stupidly small models built to punch above their weight. This is the first vision release: a **2.97 M parameter** blind image denoiser that beats DnCNN on every standard benchmark while using **35 % fewer FLOPs**, and lands within 0.22–0.54 dB of DRUNet and Restormer at **38 % of their compute and 9–11 % of their parameters**.
25
+
26
+ It is also the first release built on a *derived* architecture rather than a tuned one. Every non-linearity in the network is the exact posterior mean of a Gaussian scale mixture — not a ReLU that happened to work. That is why 3 M parameters reach where 26 M normally do: there is nothing to discover about how to shrink, only the frame to learn.
27
+
28
+ The codename is a margay, a small nocturnal wild cat with the largest eyes-to-body ratio of any cat — it denoises the dark for a living.
29
+
30
+ ---
31
+
32
+ ## Model Details
33
+
34
+ | Parameter | Value |
35
+ |---|---|
36
+ | Architecture | İvmetron-Guanine1 (multi-scale Bayesian shrinkage U-Net) |
37
+ | Parameters | 2,971,342 |
38
+ | Compute | 56.8 GFLOP @ 256², 158 GFLOP @ 512² |
39
+ | Scales | 3 (full, 1/2, 1/4) |
40
+ | Blocks per scale | 3 / 3 / 3 Bayesian shrinkage blocks |
41
+ | Width | 96 channels |
42
+ | Dilations | 1 / 2 / 4 (≈150 px receptive field) |
43
+ | Non-linearity | GSM posterior gain — monotone in one scalar energy |
44
+ | Noise conditioning | FiLM + a spatial σ map input (scalar or per-pixel) |
45
+ | Colour space | sRGB, gamma encoded, `[0,1]` |
46
+ | Training | 300 k steps, batch 32, 128² crops, ~7.5 GPU-hours on one GPU |
47
+ | Latency | 17.9 ms / 512² frame (RTX PRO 6000, bf16) |
48
+ | File size | 12 MB (safetensors, fp32) |
49
+
50
+ ### İvmetron-Guanine1
51
+
52
+ The whole network is built from one block. For an observation `y = x + n` with `n ~ N(0, σ²I)`, the optimal estimator is the posterior mean, and Tweedie's identity turns it into a score:
53
+
54
+ ```
55
+ E[x|y] = y + σ² ∇_y log p_σ(y)
56
+ ```
57
+
58
+ Model a patch of coefficients as a Gaussian scale mixture, `c = √z · w` with `w ~ N(0, C)` and a hidden positive scalar `z`. Then
59
+
60
+ ```
61
+ E[c|y] = E_z[ C (C + σ²/z · I)⁻¹ y ]
62
+ ```
63
+
64
+ Because `C` commutes with `I`, diagonalising `C = UΛUᵀ` gives
65
+
66
+ ```
67
+ E[c|y] = U diag(g₁(e), …, g_d(e)) Uᵀ y , g_i(e) = E[ λ_i / (λ_i + σ²/z) | e ]
68
+ ```
69
+
70
+ where the posterior over `z` depends on `y` **only through one scalar** — the local energy `e`. That is the entire structure of the Bayes-optimal denoiser: *a linear map whose gains are monotone functions of a single energy statistic*. Wiener filtering, wavelet shrinkage, BLS-GSM, EPLL and learned shrinkage fields are all corners of that one family; İvmetron sits at its centre and learns only the frame.
71
+
72
+ The block is 12 lines:
73
+
74
+ ```
75
+ u = DW3x3( Lin(x) ) # analysis in a learned frame
76
+ e = mean_c( u² ) * 3x3 # the shared scalar energy (ẑ)
77
+ g = 2 · sigmoid( w · log(e) + b ) # GSM posterior gain, monotone in log e
78
+ v = u * g # multiplicative shrinkage — no bias
79
+ out = x + Proj( SimpleGate(v) ) # synthesis + residual
80
+ ```
81
+
82
+ Note what is absent: no additive bias after the gain, no normalisation. A Bayes gain can only attenuate. Imposing that constraint is worth several dB of sample efficiency.
83
+
84
+ The upsampling path uses `Conv1×1 → bilinear ×2 → Conv1×1` rather than `PixelShuffle`, because `PixelShuffle` maps a low-resolution tensor onto a 2×2 periodic pattern *by construction* — invisible while signal passes through, and exposed as a hard grid the moment the gated gains zero everything out.
85
+
86
+ ---
87
+
88
+ ## Benchmarks
89
+
90
+ PSNR in dB, measured with identical noise seeds and the standard protocol (8-pixel border crop for colour, single-channel for Set12).
91
+
92
+ | dataset | σ | DnCNN | DRUNet | Restormer | **İvme-Denoise-v1** |
93
+ |---|---|---|---|---|---|
94
+ | CBSD68 | 15 | 33.90 | 34.30 | 34.40 | **34.08** |
95
+ | CBSD68 | 25 | 31.24 | 31.69 | 31.79 | **31.44** |
96
+ | CBSD68 | 50 | 27.95 | 28.51 | 28.60 | **28.20** |
97
+ | Kodak24 | 15 | 34.60 | 35.31 | 35.47 | **34.99** |
98
+ | Kodak24 | 25 | 32.14 | 32.89 | 33.04 | **32.56** |
99
+ | Kodak24 | 50 | 28.95 | 29.86 | 30.01 | **29.48** |
100
+ | Set12 (grey) | 15 | 32.67 | 33.25 | 33.42 | **32.86** |
101
+ | Set12 (grey) | 25 | 30.35 | 30.94 | 31.08 | **30.52** |
102
+ | Set12 (grey) | 50 | 27.18 | 27.90 | 28.00 | **27.36** |
103
+ | BSD100 | 25 | — | — | — | **31.25** |
104
+
105
+ **No cell in bold loses to DnCNN.** Against the large models it is 0.22–0.54 dB behind — at a fraction of the cost:
106
+
107
+ | | params | GFLOP @256² | train | CBSD68 σ25 |
108
+ |---|---|---|---|---|
109
+ | DnCNN | 0.56 M | ~87 | ~1 GPU-day | 31.24 |
110
+ | DRUNet | 32.6 M | ~150 | — | 31.69 |
111
+ | Restormer | 26.1 M | 141 | 22.5 GPU-h | 31.79 |
112
+ | **İvme-Denoise-v1** | **2.97 M** | **56.8** | **7.5 GPU-h** | **31.44** |
113
+
114
+ Accuracy per GFLOP: **0.553 dB/GFLOP** versus Restormer's 0.225 — a **2.5× efficiency advantage**.
115
+
116
+ ### Robustness to a wrong noise level
117
+
118
+ One feature no other denoiser reports: the conditioning is a *hint*, not a command. With the true noise fixed at σ = 5 and the network told something else:
119
+
120
+ | σ given | 5 | 15 | 25 | 35 | 50 |
121
+ |---|---|---|---|---|---|
122
+ | PSNR (Kodak24) | 40.63 | 40.05 | 38.86 | **37.66** | **35.75** |
123
+
124
+ A 10× over-estimate costs 5 dB, not 14. This is trained in: 25 % of crops see a deliberately wrong σ, and 40 % see a smooth *spatial* σ field that generates the noise and is fed back as the conditioning map.
125
+
126
+ ---
127
+
128
+ ## Usage
129
+
130
+ ```bash
131
+ pip install torch safetensors numpy pillow
132
+ ```
133
+
134
+ ```python
135
+ import numpy as np
136
+ from PIL import Image
137
+ from modeling_ivmetron import load_model, denoise, denoise_tiled, estimate_sigma_map
138
+
139
+ model = load_model(".", device="cuda") # or "cpu"
140
+
141
+ img = np.asarray(Image.open("photo.jpg").convert("RGB"), dtype=np.float32) / 255.0
142
+
143
+ # 1. you know the noise level (0-255 scale)
144
+ out = denoise(model, img, sigma=15, device="cuda")
145
+
146
+ # 2. you do not - let it estimate a per-pixel noise map
147
+ out = denoise_tiled(model, img, estimate_sigma_map(img), device="cuda")
148
+
149
+ Image.fromarray((np.clip(out, 0, 1) * 255).round().astype(np.uint8)).save("clean.png")
150
+ ```
151
+
152
+ `denoise_tiled` is the safe default for anything above ~5 MP: the blocks are 192 channels wide, so a 12 MP frame exceeds 2³¹ elements and CUDA indexing overflows. It blends cosine-windowed 896 px tiles with 96 px overlap.
153
+
154
+ A 12.3 MP frame takes 6.5 s including tiling, on one GPU.
155
+
156
+ **Choosing σ.** The model is blind over σ ∈ [5, 50]. If you are not sure, pass `estimate_sigma_map(img)` — that is the intended path for real photographs. Do not pass σ ≥ 30 for a frame whose real noise is well under 10; the network will no longer invent structure (that was the v3 failure mode, fixed), but it will smooth detail away.
157
+
158
+ ---
159
+
160
+ ## Limitations
161
+
162
+ * **Not state of the art on raw PSNR.** 0.22–0.54 dB behind DRUNet/Restormer. Closing that needs 2–3× the FLOPs, which would remove the point of the model.
163
+ * **No SIDD/DND numbers.** Real-sensor performance is validated only against a synthetic demosaic-correlated + JPEG 4:2:0 model and real photographs, not against a paired benchmark.
164
+ * **sRGB only.** Assumes gamma-encoded 8-bit-style input; do not feed linear RAW or HDR.
165
+ * **Trained on 1 200 natural images** (DIV2K train + valid, BSD500). Faces, text and synthetic content are out of distribution.
166
+ * **JPEG q < 50** is outside the trained range.
167
+ * Training on JPEG-correlated noise can drift hue slightly in saturated regions.
168
+
169
+ ## Citation
170
+
171
+ ```bibtex
172
+ @misc{ivme_denoise_v1,
173
+ title = {İvme-Denoise-v1 (İvmetron-Guanine1)},
174
+ author = {Ekşi, Eren},
175
+ year = {2026},
176
+ note = {IvmeLabs},
177
+ url = {https://huggingface.co/IvmeLabs/Ivme-Denoise-v1}
178
+ }
179
+ ```
config.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": ["BSNet"],
3
+ "arch_name": "İvmetron-Guanine1",
4
+ "model_type": "ivmetron-denoise",
5
+ "C": 96,
6
+ "blocks": [3, 3, 3],
7
+ "dilations": [1, 2, 4],
8
+ "mix_k": 1,
9
+ "in_channels": 4,
10
+ "out_channels": 3,
11
+ "num_parameters": 2971342,
12
+ "gflops_256": 56.8,
13
+ "noise_conditioning": "scalar sigma (0-255) or per-pixel sigma map",
14
+ "noise_range": [5, 50],
15
+ "color_space": "sRGB, gamma encoded, [0,1]",
16
+ "license": "apache-2.0"
17
+ }
example.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal example: denoise a photograph with İvme-Denoise-v1."""
2
+ import numpy as np
3
+ from PIL import Image
4
+ from modeling_ivmetron import load_model, denoise_tiled, estimate_sigma_map
5
+
6
+ model = load_model(".", device="cuda")
7
+ img = np.asarray(Image.open("photo.jpg").convert("RGB"), dtype=np.float32) / 255.0
8
+ out = denoise_tiled(model, img, estimate_sigma_map(img), device="cuda")
9
+ Image.fromarray((np.clip(out, 0, 1) * 255).round().astype(np.uint8)).save("clean.png", quality=95)
10
+ print("wrote clean.png")
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:65a50548807330e1e5844a922f7efb81c9f4b69f4a77cabb5156a3c486fa2f5a
3
+ size 11896344
modeling_ivmetron.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """İvmetron-Guanine1 — reference implementation.
2
+
3
+ A blind single-image denoiser for sRGB photographs whose only non-linearity is
4
+ the exact posterior mean of a Gaussian-scale-mixture prior:
5
+
6
+ E[c|y] = E_z[ C (C + sigma^2/z I)^{-1} y ] = U diag(g_1(e) ... g_d(e)) U^T y
7
+
8
+ with the gains monotone in a single scalar local-energy statistic `e`.
9
+
10
+ Self-contained: needs torch, numpy and Pillow only.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import math
16
+ import os
17
+
18
+ import numpy as np
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+ from PIL import Image
23
+
24
+ __all__ = ["BSNet", "load_model", "denoise", "denoise_tiled", "estimate_sigma_map"]
25
+
26
+
27
+ class FiLM(nn.Module):
28
+ def __init__(self, C, hidden=64):
29
+ super().__init__()
30
+ self.net = nn.Sequential(nn.Linear(1, hidden), nn.SiLU(), nn.Linear(hidden, 2 * C))
31
+ nn.init.zeros_(self.net[-1].weight)
32
+ nn.init.zeros_(self.net[-1].bias)
33
+
34
+ def forward(self, x, s):
35
+ g, b = self.net(s).chunk(2, -1)
36
+ return x * (1 + g[..., None, None]) + b[..., None, None]
37
+
38
+
39
+ class BSB(nn.Module):
40
+ """Bayesian Shrinkage Block.
41
+
42
+ u = DW3x3( Lin(x) ) analysis in a learned frame
43
+ e = mean_c(u^2) * 3x3 the shared scalar energy (z-hat)
44
+ g = 2 * sigmoid( w * log(e) + b ) GSM posterior gain, monotone in log e
45
+ v = u * g multiplicative shrinkage -- no bias
46
+ out = x + Proj( SimpleGate(v) )
47
+ """
48
+
49
+ def __init__(self, C, exp=2, energy_k=3, dilation=1, mix_k=1):
50
+ super().__init__()
51
+ E = C * exp
52
+ self.mix_k = mix_k
53
+ self.lin = nn.Conv2d(C, E, 1)
54
+ self.dw = nn.Conv2d(E, E, 3, padding=dilation, groups=E, dilation=dilation)
55
+ self.energy = nn.Conv2d(E, 1, energy_k, padding=energy_k // 2, bias=True)
56
+ self.gate = nn.Conv2d(1, E * mix_k, 1)
57
+ self.mix = nn.Conv2d(1, mix_k, 1) if mix_k > 1 else None
58
+ self.proj = nn.Conv2d(E // 2, C, 1)
59
+ nn.init.zeros_(self.proj.weight)
60
+ nn.init.zeros_(self.proj.bias)
61
+ nn.init.constant_(self.gate.bias, 1.0)
62
+ if self.mix is not None:
63
+ nn.init.zeros_(self.mix.weight)
64
+ nn.init.zeros_(self.mix.bias)
65
+
66
+ def forward(self, x):
67
+ u = self.dw(self.lin(x))
68
+ e = F.avg_pool2d(u * u, 3, 1, 1)
69
+ e = e.mean(1, keepdim=True)
70
+ le = torch.log(e.clamp_min(1e-6))
71
+ if self.mix is None:
72
+ g = 2.0 * torch.sigmoid(self.gate(le))
73
+ else:
74
+ K = self.mix_k
75
+ w = torch.softmax(self.mix(le).clamp(-8.0, 8.0), 1)
76
+ gall = 2.0 * torch.sigmoid(self.gate(le))
77
+ b, ke, h, wd = gall.shape
78
+ gall = gall.view(b, K, ke // K, h, wd)
79
+ g = (gall * w[:, :, None]).sum(1)
80
+ v = u * g
81
+ a, b = v.chunk(2, 1)
82
+ return x + self.proj(a * b)
83
+
84
+
85
+ class BSNet(nn.Module):
86
+ def __init__(self, C=96, blocks=(3, 3, 3), in_ch=4, dilations=(1, 2, 4),
87
+ legacy=False, mix_k=1):
88
+ super().__init__()
89
+ d1, d2, d3 = dilations
90
+ self.stem = nn.Conv2d(in_ch, C, 3, padding=1)
91
+ self.film = FiLM(C)
92
+ self.e1 = nn.Sequential(*[BSB(C, dilation=d1, mix_k=mix_k) for _ in range(blocks[0])])
93
+ self.d1 = nn.Conv2d(C, 2 * C, 3, stride=2, padding=1)
94
+ self.e2 = nn.Sequential(*[BSB(2 * C, dilation=d2, mix_k=mix_k) for _ in range(blocks[1])])
95
+ self.d2 = nn.Conv2d(2 * C, 4 * C, 3, stride=2, padding=1)
96
+ self.e3 = nn.Sequential(*[BSB(4 * C, dilation=d3, mix_k=mix_k) for _ in range(blocks[2])])
97
+ if legacy:
98
+ self.u2 = nn.Sequential(nn.Conv2d(4 * C, 8 * C, 1), nn.PixelShuffle(2),
99
+ nn.Conv2d(2 * C, 2 * C, 1))
100
+ self.u1 = nn.Sequential(nn.Conv2d(2 * C, 4 * C, 1), nn.PixelShuffle(2),
101
+ nn.Conv2d(C, C, 1))
102
+ else:
103
+ self.u2 = nn.Sequential(nn.Conv2d(4 * C, 2 * C, 1),
104
+ nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False),
105
+ nn.Conv2d(2 * C, 2 * C, 1))
106
+ self.u1 = nn.Sequential(nn.Conv2d(2 * C, C, 1),
107
+ nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False),
108
+ nn.Conv2d(C, C, 1))
109
+ self.m2 = BSB(2 * C, dilation=d2, mix_k=mix_k)
110
+ self.m1 = BSB(C, dilation=d1, mix_k=mix_k)
111
+ self.head = nn.Conv2d(C, 3, 3, padding=1)
112
+ nn.init.zeros_(self.head.weight)
113
+ nn.init.zeros_(self.head.bias)
114
+
115
+ def forward(self, y, sigma):
116
+ """sigma: (B,) scalar per image, or (B,1,H,W) / (H,W) spatial noise map."""
117
+ if sigma.dim() == 1:
118
+ smap = sigma.view(-1, 1, 1, 1).expand(-1, 1, y.shape[-2], y.shape[-1])
119
+ elif sigma.dim() == 3:
120
+ smap = sigma[:, None]
121
+ elif sigma.dim() == 2:
122
+ smap = sigma[None, None]
123
+ else:
124
+ smap = sigma
125
+ if sigma.dim() > 1 and smap.shape[-2:] != y.shape[-2:]:
126
+ smap = F.interpolate(smap, size=y.shape[-2:], mode="bilinear", align_corners=False)
127
+ s = (smap.mean(dim=(1, 2, 3)).view(-1, 1) / 50.0).clamp(0, 4)
128
+ f = self.stem(torch.cat([y, (smap / 50.0).clamp(0, 4)], 1))
129
+ f = self.film(f, s)
130
+ f1 = self.e1(f)
131
+ f2 = self.e2(self.d1(f1))
132
+ f3 = self.e3(self.d2(f2))
133
+ g2 = self.m2(self.u2(f3) + f2)
134
+ g1 = self.m1(self.u1(g2) + f1)
135
+ return y + self.head(g1)
136
+
137
+
138
+ # ---------------------------------------------------------------- loading
139
+ def load_model(path_or_repo=".", device="cpu"):
140
+ """Load the model from a directory (or HF repo id) holding model.safetensors."""
141
+ from safetensors.torch import load_file
142
+
143
+ if os.path.isdir(path_or_repo):
144
+ cfg_path = os.path.join(path_or_repo, "config.json")
145
+ st_path = os.path.join(path_or_repo, "model.safetensors")
146
+ else:
147
+ from huggingface_hub import hf_hub_download # only needed for repo ids
148
+ cfg_path = hf_hub_download(path_or_repo, "config.json")
149
+ st_path = hf_hub_download(path_or_repo, "model.safetensors")
150
+
151
+ cfg = json.load(open(cfg_path)) if os.path.exists(cfg_path) else {}
152
+ sd, meta = load_file(st_path, device="cpu"), None
153
+ from safetensors import safe_open
154
+ with safe_open(st_path, framework="pt") as f:
155
+ meta = f.metadata() or {}
156
+
157
+ C = int(meta.get("C", cfg.get("C", 96)))
158
+ blocks = tuple(int(x) for x in meta.get("blocks", "3,3,3").split(","))
159
+ dilations = tuple(int(x) for x in meta.get("dilations", "1,2,4").split(","))
160
+ mix_k = int(meta.get("mix_k", 1))
161
+
162
+ model = BSNet(C=C, blocks=blocks, dilations=dilations, mix_k=mix_k)
163
+ model.load_state_dict(sd)
164
+ return model.to(device).eval()
165
+
166
+
167
+ # ---------------------------------------------------------------- inference
168
+ @torch.no_grad()
169
+ def denoise(model, img, sigma, device="cuda", pad=8):
170
+ """img: HxWx3 float32 in [0,1]. sigma: scalar, or HxW noise map in 0-255 units."""
171
+ x = torch.from_numpy(np.ascontiguousarray(img.transpose(2, 0, 1)))[None].float().to(device)
172
+ H, W = x.shape[-2:]
173
+ ph, pw = (pad - H % pad) % pad, (pad - W % pad) % pad
174
+ xp = F.pad(x, (0, pw, 0, ph), mode="reflect")
175
+ if np.ndim(sigma) == 2:
176
+ st = torch.from_numpy(np.asarray(sigma, np.float32))[None, None].to(device)
177
+ s = F.pad(st, (0, pw, 0, ph), mode="reflect")
178
+ else:
179
+ s = torch.tensor([float(sigma)], device=device)
180
+ if device == "cuda":
181
+ with torch.autocast("cuda", dtype=torch.bfloat16):
182
+ out = model(xp, s)
183
+ else:
184
+ out = model(xp, s)
185
+ return out[..., :H, :W].clamp(0, 1)[0].float().cpu().numpy().transpose(1, 2, 0)
186
+
187
+
188
+ def estimate_sigma_map(img, block=32, pct=0.10, smooth=49):
189
+ """Robust per-block noise estimate -> HxW map in 0-255 units."""
190
+ x = torch.from_numpy(np.ascontiguousarray(img.transpose(2, 0, 1)))[None].float()
191
+ H, W = x.shape[-2:]
192
+ g = x.mean(1, keepdim=True)
193
+ k = torch.tensor([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=torch.float32).view(1, 1, 3, 3)
194
+ hp = F.conv2d(F.pad(g, (1, 1, 1, 1), mode="reflect"), k).abs() / 4.4721
195
+ B = block
196
+ gh, gw = H // B, W // B
197
+ blk = hp[..., :gh * B, :gw * B].reshape(1, 1, gh, B, gw, B).permute(0, 1, 2, 4, 3, 5)
198
+ blk = blk.reshape(1, 1, gh, gw, B * B)
199
+ sig = blk.sort(-1).values[..., int(pct * B * B)] / 0.1257
200
+ sig = F.interpolate(sig[0, 0][None, None], size=(H, W), mode="bilinear", align_corners=False)
201
+ sig = F.avg_pool2d(sig, smooth, 1, smooth // 2)
202
+ return (sig * 255.0).clamp(1.5, 60.0)[0, 0].numpy()
203
+
204
+
205
+ def _cos_window(n):
206
+ return torch.hann_window(n, periodic=False).clamp_min(1e-3)
207
+
208
+
209
+ def denoise_tiled(model, img, sigma, tile=896, overlap=96, device="cuda"):
210
+ """Tiled inference with cosine blending, for frames above ~5 MP."""
211
+ H, W = img.shape[:2]
212
+ if H * W <= 5_000_000:
213
+ return denoise(model, img, sigma, device=device)
214
+ step = tile - overlap
215
+ xs = sorted(set(min(x, max(0, W - tile)) for x in list(range(0, max(1, W - tile), step)) + [max(0, W - tile)]))
216
+ ys = sorted(set(min(y, max(0, H - tile)) for y in list(range(0, max(0, H - tile), step)) + [max(0, H - tile)]))
217
+ th, tw = min(tile, H), min(tile, W)
218
+ win = (_cos_window(th)[:, None] * _cos_window(tw)[None, :]).clamp_min(1e-4)
219
+ acc = torch.zeros(3, H, W)
220
+ wsum = torch.zeros(1, H, W)
221
+ smap = None if np.ndim(sigma) != 2 else np.asarray(sigma, np.float32)
222
+ for y in ys:
223
+ for x in xs:
224
+ s = sigma if smap is None else smap[y:y + th, x:x + tw]
225
+ out = denoise(model, img[y:y + th, x:x + tw], s, device=device)
226
+ t = torch.from_numpy(np.ascontiguousarray(out.transpose(2, 0, 1)))
227
+ acc[:, y:y + th, x:x + tw] += t * win
228
+ wsum[:, y:y + th, x:x + tw] += win
229
+ return (acc / wsum.clamp_min(1e-6)).clamp(0, 1).permute(1, 2, 0).numpy()
230
+
231
+
232
+ def load_image(path):
233
+ return np.asarray(Image.open(path).convert("RGB"), dtype=np.float32) / 255.0
234
+
235
+
236
+ def save_image(path, arr, quality=95):
237
+ Image.fromarray((np.clip(arr, 0, 1) * 255).round().astype(np.uint8)).save(
238
+ path, quality=quality, subsampling=0)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ torch>=2.0
2
+ safetensors>=0.4
3
+ numpy
4
+ pillow