~/satyajit

Qwen-Image-2.1 Outpaint LoRA: 1.12% of the weights, trained to leave the picture where it was

mdjsonmcp

2026-09-27 · 21 min · image-generation · diffusion · qwen · lora · fine-tuning · open-weights · explainer

Qwen-Image-2.1 has no outpainting mode. It is an instruction editor: you give it a picture and a sentence, and it draws a new picture. On 26 September ausboss published a LoRA that turns it into an outpainter, and a repost summed it up as "extends images any direction - keeps 45-93% canvas - targets 1-2 MP".

The recipe on the card is short. Pad the picture with flat gray #808080 to a canvas whose sides are multiples of 32, give the canvas to Qwen-Image-2.1 as the reference image, and start the prompt with the trigger:

Outpaint the image: replace the solid gray areas with a seamless continuation of the scene, keeping the existing picture unchanged.

The card's claim is narrower than "outpainting works". Without the LoRA, it says, Qwen 2.1 "often reframes or rescales the picture … or returns a gray frame untouched. With it, the known picture stays pixel-registered, which is what makes a clean stitch possible." This piece is about that word, registered: why an editor moves the picture at all, what 1.12% of its weights change so that it stops, and what the repost's numbers mean. I read the card, all four checkpoints' headers over HTTP range requests, and the diffusers and stable-diffusion.cpp code, then ran the LoRA on the four-core box that builds this site.

ausboss/Qwen-Image-2.1-Outpaint-LoRA@449336d · snapshot 2026-09-27
repo size
641.8 MB
task
image-to-image
license
other
safetensors
4 shards
largest file
159.4 MB
files
8
downloads
0
likes
24
loraoutpaintinguncropqwen-imageqwen-image-2.1comfyuiai-toolkit

Measured from the headers: v1 and v2 are both 384 BF16 tensors, rank 32 on all six linear projections of all 32 blocks, 79,691,776 parameters each (1.12% of the 7.1B denoiser). No alpha tensor; the card gives alpha 32 = rank, so every loader's default scale of 1 is the right one. The quality numbers are the author's own renders on 10 to 12 pictures. Qwen Research License, non-commercial, inherited from the base model.

repo last modified 2026-09-27

How Qwen-Image-2.1 edits

The release piece covers the architecture. Four facts from it decide how outpainting behaves.

The reference is a prefix, not an input channel. The denoiser is one stream of 32 blocks. Qwen3-VL encodes the prompt, sees the reference and reserves slots for it in the text; the reference's VAE latents are substituted into those slots, and the target's noisy latents are appended at the end. patch_size is 1 on a 16x VAE, so an H x W image is exactly (H/16) x (W/16) tokens, and each vision slot stands for 2 x 2 of them: diffusers names the constant _IMG_TOKENS_PER_SLOT = 4 (measured from the configs and code).

The mask is block-causal. Text is causal, each image block is bidirectional inside itself, and a later block sees every earlier one. The target attends to the reference; the reference never attends to the target.

An attention mask drawn as a grid of query rows against key columns, divided into four segments: system prefix, optional input image, edit instruction, target image. The text segments are causal staircases; the image segments are solid blocks, bidirectional inside themselves. The target image rows see every column.
The mixed-granularity mask. For outpainting, the padded canvas is the input image and the output is the target image, which reads the canvas but is never read by it (Qwen-Image-2.1 release, architecture figure).

The reference is clean. With causal_condition: true, text and condition-image tokens are modulated from t = 0, not from the sampled noise level. The padded canvas is a finished picture at every step.

The grids line up only if the sizes match. Rotary positions have three axes, and in diffusers' words every image block "freezes the frame axis at the position reached by the preceding text and lays its tokens out on a height/width grid centred on zero". So if the reference and the target are the same size, reference token (h, w) and target token (h, w) carry the same height and width rotations and differ only on the frame axis, 16 of each head's 128 rotary dimensions. stable-diffusion.cpp builds the same centred grid (measured from both sources).

So the editor has a positional path to "the same pixel in the reference" in every layer (reasoned). What it does not have is anything that forces it to use that path. There is no mask channel saying which pixels are known, and no sampler step that overwrites them. The target starts as pure noise over the whole canvas (randn_tensor in prepare_latents), and the model paints all of it. Keeping the picture in place is a learned behaviour, learned from edit data Qwen has not published, and an edit may change the framing. A picture on a flat gray field is ambiguous as well: it could be a print on a mat, and recomposing it is a defensible reading (reasoned). The card's failures follow: a fisheye kitchen "shrank inside its frame", a street scene "was recomposed", and some runs returned the gray frame untouched.

