Image-to-Image
Diffusers
Safetensors
PyTorch
flux2
flux
image-upscaling
latent-upscaling
super-resolution
diffusion
flow-matching
rectified-flow
generative-models
image-generation
Instructions to use MinhNH232331M/FlowUpscaler-diffusers with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use MinhNH232331M/FlowUpscaler-diffusers with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline from diffusers.utils import load_image # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("MinhNH232331M/FlowUpscaler-diffusers", torch_dtype=torch.bfloat16, device_map="cuda") prompt = "Turn this cat into a dog" input_image = load_image("https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png") image = pipe(image=input_image, prompt=prompt).images[0] - Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse files- LICENSE +24 -0
- README.md +246 -0
- config.json +3 -0
- flow_upscaler.safetensors +3 -0
- flow_upscaler_pipeline.py +350 -0
- 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 |
+

|
| 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 |
+

|
| 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 |
+

|
| 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
|