I’m working on an image stitching project using Poisson blending to seamlessly blend overlapping images. While the blending quality is generally good, I’m encountering an issue where the resulting stitched image has blurred layers the same size as the masks, leading to a ghostly, blurry result.
Problem:
When stitching multiple images using their corresponding homographies, the final panorama shows ghosting and blurring in the areas where images overlap.
Linear blending result
Poisson blending result
import cv2
import numpy as np
import os
import matplotlib.pyplot as plt
from skimage import img_as_ubyte
def process_images_and_masks(images: list[np.ndarray], homographies: list[np.ndarray]) -> tuple[list[np.ndarray], list[np.ndarray]]:
"""
Process images with their homographies and return the warped images and masks.
"""
def get_transformed_corners(image, H):
h, w = image.shape[:2]
corners = np.float32([[0, 0], [0, h-1], [w-1, h-1], [w-1, 0]]).reshape(-1, 1, 2)
transformed_corners = cv2.perspectiveTransform(corners, H)
return transformed_corners
# Calculate the bounds of the canvas
all_corners = []
for image, H in zip(images, homographies):
transformed_corners = get_transformed_corners(image, H)
all_corners.append(transformed_corners)
all_corners = np.concatenate(all_corners, axis=0).reshape(-1, 2)
min_x, min_y = np.min(all_corners, axis=0).astype(int)
max_x, max_y = np.max(all_corners, axis=0).astype(int)
canvas_width = max_x - min_x
canvas_height = max_y - min_y
# Adjust the homographies to fit all images within the canvas
translation = np.array([[1, 0, -min_x], [0, 1, -min_y], [0, 0, 1]])
adjusted_homographies = [translation @ H for H in homographies]
new_images = []
visible_masks = []
for image, H in zip(images, adjusted_homographies):
warped_image = cv2.warpPerspective(image, H, (canvas_width, canvas_height))
# Create a binary mask for the original image
mask = np.ones(image.shape[:2], dtype=np.uint8) * 255
# Warp the mask using the same homography
warped_mask = cv2.warpPerspective(mask, H, (canvas_width, canvas_height))
# Append the results to the lists
new_images.append(warped_image)
visible_masks.append(warped_mask)
return new_images, visible_masks
def divAB(a, b, fill=np.nan):
""" Element-wise division of a by b, handling division by zero. """
with np.errstate(divide='ignore', invalid='ignore'):
c = np.true_divide(a, b)
if np.isscalar(c):
return c if np.isfinite(c) else fill
else:
c[~np.isfinite(c)] = fill
return c
def computeGrad(img):
# Compute gradient of the image.
out_x = np.zeros(img.shape, np.double)
out_y = np.zeros(img.shape, np.double)
out_x[:, 1:-1, :] = 0.5 * (img[:, 2:, :].astype('double') - img[:, :-2, :].astype('double'))
out_y[1:-1, :, :] = 0.5 * (img[2:, :, :].astype('double') - img[:-2, :, :].astype('double'))
return out_x, out_y
def computeGuidanceField(grads, masks):
# Convert masks to float64 and normalize to [0, 1]
masks = [mask[:, :, np.newaxis].astype(np.float64) / 255.0 for mask in masks]
# Stack the gradients and masks
grad_x_stack = np.stack([grad[0] for grad in grads], axis=0) # Shape: (num_images, H, W, C)
grad_y_stack = np.stack([grad[1] for grad in grads], axis=0)
mask_stack = np.stack(masks, axis=0) # Shape: (num_images, H, W, 1)
# Sum of masks at each pixel
mask_sum = np.sum(mask_stack, axis=0)
# Avoid division by zero
mask_sum[mask_sum == 0] = 1
# Normalize masks to sum to 1 at each pixel
normalized_masks = mask_stack / mask_sum
# Weighted sum of gradients
guide_x = np.sum(grad_x_stack * normalized_masks, axis=0)
guide_y = np.sum(grad_y_stack * normalized_masks, axis=0)
return guide_x, guide_y
def convertDouble2Uint8(img):
img = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX)
return img.astype(np.uint8)
def mft(U):
# Define a shortcut for the Fourier transform.
return np.fft.fftshift(np.fft.fft2(U))
def imft(U):
# Define a shortcut for the inverse Fourier transform.
return np.fft.ifft2(np.fft.ifftshift(U)).real
# Expand the image to create symmetry to avoid boundary problems
def expandImage(original_image, d="x"):
# Get the dimensions of the original image
rows, cols, ch = np.shape(original_image)
# Create an empty array to hold the final image
final_image = np.empty((rows * 2, cols * 2, ch), dtype=original_image.dtype)
if d == "x":
# Place the original and mirror images in the final image
final_image[0:rows, 0:cols, :] = original_image
final_image[0:rows, cols:cols*2, :] = -np.flip(original_image, axis=1)
final_image[rows:rows*2, 0:cols, :] = np.flip(original_image, axis=0)
final_image[rows:rows*2, cols:cols*2, :] = -np.flip(original_image, axis=(0,1))
elif d == "y":
# Place the original and mirror images in the final image
final_image[0:rows, 0:cols, :] = original_image
final_image[0:rows, cols:cols*2, :] = np.flip(original_image, axis=1)
final_image[rows:rows*2, 0:cols, :] = -np.flip(original_image, axis=0)
final_image[rows:rows*2, cols:cols*2, :] = -np.flip(original_image, axis=(0,1))
return final_image
def poissonSolver(gx, gy):
print("poissonSolver")
# Initialization of the output image
I = np.zeros(gx.shape)
# Expand the image to create symmetry to avoid boundary problems
gx = expandImage(gx, "x")
gy = expandImage(gy, "y")
H,W,C = gx.shape
# Define frequency domain
wx, wy = np.meshgrid(np.arange(1, W+1, 1), np.arange(1, H+1, 1))
wx0 = np.floor(W/2)+1
wy0 = np.floor(H/2)+1 # Zero frequency
wx = wx - wx0
wy = wy - wy0
cx = ((1j*2*np.pi)/W)*wx
cy = ((1j*2*np.pi)/H)*wy
d = (cx)**2 + (cy)**2
print("---", gx.shape)
print(f"{'-->':>4} Print zeros : {np.argwhere(np.abs(d) == 0)}, Center: ({int(wy0)}, {int(wx0)})")
del wx, wy
for c in range(0, C):
Gx = gx[:,:,c]
Gy = gy[:,:,c]
Vx = (cx) * mft(Gx)
Vy = (cy) * mft(Gy)
# FT_I = ( Vx + Vy ) / ( d )
FT_I = np.zeros_like(Vx)
np.divide(( Vx + Vy ), d, out=FT_I, where=d!=0) # Only divide non-zeros else 1
FT_I[int(wy0-1), int(wx0-1)] = 10 # Set DC value (undefined in the previous div.)
Aux = imft(FT_I)
I[:,:,c] = Aux[0:int(H/2), 0:int(W/2)] # Keep the original portion of the space.
cv2.normalize(I, I, 0, 1, cv2.NORM_MINMAX)
return I
def poisson_blending(images_list, homographies_list) -> np.ndarray:
new_images, visible_masks = process_images_and_masks(images_list, homographies_list)
grads = [computeGrad(img) for img in new_images]
guide_x, guide_y = computeGuidanceField(grads, visible_masks)
img_out = poissonSolver(guide_x, guide_y)
img_out = convertDouble2Uint8(img_out)
return guide_x, guide_y, img_out
# Directory where images and H matrices are stored
output_dir = 'samples/output_images'
# List to store images and homographies
images = []
homographies = []
# Load the images
image_filenames = sorted([f for f in os.listdir(output_dir) if f.endswith('.png')])
for image_filename in image_filenames:
image_path = os.path.join(output_dir, image_filename)
image = cv2.imread(image_path)
images.append(image)
# Load the H matrices (homographies)
H_matrices_filename = os.path.join(output_dir, 'H_matrices.npy')
homographies = np.load(H_matrices_filename, allow_pickle=True)
guide_x, guide_y, img_out = poisson_blending(images, homographies)
# Display the resulting image
plt.axis("off") # Hide axis
plt.imshow(cv2.cvtColor(img_out, cv2.COLOR_BGR2RGB)) # Convert from BGR to RGB for correct display
plt.show()
Additional Information:
For each image, there is a corresponding homography that positions the image in 2D space.
The images and homographies are loaded from the samples/output_images directory.
The masks are created as binary masks (all ones) and warped using the homographies.
Data link:
https://drive.google.com/drive/folders/1UXSKOsqHCyrW7_n-O8oetcMNekKV5mt-?usp=sharing
How can I adjust the Poisson blending process to eliminate the blurring and achieve a seamless panorama?