fix(flux2): img2img posterization - noise scaling and sigma schedule - #9556
Draft
Pfannkuchensack wants to merge 3 commits into
Draft
fix(flux2): img2img posterization - noise scaling and sigma schedule#9556Pfannkuchensack wants to merge 3 commits into
Pfannkuchensack wants to merge 3 commits into
Conversation
The FLUX.2 transformer operates on BN-normalized latents, while the VAE
encode node emits raw ones. The img2img/inpainting preblend was computed
in raw space and BN-normalized afterwards:
x = t_0 * noise + (1 - t_0) * init_latents
x = (x - bn_mean) / bn_std
which expands to t_0 * (noise - bn_mean) / bn_std + (1 - t_0) * norm(init)
-- the noise term is divided by bn_std as well. Measured on the BFL FLUX.2
VAE, bn.running_var is 2.91..3.47 across all 128 channels, so bn_std ~ 1.77
and the start latents carried only ~57% of the noise their timestep implies.
The model then over-denoises, eats real image detail and flattens it into
posterized patches, progressively worse at higher denoise strengths.
The noise fed to RectifiedFlowInpaintExtension was normalized the same way,
so inpainting and outpainting were affected too.
Build the start latents from the normalized operands instead: normalize
only init_latents, leave the noise at N(0, 1) -- which is exactly the
distribution the transformer expects in normalized space. pack_flux2 is a
pure rearrange, so blending before or after packing is equivalent.
txt2img is unaffected (no init latents, so nothing was normalized), and so
is the reference-image path, which normalizes plain VAE latents with no
noise mixed in.
Closes invoke-ai#8964
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pfannkuchensack
requested review from
JPPhoto,
blessedcoolant,
dunkeroni and
lstein
as code owners
August 29, 2026 06:37
Pfannkuchensack
marked this pull request as draft
August 29, 2026 07:01
get_schedule_flux2() returns an unshifted linear schedule because the
FlowMatchEulerDiscreteScheduler used for txt2img applies the exponential
shift from mu itself. img2img and inpainting bypass that scheduler and step
the schedule manually, so they ran the model on a completely different sigma
trajectory. With 9 steps:
txt2img: 1.0 0.984 0.964 0.938 0.904 0.858 0.790 0.683 0.485 -> 0
img2img: 1.0 0.889 0.778 0.667 0.556 0.444 0.333 0.222 0.111 -> 0
txt2img never evaluates the model below sigma 0.485 -- the final step jumps
from there straight to 0. img2img called it at 0.44, 0.33, 0.22 and 0.11,
outside the range a distilled model such as FLUX.2 Klein is trained for, and
it leaves a grainy residue there.
This was invisible before the preceding commit: the img2img start latents
carried only ~57% of their nominal noise, so there was little left to remove
and the residue never showed. With the noise at full scale it does.
Apply the same shift in the manual path, before clipping, so that
denoising_start/end select a fraction of the schedule the model is actually
run on. New time_shift_flux2() mirrors diffusers'
_time_shift_exponential(mu, 1.0, t), which is how the scheduler invokes it;
a test pins the two against each other across step counts.
txt2img is untouched: the shift is applied only when the manual Euler path
is taken, which is exactly when the scheduler is not used. img2img at
strength 1.0 remains pixel-identical to txt2img with the same seed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
fix — FLUX.2 img2img and inpainting posterize photographic detail into flat patches, progressively worse the higher the denoise strength. Two bugs in the same code path, the second masked by the first.
1. The img2img noise was divided by
bn_stdThe FLUX.2 transformer operates on BN-normalized latents, while
flux2_vae_encodeemits raw ones.Flux2DenoiseInvocationcomputed the rectified-flow preblend in raw space and BN-normalized the result afterwards:Expanded, that is
t_0 * (noise - bn_mean) / bn_std + (1 - t_0) * normalize(init_latents)— the noise term gets divided bybn_stdtoo. Measured on the BFL FLUX.2 VAE,bn.running_varis 2.91…3.47 uniformly across all 128 packed channels, sobn_std ≈ 1.77and1 / bn_std ≈ 0.566: the start latents carried only ~57 % of the noise their timestep claims. The model is handed timestept_0but a sample noised to roughly0.57 · t_0, so it over-denoises, eats real image detail and flattens it into posterized patches. The absolute error scales witht_0, which is exactly the reported "the greater the level of denoise, the more posterization occurs".The noise handed to
RectifiedFlowInpaintExtensionwas normalized the same way, so inpainting and outpainting were affected as well.Fix:
_prepare_normalized_start_latents()builds the start latents from the normalized operands instead of normalizing the blended tensor. Onlyinit_latentsare normalized; the noise stays at N(0, 1), which is precisely the distribution the transformer expects in normalized space.pack_flux2is a purerearrange, so blending before or after packing is equivalent.2. img2img ran an unshifted sigma schedule
get_schedule_flux2()returns an unshifted linear schedule, because theFlowMatchEulerDiscreteSchedulerused for txt2img applies the exponential shift frommuitself. img2img and inpainting bypass that scheduler and step the schedule manually, so they ran the model on a different sigma trajectory entirely. With 9 steps:txt2img never evaluates the model below sigma 0.485 — the final step jumps from there straight to 0. img2img called it at 0.44, 0.33, 0.22 and 0.11, outside the range a distilled model like FLUX.2 Klein is trained for, and it leaves a grainy residue there.
This was invisible while bug 1 was present: with only ~57 % of the nominal noise there was little left to remove, so the residue never showed. Fixing bug 1 alone therefore trades posterization for graininess — the two fixes belong together.
Fix: apply the same shift in the manual path, before clipping, so
denoising_start/denoising_endselect a fraction of the schedule the model is actually run on. The newtime_shift_flux2()mirrors diffusers'_time_shift_exponential(mu, 1.0, t), which is how the scheduler invokes it; a test pins the two against each other across step counts.Not affected
Flux2RefImageExtensionnormalizes plain VAE latents with no noise mixed in, which is correct. This matches the issue thread, where the reporter notes reference images look fine and only canvas img2img posterizes.Related Issues / Discussions
Closes #8964
QA Instructions
Automated
15 tests. The normalization ones cover: the noise term keeps unit scale at
t_0∈ {0.2, 0.5, 0.85, 1.0}; the result provably differs from normalizing the raw mixture by exactlyt_0 * (noise - normalize(noise)); init latents are normalized; theadd_noise=Falsepath; the no-BN-stats fallback. The schedule ones pintime_shift_flux2()against a realFlowMatchEulerDiscreteSchedulerat 4/9/20/30 steps, plus endpoints, monotonicity, and the schedule floor.Measured end-to-end
FLUX.2 Klein 9B fp8, seed 424242, 9 steps, euler, 1024×1024, one txt2img base image re-run through img2img at four strengths on
mainand on this branch.Endpoint test. At strength 1.0,
denoising_start = 0, sox = 1·noise + 0·init— the init image drops out mathematically and img2img must reproduce txt2img exactly at the same seed:mainDetail and tonality. HF energy is Laplacian variance over the frame; "sky levels" counts distinct 8-bit grey levels in a fixed 160×280 smooth-sky patch — the direct posterization measure.
main/ branchmain/ branchmain/ branchmain/ branchOn
main, detail energy falls below the source image from strength 0.75 up, and the distinct tone count in the sky collapses monotonically (61 → 43 → 8). At strength 1.0 the sky is an 8-level flat field. On this branch the counts stay near or above the reference at every strength.Canvas path, at the strengths users actually pick
The runs above drive the denoise node directly. The canvas adds two things: it composites the visible raster layers, and with Scale Before Processing it resizes the composite down before the VAE encode and back up after the decode. It also maps the UI strength non-linearly — for FLUX/FLUX.2 with Optimized Denoising (on by default):
So the reporter's "denoise around 5+" is
denoising_start ≈ 0.13, not 0.5 — a far more aggressive setting than a linear reading suggests, and squarely where this bug is worst.Re-run through a graph mirroring
addImageToImage()(1536×1536 raster layer, Scale Before Processing to 1024, resized back), at the UI strengths a user would actually pick:ds0.214) —main/ branchds0.129) —main/ branchds0.056) —main/ branchOn
main, every canvas result loses roughly 40 % of the source's detail energy and the distinct tone count in the sky falls monotonically to 27 — below the source's 50 at every strength. On this branch the tone count stays at or above the source and detail energy tracks it. Visually: onmainthe sky loses all cloud structure and the knit of the sweater flattens into grey; on the branch both survive.Scheduler coverage
All three FLUX.2 schedulers were run on
mainand on this branch, txt2img and img2img (strength 0.5), same seed:mainvs branchmainvs branchmainvs branchtxt2img is untouched for euler and heun, as intended. LCM differs — but two runs of the same code differ by the same margin (L1 43.5 vs 48.9 for
mainvs branch), soFlowMatchLCMScheduleris simply not reproducible from a seed here. That is pre-existing and unrelated to this PR; it may deserve an issue of its own.The img2img rows are bit-identical across schedulers because the scheduler is only constructed when the manual Euler path is not taken — so for img2img and inpainting the scheduler dropdown has no effect at all and Euler is always used. Also pre-existing, and worth noting here because it is what makes the exponential/
mushift the correct one to apply on that path: it is Euler's own shift, and Euler is what actually runs.Manual
mainthe artifact worsens with strength; it should not here.Merge Plan
Two commits, best kept separate: the first is the correctness fix, the second removes the graininess it exposes. Shipping only the first would trade one artifact for another. Backend only, no schema or frontend impact.
Note this changes img2img/inpaint sigma schedules for all FLUX.2 models, [dev] included. That is the intended direction — txt2img already shifts for both — but it is a visible behaviour change. Verified end-to-end on Klein 9B; [dev] has not been run.
Checklist
What's Newcopy (if doing a release after this PR)🤖 Generated with Claude Code