Project 4: Image Warping
and Mosaicing

E Harrison  —  CS180, Fall 2026

CS180 Projects  /  Home  /  Part A Assignment

Overview

In this project I stitch photos together into panoramas. I pick matching points between overlapping photos, solve for the homography between them, warp the images into a shared frame, and blend them into a mosaic.

Part A: Image Warping and Mosaicing


A.1  Shoot the Pictures

I shot three sets of photos around campus, rotating the camera about a fixed center of projection between shots. The Gateway and Sather Gate sets pan left to right, and the Tree set pans from the ground up to the sky.

Gateway
Gateway 1
Image 1
Gateway 2
Image 2
Gateway 3
Image 3

Sather Gate
Sather 1
Image 1
Sather 2
Image 2
Sather 3
Image 3

Tree
Tree 1
Image 1
Tree 2
Image 2
Tree 3
Image 3

A.2  Recover Homographies

A homography maps a point (x, y) in one image to (x', y') in another. Fixing h33 = 1 leaves 8 unknowns, and each correspondence gives two linear equations:

xy1000−x·x'−y·x'
000xy1−x·y'−y·y'
h11
h12
h13
h21
h22
h23
h31
h32
=
x'
y'

I picked 5–6 correspondences per pair, stacked their equations into Ah = b, and solved the overdetermined system with least squares. Each mosaic uses image 2 as the reference, so I recover two homographies per set: image 1 → image 2 and image 3 → image 2.

def compute_H(im1_pts, im2_pts):
    A, b = [], []
    for (x, y), (xp, yp) in zip(im1_pts, im2_pts):
        A.append([x, y, 1, 0, 0, 0, -x * xp, -y * xp])
        A.append([0, 0, 0, x, y, 1, -x * yp, -y * yp])
        b.append(xp)
        b.append(yp)
    A, b = np.array(A), np.array(b)
    H = np.linalg.lstsq(A, b)
    return H[0]

def get_homography_matrix(im1_pts, im2_pts):
    H_vector = compute_H(im1_pts.astype(np.float64), im2_pts.astype(np.float64))
    return np.append(H_vector, 1.0).reshape(3, 3)

Below are the correspondences for each pair (matching numbers are the same point), the full system of equations (click to expand), and the recovered homography.

