Search…

Feature Matching (SIFT/ORB) and Image Stitching

In this series (30 parts)
  1. Computer Vision Roadmap: From Basics to Real Projects
  2. What is Computer Vision? From Pixels to Decisions
  3. A Short History of Computer Vision: 1545 to Now
  4. Math for CV Beginners: Vectors, Matrices, Convolutions
  5. Image Fundamentals: Color, Histograms, Noise, Filtering
  6. How Images and Video Are Stored: Colour, JPEG, Frames
  7. OpenCV Setup + First 10 Tasks in Python
  8. Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
  9. CV Project Workflow: Dataset, Baseline, Error Iteration
  10. Intensity Transforms and Frequency-Domain Filtering
  11. Edge Detection and Thresholding That Actually Work
  12. Morphology, Contours, and Shape Analysis for Real Images
  13. Feature Matching (SIFT/ORB) and Image Stitching
  14. Camera Calibration and Perspective Correction
  15. Epipolar Geometry, Stereo Vision, and Depth Estimation
  16. Optical Flow and Motion Tracking in Video
  17. HOG, HOF and MBH: Descriptors Before Deep Learning
  18. Your First Image Classifier (PyTorch + Transfer Learning)
  19. Data Pipelines and Augmentation for Vision Models
  20. CNN Architectures Explained: From LeNet to ResNet
  21. YOLO Detection Pipeline: Data to Inference
  22. Semantic and Instance Segmentation: U-Net to Mask R-CNN
  23. Vision Transformers (ViT) and When to Use Them
  24. CV Explainability: Grad-CAM, Failures, Bias Checks
  25. 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
  26. Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
  27. Video Understanding: Flow Networks, Interpolation, Stabilisation
  28. Real-Time CV Systems: Tracking, Latency, Streaming
  29. Deploying CV Models: ONNX, TensorRT, Edge, and APIs
  30. Responsible CV: Privacy, Fairness, Security, Governance

Every method so far has worked inside one image. This post is about relating two images: finding the same physical point in both, throwing away the matches that are wrong, and computing the transformation that lines the images up. That single idea underpins panoramas, visual tracking, augmented reality, and 3D reconstruction.

Prerequisites: Edge detection and thresholding for gradients, and math for computer vision for matrix multiplication.

The problem: photographing a long shelf

A warehouse needs a single wide photo of each aisle for inventory checks. An aisle is 12 metres long, so a phone must take five overlapping photos. Those five need to become one image, aligned well enough that a barcode near a seam is still readable.

You cannot just place the photos side by side. The camera moved, rotated slightly, and tilted between shots, so the same shelf edge appears at a different position and angle in each photo. You need to work out, from the images themselves, exactly how each one relates to the next.

What makes a good keypoint

Pick a random pixel in a photo and try to find the same pixel in the next photo. If it sits on a plain white wall, thousands of pixels look identical and you cannot. If it sits on a straight shelf edge, you can tell which side of the edge you are on but not where along it — you can slide freely in one direction. Only at a corner are you locked in both directions.

Flat: slides freely both ways (5×5)
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
200
Edge: slides vertically (5×5)
200
200
200
20
20
200
200
200
20
20
200
200
200
20
20
200
200
200
20
20
200
200
200
20
20
Corner: locked both ways (5×5)
200
200
200
20
20
200
200
200
20
20
200
200
200
20
20
20
20
20
20
20
20
20
20
20
20

That is the whole intuition behind corner detection. Shift a small window by one pixel in every direction and measure how much the patch content changes:

# Patch type Change when shifted horizontally Change when shifted vertically Good keypoint? target
1 Flat region near zero near zero No
2 Vertical edge large near zero No
3 Horizontal edge near zero large No
4 Corner large large Yes

The Harris corner criterion in one table: a corner changes a lot in every direction

Both SIFT and ORB build on this. They add two things a plain corner detector lacks: a scale, so the same corner is found whether the camera is near or far, and an orientation, so the same corner is found whether the camera is upright or rotated.

keypoint A location in an image, plus its scale and orientation, chosen because the patch around it is distinctive enough to recognise again in another image. descriptor A fixed-length vector summarising the appearance of the patch around a keypoint. Two descriptors are compared by distance, so a small distance means the patches look alike.

SIFT versus ORB

