HDR Per-Channel Norm
The 20-line fix for banded HDR latents
- image
- image_norm
- stats_json
A VAE is a statistical object. It was trained on images whose channels sit in a fairly narrow, fairly symmetric range, and it encodes happily when your data looks like that. Feed it a golden-hour plate, a fire, or a neon sign - scene-linear values with a huge channel spread and a strong cast - and the latent distribution gets shoved out where the encoder has no idea what to do with it. What you see on the way back is colour banding and mud in exactly the bright, saturated places you cared about.
HDR Per-Channel Norm is the small node that stops that. It is a straight port of LTX-Video's vae_per_channel_normalize=True behaviour, exposed so you can use it with any model instead of only inside an LTX graph.
How it works
It measures one mean and one standard deviation per channel across the whole batch, then normalises:
x_norm = (x - mean) / std
x_vae = clamp((x_norm + norm_center) / (2 * norm_center), 0, 1)
That second line is the whole trick. Z-scoring alone gives you values centred on zero with negatives and values past 1, which is not what the VAE wants. Rescaling the ±norm_center sigma window into [0, 1] gives you something the encoder accepts as an ordinary image, with the extremes clipped rather than squashed. The output is genuinely clamped: norm_center=3 means anything beyond three sigma is cut off. If your plate has one tiny blown specular that dominates the statistics, that clipping is where the damage happens - which is why the node hands you the stats on the way out.
Inputs and outputs
Two things to set:
- image - the HDR batch, usually scene-linear. The statistics are measured once per channel over the entire batch, not per frame, so a batch with one wildly different frame drags the window for all of them.
- norm_center - the half-width in sigma mapped to
[0, 1]. Default 3.0 covers ±3σ, which is right for most footage. Raise it toward 8 if you have legitimate extreme outliers you don't want clipped and the latent can take the compression.
Two outputs: image_norm, which goes into your VAE Encode (or directly into the legacy HDR Turbo Encoder, which is what the source comments suggest), and stats_json, a plain string holding the per-channel mean, std and norm_center.
That string is not decoration. Radiance ships a matching de-normaliser, HDR Per-Channel Denorm, whose stats_json input is a forced wire; it inverts the maths after decode to recover scene-linear values. If you skip storing the stats you have a normalised image and no way home. Save it, or wire it, but don't bin it.
The practical ordering: HDR image → Per-Channel Norm → VAE Encode (HDR) with the SDR-safe path, or → Decode → Denorm if you're going round the houses manually. For most modern work you can skip this node entirely by using VAE Encode (HDR) with hdr_mode on Compress (Log), which handles the HDR coding internally and inverts it exactly on decode. Reach for Per-Channel Norm when you're on a plain VAE and hitting cast-driven banding.
Installing
ComfyUI Manager, search Radiance, install, restart ComfyUI, refresh the browser. Manually:
cd ComfyUI/custom_nodes
git clone https://github.com/fxtd-studios/radiance.git
cd radiance
python -m pip install -r requirements.txt
Windows portable users install with python_embeded\python.exe. The requirements are heavier than most packs - OpenEXR, OpenImageIO, OpenColorIO, diffusers, accelerate - and they land in ComfyUI's own Python environment. Radiance is a "kitchen sink" pack: 147 visible nodes, GPL-3.0, by FXTD Studios, and it installs a viewer and a project manager alongside the colour tools. If you only want HDR plumbing, you are still getting all of it, and all of its dependencies.
Troubleshooting
The output looks flat and grey. You fed it something that was already normalised, or your plate's blacks are negative. This node expects scene-linear with a sensible black point. Run an exposure/black-level pass first.
Everything highlights clipped to white. Drop norm_center and check the stats in the console log (it prints mean and std at info level). One hot pixel can set a std that eats your whole dynamic range.
Banding, still. Check you're feeding the SDR path a decoded image before denormalising, and that stats_json came from the same run. Stats from an earlier image will "invert" to nonsense, quietly - the numbers are all valid JSON, they're just the wrong numbers.
The honest caveat: this is a statistical convenience for a VAE that was never trained on HDR. It reduces one class of artifact; it doesn't make the latent HDR-aware. If that's what you want, VAE Encode (HDR) is the node that actually was built for it.
Inputs (2)
| Name | Type | Default | Description |
|---|---|---|---|
| image | IMAGE | HDR image or batch, usually scene-linear. One mean and std per channel is measured over the whole batch; values beyond +/- norm_center std are clipped in the output. | |
| norm_center | FLOAT | 3.01–8 | Window half-width in σ units mapped to [0,1]. 3.0 captures ±3σ. |
Outputs (2)
| Name | Type | Description |
|---|---|---|
| image_norm | IMAGE | — |
| stats_json | STRING | — |