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

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora
> date: 2026-09-27
> tags: 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](https://huggingface.co/ausboss/Qwen-Image-2.1-Outpaint-LoRA) 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:

```text
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.

<ModelCard
  repo="ausboss/Qwen-Image-2.1-Outpaint-LoRA"
  claimed="Extends a picture in any direction; the known picture stays pixel-registered; v2 at 1-2 MP"
  note="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."
/>

## How Qwen-Image-2.1 edits

[The release piece](/articles/qwen-image-2-1#block-causal-attention-and-why-the-cache-is-exact) 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.

<Figure
  src="https://ai.thesatyajit.com/articles/qwen-image-2-1/fig1.png"
  alt="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."
  caption="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

$$
\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.

<SeamToy />

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

<Figure
  src="https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora/fig1.jpg"
  alt="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."
  caption="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)."
/>

<Figure
  src="https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora/fig2.jpg"
  alt="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."
  caption="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).

<Figure
  src="https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora/fig3.jpg"
  alt="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."
  caption="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 share | Even frame, zoom `z` | One side only, `e` |
|---|---:|---:|
| 93% (v2's smallest extension) | 1.04x | 7.5% |
| 74% (v2's median) | 1.16x | 35% |
| 45% (v2's largest) | 1.49x | 122% |
| 30% to 12% (v1's small windows) | 1.83x to 2.89x | 233% to 733% |
| under ~15% (the card's limit) | over 2.58x | over 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](/articles/qwen-image-2-1#the-cache-as-three-other-people-measured-it)
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.

<PaddingPlanner />

## Classical outpainting, for contrast

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

- **A mask channel.** The Stable Diffusion inpainting UNets take 9 input channels instead of 4: the
  noisy latent, the masked image's latent and the mask (measured from the 1.5 and XL inpainting
  configs). A public diffusers conversion of FLUX.1 Fill lists 384 input channels against 64 output
  channels for the same reason. The model is told which pixels are known.
- **Latent blending.** Keep an ordinary model and, at every step, overwrite the known region with the
  original noised to the current level: [RePaint](https://arxiv.org/abs/2201.09865) in pixel space,
  [Blended Latent Diffusion](https://arxiv.org/abs/2206.02779) in latent space. The known region is
  pinned by construction, and the seam is where it fails, because the model never chose that content.

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.

| File | Step | Tensors | Rank | Parameters | Bytes |
|---|---:|---:|---:|---:|---:|
| `qwen-image-2.1-outpaint.safetensors` (v1) | 1,500 | 384 | 32 | 79,691,776 | 159,436,576 |
| `qwen-image-2.1-outpaint-v2.safetensors` (v2) | 2,000 | 384 | 32 | 79,691,776 | 159,436,576 |
| `checkpoints/…_000001250` (v1) | 1,250 | 384 | 32 | 79,691,776 | 159,436,576 |
| `checkpoints/…_000000500` (v1) | 500 | 384 | 32 | 79,691,776 | 159,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](/articles/qwen-image-2-1-few-step#the-adapter-does-not-load-as-shipped).
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):

| Layer | v1 | v2 |
|---|---:|---:|
| `img_mlp.gate_up` | 54.6% | 56.8% |
| `img_mlp.out` | 11.9% | 10.1% |
| `attn.to_v` | 10.7% | 11.4% |
| `attn.to_out.0` | 10.5% | 10.8% |
| `attn.to_q` | 6.5% | 5.8% |
| `attn.to_k` | 5.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](/articles/qwen-image-2-1#running-it-on-four-cpu-cores-with-no-gpu-at-all)
at commit `2bb7294`, so I read that build's source first.

- **Editing is supported.** `-r` passes a reference, and Qwen3-VL's vision tower, which the
  text-encoder GGUF lacks, comes through `--llm_vision`. I used Qwen's
  `mmproj-Qwen3VL-8B-Instruct-Q8_0.gguf`, 752,289,728 bytes, SHA-256 matching the Hub (measured).
- **A same-size reference is not resampled.** The log says "resize vae ref image 0 from 416x512 to
  416x512" (measured).
- **The LoRA maps as shipped.** `diffusion_model.` becomes `model.diffusion_model.`, and
  `img_mlp.gate_up` matches the fused weight by name. The log confirms 384 of 384 tensors applied.

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](/articles/qwen-image-2-1-few-step#prunas-adapter-on-four-cpu-cores),
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.

<div className="grid gap-x-4 sm:grid-cols-2">

<Figure
  src="https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora/cpu-input.jpg"
  alt="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."
  caption="The input: 57.7% of the canvas kept, the rest painted #808080. Photo: NASA/Bill Ingalls, Artemis I Prelaunch, public domain."
/>

<Figure
  src="https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora/cpu-photo.jpg"
  alt="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."
  caption="What was actually outside the kept rectangle, for scoring the fill. Photo: NASA/Bill Ingalls, public domain."
/>

<Figure
  src="https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora/cpu-lora.jpg"
  alt="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."
  caption="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."
/>

<Figure
  src="https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora/cpu-control.jpg"
  alt="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."
  caption="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."
/>

<Figure
  src="https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora/cpu-lora-stitched.jpg"
  alt="The LoRA output with the original photograph pasted back over the kept rectangle through an 8 pixel feather. No edge is visible anywhere."
  caption="With the LoRA, the original pasted back over an 8 px feather: no seam."
/>

<Figure
  src="https://ai.thesatyajit.com/articles/qwen-image-2-1-outpaint-lora/cpu-control-stitched.jpg"
  alt="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."
  caption="The control, with the original pasted back the same way: doubled towers, a doubled sun rim and a seam."
/>

</div>

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

| | Pruna + outpaint v2 | Pruna only |
|---|---:|---:|
| Kept-area PSNR against the original | 35.64 dB | 15.53 dB |
| Kept-area RMS error, 0-255 | 4.21 | 42.68 |
| Best global fit of the kept picture | no shift, scale 1.00 | 28 px left, 6 px down, scale 0.98 |
| Mean error left after that fit | 1.75 | 8.10 |
| Gray left in the padding | 0.0% | 0.0% |
| Fill error against the real photo, mean absolute | 7.47 | 10.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.

| Run | Box | Conditioning | Per step | Sampling | Total | Peak RSS |
|---|---|---:|---:|---:|---:|---:|
| Pruna + outpaint v2 | shared, then quiet | 409.25 s | 172.09 to 759.20 s | 2,307.24 s | 2,813 s | 9.03 GiB |
| Pruna only | quiet | 36.68 s | 144.12 to 151.41 s | 1,167.65 s | 1,247 s | 11.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](/articles/qwen-image-2-1); for running it without a
GPU, [the stable-diffusion.cpp piece](/articles/stable-diffusion-cpp) and
[the few-step piece](/articles/qwen-image-2-1-few-step); for how prompts reach it,
[the pocket rewriter](/articles/qwen-image-2-1-pocket-rewriter); and for the block itself,
[the diffusion transformer explainer](/architectures/diffusion-transformer).