PropertySIFTORB
Descriptor128 floats256 bits (32 bytes)
Memory per keypoint512 bytes32 bytes
Distance metricEuclidean (L2)Hamming
Typical time, 1000 keypoints~120 ms~4 ms
Scale invariantYes, image pyramidYes, image pyramid
Rotation invariantYesYes
Robust to viewpoint changeVery goodModerate
Robust to blurGoodWeaker
LicenceFree since 2020Always free
SIFT and ORB compared. ORB trades accuracy for roughly 30x more speed.

The memory figure is the one people underestimate. Matching 2,000 SIFT keypoints against 2,000 others means comparing 128-dimensional float vectors four million times. ORB compares 256-bit strings, which a CPU does with a single XOR and a population-count instruction.

Hamming distance is just the number of differing bits. Here are two 16-bit slices of ORB descriptors:

Descriptor A (1×16)
1
0
1
1
0
0
1
0
1
1
0
0
0
1
1
0
XOR
Descriptor B (1×16)
1
0
1
0
0
1
1
0
1
1
0
0
0
1
1
1
count
A XOR B → Hamming = 3 (1×16)
0
0
0
1
0
1
0
0
0
0
0
0
0
0
0
1

Three bits differ out of sixteen, so the Hamming distance is 3. For a real 256-bit descriptor, a distance under about 50 usually means a genuine match and over 80 usually does not.

import cv2

img1 = cv2.imread("shelf_01.jpg", cv2.IMREAD_GRAYSCALE)
img2 = cv2.imread("shelf_02.jpg", cv2.IMREAD_GRAYSCALE)

# ORB: fast, binary descriptors
orb = cv2.ORB_create(nfeatures=3000)
kp1, des1 = orb.detectAndCompute(img1, None)
kp2, des2 = orb.detectAndCompute(img2, None)
print(f"{len(kp1)} and {len(kp2)} keypoints")

# SIFT: slower, more accurate
sift = cv2.SIFT_create()
kps, dess = sift.detectAndCompute(img1, None)
print(f"SIFT descriptor shape: {dess.shape}")   # (N, 128)

Matching, and why raw matches are mostly wrong

For each descriptor in image 1, find the closest descriptor in image 2. Simple, and mostly wrong. A shelf has dozens of near-identical bracket corners, and the closest descriptor is often a different bracket.

David Lowe’s fix is one line and removes most of the damage. For each keypoint, find the two nearest neighbours and compare their distances:

ratio=d1d2\text{ratio} = \frac{d_1}{d_2}

If the best match is much closer than the second best, the match is distinctive and probably right. If both are about equally close, the feature is ambiguous and you should discard it rather than guess.

# Match d1d_1 nearest d2d_2 second nearest Ratio Keep? (< 0.75) target
1 1 31 88 0.35 keep
2 2 24 91 0.26 keep
3 3 42 51 0.82 drop
4 4 55 62 0.89 drop
5 5 18 74 0.24 keep
6 6 60 66 0.91 drop
7 7 37 52 0.71 keep
8 8 49 53 0.92 drop

Lowe's ratio test on eight matches. Four survive.

Work through match 3: the nearest descriptor is at distance 42 and the second nearest at 51. The ratio is 42/51=0.8242/51 = 0.82, above the 0.75 threshold, so this keypoint looks almost as similar to two different places. Drop it.

Match 5 has a ratio of 18/74=0.2418/74 = 0.24. Its best match is four times closer than its runner-up. That is a confident match.

The shape of that plot is the argument for the ratio test. Correct matches spread across low ratios; wrong matches pile up near 1.0 because a random descriptor is roughly equidistant from everything. One threshold cuts cleanly between the two populations.

# Ratio threshold Matches kept Roughly how many are correct target Use when
1 0.6 few ~95% You need very high precision
2 0.75 moderate ~85% The standard default
3 0.8 more ~70% Few features available, RANSAC will clean up
4 0.9 many ~40% Almost never — too much noise for RANSAC

Ratio threshold trade-off

bf = cv2.BFMatcher(cv2.NORM_HAMMING)          # NORM_L2 for SIFT
knn = bf.knnMatch(des1, des2, k=2)

good = [m for m, n in knn if m.distance < 0.75 * n.distance]
print(f"{len(knn)} raw -> {len(good)} after ratio test")

RANSAC: ignoring the matches that are still wrong

After the ratio test roughly 15% of matches are still wrong. Fitting a transformation to all of them gives a result pulled badly off by the outliers, because least squares gives every point a vote weighted by its error squared.

RANSAC Random Sample Consensus. Repeatedly fit a model to a small random subset, count how many of all points agree with it, and keep the model with the most agreement.

