İvme-Denoise-v1 (İvmetron-Guanine1): 2.97M-param Bayesian shrinkage denoiser
Browse files- README.md +179 -0
- config.json +17 -0
- example.py +10 -0
- model.safetensors +3 -0
- modeling_ivmetron.py +238 -0
- 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
|