D
P
0
← All articles Baca dalam Bahasa Indonesia

CSS Layout, Overflow & Cascade

AI Outpaint Models Repaint the Whole Source Image? Measure the PSNR First, Then Steer It With a Blockout

· · 9 min read
AI Outpaint Models Repaint the Whole Source Image? Measure the PSNR First, Then Steer It With a Blockout

The job sounded small: build a seam strip that extends a photo downward so the next scroll zone continues from the same world. The chain of worlds was already fixed, running from a cave to a beach, then dark pre-dawn water, ivory fog, and finally the form. The motion convention was locked too. The next world enters from the bottom of the viewport, so the strip grows downward rather than sideways.

My first version blended cleanly. No line at the join, no break. The client rejected it anyway, and the reason had nothing to do with blend quality: "Abis garis pantai masa tiba tiba ada air lagi," meaning that after the shoreline there is suddenly open water again. The client called that version a bug and AI slop. And the call was right: the strip I shipped continued open water below the shoreline, pixel smooth and geographically wrong.

Smooth is not the same as correct

That was the correction I needed. Pixel smoothness is not the success criterion for a seam strip, because a reader is not scanning for a seam, they are reading a place. What has to hold is semantic continuity, the geography of the world, and a clean blend cannot cover for geography that is wrong.

The process fix is cheap and I have used it ever since. Before touching any model, write out a plausible geography order in words, then let the generation or the composite follow that order.

waves -> foam line -> wet sand -> dark

Those four words cost nothing to write and cost a revision round to skip. Once the order exists on paper I have something to reject a pretty but nonsensical generation with, and later that same order becomes the raw material for the blockout.

Three models, one measuring stick

The second problem lives in a different layer, and it surprised me more. Generative extend and outpaint models do not treat the source image as immutable. Prompt instructions to preserve a region are either ignored outright, so the scene comes back fully repainted, or the model retones the whole image.

So I stopped judging results by eye and measured this entire three-part workflow with PSNR against the original. PSNR is logarithmic, 20 * log10(MAX / RMSE), so a higher number means the result sits closer to the source, and a gap of a few dB means far more than the raw digits suggest.

Three models, three different kinds of outcome:

seedream_v5_pro costs 3 credits and takes --aspect_ratio 21:9|16:9 --resolution 2k. For making a fresh frame it is good, and its golden hour palette is precise when you name the colors as hex in the prompt. But given an explicit "keep top exactly" instruction, it still repainted the whole scene. PSNR against the original landed around 16 and the composition shifted. I logged the same symptom in two separate places.

The outpaint model costs 2 credits and its only parameters are medias and aspect_ratio. There is no prompt parameter at all, which explains a lot: there is nowhere to argue with it. It continued the composition correctly and placed the original image in the center of its output, but it retoned the whole thing globally at a PSNR around 18. A naive splice from that output showed a visible color jump.

nano_banana_2 also costs 2 credits and gave the strongest preservation, PSNR around 28.6.

The spread is large once you read it as error rather than as a score. From 18 to 28.6 is 10.6 dB, and because PSNR uses 20 * log10, the RMS error ratio is 10^(10.6/20), roughly 3.4 times smaller. From 16 to 28.6 is 12.6 dB, or 10^(12.6/20), roughly 4.3 times smaller. Trying all three once costs 3 + 2 + 2 = 7 credits, which is cheap next to guessing which model respects its source.

The credits are paid, so the order of commands matters. I check the balance first, read the model parameters with hf model get rather than the model params form that does not exist, and run cost before create.

hf account status
# ~944 credits left as of 9 Jul 2026, max plan
 
hf model get outpaint
hf generate cost nano_banana_2 --prompt "..."
 
hf generate create nano_banana_2 \
  --prompt "render the blockout following the geography exactly" \
  --image blockout.png --wait --wait-timeout 5m
 
curl -o strip.png "https://<cloudfront-host>/..."