Gateway: image 1 → image 2 (6 correspondences)
Gateway image 1 points
Image 1
Gateway image 2 points
Image 2
System of equations Ah = b
A
1 1202 9681000-107 520-284 928
0001 1202 9681-3 561 600-9 438 240
1 9362 6811000-2 230 272-3 088 512
0001 9362 6811-5 076 192-7 029 582
2 2442 4461000-3 197 700-3 485 550
0002 2442 4461-5 304 816-5 782 344
2 9732 5011000-5 714 106-4 806 922
0002 9732 5011-6 968 712-5 862 344
2 2451 2791000-3 196 880-1 821 296
0002 2451 2791-2 898 295-1 651 189
2 3981 8491000-3 719 298-2 867 799
0002 3981 8491-4 366 758-3 367 029
b
96
3 180
1 152
2 622
1 425
2 364
1 922
2 344
1 424
1 291
1 551
1 821
H =
3.34410.0326-3653.94
1.54142.5539-3202.77
7.579e-42.314e-51.0000
Gateway: image 3 → image 2 (5 correspondences)
Gateway image 3 points
Image 3
Gateway image 2 points
Image 2
System of equations Ah = b
A
3752 4001000-534 375-3 420 000
0003752 4001-886 500-5 673 600
9982 3221000-1 918 156-4 462 884
0009982 3221-2 339 312-5 442 768
1 1123 3111000-2 288 496-6 814 038
0001 1123 3111-3 754 112-11 177 936
1 5112 7311000-3 806 209-6 879 389
0001 5112 7311-4 347 147-7 857 087
1 3851 7861000-3 229 820-4 164 952
0001 3851 7861-2 447 295-3 155 862
b
1 425
2 364
1 922
2 344
2 058
3 376
2 519
2 877
2 332
1 767
H =
0.27050.01771155.18
-0.49890.7636510.54
-2.394e-46.348e-71.0000
Sather Gate: image 1 → image 2 (5 correspondences)
Sather Gate image 1 points
Image 1
Sather Gate image 2 points
Image 2
System of equations Ah = b
A
2 4861 2431000-1 904 276-952 138
0002 4861 2431-2 495 944-1 247 972
3 1841 5241000-4 747 344-2 272 284
0003 1841 5241-4 473 520-2 141 220
4 0921 0611000-9 509 808-2 465 764
0004 0921 0611-4 370 256-1 133 148
2 8125 3121000-3 090 388-5 837 888
0002 8125 3121-14 515 544-27 420 544
2 3625 2961000-1 424 286-3 193 488
0002 3625 2961-12 383 966-27 766 928
b
766
1 004
1 491
1 405
2 324
1 068
1 099
5 162
603
5 243
H =
1.5543-3.039e-4-2863.59
0.33881.3958-1256.01
1.214e-46.840e-61.0000
Sather Gate: image 3 → image 2 (5 correspondences)
Sather Gate image 3 points
Image 3
Sather Gate image 2 points
Image 2
System of equations Ah = b
A
9478231000-2 200 828-1 912 652
0009478231-1 011 396-878 964
2 4891 0021000-9 689 677-3 900 786
0002 4891 0021-2 615 939-1 053 102
1 7951 2911000-5 621 940-4 043 412
0001 7951 2911-2 568 645-1 847 421
2 5694 8491000-10 237 465-19 323 265
0002 5694 8491-13 435 870-25 360 270
9663 4441000-2 213 106-7 890 204
0009663 4441-3 444 756-12 281 304
b
2 324
1 068
3 893
1 051
3 132
1 431
3 985
5 230
2 291
3 566
H =
0.7074-0.03761525.60
-0.17690.8542463.34
-6.259e-5-9.425e-61.0000
Tree: image 1 → image 2 (6 correspondences)
Tree image 1 points
Image 1
Tree image 2 points
Image 2
System of equations Ah = b
A
1 8299511000-3 467 784-1 803 096
0001 8299511-5 684 532-2 955 708
7827091000-751 502-681 349
0007827091-2 310 028-2 094 386
3 2191 2241000-10 262 172-3 902 112
0003 2191 2241-10 603 386-4 031 856
3 6111 0291000-12 768 496-3 638 544
0003 6111 0291-11 309 652-3 222 828
1 4781 1001000-2 324 894-1 730 300
0001 4781 1001-4 807 934-3 578 300
1851 2581000-63 825-434 010
0001851 2581-637 325-4 333 810
b
1 896
3 108
961
2 954
3 188
3 294
3 536
3 132
1 573
3 253
345
3 445
H =
0.8599-0.1567351.90
-0.02640.55942430.35
1.829e-6-7.192e-51.0000
Tree: image 3 → image 2 (6 correspondences)
Tree image 3 points
Image 3
Tree image 2 points
Image 2
System of equations Ah = b
A
1 8214 1541000-3 452 616-7 875 984
0001 8214 1541-5 659 668-12 910 632
8653 9641000-831 265-3 809 404
0008653 9641-2 555 210-11 709 656
3 1804 3521000-10 137 840-13 874 176
0003 1804 3521-10 474 920-14 335 488
3 5134 1661000-12 421 968-14 730 976
0003 5134 1661-11 002 716-13 047 912
1 4774 3121000-2 323 321-6 782 776
0001 4774 3121-4 804 681-14 026 936
1654 5371000-56 925-1 565 265
0001654 5371-568 425-15 629 965
b
1 896
3 108
961
2 954
3 188
3 294
3 536
3 132
1 573
3 253
345
3 445
H =
1.34370.1861-568.98
-6.685e-31.5287-1979.42
-2.511e-69.687e-51.0000

A.3  Warp the Images

Both warps use inverse warping. First I push the source image's four corners through H to get the bounding box of the output. Then, for every output pixel, I map it back into the source image with H−1 (dividing by the homogeneous coordinate) and sample a color there. Pixels that land outside the source are left black. Going backward means every output pixel gets exactly one color, so there are no holes.

The two methods differ only in how they sample that color. Nearest neighbor rounds the source location to the closest pixel. Bilinear takes the four surrounding pixels and blends them by distance: first along x (top and bottom pairs), then along y.

def warp_image(im, H, color_fn):
    min_x, min_y, max_x, max_y = find_warp_image_bounds(im, H)
    height = max_y - min_y + 1
    width = max_x - min_x + 1
    warped_im = np.zeros((height, width, 3))
    H_inv = np.linalg.inv(H)
    for yp in range(height):
        for xp in range(width):
            point = np.array([xp + min_x, yp + min_y, 1.0])
            point_approx = H_inv @ point
            point_approx /= point_approx[2]
            color = color_fn(im, point_approx)
            warped_im[yp, xp] = color
    return warped_im

