DEV Community

Cover image for Growing an image past its border without a visible seam
Wlad Radchenko
Wlad Radchenko

Posted on

Growing an image past its border without a visible seam

You have a photo and you want more of it. More sky above the head, more room to the left of the subject, a wider frame than the camera gave you. Outpainting is the name for inventing those new pixels. The model part is well covered everywhere. The part nobody talks about is the few dozen lines that run before the model, and that part is where the seam is won or lost.

If you get the prep wrong, the new region meets the old photo at a hard line. The colors step. The texture changes mid-wall. You can point at exactly where the real photo ended. This post is a walkthrough of the prep code in Wunjo Make that stops that from happening. It lives in one file, visual_generation/generation/outpaint.py, and it is all NumPy and OpenCV. No network in sight.

A wide creative workspace
Outpainting is asking for canvas that was never photographed. The trick is making the new canvas agree with the old one at the join. Photo: Unsplash.

The shape of the problem

Say you want to extend an image to the left. You need a bigger canvas, the old pixels parked on the right side of it, and an empty strip on the left for the new content. Three things have to be true at the end:

  1. The known pixels survive untouched in the middle and far side.
  2. The model is told, precisely, which strip is allowed to change.
  3. The old edge does not become a wall the model paints up against.

The whole function is built around those three. Here are the defaults it runs with:

def process_image(image, fill_color=(0, 0, 0), mask_offset=50, blur_radius=500,
                  expand_pixels=256, direction="left",
                  inpaint_mask_color=50, max_size=1024):
Enter fullscreen mode Exit fullscreen mode

expand_pixels=256 is how much new canvas you grow. mask_offset=50 is the overlap that hides the seam, and it is the most important number in the file. blur_radius=500 feathers the mask edge. max_size=1024 is the hard cap on the output dimension. Keep those four in mind.

Step 1. Size the new canvas, and crop if it would blow the cap

First it computes the new size by adding expand_pixels on the axis you are growing:

new_height = height + (expand_pixels if direction in ["top", "bottom"] else 0)
new_width  = width  + (expand_pixels if direction in ["left", "right"] else 0)
Enter fullscreen mode Exit fullscreen mode

Then the part people forget. If growing the image would push it past max_size, it does not let the canvas grow without limit. It crops the same number of pixels off the opposite side first, so the output stays at max_size:

if new_width > max_size:
    if direction == "left":
        image = image[:, :max_size]            # drop the far-right columns
    elif direction == "right":
        image = image[:, new_width - max_size:] # drop the far-left columns
    new_width = max_size
Enter fullscreen mode Exit fullscreen mode

This is a practical decision, not a cosmetic one. The model downstream has a size it is happy with. Letting the canvas creep past 1024 on every pass would either run out of memory or force a resize that softens the whole image. So the canvas slides: you gain 256 new pixels on one edge and lose the equivalent off the other, holding the frame at a fixed size.

Step 2. Three buffers

After the size is settled, it builds three arrays the same size as the new canvas:

new_image    = np.full((new_height, new_width, 3), fill_color, dtype=np.uint8)  # the canvas
mask         = np.full_like(new_image, 255, dtype=np.uint8)  # soft guidance mask
inpaint_mask = np.full_like(new_image, 0,   dtype=np.uint8)  # hard fill region

mask         = cv2.cvtColor(mask, cv2.COLOR_BGR2GRAY)
inpaint_mask = cv2.cvtColor(inpaint_mask, cv2.COLOR_BGR2GRAY)
Enter fullscreen mode Exit fullscreen mode

Two different masks, and the difference matters.

mask is the soft one that the generation step uses. It starts all white (255), and white here means "keep this." inpaint_mask is the hard one, a quick scratch buffer used a moment later to pre-fill the empty strip. It starts all black (0).

Step 3. Place the pixels and carve the strips

This is the heart of it. For the left direction:

new_image[:, expand_pixels:] = image[:, : max_size - expand_pixels]
mask[:, : expand_pixels + mask_offset] = inpaint_mask_color   # 50
inpaint_mask[:, :expand_pixels] = 255
Enter fullscreen mode Exit fullscreen mode

Line one drops the real pixels into the canvas, shifted right by expand_pixels so the left strip is left empty for the new content.

Line three marks the hard fill region. Exactly the expand_pixels columns on the left, set to 255. That is the genuinely empty part, the 256 columns that were never photographed.

Line two is the trick. It paints the soft mask to a low value (inpaint_mask_color, which is 50) across expand_pixels + mask_offset columns. Not 256. 306. It reaches 50 pixels past the new region, into the real photo.

That 50-pixel overlap is the whole game. If the editable region stopped exactly at the old border, the model would have to match the real photo perfectly at a single column, and it never will, so you would see a line. By letting the model also repaint a thin band of real pixels right at the join, you give it room to ramp the new content into the old. It blends across the edge instead of butting up against it.

Note the mask value is 50, not 0. The region is not marked "fully replace" but "mostly free," a gray instruction rather than a hard switch. Softer guidance near the seam, which is exactly what you want there.

Step 4. Father the mask edge

A sharp transition in the mask still prints a sharp transition in the result. So the mask gets a big Gaussian blur:

if blur_radius % 2 == 0:
    blur_radius += 1                 # OpenCV needs an odd kernel