Two details save real time here. Media input goes through the --image flag, which accepts a uuid or a file path, while the --medias flag with its array of objects often fails through the CLI. And results are collected with hf generate wait <job_id> or the --wait --wait-timeout 5m flags, which hand back a cloudfront URL to pull down with curl.

The guided blockout

Since nano_banana_2 respects its source the most, it is also the one most worth directing. And the direction it actually obeys turns out not to be a sentence but a picture.

I call the technique a guided blockout: rough in the extension area with color blocks sampled from the photo itself, add white guide lines at the boundaries between zones, then send it with the prompt "render the blockout following the geography exactly". The color blocks tell the model which color belongs where, and the white lines tell it at which row one zone stops and the next begins.

This is where the written geography order pays for itself a second time. Waves, foam line, wet sand, dark: that is the block list, top to bottom. In my experience this is the most reliable way to control the geography of the result, considerably more reliable than adding another sentence to the prompt.

Tone match before the splice

If you go with the outpaint model, there is one step you cannot skip. Because it retones globally, splicing straight from its output shows the color jump. Helpfully, it places the original in the center of its output, so there is always an overlap zone between the untouched pixels and its retoned version, and that zone is where the correction comes from.

The correction is a linear mean and standard deviation fit per RGB channel, computed on the overlap zone only, then applied across the whole output:

# for each channel c in R, G, B, statistics come FROM THE OVERLAP ZONE only
out[c] = (gen[c] - mean(gen_overlap[c])) / std(gen_overlap[c]) \
         * std(orig_overlap[c]) + mean(orig_overlap[c])

Two parameters per channel, six numbers in total, and the color jump is gone. The part that matters is taking the statistics from the overlap zone rather than from the whole image, because the only region you can honestly compare is the region that exists in both versions.

Assembling the strip

Once the image is right there is still the matter of joining it to the page. My first rule: the original rows stay fully opaque until past the maximum visible edge row. For a cover fit, that row works out to edgeRow = 540 + (svh / 2) / scale, with a maximum around 1069 when the scale settles at 1.02.

Only after that row do I put a thin 10 pixel alpha ramp, with the generated content below it. The order matters. If the ramp starts before the maximum edge row, there is a scroll position where the user sees generated pixels while they should still be seeing the original photograph.

The generated part is not mapped straight through either. There is a progressive perspective compression so the texture keeps reading as aerial instead of spreading into a wall:

const edgeRow = 540 + (svh / 2) / scale;   // max ~1069 when scale settles at 1.02
 
// 0 .. edgeRow            : original rows, full alpha
// edgeRow .. edgeRow + 10 : alpha ramp
// below that              : generated content, mapped through
const src = H * (0.6 * t + 0.4 * t * t);   // t runs 0 -> 1

The effect of that curve is easy to check for yourself. At t = 0.5 it evaluates to 0.6 * 0.5 + 0.4 * 0.25 = 0.4, so the top half of the strip consumes only 40 percent of the source height and the bottom half has to carry the remaining 60 percent. Read as a rate, the derivative moves from 0.6 at t = 0 to 1.4 at t = 1, meaning the tail pulls in source rows 1.4 / 0.6, about 2.33 times faster than the head does. That is the compression. The strip closes out with a dark ramp and then an alpha feather into the texture below it.

Do not let a cover fit crop the seam

One last thing can undo all of the above in a single line of CSS. If the strip is applied as a background with a cover fit, the join whose rows you carefully computed gets cropped away the moment the viewport ratio changes.

So edge elements that must not be cropped, this seam strip included, I build as an img with height: auto pinned absolutely over a background texture that uses cover and is genuinely crop safe.

.seam-strip {
  position: absolute;
  width: 100%;
  height: auto; /* not object-fit: cover, the rows have to survive intact */
}

The texture can be cropped because nothing depends on its exact rows. The strip cannot, because its whole purpose lives at specific rows.

What I took away