The loop:

  1. Pick 4 matches at random (4 is the minimum needed for a homography).
  2. Compute the transformation from just those 4.
  3. Apply it to every other match and measure the error.
  4. Count how many fall within a threshold — these are the inliers.
  5. Repeat, then keep the model with the most inliers and refit using all of them.

How many iterations do you need?

There is a formula, and it explains why RANSAC is practical:

N=log(1p)log(1ws)N = \frac{\log(1 - p)}{\log(1 - w^s)}

where pp is the confidence you want, ww is the fraction of matches that are inliers, and ss is the sample size.

With p=0.99p = 0.99, w=0.5w = 0.5, s=4s = 4:

ws=0.54=0.0625w^s = 0.5^4 = 0.0625 N=log(0.01)log(10.0625)=4.6050.0645=71.472 iterationsN = \frac{\log(0.01)}{\log(1 - 0.0625)} = \frac{-4.605}{-0.0645} = 71.4 \rightarrow 72 \text{ iterations}

72 iterations to be 99% sure of finding a clean sample, even when half the matches are garbage. Now try a much worse case, w=0.3w = 0.3:

0.34=0.0081,N=4.605log(0.9919)=4.6050.008133=5660.3^4 = 0.0081, \qquad N = \frac{-4.605}{\log(0.9919)} = \frac{-4.605}{-0.008133} = 566

# Inlier fraction ww w4w^4 Iterations for 99% confidence target
1 0.8 0.410 9
2 0.6 0.130 34
3 0.5 0.063 72
4 0.4 0.026 177
5 0.3 0.008 566
6 0.2 0.0016 2877

RANSAC iterations needed, computed from N = log(1-p)/log(1-w^s) with p=0.99 and s=4

The cost grows steeply as the inlier fraction falls. That is precisely why the ratio test matters: pushing ww from 0.3 to 0.5 cuts the work by a factor of eight, and the ratio test is one line of code.

Homography: the transformation itself

A homography A 3x3 matrix that maps points from one image plane to another. It is exact when the scene is flat, or when the camera only rotates about its own centre. takes a point (x,y)(x, y) and produces (u,v)(u, v):

[uvw]=[h11h12h13h21h22h23h31h32h33][xy1],u=uw,v=vw\begin{bmatrix} u' \\ v' \\ w' \end{bmatrix} = \begin{bmatrix} h_{11} & h_{12} & h_{13} \\ h_{21} & h_{22} & h_{23} \\ h_{31} & h_{32} & h_{33} \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix}, \qquad u = \frac{u'}{w'}, \quad v = \frac{v'}{w'}

That final division by ww' is what makes it a perspective transform rather than an affine one. It is why parallel shelf edges converge in a tilted photo.

Work one point through. Suppose RANSAC returns:

H=[1.200.10300.051.10200.00020.00011]H = \begin{bmatrix} 1.20 & 0.10 & 30 \\ 0.05 & 1.10 & -20 \\ 0.0002 & 0.0001 & 1 \end{bmatrix}

Apply it to the keypoint at (200,150)(200, 150):

u=1.20(200)+0.10(150)+30=240+15+30=285u' = 1.20(200) + 0.10(150) + 30 = 240 + 15 + 30 = 285 v=0.05(200)+1.10(150)20=10+16520=155v' = 0.05(200) + 1.10(150) - 20 = 10 + 165 - 20 = 155 w=0.0002(200)+0.0001(150)+1=0.04+0.015+1=1.055w' = 0.0002(200) + 0.0001(150) + 1 = 0.04 + 0.015 + 1 = 1.055

Divide:

u=2851.055=270.1,v=1551.055=146.9u = \frac{285}{1.055} = 270.1, \qquad v = \frac{155}{1.055} = 146.9

So the point at (200,150)(200, 150) in image 1 should appear at (270.1,146.9)(270.1, 146.9) in image 2.

Reprojection error decides inliers

The matching step said this keypoint appears at (272,145)(272, 145) in image 2. The homography predicted (270.1,146.9)(270.1, 146.9). The reprojection error The distance in pixels between where a model predicts a point should land and where it was actually measured. is:

e=(272270.1)2+(145146.9)2=1.92+1.92=7.22=2.69 pxe = \sqrt{(272 - 270.1)^2 + (145 - 146.9)^2} = \sqrt{1.9^2 + 1.9^2} = \sqrt{7.22} = 2.69 \text{ px}

With a threshold of 3.0 pixels, 2.69 is under it, so this match is an inlier and votes for this homography.

# Match Measured (u,v)(u,v) Predicted (u,v)(u,v) Error (px) Inlier at 3.0 px? target
1 A (272, 145) (270.1, 146.9) 2.69 yes
2 B (415, 302) (414.2, 301.4) 1.00 yes
3 C (88, 511) (90.1, 509.8) 2.42 yes
4 D (630, 120) (511.7, 388.2) 293.1 no
5 E (201, 460) (199.8, 461.5) 1.92 yes

Reprojection errors for five matches. Outliers are not close calls — they are wrong by hundreds of pixels.

Notice how separated the outlier is. Wrong matches are not slightly off, they are completely off, which is why a threshold anywhere from 2 to 5 pixels gives the same answer.

import numpy as np

src = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
dst = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)