mask = cv2.GaussianBlur(mask, (blur_radius, blur_radius), 0)
Enter fullscreen mode Exit fullscreen mode

blur_radius=500 becomes a 501x501 kernel. That is enormous, and on purpose. It smears the boundary between the kept region and the editable region across hundreds of pixels, so there is no single row or column where guidance flips. The model gets a gradient of freedom, most free in the new strip, gently constrained as it moves back into real territory. The odd-number guard is there because OpenCV rejects an even kernel size.

Step 5. Pre-fill the empty strip with Telea

Here is the last and most underrated step. The empty strip is still flat fill_color (black, by default). Handing a model a black bar to start from is a bad init. So before returning, it fills that strip with a cheap classical inpaint:

_, mask_np = cv2.threshold(inpaint_mask, 128, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
inpaint = cv2.inpaint(new_image, mask_np, 3, cv2.INPAINT_TELEA)
Enter fullscreen mode Exit fullscreen mode

cv2.inpaint with INPAINT_TELEA (the Fast Marching Method) walks inward from the border of the hole and paints each new pixel from its neighbors. The 3 is the inpaint radius, how far it looks for those neighbors. It is not smart. It will not invent a tree or a window. What it does is bleed the edge colors of the real photo into the empty strip, so the strip starts as a soft smear of the right colors instead of a black void.

Why bother, if a generator is about to repaint it anyway? Because the generator does better work when it starts from a plausible color field than from flat black. The smear carries the lighting and palette of the old photo into the new region before generation even begins. The black-bar init pulls the result toward dark, muddy borders. The Telea smear pulls it toward the photo it is extending.

The function returns two things: inpaint, the canvas with real pixels plus a softly pre-filled strip, and mask, the feathered soft mask. That pair is the init image and the mask you hand to an inpainting or diffusion model. A conditioning setup like this is the same idea behind ControlNet (Zhang, Rao, Agrawala, ICCV 2023): you do not just ask the model to generate, you hand it a structured starting point and a map of where it is allowed to work.

Doing it on both sides: the centering pass

decode_image is the wrapper that turns a single landscape-or-portrait image into a fixed canvas (default 576x1024). It figures out which axis is short, then calls process_image for both sides of that axis and stitches them:

expand_pixels_half_x = int((max_width - width) / 2)
expand_pixels_half_y = int((max_height - height) / 2)

if expand_pixels_half_x > 0:
    expand_pixels_half = expand_pixels_half_x
    directions = ["left", "right"]
else:
    expand_pixels_half = expand_pixels_half_y
    directions = ["top", "bottom"]
Enter fullscreen mode Exit fullscreen mode

Splitting the needed growth in half and growing both edges keeps the original subject centered, instead of shoving it to one side. merge_images then pastes the two halves back together into one canvas.

The wrapper also returns its own combined binary mask, and it pulls in by padding=20 on the long axis:

mask_combined[pad_y_start:pad_y_end, pad_x_start:pad_x_end] = 0
Enter fullscreen mode Exit fullscreen mode

That 20-pixel inset is the same instinct as the 50-pixel offset upstream: do not trust the boundary exactly, let the editable zone eat slightly into the known region so the join has somewhere to blend.


Wlad Radchenko About the author. I'm Wlad Radchenko, a software engineer. The code in this article comes from Wunjo Make (open source), local software for video makers, and Wunjo Design, an offline PWA for designers. Get in touch to find more on GitHub and LinkedIn.

Gotchas you will actually hit

A few things that bit me, in case you build something similar.

The mask polarity is easy to get backwards. In this code white (255) means keep and the low value (50) means free to change. If your downstream model expects the opposite, you will outpaint the wrong region and destroy the photo. Check the convention before anything else.

The 50-pixel overlap is tunable, but do not drop it to zero. Zero overlap brings the hard seam right back. Too large, and the model starts rewriting parts of the photo you wanted kept. Fifty pixels on a 1024 canvas is a reasonable middle.

The Gaussian kernel must be odd. The code guards for this with the % 2 check, and if you change blur_radius, keep that guard or OpenCV will throw.

The opposite-side crop in step 1 is silent. If you feed in an image that is already near max_size and keep extending, you are quietly losing content off the far edge each pass. That is correct for a fixed-canvas product, but surprising if you expected pure growth.

Takeaway

The model invents the new pixels. Everything that makes them join cleanly happens in classical code before the model runs: pad the canvas, place the known pixels, mark the empty strip, overlap the editable mask 50 pixels into the real photo so it can blend across the old edge, feather that mask with a 501-pixel blur, and pre-fill the empty strip with a Telea smear so the model starts from the right colors instead of black. No generation in any of it.

The full file is visual_generation/generation/outpaint.py in the Wunjo Make repo. If you are fighting a visible seam in your own outpainting, the mask_offset overlap and the Telea pre-fill are the two places I would look first.

References

  • Zhang, Rao, Agrawala. "Adding Conditional Control to Text-to-Image Diffusion Models." ICCV 2023. arXiv:2302.05543
  • Telea. "An Image Inpainting Technique Based on the Fast Marching Method." Journal of Graphics Tools, 2004. (The INPAINT_TELEA method in OpenCV.)

Top comments (0)