#808080 is not an arbitrary gray. Pixels are normalised to -1 to 1, and 128 maps to 128/127.5 − 1 ≈ 0.004, as close to "no signal" as an opaque 8-bit pixel gets (reasoned). It must be opaque: stable-diffusion.cpp composites any alpha onto white before the vision encoder sees the reference (measured from conditioner.hpp), so a transparent pad arrives as white.

What "pixel-registered" buys

Registered means a pixel of the kept picture comes out at the coordinates it went in at: not shifted, not rescaled, not redrawn nearby.

It matters because nobody keeps the model's version of the known region. A VAE round trip is lossy, so every workflow on the card ends by pasting the exact original back over the kept rectangle (AusBoss's Stitch Inpaint node, over a 32 px feather). The paste is invisible only if the model's frame agrees with the original along the border. If the model moved the scene by d and scaled it by s about the centre c, a kept point p comes back at c + s(p − c) + d, and the disagreement at the border is

∥(s−1)(p−c)+d∥\lVert (s - 1)(p - c) + d \rVert

A shift costs the same everywhere. A rescale costs nothing at the centre and most at the corners, which is where the seam is. A feather blends intensities; it cannot move content, so a misaligned line becomes two ghosted copies across the ramp.

worst seam offset6.7 pxat the centre6.7 px
A toy, not a render. The dashed box is where the exact original pixels go back. Outside it is the model's frame; if that frame moved the scene, every line that crosses the box breaks. The offset is (s − 1)(p − c) + d, linear in position, so a shift costs the same everywhere and a rescale costs most at the corners farthest from the centre. A feathered paste-back blends two copies across the ramp; it cannot move either one, so a broken line becomes a ghosted one.

The card's comparison figure shows each failure in one row; two of its three rows are below.

Four panels of a man sitting on a wooden fence above a coastline. The padded input shows the kept photo in the upper middle of a tall gray canvas. The no-LoRA result has a lighter rectangle around the kept region where the sky and sea change tone at its border. The v1 and v2 results continue the horizon, the water and the fence through the edge with no visible box.
Coastline: without the LoRA the new sky and sea come out a different tone and scale, so the pasted-back original shows as a box. Rendered in ComfyUI on the INT8 base, 25 steps, CFG 1, seed 42 (Qwen-Image-2.1 Outpaint LoRA model card, comparison.jpg).
Four panels of a woman in a dark jacket bending over driftwood. The padded input keeps about a fifth of a tall canvas, in the upper right. The no-LoRA result rescales and redraws her larger, so the pasted-back original sits beside a second head and jacket, with a rectangular edge in the sky. The v1 and v2 results keep her in place and extend the driftwood and a blurred grassy foreground.
Driftwood: without the LoRA, Qwen rescales and redraws the subject and the paste-back produces a second person. About a fifth of the picture was kept, below v2's training range, and v2 still holds it (Qwen-Image-2.1 Outpaint LoRA model card, comparison.jpg).

The tilt test is the cleanest evidence, because a rotated picture leaves diagonal gray wedges that neither adapter trained on. In 12 renders per column, the base moved the picture by more than 2 px near the tilted edges in 12, and each adapter in 0; the largest shift was 95 px for the base and 0 px with either LoRA (reported).

Four panels of a pier on a misty lake at sunrise. The input is the photo rotated 17 degrees inside a gray canvas, leaving gray wedges in all four corners. The no-LoRA result has a mountain cut off along the tilted edge and a hard step in the mist on the right. The v1 and v2 results continue the mountains, mist and sky through the corners with no visible edge.
Tilted pier: the picture rotated 17 degrees, pasted back after the fill. Without the LoRA the scene shifts and the mountain is cut along the tilt; both adapters keep it in place (Qwen-Image-2.1 Outpaint LoRA model card, rotation.jpg).

In PSNR on the kept area against the original, the card reports 16.4 dB without the LoRA and 34.7 dB with v2 on ten "subtle crops", and 25.5 dB against 34.0 dB on eight "big crops". In 8-bit terms 34.7 dB is an RMS error of about 4.7 levels out of 255, and 16.4 dB about 38.6 (reasoned): a small error spread over the picture, against a different picture. Why the base does worse on the subtle crops, the card does not say, and I cannot either.