H, mask = cv2.findHomography(src, dst, cv2.RANSAC,
                             ransacReprojThreshold=3.0,
                             maxIters=2000, confidence=0.995)

inliers = int(mask.sum())
print(f"{inliers} / {len(good)} inliers ({inliers/len(good):.0%})")

The inlier count is your health check. Below about 15 inliers, do not trust the homography at all. Between 15 and 30 it is usable but fragile. Above 50 with a ratio over 60%, the alignment is solid.

# Inliers Inlier ratio Verdict target What to do
1 < 10 any Failed Reject the pair — no reliable overlap
2 10–25 < 40% Unreliable Retake with more overlap
3 25–50 > 50% Usable Proceed, check the seam visually
4 > 50 > 60% Good Proceed

Reading the inlier count. Always check it — findHomography returns a matrix even when the result is meaningless.

Stitching the panorama

Two homographies chain by multiplication. If H12H_{12} maps photo 1 to photo 2 and H23H_{23} maps photo 2 to photo 3, then photo 1 maps to photo 3 by:

H13=H23H12H_{13} = H_{23} \cdot H_{12}

Order matters — matrices do not commute. This chaining is also where error accumulates: five photos means four chained homographies, and a half-pixel error in each compounds along the chain. Real panorama software runs a global refinement (bundle adjustment) at the end to spread that error evenly rather than letting it pile up at one end.

stitcher = cv2.Stitcher_create(cv2.Stitcher_PANORAMA)
status, pano = stitcher.stitch([img1, img2, img3, img4, img5])

codes = {
    cv2.Stitcher_OK: "ok",
    cv2.Stitcher_ERR_NEED_MORE_IMGS: "not enough overlap between photos",
    cv2.Stitcher_ERR_HOMOGRAPHY_EST_FAIL: "could not align — too few good matches",
    cv2.Stitcher_ERR_CAMERA_PARAMS_ADJUST_FAIL: "inconsistent geometry across the set",
}
print(codes[status])

For a straightforward panorama, use cv2.Stitcher. Build the pipeline by hand when you need control over which pairs are matched, when you want to blend seams your own way, or when you are debugging a failure and need to see the inlier counts.

When feature matching fails

# Situation Why it fails What to do instead
1 Blank wall or clear sky No corners exist to detect Add overlap that includes textured areas
2 Repeating tiles or brickwork Every corner looks like every other Tighten the ratio test to 0.6; rely on RANSAC
3 Motion blur Corners smear into edges Shorter exposure, or a wider ratio plus more iterations
4 Large viewpoint change The patch itself looks different Take intermediate photos; use SIFT over ORB
5 Moving objects, e.g. a forklift Matches on it are real but inconsistent RANSAC handles it if the static scene dominates
6 Scene is not flat and camera translated A homography cannot describe it You need epipolar geometry, not a homography

Failure modes, causes, and fixes

That last row is a genuine limit rather than a tuning problem. A homography is exact only when the scene is flat, or when the camera rotates about its own centre without moving. Walk sideways past a shelf with objects at different depths and no single 3×3 matrix can align both the near and far objects at once. The near items shift more than the far ones — that is parallax The apparent shift of nearby objects relative to distant ones when the camera moves sideways. It carries depth information, and it is exactly what a homography cannot represent. .

Practice task

Photograph a bookshelf in four overlapping shots, sweeping left to right.

  1. Detect ORB keypoints in each. Record how many are found per image.
  2. Match consecutive pairs raw, without the ratio test. Draw the matches and count the obviously wrong lines.
  3. Apply the ratio test at 0.75. Draw again and count how many survived.
  4. Run findHomography with RANSAC and record the inlier count per pair.
  5. Repeat steps 3–4 at ratio thresholds 0.6, 0.7, 0.8, and 0.9. Plot inlier count and inlier ratio against threshold.
  6. Warp image 2 into image 1’s frame and look at the seam.
  7. Now photograph a blank wall the same way and repeat. Note where it breaks.

