MinhNH232331M commited on
Commit
e440059
·
verified ·
1 Parent(s): f0ffd34

Upload folder using huggingface_hub

Browse files
Files changed (6) hide show
  1. LICENSE +24 -0
  2. README.md +246 -0
  3. config.json +3 -0
  4. flow_upscaler.safetensors +3 -0
  5. flow_upscaler_pipeline.py +350 -0
  6. upscaler_unet.py +621 -0
LICENSE ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org/>
README.md ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: unlicense
3
+ tags:
4
+ - flux2
5
+ - flux
6
+ - image-to-image
7
+ - image-upscaling
8
+ - latent-upscaling
9
+ - super-resolution
10
+ - diffusion
11
+ - flow-matching
12
+ - rectified-flow
13
+ - generative-models
14
+ - image-generation
15
+ - pytorch
16
+ library_name: pytorch
17
+ base_model:
18
+ - TensorForger/FlowUpscaler
19
+ pipeline_tag: image-to-image
20
+ ---
21
+
22
+
23
+ # Flow Upscaler
24
+
25
+
26
+
27
+ **Flow Upscaler** is a fast latent upscaler model that works in the [Flux.2](https://bfl.ai/models/flux-2) latent space.
28
+
29
+ Under the hood, it is a lightweight **Rectified Flow** model with **59M** parameters that generates upscaled latents in a single denoising step.
30
+
31
+
32
+ ## Inference pipeline (`flow_upscaler_pipeline.py`)
33
+
34
+ `flow_upscaler_pipeline.py` is a standalone, diffusers-based image-in /
35
+ image-out wrapper around the model — the plain-Python equivalent of the
36
+ ComfyUI node. `FlowUpscalerPipeline` combines the UNet defined in
37
+ `upscaler_unet.py` (weights: `flow_upscaler.safetensors`) with a shared
38
+ Flux.2 VAE and runs the full loop:
39
+
40
+ 1. **Encode** (`encode_image`): the input image is resized to a multiple of
41
+ 16, VAE-encoded, and the 32-channel latents are normalized with the VAE's
42
+ BatchNorm running stats — the same convention the model was trained with.
43
+ 2. **Upscale** (`upscale_latents`): pure noise is denoised into the 2x latent
44
+ in a **single FlowMatchEuler step**, conditioned on the small latent.
45
+ Passes chain for 4x/8x because a pass's output lives in the same
46
+ normalized latent space as its conditioning input.
47
+ 3. **Decode + color fix** (`upscale_image`): latents are denormalized and
48
+ VAE-decoded. With the default `color_fix=True`, each pass ends with a
49
+ latent low-frequency transplant and the decoded image gets a low-band
50
+ L/a/b transplant from the input — the two-stage drift correction detailed
51
+ in the next section.
52
+
53
+ Memory is bounded for large outputs: the VAE switches to tiling above 2048px,
54
+ the UNet runs through a chunked inference executor (`upscaler_unet.py`), and
55
+ outputs are capped at `MAX_OUTPUT_SIDE = 16384` px per side (excess passes
56
+ are dropped with a warning). This keeps even 16K outputs within a 24GB-class
57
+ GPU in bf16.
58
+
59
+ ```python
60
+ import torch
61
+ from diffusers import AutoencoderKLFlux2 # any Flux.2 checkpoint's VAE
62
+ from PIL import Image
63
+
64
+ from flow_upscaler_pipeline import FlowUpscalerPipeline
65
+
66
+ vae = AutoencoderKLFlux2.from_pretrained(
67
+ "black-forest-labs/FLUX.2-dev", subfolder="vae", torch_dtype=torch.bfloat16
68
+ ).to("cuda")
69
+ pipeline = FlowUpscalerPipeline("flow_upscaler.safetensors", vae, dtype=torch.bfloat16)
70
+
71
+ image = Image.open("input.png")
72
+ result = pipeline.upscale_image(image, num_passes=2) # 2 passes = 4x
73
+ result.save("output.png")
74
+ ```
75
+
76
+ For the VAE, consider
77
+ [MageFlow-VAE-diffusers](https://huggingface.co/MinhNH232331M/MageFlow-VAE-diffusers)
78
+ — an efficient drop-in replacement for the Flux.2 VAE that operates in the
79
+ same latent space (~10x faster encode, ~6x faster decode, roughly half the
80
+ peak VRAM, with a chunked decoder that keeps memory near-constant at large
81
+ resolutions). Since upscaling cost here is dominated by the encode/decode
82
+ ends, it speeds up the whole loop.
83
+
84
+ `upscale_image` knobs: `num_passes` (2x per pass), `seed`, `color_fix`
85
+ (master switch for both correction stages), and the per-stage
86
+ `transplant_strength` / `color_fix_strength` sliders described below.
87
+ `transplant_strength` also acts as a fidelity/realism control — lower values
88
+ track the input more faithfully, higher values give the model's crisper
89
+ rendition.
90
+
91
+
92
+ ## Color drift and corrections (this repo's pipeline)
93
+
94
+ Chained single-step passes drift measurably. The local wrapper
95
+ (`flow_upscaler_pipeline.py`, used by `app_local.py`) measures and corrects
96
+ three drift modes, all enabled by the default `color_fix=True`:
97
+
98
+ | Drift mode | Magnitude (uncorrected) | Cause |
99
+ |---|---|---|
100
+ | Per-channel latent mean drift | ~0.1 sigma/pass -> 2-5/255 RGB cast after 2 passes | single-step Euler DC error (VAE round-trip is clean) |
101
+ | Chroma amplification | ~8%/pass mean chroma; p99 +14/255 after 3 passes, concentrated on already-colorful regions (lips, skin) | model bias, rides on high-band latent content through the nonlinear decoder |
102
+ | Tone-curve stretch | dL_std +1.1, shadows -2.75, highlights +2 after 3 passes | side effect of the latent transplant below (raw model output is slightly *flat*) |
103
+
104
+ Two-stage correction:
105
+
106
+ 1. **Latent low-frequency transplant** (`_lowfreq_transplant`), after every
107
+ pass: the output latent's low band (antialiased downsample by
108
+ `LOWFREQ_FACTOR = 8`) is replaced with the conditioning's. Fixes the mean
109
+ drift and keeps the next pass's conditioning on-manifold, which measurably
110
+ sharpens results. Cost: ~1.5 ms / +33 MiB at an 8K-UHD output.
111
+ `transplant_strength` interpolates this stage from off (softer, truest to input structure) to
112
+ full (default, crisper); color is unaffected at any setting. Beyond drift
113
+ correction, this knob doubles as a **fidelity/realism control**: lower
114
+ values stay measurably closer to the input (PSNR to input 43.9 dB at 0.0
115
+ vs 38.4 dB at 1.0 in the 2-pass test below), higher values favor the
116
+ model's sharper, more confident rendition.
117
+ 2. **Pixel-space low-band L/a/b transplant** (`_lab_stat_match`), after the
118
+ final decode: Lab diff maps computed at the input's resolution
119
+ (area-downsampled output vs input, fp32), bilinearly upsampled and added
120
+ to the decoded image in fp16 row strips on GPU. Below the input's Nyquist
121
+ the input is ground truth, so regional color *and* tone are pinned exactly
122
+ while the model keeps all detail above it. Cost incl. uint8 download:
123
+ 13 / 54 / 235 ms and +51 / +128 / +404 MiB transient VRAM at 2K / 4K / 8K.
124
+ `color_fix_strength` interpolates this stage linearly; residual drift scales with `1 - strength`.
125
+
126
+ ### Measured results
127
+
128
+ Protocol: output area-downsampled (`INTER_AREA`) back to input size and
129
+ compared against the input in Lab; worst cases from a 512px-base photo set,
130
+ 2-3 pass chains, seed 42, NVIDIA L4, bf16. Reproduce with
131
+ `test_flow_upscaler.ipynb` (one section per design choice, with plots).
132
+
133
+ | Metric (at input scale) | Uncorrected | Both fixes on |
134
+ |---|---|---|
135
+ | RGB mean cast, 2 passes | up to 5/255 per channel | <= 0.05/255 |
136
+ | Mean chroma (Lab C_mean) | +7...+15% | within +/-0.5% |
137
+ | Chroma p99 (worst case, 3 passes) | +13.8/255 | +0.6/255 |
138
+ | Tone contrast (L_std), 3 passes | +1.08 | +0.03 |
139
+ | Shadows p5 / highlights p95 | -2.75 / +1.96 | 0.00 / 0.00 |
140
+
141
+ Knob response (2-pass restore-photo test, other knob at its 1.0 default —
142
+ the two stages are orthogonal by construction and by measurement):
143
+
144
+ | `transplant_strength` | 1.0 | 0.5 | 0.0 |
145
+ |---|---|---|---|
146
+ | chroma error dC | -0.02 | -0.02 | -0.02 |
147
+ | sharpness (mean abs Laplacian) | 7.94 | 4.86 | 3.28 |
148
+ | PSNR to input (dB) | 38.4 | 41.7 | 43.9 |
149
+
150
+ | `color_fix_strength` | 1.0 | 0.5 | 0.0 |
151
+ |---|---|---|---|
152
+ | chroma error dC | -0.02 | +0.53 | +1.16 |
153
+ | sharpness | 7.94 | 7.98 | 8.07 |
154
+
155
+ Compounding is per pass: before the pixel-space fix, mean chroma ran +8.6%
156
+ after 1 pass and +14.8% after 2; the latent mean drift is ~0.1 sigma per pass on average with
157
+ 0.35-0.48 sigma outlier channels. The VAE round-trip itself is clean (chroma
158
+ -0.02, RGB means <= 0.9/255) — every drift mode above is created by the flow
159
+ UNet or by the correction stack itself.
160
+
161
+ ### Nyquist / frequency-band analysis
162
+
163
+ Band bookkeeping, with frequencies written as fractions of each grid's
164
+ Nyquist: one latent pixel decodes to 8 image pixels, so the latent grid's
165
+ Nyquist sits at 1/16 cycles/px in pixel space; a conditioning at half the
166
+ output resolution owns everything below 0.5x the output-latent Nyquist. Each
167
+ correction stage operates in the widest band where its reference is ground
168
+ truth *and* its domain assumption holds:
169
+
170
+ * **Stage 1** corrects below output-latent Nyquist / `LOWFREQ_FACTOR` (= /8).
171
+ The limiting factor is not the conditioning (ground truth up to 0.5) but
172
+ how faithfully latent-space bands map to pixel-space bands — see below.
173
+ * **Stage 2** corrects below the *input image's* Nyquist, in pixel space,
174
+ where the low band is the pixel-space low band by definition — no mapping
175
+ assumption needed. Everything above the input Nyquist is model-invented
176
+ detail with no reference, and is deliberately left untouched.
177
+
178
+ The latent->pixel mapping was measured directly on the Mage decoder: inject a
179
+ band-limited 0.1-sigma perturbation into one latent spectral band, decode,
180
+ and integrate the luma response spectrum per pixel band (bands as fractions
181
+ of the latent Nyquist; pixel content above 1.0 is finer than the latent grid
182
+ can represent spatially):
183
+
184
+ | Latent band | -> same pixel band | -> finer than latent Nyquist |
185
+ |---|---|---|
186
+ | 0 - 1/16 | 87.7% | 7.8% |
187
+ | 1/16 - 1/8 | 90.6% | 4.2% |
188
+ | 1/8 - 1/4 | 77.1% | 15.0% |
189
+ | 1/4 - 1/2 | 64.7% | 26.2% |
190
+ | 1/2 - 1 | 38.0% | 58.1% |
191
+
192
+ Three design rules fall out of this table:
193
+
194
+ 1. Latent band surgery is sound only in the top two rows (~88-91%
195
+ correspondence) — exactly where `LOWFREQ_FACTOR` 8-16 operates. Below 77%
196
+ the spliced content scatters across unrelated pixel frequencies and reads
197
+ as ghosting (matches the failed wide-band experiments below).
198
+ 2. The ~8% upward leakage from the lowest band is the latent transplant's
199
+ sharpening / tone-stretch side effect, quantified — the energy it injects
200
+ into finer-than-latent-Nyquist pixel detail.
201
+ 3. Energy fractions understate *statistical* bias: high-band latent chroma
202
+ texture rectifies through the nonlinear decoder into a mean-saturation
203
+ shift carrying almost no low-band energy (the mechanism behind drift
204
+ mode 2). Exact guarantees therefore only come from the pixel-space stage.
205
+
206
+
207
+
208
+
209
+
210
+ **[ComfyUI Node](https://github.com/TensorForger/comfyui-flow-upscaler)**
211
+
212
+ Features:
213
+
214
+ * Upscaling from **512x512** to **1024x1024** takes **8ms***
215
+ * The model is trained for **2X** upscaling, but multiple passes can be chained to reach up to **8K** resolution
216
+ * A full pipeline with Flux generation, upscaling to **8K**, and decoding runs in just **25 seconds** (on RTX 5090)
217
+ * The training process uses **Flow Distillation** with Flux.2 as a teacher, forcing the model to learn strong image semantics
218
+
219
+ *On RTX 5090, in latent space, without decoding, see benchmark [here](https://github.com/tensorforger/CTGMWorkshop).
220
+
221
+ Here is one **4X** upscaled image (two passes):
222
+
223
+ ![comparison](https://raw.githubusercontent.com/tensorforger/tensorforger/main/assets/upscaler_comparison.png)
224
+
225
+ ## How it works
226
+
227
+ Architecturally, Flow Upscaler is a U-Net with SDXL-style ResNet blocks. It takes a noisy sample as input and predicts velocity as output. The generation process happens directly in high-resolution latent space.
228
+
229
+ The low-resolution latents are passed through a separate conditioning encoder that produces control signals, which are injected into the main U-Net encoder using FiLM conditioning.
230
+
231
+ No attention layers are used, so compute scales linearly with image area. This makes generation at **8K** resolution possible.
232
+
233
+ ![example](https://raw.githubusercontent.com/tensorforger/tensorforger/main/assets/flow_upscaler_architecture.PNG)
234
+
235
+ The model is trained using **Flow Distillation** with Flux.2-klein-4B as a teacher. We generated **20K** diverse images with Flux, storing the initial noise, generated latents, and downscaled latents used for conditioning.
236
+
237
+ The downscaled latents are created by decoding high-resolution latents, downscaling them in pixel space, and encoding them back into latents. Direct latent downscaling introduces artifacts and breaks latent patterns, resulting in blurry decoded images.
238
+
239
+ ![example](https://raw.githubusercontent.com/tensorforger/tensorforger/main/assets/flow_upscaler_training_approach.PNG)
240
+
241
+
242
+ ## Training code
243
+
244
+ If you want to explore the training code or use the model outside ComfyUI, see:
245
+
246
+ `notebooks/flow_upscaler` in [https://github.com/tensorforger/CTGMWorkshop](https://github.com/tensorforger/CTGMWorkshop)
config.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "model_type": "custom"
3
+ }
flow_upscaler.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:91eb93b40179441e569e01fe04f20fd9b951e434f88c84dd0ba523f830a81839
3
+ size 237085968
flow_upscaler_pipeline.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Latent-space 2x upscaling with FlowUpscaler (single-step rectified flow).
2
+
3
+ Inference convention replicated from the reference ComfyUI node
4
+ (github.com/TensorForger/comfyui-flow-upscaler) and the training notebooks in
5
+ github.com/tensorforger/CTGMWorkshop (notebooks/flow_upscaler): the UNet
6
+ consumes *unpatchified* 32-channel Flux.2 latents normalized with the VAE's
7
+ BatchNorm running stats, and denoises pure noise into the 2x latent in a
8
+ single FlowMatchEuler step. Passes can be chained for 4x/8x — the output of a
9
+ pass lives in the same normalized latent space as its conditioning input.
10
+
11
+ On top of the reference convention, two color corrections are applied: each
12
+ pass ends with a low-frequency transplant from the conditioning latent
13
+ (`_lowfreq_transplant`, fixes per-channel mean drift) and the decoded image
14
+ gets a low-band L/a/b transplant from the input (`_lab_stat_match`, fixes
15
+ the ~8%/pass chroma amplification and the tone-curve stretch the latent
16
+ transplant cannot see).
17
+ """
18
+
19
+ import logging
20
+
21
+ import numpy as np
22
+ import torch
23
+ import torch.nn.functional as F
24
+ from PIL import Image
25
+ from safetensors.torch import load_file
26
+
27
+ from diffusers import FlowMatchEulerDiscreteScheduler
28
+
29
+ from upscaler_unet import UpscalerUNet
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def patchify_latents(latents: torch.Tensor) -> torch.Tensor:
35
+ """(B, C, H, W) -> (B, 4C, H/2, W/2) via 2x2 space-to-channel."""
36
+ batch_size, num_channels, height, width = latents.shape
37
+ latents = latents.view(batch_size, num_channels, height // 2, 2, width // 2, 2)
38
+ latents = latents.permute(0, 1, 3, 5, 2, 4)
39
+ return latents.reshape(batch_size, num_channels * 4, height // 2, width // 2)
40
+
41
+
42
+ def unpatchify_latents(latents: torch.Tensor) -> torch.Tensor:
43
+ """(B, 4C, H, W) -> (B, C, 2H, 2W), inverse of :func:`patchify_latents`."""
44
+ batch_size, num_channels, height, width = latents.shape
45
+ latents = latents.reshape(batch_size, num_channels // 4, 2, 2, height, width)
46
+ latents = latents.permute(0, 1, 4, 2, 5, 3)
47
+ return latents.reshape(batch_size, num_channels // 4, height * 2, width * 2)
48
+
49
+
50
+ # sRGB <-> Lab (D65), cv2 float conventions: L in [0, 100], a/b around 0.
51
+ _RGB2XYZ = torch.tensor(
52
+ [[0.412453, 0.357580, 0.180423],
53
+ [0.212671, 0.715160, 0.072169],
54
+ [0.019334, 0.119193, 0.950227]]
55
+ )
56
+ _XYZ2RGB = torch.linalg.inv(_RGB2XYZ)
57
+ _LAB_WHITE = torch.tensor([0.950456, 1.0, 1.088754])
58
+
59
+
60
+ def _rgb_to_lab(rgb: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
61
+ """(B, 3, H, W) sRGB in [0, 1] -> (L, a, b) planes."""
62
+ lin = torch.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
63
+ xyz = torch.einsum("ij,bjhw->bihw", _RGB2XYZ.to(rgb.device, rgb.dtype), lin)
64
+ xyz = xyz / _LAB_WHITE.to(rgb.device, rgb.dtype).view(1, 3, 1, 1)
65
+ f = torch.where(
66
+ xyz > 0.008856, xyz.clamp(min=0.0) ** (1.0 / 3.0), 7.787 * xyz + 16.0 / 116.0
67
+ )
68
+ fx, fy, fz = f[:, 0], f[:, 1], f[:, 2]
69
+ return 116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)
70
+
71
+
72
+ def _lab_to_rgb(lightness: torch.Tensor, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
73
+ """Inverse of :func:`_rgb_to_lab`, output clamped to [0, 1]."""
74
+ fy = (lightness + 16.0) / 116.0
75
+ f = torch.stack([fy + a / 500.0, fy, fy - b / 200.0], dim=1)
76
+ xyz = torch.where(f > 0.206893, f**3, (f - 16.0 / 116.0) / 7.787)
77
+ xyz = xyz * _LAB_WHITE.to(f.device, f.dtype).view(1, 3, 1, 1)
78
+ lin = torch.einsum("ij,bjhw->bihw", _XYZ2RGB.to(f.device, f.dtype), xyz)
79
+ return torch.where(
80
+ lin <= 0.0031308, lin * 12.92, 1.055 * lin.clamp(min=0.0) ** (1.0 / 2.4) - 0.055
81
+ ).clamp(0.0, 1.0)
82
+
83
+
84
+ class FlowUpscalerPipeline:
85
+ """Wraps UpscalerUNet + the Flux.2 VAE for image-in / image-out 2x upscaling.
86
+
87
+ The UNet defaults to float32 (the training dtype); bf16 matches it to
88
+ ~54 dB PSNR while halving the forward peak and runtime (app_local passes
89
+ bf16). The shared VAE is used as-is (bf16 in this project).
90
+ """
91
+
92
+ # Encode/decode images larger than this (pixels per side) with VAE tiling
93
+ # to keep peak activation memory bounded on 24GB-class GPUs.
94
+ TILED_VAE_THRESHOLD = 2048
95
+
96
+ # Hard ceiling on the output side. Peak memory scales with latent *area*;
97
+ # with the chunked inference executor (upscaler_unet) the bf16 UNet pass
98
+ # measured 2.5GiB at an 8K-UHD output and 7.6GiB at 16K-UHD (decode
99
+ # 5.5GiB), so even 16K fits a 23GB L4 alongside the app's resident
100
+ # pipelines. fp32 needs roughly double.
101
+ MAX_OUTPUT_SIDE = 16384
102
+
103
+ def __init__(
104
+ self,
105
+ model_path: str,
106
+ vae,
107
+ device: str = "cuda",
108
+ dtype: torch.dtype = torch.float32,
109
+ ):
110
+ self.unet = UpscalerUNet()
111
+ self.unet.load_state_dict(load_file(model_path))
112
+ self.unet.to(device, dtype).eval()
113
+ self.vae = vae
114
+ self.device = device
115
+ self.dtype = dtype
116
+
117
+ def _bn_stats(self) -> tuple[torch.Tensor, torch.Tensor]:
118
+ mean = self.vae.bn.running_mean.view(1, -1, 1, 1)
119
+ std = torch.sqrt(self.vae.bn.running_var.view(1, -1, 1, 1) + self.vae.config.batch_norm_eps)
120
+ return mean.to(self.device, self.dtype), std.to(self.device, self.dtype)
121
+
122
+ def normalize_latents(self, latents: torch.Tensor) -> torch.Tensor:
123
+ """Raw VAE latents (B, 32, H, W) -> bn-normalized, still unpatchified."""
124
+ mean, std = self._bn_stats()
125
+ latents = patchify_latents(latents.to(self.device, self.dtype))
126
+ return unpatchify_latents((latents - mean) / std)
127
+
128
+ def denormalize_latents(self, latents: torch.Tensor) -> torch.Tensor:
129
+ mean, std = self._bn_stats()
130
+ latents = patchify_latents(latents)
131
+ return unpatchify_latents(latents * std + mean)
132
+
133
+ # Low-pass cutoff for the color fix: the low band is what survives an
134
+ # antialiased downsample of the output latent by this factor. 8 pins the
135
+ # decoded channel means to <1/255 (4 left ~1.5/255 residue in tests).
136
+ LOWFREQ_FACTOR = 8
137
+
138
+ def _lowfreq_transplant(
139
+ self, latents: torch.Tensor, cond: torch.Tensor, strength: float = 1.0
140
+ ) -> torch.Tensor:
141
+ """Replace the low band of `latents` with the conditioning's, in place.
142
+
143
+ `strength` interpolates between the latents' own low band (0.0 — no
144
+ transplant: softer output, truest to the input's structure) and the
145
+ conditioning's (1.0, default — on-manifold conditioning for the next
146
+ pass, visibly crisper). Color stays correct at any strength; the
147
+ pixel-space stage handles that.
148
+
149
+ The single-step flow drifts per-channel latent means by ~0.1 sigma per
150
+ pass, which compounds over chained passes into a visible color shift.
151
+ The conditioning latent is ground truth for everything below its
152
+ Nyquist, so swapping the low band in pins global and regional color
153
+ while keeping the UNet's detail — and hands the next pass an
154
+ on-manifold conditioning (measurably sharper output). Low bands come
155
+ from antialiased downsamples, so the full-res upsampled conditioning
156
+ is never materialized (+33 MiB peak at an 8K-UHD output).
157
+ """
158
+ if strength <= 0.0:
159
+ return latents
160
+ height, width = latents.shape[-2:]
161
+ small = (max(1, height // self.LOWFREQ_FACTOR), max(1, width // self.LOWFREQ_FACTOR))
162
+
163
+ def low_band(x: torch.Tensor) -> torch.Tensor:
164
+ x = F.interpolate(x, size=small, mode="bilinear", antialias=True)
165
+ return F.interpolate(x, size=(height, width), mode="bilinear")
166
+
167
+ diff = low_band(cond).sub_(low_band(latents))
168
+ return latents.add_(diff, alpha=float(strength))
169
+
170
+ @torch.no_grad()
171
+ def upscale_latents(
172
+ self,
173
+ latents_small: torch.Tensor,
174
+ generator: torch.Generator | None = None,
175
+ color_fix: bool = True,
176
+ transplant_strength: float = 1.0,
177
+ ) -> torch.Tensor:
178
+ """One 2x pass in normalized latent space: (B, 32, H, W) -> (B, 32, 2H, 2W)."""
179
+ batch_size, _, height, width = latents_small.shape
180
+ latents_small = latents_small.to(self.device, self.dtype)
181
+
182
+ scheduler = FlowMatchEulerDiscreteScheduler()
183
+ scheduler.set_timesteps(1, mu=1.0)
184
+
185
+ latents = torch.normal(
186
+ mean=0.0,
187
+ std=1.0,
188
+ size=(batch_size, 32, height * 2, width * 2),
189
+ dtype=self.dtype,
190
+ device=self.device,
191
+ generator=generator,
192
+ )
193
+ for t in scheduler.timesteps:
194
+ t = t.to(self.device).view(1)
195
+ velocity = self.unet(sample=latents, timestep=t, latents_small=latents_small)
196
+ latents = scheduler.step(velocity, t, latents).prev_sample
197
+ if color_fix:
198
+ latents = self._lowfreq_transplant(latents, latents_small, strength=transplant_strength)
199
+ return latents
200
+
201
+ @torch.no_grad()
202
+ def encode_image(self, image: Image.Image) -> torch.Tensor:
203
+ """PIL image -> normalized unpatchified latents (1, 32, H/8, W/8).
204
+
205
+ The image is resized to a multiple of 16 so the /8 latent stays
206
+ patchifiable (this mirrors how training conditioning latents were made:
207
+ decode -> downscale in pixel space -> encode).
208
+ """
209
+ image = image.convert("RGB")
210
+ width = max(16, round(image.width / 16) * 16)
211
+ height = max(16, round(image.height / 16) * 16)
212
+ if (width, height) != image.size:
213
+ image = image.resize((width, height), Image.LANCZOS)
214
+
215
+ pixels = torch.from_numpy(np.array(image)).float().div(127.5).sub(1.0)
216
+ pixels = pixels.permute(2, 0, 1).unsqueeze(0).to(self.device, self.vae.dtype)
217
+ previous_tiling = self.vae.use_tiling
218
+ if max(image.size) > self.TILED_VAE_THRESHOLD:
219
+ self.vae.use_tiling = True
220
+ try:
221
+ latents = self.vae.encode(pixels).latent_dist.mode()
222
+ finally:
223
+ self.vae.use_tiling = previous_tiling
224
+ return self.normalize_latents(latents)
225
+
226
+ @torch.no_grad()
227
+ def _decode_to_tensor(self, latents: torch.Tensor) -> torch.Tensor:
228
+ """Normalized latents -> (1, 3, H, W) pixels in [0, 1], still on GPU."""
229
+ latents = self.denormalize_latents(latents).to(self.vae.dtype)
230
+ output_side = max(latents.shape[-2:]) * 8
231
+ previous_tiling = self.vae.use_tiling
232
+ if output_side > self.TILED_VAE_THRESHOLD:
233
+ self.vae.use_tiling = True
234
+ try:
235
+ decoded = self.vae.decode(latents, return_dict=False)[0]
236
+ finally:
237
+ self.vae.use_tiling = previous_tiling
238
+ return decoded.add_(1.0).div_(2.0).clamp_(0.0, 1.0)
239
+
240
+ @staticmethod
241
+ def _tensor_to_pil(pixels: torch.Tensor) -> Image.Image:
242
+ # Quantize on-device: the download is uint8, a third of the float
243
+ # traffic the old CPU-side conversion paid.
244
+ array = pixels[0].permute(1, 2, 0).float().mul(255.0).round().to(torch.uint8)
245
+ return Image.fromarray(array.cpu().numpy())
246
+
247
+ @torch.no_grad()
248
+ def decode_latents(self, latents: torch.Tensor) -> Image.Image:
249
+ return self._tensor_to_pil(self._decode_to_tensor(latents))
250
+
251
+ # Row-strip height for the color-fix Lab math. The ops are elementwise
252
+ # (no halo), so strips exist purely to bound the fp16 working set.
253
+ LAB_STRIP_ROWS = 256
254
+
255
+ @torch.no_grad()
256
+ def _lab_stat_match(
257
+ self, image: torch.Tensor, reference: torch.Tensor, strength: float = 1.0
258
+ ) -> torch.Tensor:
259
+ """Pin decoded pixels' regional color and tone to the reference's.
260
+
261
+ `strength` linearly interpolates the low band between the image's own
262
+ (0.0) and the reference's (1.0, default): drift metrics scale with
263
+ 1 - strength, so intermediate values trade source fidelity for the
264
+ model's punchier rendition.
265
+
266
+ Two spread errors survive the latent transplant (a mean fix): the
267
+ flow UNet amplifies chroma ~8% per pass concentrated on already
268
+ colorful regions (a global gain pinned mean chroma but left dC_p99
269
+ +14 on lips/skin after 3 passes), and the latent transplant itself
270
+ stretches the tone curve (raw UNet output is slightly *flat*; the
271
+ transplant's sharpening side effect crushed shadows dp5 -2.75 and
272
+ pushed highlights dp95 +2 after 3 passes, which a global L match
273
+ only half-fixed). Below the reference's Nyquist both are ground
274
+ truth, so the full L/a/b low band is corrected exactly: diff maps
275
+ computed at the reference's resolution (area-downsampled proxy vs
276
+ reference, in fp32 — full-res stats vs an upsampled reference would
277
+ be blur-biased), bilinearly upsampled and added to the full-res
278
+ planes. Measured: chroma p95 +4.9 -> +0.0, p99 +13.8 -> +0.6; tone
279
+ dp5/dp95 -> 0.00 exactly; no halos at the strongest edges, detail
280
+ above the reference Nyquist untouched. Full-res strips run in fp16 —
281
+ within 1/255 of fp32 output; bf16's 8-bit mantissa steps exceed
282
+ uint8 resolution and band on gradients.
283
+
284
+ `image`: (1, 3, H, W) in [0, 1]; `reference`: same layout at its own
285
+ smaller size. Returns (H, W, 3) uint8 on the same device.
286
+ """
287
+ proxy = F.interpolate(image.float(), size=reference.shape[-2:], mode="area")
288
+ diff = torch.stack(
289
+ [r - p for r, p in zip(_rgb_to_lab(reference.float()), _rgb_to_lab(proxy))],
290
+ dim=1,
291
+ )
292
+ del proxy
293
+ if strength != 1.0:
294
+ diff = diff * strength
295
+
296
+ height, width = image.shape[-2:]
297
+ diff = F.interpolate(diff.to(torch.float16), size=(height, width), mode="bilinear")
298
+ out = torch.empty(height, width, 3, dtype=torch.uint8, device=image.device)
299
+ for r0 in range(0, height, self.LAB_STRIP_ROWS):
300
+ r1 = min(r0 + self.LAB_STRIP_ROWS, height)
301
+ l, a, b = _rgb_to_lab(image[:, :, r0:r1].to(torch.float16))
302
+ rgb = _lab_to_rgb(
303
+ l + diff[:, 0, r0:r1], a + diff[:, 1, r0:r1], b + diff[:, 2, r0:r1]
304
+ )
305
+ out[r0:r1] = rgb[0].permute(1, 2, 0).float().mul_(255.0).round_().to(torch.uint8)
306
+ return out
307
+
308
+ @torch.no_grad()
309
+ def upscale_image(
310
+ self,
311
+ image: Image.Image,
312
+ num_passes: int = 1,
313
+ seed: int = 42,
314
+ color_fix: bool = True,
315
+ transplant_strength: float = 1.0,
316
+ color_fix_strength: float = 1.0,
317
+ ) -> Image.Image:
318
+ # Reduce passes if the result would exceed MAX_OUTPUT_SIDE (e.g. a
319
+ # 2048px generation with 2 passes requested runs only one). Checked
320
+ # before encoding so oversized inputs are rejected without GPU work.
321
+ requested = max(1, int(num_passes))
322
+ num_passes = requested
323
+ side = max(round(image.width / 16), round(image.height / 16)) * 16
324
+ while num_passes > 0 and side * (2 ** num_passes) > self.MAX_OUTPUT_SIDE:
325
+ num_passes -= 1
326
+ if num_passes == 0:
327
+ raise ValueError(
328
+ f"Input of {image.size} cannot be upscaled within the "
329
+ f"{self.MAX_OUTPUT_SIDE}px output limit — downscale it first."
330
+ )
331
+ if num_passes < requested:
332
+ logger.warning(
333
+ "Reduced upscale passes %d -> %d to keep output within %dpx",
334
+ requested, num_passes, self.MAX_OUTPUT_SIDE,
335
+ )
336
+
337
+ generator = torch.Generator(device=self.device).manual_seed(seed)
338
+ latents = self.encode_image(image)
339
+ for _ in range(num_passes):
340
+ latents = self.upscale_latents(
341
+ latents, generator=generator, color_fix=color_fix,
342
+ transplant_strength=transplant_strength,
343
+ )
344
+ decoded = self._decode_to_tensor(latents)
345
+ if not color_fix:
346
+ return self._tensor_to_pil(decoded)
347
+ reference = torch.from_numpy(np.asarray(image.convert("RGB")).copy())
348
+ reference = reference.to(self.device).permute(2, 0, 1)[None].float().div_(255.0)
349
+ matched = self._lab_stat_match(decoded, reference, strength=float(color_fix_strength))
350
+ return Image.fromarray(matched.cpu().numpy())
upscaler_unet.py ADDED
@@ -0,0 +1,621 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+
5
+
6
+ def make_group_norm(
7
+ channels: int, max_groups: int = 32, eps: float = 1e-6
8
+ ) -> nn.GroupNorm:
9
+ groups = min(max_groups, channels)
10
+ while channels % groups != 0 and groups > 1:
11
+ groups -= 1
12
+ return nn.GroupNorm(groups, channels, eps=eps)
13
+
14
+
15
+ # ---- chunked inference execution -------------------------------------------
16
+ # At large latents a block's transient temporaries (SiLU copies, full-res
17
+ # FiLM scale/shift maps, conv workspaces) dwarf its input/output tensors.
18
+ # The chunked path caps the live set at ~3 activation-sized tensors per
19
+ # block: convs write row strips (1px halo for 3x3) into preallocated
20
+ # outputs, FiLM applies in strips, and GN runs as the native full-tensor op
21
+ # (its output just fills a live-set slot the convs already require, so
22
+ # keeping it native costs no memory and keeps the chunked path bit-identical
23
+ # to the standard one). Inference-only; callers gate on
24
+ # torch.is_grad_enabled().
25
+
26
+ CHUNK_MIN_PIXELS = 512 * 512 # engage the chunked path at/above this H*W
27
+ CHUNK_STRIP_PIXELS = 128 * 1024 # target H*W of one conv row strip
28
+
29
+
30
+ def _strip_rows(height: int, width: int) -> int:
31
+ return max(8, min(height, CHUNK_STRIP_PIXELS // max(1, width)))
32
+
33
+
34
+ def _use_chunked(x: torch.Tensor) -> bool:
35
+ return not torch.is_grad_enabled() and x.shape[-2] * x.shape[-1] >= CHUNK_MIN_PIXELS
36
+
37
+
38
+ def chunked_conv(
39
+ x: torch.Tensor,
40
+ weight: torch.Tensor,
41
+ bias: torch.Tensor | None = None,
42
+ out: torch.Tensor | None = None,
43
+ accumulate: bool = False,
44
+ ) -> torch.Tensor:
45
+ """Stride-1 odd-kernel conv2d in row strips.
46
+
47
+ With ``accumulate`` the strips are added into ``out``, which lets the last
48
+ conv of a block write straight into its residual buffer.
49
+ """
50
+ pad = weight.shape[-1] // 2
51
+ batch, _, height, width = x.shape
52
+ if out is None:
53
+ out = x.new_empty(batch, weight.shape[0], height, width)
54
+ rows = _strip_rows(height, width)
55
+ for r0 in range(0, height, rows):
56
+ r1 = min(r0 + rows, height)
57
+ a = max(r0 - pad, 0)
58
+ y = F.conv2d(x[:, :, a : min(r1 + pad, height)], weight, bias, padding=pad)
59
+ y = y[:, :, r0 - a : r0 - a + r1 - r0]
60
+ if accumulate:
61
+ out[:, :, r0:r1] += y
62
+ else:
63
+ out[:, :, r0:r1] = y
64
+ return out
65
+
66
+
67
+ def chunked_film_(film: "FilmCond2D", x: torch.Tensor, cond: torch.Tensor) -> torch.Tensor:
68
+ """FilmCond2D applied in place in row strips.
69
+
70
+ Avoids materializing the full-res 2x-channel scale/shift map (0.74GiB at
71
+ an 8K-UHD latent for film_cond_1).
72
+ """
73
+ silu, proj = film.cond_proj[0], film.cond_proj[1]
74
+ height, width = x.shape[-2:]
75
+ rows = _strip_rows(height, width)
76
+ for r0 in range(0, height, rows):
77
+ r1 = min(r0 + rows, height)
78
+ scale_shift = proj(silu(cond[:, :, r0:r1]))
79
+ scale, shift = scale_shift.chunk(2, dim=1)
80
+ x[:, :, r0:r1].mul_(1 + scale).add_(shift)
81
+ return x
82
+
83
+
84
+ class SinusoidalTimeEmbedding(nn.Module):
85
+ def __init__(self, dim: int = 128, max_period: int = 10000):
86
+ super().__init__()
87
+ self.dim = dim
88
+ self.max_period = max_period
89
+
90
+ def forward(self, timesteps: torch.Tensor) -> torch.Tensor:
91
+ half = self.dim // 2
92
+
93
+ freqs = torch.exp(
94
+ -torch.log(torch.tensor(float(self.max_period), device=timesteps.device))
95
+ * torch.arange(half, device=timesteps.device, dtype=timesteps.dtype)
96
+ / half
97
+ )
98
+ args = timesteps[:, None] * freqs[None]
99
+ emb = torch.cat([torch.sin(args), torch.cos(args)], dim=-1)
100
+
101
+ if self.dim % 2 == 1:
102
+ emb = F.pad(emb, (0, 1))
103
+
104
+ return emb
105
+
106
+
107
+ class ConditioningEncoder(nn.Module):
108
+ def __init__(self, time_dim: int = 128, cond_dim: int = 256):
109
+ super().__init__()
110
+ self.time_embed = SinusoidalTimeEmbedding(time_dim)
111
+
112
+ self.time_proj = nn.Sequential(
113
+ nn.Linear(time_dim, cond_dim),
114
+ nn.SiLU(),
115
+ nn.Linear(cond_dim, cond_dim),
116
+ )
117
+
118
+ def forward(self, timestep: torch.Tensor) -> torch.Tensor:
119
+ # Sinusoidal embedding stays fp32; cast to the projection weight
120
+ # dtype so the model can run in bf16.
121
+ emb = self.time_embed(timestep).to(self.time_proj[0].weight.dtype)
122
+ return self.time_proj(emb)
123
+
124
+
125
+ class ConditionedResidualBlock(nn.Module):
126
+ """
127
+ SDXL-style residual block:
128
+ GN -> SiLU -> Conv
129
+ + condition (scale/shift)
130
+ GN -> SiLU -> Dropout -> Conv
131
+ + skip connection
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ input_channels: int,
137
+ output_channels: int,
138
+ cond_dim: int = 256,
139
+ dropout: float = 0.0,
140
+ ):
141
+ super().__init__()
142
+
143
+ self.norm1 = make_group_norm(input_channels)
144
+ self.conv1 = nn.Conv2d(
145
+ input_channels, output_channels, kernel_size=3, padding=1
146
+ )
147
+
148
+ self.cond_proj = nn.Sequential(
149
+ nn.SiLU(),
150
+ nn.Linear(cond_dim, 2 * output_channels),
151
+ )
152
+
153
+ self.norm2 = make_group_norm(output_channels)
154
+ self.dropout = nn.Dropout(dropout)
155
+ self.conv2 = nn.Conv2d(
156
+ output_channels, output_channels, kernel_size=3, padding=1
157
+ )
158
+
159
+ if input_channels != output_channels:
160
+ self.skip = nn.Conv2d(
161
+ input_channels, output_channels, kernel_size=1, bias=False
162
+ )
163
+ else:
164
+ self.skip = nn.Identity()
165
+
166
+ def forward(self, x: torch.Tensor, cond: torch.Tensor) -> torch.Tensor:
167
+ if _use_chunked(x):
168
+ return self._forward_chunked(x, cond)
169
+ # In-place ops need the grad guard: silu/mul_ backward reads the
170
+ # pre-op values. At inference they drop several activation-sized
171
+ # temporaries per block (~0.5GiB peak at an 8K-UHD latent).
172
+ inplace = not torch.is_grad_enabled()
173
+ residual = self.skip(x)
174
+
175
+ h = self.norm1(x)
176
+ h = F.silu(h, inplace=inplace)
177
+ h = self.conv1(h)
178
+
179
+ scale_shift = self.cond_proj(cond)
180
+ scale, shift = scale_shift.chunk(2, dim=1)
181
+ scale = scale[:, :, None, None]
182
+ shift = shift[:, :, None, None]
183
+
184
+ h = self.norm2(h)
185
+ if inplace:
186
+ h.mul_(1 + scale).add_(shift)
187
+ else:
188
+ h = h * (1 + scale) + shift
189
+ h = F.silu(h, inplace=inplace)
190
+ h = self.dropout(h)
191
+ h = self.conv2(h)
192
+
193
+ return h.add_(residual) if inplace else h + residual
194
+
195
+ def _forward_chunked(self, x: torch.Tensor, cond: torch.Tensor) -> torch.Tensor:
196
+ # Large-latent inference: native out-of-place GN keeps x pristine so
197
+ # it doubles as the residual; convs run in row strips. Peak live set
198
+ # is 3 activation-sized tensors, and the output is bit-identical to
199
+ # the standard path. Dropout is identity at inference and is skipped.
200
+ residual = x if isinstance(self.skip, nn.Identity) else self.skip(x)
201
+ h = self.norm1(x)
202
+ F.silu(h, inplace=True)
203
+ h = chunked_conv(h, self.conv1.weight, self.conv1.bias)
204
+
205
+ scale_shift = self.cond_proj(cond)
206
+ scale, shift = scale_shift.chunk(2, dim=1)
207
+ h = self.norm2(h)
208
+ h.mul_(1 + scale[:, :, None, None]).add_(shift[:, :, None, None])
209
+ F.silu(h, inplace=True)
210
+ out = chunked_conv(h, self.conv2.weight, self.conv2.bias)
211
+ del h
212
+ return out.add_(residual)
213
+
214
+
215
+ class DownStage(nn.Module):
216
+ def __init__(
217
+ self,
218
+ input_channels: int,
219
+ output_channels: int,
220
+ cond_dim: int = 256,
221
+ dropout: float = 0.0,
222
+ num_blocks: int = 1,
223
+ downsample_first: bool = False,
224
+ ):
225
+ super().__init__()
226
+ self.downsample_first = downsample_first
227
+
228
+ self.blocks = nn.ModuleList()
229
+ for i in range(num_blocks):
230
+ in_ch = input_channels if i == 0 else output_channels
231
+ self.blocks.append(
232
+ ConditionedResidualBlock(
233
+ input_channels=in_ch,
234
+ output_channels=output_channels,
235
+ cond_dim=cond_dim,
236
+ dropout=dropout,
237
+ )
238
+ )
239
+
240
+ self.downsample = nn.Conv2d(
241
+ output_channels, output_channels, kernel_size=3, stride=2, padding=1
242
+ )
243
+
244
+ def forward(self, x: torch.Tensor, cond: torch.Tensor):
245
+
246
+ if self.downsample_first:
247
+ x = self.downsample(x)
248
+
249
+ for block in self.blocks:
250
+ x = block(x, cond)
251
+ skip = x
252
+
253
+ if not self.downsample_first:
254
+ x = self.downsample(x)
255
+
256
+ return x, skip
257
+
258
+
259
+ class UpStage(nn.Module):
260
+ def __init__(
261
+ self,
262
+ input_channels: int,
263
+ skip_channels: int,
264
+ output_channels: int,
265
+ cond_dim: int = 256,
266
+ dropout: float = 0.0,
267
+ num_blocks: int = 1,
268
+ ):
269
+ super().__init__()
270
+
271
+ self.upsample = nn.Upsample(
272
+ scale_factor=2, mode="bilinear", align_corners=False
273
+ )
274
+
275
+ self.blocks = nn.ModuleList()
276
+ for i in range(num_blocks):
277
+ in_ch = (input_channels + skip_channels) if i == 0 else output_channels
278
+ self.blocks.append(
279
+ ConditionedResidualBlock(
280
+ input_channels=in_ch,
281
+ output_channels=output_channels,
282
+ cond_dim=cond_dim,
283
+ dropout=dropout,
284
+ )
285
+ )
286
+
287
+ def forward(
288
+ self, x: torch.Tensor, skip: torch.Tensor, cond: torch.Tensor
289
+ ) -> torch.Tensor:
290
+ x = self.upsample(x)
291
+
292
+ if x.shape[-2:] != skip.shape[-2:]:
293
+ x = F.interpolate(
294
+ x, size=skip.shape[-2:], mode="bilinear", align_corners=False
295
+ )
296
+
297
+ if torch.is_grad_enabled() or not self._can_split(x, skip):
298
+ x = torch.cat([x, skip], dim=1)
299
+ for block in self.blocks:
300
+ x = block(x, cond)
301
+ return x
302
+
303
+ if _use_chunked(x):
304
+ # Hand the tensors over in a list so this frame stops pinning
305
+ # them — the method frees each one as soon as it is consumed.
306
+ tensors = [x, skip]
307
+ del x, skip
308
+ x = self._split_concat_block0_chunked(tensors, cond)
309
+ else:
310
+ x = self._split_concat_block0(x, skip, cond)
311
+ for block in self.blocks[1:]:
312
+ x = block(x, cond)
313
+ return x
314
+
315
+ def _can_split(self, x: torch.Tensor, skip: torch.Tensor) -> bool:
316
+ b0 = self.blocks[0]
317
+ ca, cb = x.shape[1], skip.shape[1]
318
+ groups = b0.norm1.num_groups
319
+ ga = groups * ca // (ca + cb)
320
+ return (
321
+ isinstance(b0.skip, nn.Conv2d)
322
+ and groups * ca % (ca + cb) == 0
323
+ and 0 < ga < groups
324
+ and ca % ga == 0
325
+ and cb % (groups - ga) == 0
326
+ )
327
+
328
+ def _split_concat_block0(
329
+ self, x: torch.Tensor, skip: torch.Tensor, cond: torch.Tensor
330
+ ) -> torch.Tensor:
331
+ """Run blocks[0] without materializing the (ca+cb)-channel concat.
332
+
333
+ Valid when the GroupNorm group boundaries align with the concat
334
+ boundary (checked by _can_split): GN/SiLU apply per half, and the
335
+ skip-conv/conv1 outputs are the sums of per-half convolutions. Only
336
+ the fp reduction order differs from the concat path — this is the
337
+ peak-memory point of the whole UNet at large latents (the concat is
338
+ full-res at double width), worth ~1.1GiB at an 8K-UHD output.
339
+ Inference-only (in-place ops), guarded by the grad check in forward.
340
+ """
341
+ b0 = self.blocks[0]
342
+ ca = x.shape[1]
343
+ cb = skip.shape[1]
344
+ n1 = b0.norm1
345
+ ga = n1.num_groups * ca // (ca + cb)
346
+
347
+ w = b0.skip.weight
348
+ residual = F.conv2d(x, w[:, :ca]).add_(F.conv2d(skip, w[:, ca:]))
349
+
350
+ h = F.silu(
351
+ F.group_norm(x, ga, n1.weight[:ca], n1.bias[:ca], n1.eps), inplace=True
352
+ )
353
+ del x
354
+ w1 = b0.conv1.weight
355
+ out = F.conv2d(h, w1[:, :ca], b0.conv1.bias, padding=b0.conv1.padding)
356
+ del h
357
+ h = F.silu(
358
+ F.group_norm(
359
+ skip, n1.num_groups - ga, n1.weight[ca:], n1.bias[ca:], n1.eps
360
+ ),
361
+ inplace=True,
362
+ )
363
+ del skip
364
+ out.add_(F.conv2d(h, w1[:, ca:], padding=b0.conv1.padding))
365
+ del h
366
+
367
+ scale_shift = b0.cond_proj(cond)
368
+ scale, shift = scale_shift.chunk(2, dim=1)
369
+ out = b0.norm2(out)
370
+ out.mul_(1 + scale[:, :, None, None]).add_(shift[:, :, None, None])
371
+ out = F.silu(out, inplace=True)
372
+ out = b0.conv2(b0.dropout(out))
373
+ return out.add_(residual)
374
+
375
+ def _split_concat_block0_chunked(
376
+ self, tensors: list, cond: torch.Tensor
377
+ ) -> torch.Tensor:
378
+ """_split_concat_block0 with chunked convs and native per-half GN;
379
+ conv2 accumulates into the residual buffer. Output is bit-identical
380
+ to _split_concat_block0.
381
+
382
+ ``tensors`` is [x, skip]; it is emptied so each half can be freed the
383
+ moment its contributions are computed.
384
+ """
385
+ b0 = self.blocks[0]
386
+ x, skip = tensors
387
+ tensors.clear()
388
+ ca = x.shape[1]
389
+ n1 = b0.norm1
390
+ ga = n1.num_groups * ca // (ca + skip.shape[1])
391
+
392
+ w = b0.skip.weight
393
+ residual = chunked_conv(x, w[:, :ca])
394
+ chunked_conv(skip, w[:, ca:], out=residual, accumulate=True)
395
+
396
+ h = F.group_norm(x, ga, n1.weight[:ca], n1.bias[:ca], n1.eps)
397
+ del x
398
+ F.silu(h, inplace=True)
399
+ w1 = b0.conv1.weight
400
+ out = chunked_conv(h, w1[:, :ca], b0.conv1.bias)
401
+ del h
402
+ h = F.group_norm(skip, n1.num_groups - ga, n1.weight[ca:], n1.bias[ca:], n1.eps)
403
+ del skip
404
+ F.silu(h, inplace=True)
405
+ chunked_conv(h, w1[:, ca:], out=out, accumulate=True)
406
+ del h
407
+
408
+ scale_shift = b0.cond_proj(cond)
409
+ scale, shift = scale_shift.chunk(2, dim=1)
410
+ h = F.group_norm(out, b0.norm2.num_groups, b0.norm2.weight, b0.norm2.bias, b0.norm2.eps)
411
+ del out
412
+ h.mul_(1 + scale[:, :, None, None]).add_(shift[:, :, None, None])
413
+ F.silu(h, inplace=True)
414
+ return chunked_conv(h, b0.conv2.weight, b0.conv2.bias, out=residual, accumulate=True)
415
+
416
+
417
+ class LowResEncoder(nn.Module):
418
+ def __init__(
419
+ self,
420
+ sample_channels: int = 32,
421
+ base_channels: int = 128,
422
+ cond_dim: int = 1024,
423
+ dropout: float = 0.0,
424
+ ):
425
+ super().__init__()
426
+
427
+ self.in_conv = nn.Conv2d(
428
+ sample_channels, base_channels, kernel_size=1, padding=0
429
+ )
430
+
431
+ self.block_1 = ConditionedResidualBlock(
432
+ input_channels=base_channels,
433
+ output_channels=base_channels,
434
+ cond_dim=cond_dim,
435
+ dropout=dropout,
436
+ )
437
+
438
+ self.block_2 = DownStage(
439
+ input_channels=base_channels,
440
+ output_channels=base_channels,
441
+ cond_dim=cond_dim,
442
+ dropout=dropout,
443
+ num_blocks=1,
444
+ downsample_first=True,
445
+ )
446
+
447
+ self.block_3 = DownStage(
448
+ input_channels=base_channels,
449
+ output_channels=base_channels,
450
+ cond_dim=cond_dim,
451
+ dropout=dropout,
452
+ num_blocks=1,
453
+ downsample_first=True,
454
+ )
455
+
456
+ def forward(self, latents_small, cond):
457
+ x = self.in_conv(latents_small)
458
+ block_1_out = self.block_1(x, cond)
459
+ block_2_out, _ = self.block_2(block_1_out, cond)
460
+ block_3_out, _ = self.block_3(block_2_out, cond)
461
+
462
+ return block_1_out, block_2_out, block_3_out
463
+
464
+
465
+ class FilmCond2D(nn.Module):
466
+ def __init__(self, base_channels: int = 256, cond_channels: int = 256):
467
+ super().__init__()
468
+
469
+ self.cond_proj = nn.Sequential(
470
+ nn.SiLU(),
471
+ nn.Conv2d(cond_channels, base_channels * 2, kernel_size=1),
472
+ )
473
+
474
+ def forward(self, x, cond):
475
+ if _use_chunked(x):
476
+ return chunked_film_(self, x, cond)
477
+
478
+ scale_shift = self.cond_proj(cond)
479
+ scale, shift = scale_shift.chunk(2, dim=1)
480
+
481
+ if torch.is_grad_enabled():
482
+ return x * (1 + scale) + shift
483
+ # Callers rebind x, so modifying it in place at inference is safe and
484
+ # avoids two full-res temporaries.
485
+ return x.mul_(1 + scale).add_(shift)
486
+
487
+
488
+ class UpscalerUNet(nn.Module):
489
+ def __init__(
490
+ self,
491
+ sample_channels: int = 32,
492
+ base_channels: int = 384,
493
+ time_dim: int = 512,
494
+ cond_dim: int = 1024,
495
+ dropout: float = 0.01,
496
+ ):
497
+ super().__init__()
498
+
499
+ self.conditioning = ConditioningEncoder(
500
+ time_dim=time_dim,
501
+ cond_dim=cond_dim,
502
+ )
503
+
504
+ self.in_conv = nn.Conv2d(
505
+ sample_channels, base_channels, kernel_size=1, padding=0
506
+ )
507
+
508
+ self.low_res_encoder = LowResEncoder(base_channels=base_channels)
509
+
510
+ self.film_cond_1 = FilmCond2D(
511
+ base_channels=base_channels, cond_channels=base_channels
512
+ )
513
+ self.film_cond_2 = FilmCond2D(
514
+ base_channels=base_channels, cond_channels=base_channels
515
+ )
516
+ self.film_cond_3 = FilmCond2D(
517
+ base_channels=base_channels, cond_channels=base_channels
518
+ )
519
+
520
+ self.down_stages = nn.ModuleList(
521
+ [
522
+ DownStage(
523
+ input_channels=base_channels,
524
+ output_channels=base_channels,
525
+ cond_dim=cond_dim,
526
+ dropout=dropout,
527
+ num_blocks=3,
528
+ ),
529
+ DownStage(
530
+ input_channels=base_channels,
531
+ output_channels=base_channels,
532
+ cond_dim=cond_dim,
533
+ dropout=dropout,
534
+ num_blocks=2,
535
+ ),
536
+ ]
537
+ )
538
+
539
+ self.mid_stages = nn.ModuleList(
540
+ [
541
+ ConditionedResidualBlock(
542
+ input_channels=base_channels,
543
+ output_channels=base_channels,
544
+ cond_dim=cond_dim,
545
+ dropout=dropout,
546
+ )
547
+ for i in range(1)
548
+ ]
549
+ )
550
+
551
+ self.up_stages = nn.ModuleList(
552
+ [
553
+ UpStage(
554
+ input_channels=base_channels,
555
+ skip_channels=base_channels,
556
+ output_channels=base_channels,
557
+ cond_dim=cond_dim,
558
+ dropout=dropout,
559
+ num_blocks=2,
560
+ ),
561
+ UpStage(
562
+ input_channels=base_channels,
563
+ skip_channels=base_channels,
564
+ output_channels=base_channels,
565
+ cond_dim=cond_dim,
566
+ dropout=dropout,
567
+ num_blocks=3,
568
+ ),
569
+ ]
570
+ )
571
+
572
+ self.out_conv = nn.Conv2d(
573
+ base_channels, sample_channels, kernel_size=1, padding=0
574
+ )
575
+
576
+ def forward(
577
+ self, sample: torch.Tensor, timestep: torch.Tensor, latents_small: torch.Tensor
578
+ ) -> torch.Tensor:
579
+ cond = self.conditioning(timestep)
580
+
581
+ B, C, H, W = sample.shape
582
+
583
+ lr_cond_1, lr_cond_2, lr_cond_3 = self.low_res_encoder(latents_small, cond)
584
+
585
+ lr_cond_1 = torch.nn.functional.interpolate(lr_cond_1, (H, W), mode="bilinear")
586
+ lr_cond_2 = torch.nn.functional.interpolate(
587
+ lr_cond_2, (H // 2, W // 2), mode="bilinear"
588
+ )
589
+ lr_cond_3 = torch.nn.functional.interpolate(
590
+ lr_cond_3, (H // 4, W // 4), mode="bilinear"
591
+ )
592
+
593
+ x = self.in_conv(sample)
594
+ x = self.film_cond_1(x, lr_cond_1)
595
+ # Each lr_cond is dead after its film layer; without the dels the
596
+ # full-res lr_cond_1 (~0.4GiB at an 8K-UHD latent) stays pinned for
597
+ # the whole forward.
598
+ del lr_cond_1
599
+
600
+ skips = []
601
+
602
+ x, skip = self.down_stages[0](x, cond)
603
+ skips.append(skip)
604
+
605
+ x = self.film_cond_2(x, lr_cond_2)
606
+ del lr_cond_2
607
+
608
+ x, skip = self.down_stages[1](x, cond)
609
+ skips.append(skip)
610
+
611
+ x = self.film_cond_3(x, lr_cond_3)
612
+ del lr_cond_3
613
+
614
+ for mid in self.mid_stages:
615
+ x = mid(x, cond)
616
+
617
+ for up in self.up_stages:
618
+ x = up(x, skips.pop(), cond)
619
+
620
+ x = self.out_conv(x)
621
+ return x