What "45-93% canvas" measures

The repost's middle number is v2's training distribution: "the kept picture covers 45-93 % of the canvas (median ~74 %)", by area. It is what v2 saw, not a guarantee about outputs.

Area share converts to padding two ways. Framed evenly on every side, the canvas is z times the picture in each direction and the share is 1/z². Extended on one side only, by a fraction e of the width, the share is 1/(1 + e).

Kept shareEven frame, zoom zOne side only, e
93% (v2's smallest extension)1.04x7.5%
74% (v2's median)1.16x35%
45% (v2's largest)1.49x122%
30% to 12% (v1's small windows)1.83x to 2.89x233% to 733%
under ~15% (the card's limit)over 2.58xover 567%

Reasoned from the card's shares. v2 is a modest-extension model whose median case is a 1.16x zoom-out; v1 "saw more extreme zoom-outs". Under ~15% kept, the card says, fills "invent a lot".

The multiple of 32 is architecture, not taste. The VAE compresses 16x and a vision slot is 2 x 2 latent tokens, so 32 px is the smallest step that keeps both grids whole. diffusers rounds every side to the nearest 32 (multiple_of = vae_scale_factor * 2), and stable-diffusion.cpp refuses a reference that is not a multiple of 32 (measured from both). Padding to the grid yourself means nothing resamples your picture on the way in. For the same reason the reference must go in at the size the output comes out; the card's ComfyUI recipe sets resolution 0, "reference and output share the canvas size". diffusers resizes each condition image to output_resolution² of area, so an explicit output size that differs puts the two grids on different scales, and the centred positions stop lining up pixel for pixel (reasoned from the pipeline code).

"Targets 1-2 MP" is the cost. The canvas is both the condition and the target, so the denoiser carries twice the image tokens of text-to-image at the same size: 4,096 each at 1024 x 1024, and 7,744 each at ai-toolkit's resolution: 1408, the 2 MP square on the 32 grid (reasoned). diffusers computes the condition's K and V once and reuses them, which the release piece measured at 2.55x on an A100. stable-diffusion.cpp's Qwen-Image-2.1 runner keeps no state between steps (measured from the source), so on a CPU every step pays for both.

canvas aspect

drag the picture · faint lines are the 32 px grid, one vision slot = 2 x 2 latent tokens

canvas asked1154.7 x 866.0on the 32 grid1,152 x 864 = 0.995 MPgray, L R T B115 115 87 86 pxlatent grid72 x 54condition image3,888 tokenstarget image3,888 tokensvision slots972 in the prompt
kept share of the canvas64.0%
0%v1 12-30v2 45-93100%

inside v2's range: v2 trained on 45-93 % kept, median about 74 %

Derived, not measured. The canvas is the asked size rounded to the nearest multiple of 32 on each side, as diffusers' pipeline does; the card's padding node also works on a multiple of 32. It goes in as the condition image and sets the output size, so both are 3,888 latent tokens and the denoiser carries 7,776 image tokens where text-to-image at the same size carries 3,888. The kept share is 64.0%; framing the picture evenly on all sides to the same share is a 1.25x zoom-out in each direction.

Classical outpainting, for contrast

Outpainting has been done two ways before, and the LoRA is neither.

Here the mask is implicit in the pixel values, a region of exactly one colour, and nothing is pinned. The card warns off the blending route: "Do not pin the known area with Set Latent Noise Mask: on Qwen 2.1 that draws a visible rectangle at the seam." I did not test why. One latent token covers 16 x 16 px, so a hard latent mask is blocky, but that is a guess (reasoned, not measured).

The training teaches two rules at once: copy where the reference is not gray, invent where it is. For v1, 924 pairs from 231 pictures, four layouts each; the target is the picture, and the source is "same canvas with everything outside the kept rectangle painted #808080" (reported). The card mentions no loss mask, and with a plain loss over the whole target a moved kept pixel costs as much as a bad fill (reasoned).

What is in the file

I read all four checkpoints' headers without downloading the weights.

FileStepTensorsRankParametersBytes
qwen-image-2.1-outpaint.safetensors (v1)1,5003843279,691,776159,436,576
qwen-image-2.1-outpaint-v2.safetensors (v2)2,0003843279,691,776159,436,576
checkpoints/…_000001250 (v1)1,2503843279,691,776159,436,576
checkpoints/…_000000500 (v1)5003843279,691,776159,436,576

All measured. Every file is BF16, an A and a B for each of 192 layers: to_q, to_k, to_v, to_out.0, img_mlp.gate_up and img_mlp.out in each of 32 blocks. Those are exactly the 192 tensors leejet's Q4_K GGUF quantises, and nothing else: no timestep embedder, no shared modulation, no txt_in. A block costs 2,490,368 parameters, and the file is 1.12% of the 7,115,124,736-parameter denoiser (reasoned).

The metadata carries the trigger verbatim, ai-toolkit 0.13.23, base qwen_image_2, and a step and epoch that check the card's dataset sizes: v1's steps 500, 1,250 and 1,500 read epochs 0, 1 and 1, which fits 924 pairs at batch 1, and v2's step 2,000 reads epoch 29, which fits 68 pairs (2,000 / 68 = 29.4; measured, and reasoned).

It is also portable, the opposite of the few-step adapter's trouble. The MLP target is already the fused img_mlp.gate_up that leejet's GGUF and Comfy-Org's weights use, with a 24,576-row B covering both halves. There is no alpha tensor, but the card says "rank 32 / alpha 32", so the correct scale is 1, which is what stable-diffusion.cpp's lora.hpp and ComfyUI's weight_adapter/lora.py apply when a file has no alpha (measured from both sources).

Where does the update live? Here is each layer type's share of the summed squared Frobenius norm of BA (measured, from the full v2 file and a streamed read of v1):

Layerv1v2
img_mlp.gate_up54.6%56.8%
img_mlp.out11.9%10.1%
attn.to_v10.7%11.4%
attn.to_out.010.5%10.8%
attn.to_q6.5%5.8%
attn.to_k5.7%5.1%

Queries and keys, the part of attention that decides where to look, get the least. That fits a model whose path to the matching reference token already exists, but it is a reading of weights, not an ablation (reasoned). A median of 10 of each layer's 32 singular values carries 90% of its update. And v1 and v2 get the same behaviour from nearly unrelated weights: the median cosine between their per-layer updates is 0.015, and their A subspaces overlap at 0.009, against 0.0078 for two random 32-dimensional subspaces of 4,096 dimensions (measured).

Running it on four CPU cores

The card's numbers all come from ComfyUI. The site has run Qwen-Image-2.1 on four CPU cores with stable-diffusion.cpp at commit 2bb7294, so I read that build's source first.

The picture is a public-domain NASA photograph, "Artemis I Prelaunch" by Bill Ingalls (NHQ202208310005): a rocket and two towers in silhouette against the rising sun. I scaled it to 512 px wide, cut a 512 x 416 canvas, kept the middle 384 x 320 and painted the rest #808080. That keeps 57.7%, inside v2's range, and cuts through the sun, both tower tops and the treeline, and the real photograph remains to score the fill against.

Settings follow the few-step piece, since the card's 25 steps would take three times as long: Pruna's 8-step adapter stacked with outpaint v2, both at strength 1; Pruna's 8 sigmas; Euler; CFG 1; seed 42; leejet's Q4_K denoiser and Qwen's Q4_K_M encoder; -t 4 at nice -n 19. The prompt is the trigger plus a one-line Scene:. The canvas is 0.21 MP, about a fifth of v2's smallest training size, and the stack is mine, not the card's: this tests whether the mechanism survives, not the quality. A control run dropped the outpaint LoRA and changed nothing else.

A 512 by 416 canvas of flat mid-gray with a 384 by 320 photograph in the middle: the silhouette of a launch tower and a rocket on the left and a lattice tower in front of a large white sun on the right, against an orange sky, cut off at the sun's right edge, the tower tops and the treeline.
The input: 57.7% of the canvas kept, the rest painted #808080. Photo: NASA/Bill Ingalls, Artemis I Prelaunch, public domain.
The same 512 by 416 view with nothing removed: the launch tower's antenna tip, the full sun disc behind the lattice tower, and a dark band of trees across the bottom.
What was actually outside the kept rectangle, for scoring the fill. Photo: NASA/Bill Ingalls, public domain.
The model's output with the outpaint LoRA: the whole canvas filled, the kept picture exactly where it was, the sun closed into a full disc, both tower tops extended as lattice to the top edge, and a continuous treeline across the bottom.
Pruna 8-step plus outpaint v2, generated by me on four CPU cores: the kept picture is registered to the pixel, and the sun, both towers and the treeline continue.
The control output without the outpaint LoRA: a similar sunrise scene with no gray left, but recomposed; the launch tower and the sun sit further left and lower than in the input, and the lattice tower is redrawn with different bracing.
The control, Pruna's adapter alone, generated on the same box: no gray left, but the scene is redrawn about 28 px to the left.
The LoRA output with the original photograph pasted back over the kept rectangle through an 8 pixel feather. No edge is visible anywhere.
With the LoRA, the original pasted back over an 8 px feather: no seam.
The control output with the original photograph pasted back over the kept rectangle. The launch tower and the lattice tower appear twice, once from the paste and once from the model, the sun has a doubled rim on its right, and a vertical seam runs down the right side.
The control, with the original pasted back the same way: doubled towers, a doubled sun rim and a seam.

Scored against the photograph, before any paste-back (all measured):

Pruna + outpaint v2Pruna only
Kept-area PSNR against the original35.64 dB15.53 dB
Kept-area RMS error, 0-2554.2142.68
Best global fit of the kept pictureno shift, scale 1.0028 px left, 6 px down, scale 0.98
Mean error left after that fit1.758.10
Gray left in the padding0.0%0.0%
Fill error against the real photo, mean absolute7.4710.98

With the LoRA the kept picture is where it went in. Searching shifts up to 48 px and scales from 0.84 to 1.16, then refining to 1 px and 0.01, the best fit is no shift and no scale, and one pixel off in any direction doubles the mean error, from 1.75 to at least 3.71. Its 35.64 dB sits beside the card's 34.0 to 34.7 dB for v2. The control did what the card says the base does: it recomposed, and even its best shift and scale leave 8.10 of error, because the towers are redrawn, not only moved. Its 15.53 dB sits beside the card's 16.4 dB without the LoRA. The fill-error row is my metric, not the card's, and the control's is inflated by its shift. One picture and one seed confirm the mechanism on a new runtime and a new stack, and no more.

RunBoxConditioningPer stepSamplingTotalPeak RSS
Pruna + outpaint v2shared, then quiet409.25 s172.09 to 759.20 s2,307.24 s2,813 s9.03 GiB
Pruna onlyquiet36.68 s144.12 to 151.41 s1,167.65 s1,247 s11.21 GiB

Measured. Other authors' jobs pushed the load average as high as 10 during the first run; its last two steps, at a load near 4, are the fair ones, and its conditioning also paid for the first read of the weights into the page cache. A step carries 1,752 tokens, 88 of text, 832 of condition and 832 of target, against about 1,061 for the 512 x 512 text-to-image run in the few-step piece, whose steps took about 90 s: 1.65 times the tokens for 1.6 times the time, because nothing caches the condition here (reasoned from both logs). With both adapters the quiet steps took 172 to 174 s against the control's 144 to 151 s. I did not isolate why a rank-32 side branch costs that much, and one pair of runs cannot settle it. Peak RSS counts the memory-mapped weights, so it moves with the page cache.

What the card's numbers do and do not show

The card shows failures beside fixes on pictures neither adapter trained on, and flags its own crude metric: the gray count "counts any flat mid-gray". Its limits are a one-person release's: ten and eight pictures for v2, twelve for v1, all rendered by the author, no spread across seeds, and a "fill error" (10.2 for v2 against 16.3 without the LoRA on subtle crops) with no stated formula or unit (reported). The registration claim survives that, and is easy to check yourself. The quality numbers are an author's measurement, not a benchmark.

The take

Outpainting on an editor fails in a specific way: the model is free to recompose, and sometimes does. ausboss's fix is not a new input or a sampler trick. It is 79,691,776 parameters that teach one convention: flat #808080 means unknown, and everything else stays exactly where it is. The architecture makes that learnable, with a clean reference on the same centred grid as the target. The practical rules fall out of the same arithmetic. Pad to a multiple of 32, give the reference and the output the same size, stay near 45-93% kept if you use v2, and paste the original back at the end, because registration is what makes that paste invisible.

For the model underneath, see the release piece; for running it without a GPU, the stable-diffusion.cpp piece and the few-step piece; for how prompts reach it, the pocket rewriter; and for the block itself, the diffusion transformer explainer.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Qwen-Image-2.1 Outpaint LoRA: 1.12% of the weights, trained to leave the picture where it was", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026qwenimage21outpaintlora,
  author = {Satyajit Ghana},
  title  = {Qwen-Image-2.1 Outpaint LoRA: 1.12% of the weights, trained to leave the picture where it was},
  url    = {https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora},
  year   = {2026}
}
share