Step 7 is the one to actually do. Watching the pipeline fail on a blank wall — and seeing findHomography still cheerfully return a matrix — teaches more about why the inlier count matters than any amount of reading.

Summary

Keypoints must be corners, because only a corner is unambiguous when you shift the window in both directions. Each keypoint carries a descriptor: 128 floats for SIFT, 256 bits for ORB, which is 16× smaller and roughly 30× faster to compare using Hamming distance — you counted 3 differing bits between two 16-bit slices by hand.

Raw matches are mostly wrong. Lowe’s ratio test compares each match’s nearest neighbour to its runner-up and discards anything above 0.75; you worked through eight matches and kept four. That single step matters enormously to RANSAC’s cost: at an inlier fraction of 0.5 you need 72 iterations, but at 0.3 you need 566.

RANSAC fits a homography from 4 random matches and counts how many agree. You applied a homography to the point (200,150)(200, 150) and got (270.1,146.9)(270.1, 146.9) after dividing by w=1.055w' = 1.055, then measured a reprojection error of 2.69 pixels against the observed (272,145)(272, 145) — an inlier at a 3-pixel threshold. Always check the inlier count, because findHomography returns a matrix whether or not it means anything.

What comes next

Before moving on, one contrast worth holding onto. SIFT and ORB find specific points — a corner on this particular building — and match them between views of the same scene. They answer “is this the same thing I saw before?”. HOG, covered in HOG, HOF and MBH descriptors, describes a whole region by its overall shape and answers “is this the kind of thing I am looking for?”. One is about instance identity, the other about category. That distinction — the recognition ladder from post 1 — decides which descriptor you reach for.

RANSAC gave you the relationship between two views, but not in real units — the homography is in pixels, and the pixels depend on the lens. Camera calibration and perspective correction works out the camera’s internal parameters so you can remove lens distortion and start measuring in millimetres. After that, epipolar geometry and stereo vision handles the case a homography cannot: a camera that translates through a scene with real depth.

Test your understanding
You are stitching photos of a brick wall. The ratio test at 0.75 leaves 400 matches, but RANSAC finds only 12 inliers and the warped result is badly skewed. What is the most likely cause?
Test your understanding
You walk sideways along a supermarket aisle taking photos, then stitch them. Products on the near shelf align perfectly but the far wall is doubled at every seam. Why?

Frequently asked questions

Should I use SIFT or ORB?
Start with ORB. It is roughly 30 times faster, uses 16 times less memory, and is good enough for most stitching and tracking where the viewpoint change between frames is modest. Switch to SIFT when ORB gives too few inliers — typically with large viewpoint changes, significant blur, or low-texture scenes where you need every match you can get. Both have been free to use since SIFT's patent expired in 2020.
Why does the ratio test use two nearest neighbours instead of an absolute distance cut?
Absolute descriptor distance has no fixed meaning, because a highly textured patch and a nearly flat one produce completely different distance scales. Comparing each match against its own runner-up normalises that away and asks a better question: is this match distinctly better than the alternatives? A match that is barely better than its runner-up is ambiguous no matter how small its absolute distance.
How much overlap do I need between photos?
Aim for 30 to 40 percent. The overlap region is the only place matching can happen, and after the ratio test and RANSAC you typically retain a small fraction of the keypoints found there. Below about 25 percent the matching becomes unreliable, and below 15 percent it usually fails outright. Taking two extra photos is far cheaper than debugging a failed stitch.
What inlier count means the homography is trustworthy?
Below 10 inliers, reject the pair entirely. Between 10 and 25 with a low inlier ratio, treat it as unreliable and retake with more overlap. Above 50 inliers with an inlier ratio over 60 percent, the alignment is solid. Check this every time, because findHomography returns a matrix regardless and gives no indication when the result is meaningless.
When can I not use a homography at all?
When the camera translates through a scene that has real depth variation. A homography maps one plane to another, so it is exact for a flat scene, or for any scene if the camera only rotates about its own centre. Move sideways past objects at different distances and near objects shift more than far ones. That is parallax, it carries genuine depth information, and it requires epipolar geometry rather than a single 3x3 matrix.
Start typing to search across all content
navigate Enter open Esc close