def warp_image_nearest_neighbor(im, H):
    def nearest_neighbor(src_im, point_approx):
        height, width = src_im.shape[:2]
        x, y = np.rint(point_approx[:2]).astype(int)
        if x >= 0 and x < width and y >= 0 and y < height:
            return src_im[y, x]
        return [0, 0, 0]
    return warp_image(im, H, nearest_neighbor)

def warp_image_bilinear(im, H):
    def bilinear(src_im, point_approx):
        height, width = src_im.shape[:2]
        x, y = point_approx[:2]
        if x < 0 or x > width - 1 or y < 0 or y > height - 1:
            return [0, 0, 0]
        low_x, low_y = np.floor(point_approx[:2]).astype(int)
        high_x, high_y = low_x + 1, low_y + 1
        dx = x - low_x
        dy = y - low_y
        # colors at each corner
        top_left = src_im[low_y, low_x]
        top_right = src_im[low_y, high_x]
        bottom_left = src_im[high_y, low_x]
        bottom_right = src_im[high_y, high_x]
        # x interpolation
        top = (1 - dx) * top_left + dx * top_right
        bottom = (1 - dx) * bottom_left + dx * bottom_right
        # y interpolation
        return (1 - dy) * top + dy * bottom
    return warp_image(im, H, bilinear)

Rectification

To rectify, I clicked the 4 corners of something I know is a rectangle and computed the homography that sends them to the corners of an axis-aligned rectangle.

Door
Door with window corners marked
Original, with door window corners marked
Rectified door
Rectified (bilinear)

Window
Window with pane corners marked
Original, with top-left pane corners marked
Rectified window
Rectified (bilinear)

Nearest Neighbor vs. Bilinear

The door rectification stretches the right side of the image a lot, so it's a good place to compare. Nearest neighbor is faster but gets blocky and jagged where pixels are upsampled. Bilinear is slower (four lookups per pixel instead of one) but much smoother. For the mosaics I used nearest neighbor, since the photos are big and it ran a lot faster on my computer.

Nearest neighbor vs bilinear crop
Nearest neighbor (left) vs. bilinear (right), cropped from the rectified door

A.4  Blend the Images into a Mosaic

For each set I start with image 2 as the mosaic, then warp images 1 and 3 into its frame with their homographies and add them one at a time. Each time, the canvas grows to fit the warped image's bounding box.

To hide the seams, I weight each pixel in the overlap by its distance to the edge of its image (using a distance transform), so each image fades out toward its border. Then I do two-band blending: the low frequencies (Gaussian blur) are blended with the smooth, blurred weights, and the high frequencies use the sharp weights so edges don't ghost. I used σ = 50 for the gateway and σ = 20 for Sather Gate and the tree.

def two_band_blending(im1, im2, mask, sigma):
    low_im1, high_im1 = get_two_band(im1, sigma)
    low_im2, high_im2 = get_two_band(im2, sigma)
    low_mask = cv2.GaussianBlur(mask, (0, 0), sigmaX=sigma)
    mask = mask[:, :, None]
    low_mask = low_mask[:, :, None]

    low_blended = low_mask * low_im1 + (1 - low_mask) * low_im2
    high_blended = mask * high_im1 + (1 - mask) * high_im2
    return low_blended + high_blended

# in add_image_to_mosaic: weight overlapping pixels by distance to each image's boundary
dist1 = cv2.distanceTransform(mask1, cv2.DIST_L2, 3)
dist2 = cv2.distanceTransform(mask2, cv2.DIST_L2, 3)
overlap = (mask1 == 1.0) & (mask2 == 1.0)
blend_mask[overlap] = dist1[overlap] / (dist1[overlap] + dist2[overlap])
Gateway
Gateway 1
Image 1
Gateway 2
Image 2
Gateway 3
Image 3
Gateway mosaic
Gateway mosaic

Sather Gate
Sather 1
Image 1
Sather 2
Image 2
Sather 3
Image 3
Sather Gate mosaic
Sather Gate mosaic

Tree
Tree 1
Image 1
Tree 2
Image 2
Tree 3
Image 3
Tree mosaic
Tree mosaic

The blending hides the exposure differences between shots pretty well. People who moved between shots still ghost (like the walkers under Sather Gate), and the wide-angle gateway shots leave a visible seam on the white chair in front.


Website template from Jon Barron.