Feature Matching (SIFT/ORB) and Image Stitching
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- 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.
graph LR A["Photo 1"] --> D["Detect keypoints<br/>+ descriptors"] B["Photo 2"] --> D D --> E["Match descriptors"] E --> F["Lowe's ratio test<br/>drops ~90%"] F --> G["RANSAC<br/>drops the rest"] G --> H["Homography H"] H --> I["Warp + blend"] I --> J["Panorama"]
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.
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
| Property | SIFT | ORB |
|---|---|---|
| Descriptor | 128 floats | 256 bits (32 bytes) |
| Memory per keypoint | 512 bytes | 32 bytes |
| Distance metric | Euclidean (L2) | Hamming |
| Typical time, 1000 keypoints | ~120 ms | ~4 ms |
| Scale invariant | Yes, image pyramid | Yes, image pyramid |
| Rotation invariant | Yes | Yes |
| Robust to viewpoint change | Very good | Moderate |
| Robust to blur | Good | Weaker |
| Licence | Free since 2020 | Always free |
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:
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:
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 | nearest | 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 , above the 0.75 threshold, so this keypoint looks almost as similar to two different places. Drop it.
Match 5 has a ratio of . 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:
- Pick 4 matches at random (4 is the minimum needed for a homography).
- Compute the transformation from just those 4.
- Apply it to every other match and measure the error.
- Count how many fall within a threshold — these are the inliers.
- 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:
where is the confidence you want, is the fraction of matches that are inliers, and is the sample size.
With , , :
72 iterations to be 99% sure of finding a clean sample, even when half the matches are garbage. Now try a much worse case, :
| # | Inlier fraction | 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 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 and produces :
That final division by 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:
Apply it to the keypoint at :
Divide:
So the point at in image 1 should appear at in image 2.
Reprojection error decides inliers
The matching step said this keypoint appears at in image 2. The homography predicted . The reprojection error The distance in pixels between where a model predicts a point should land and where it was actually measured. is:
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 | Predicted | 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
graph TD
A["5 overlapping photos"] --> B["ORB on each"]
B --> C["Match consecutive pairs"]
C --> D["Ratio test per pair"]
D --> E["RANSAC homography per pair"]
E --> F{"Inliers > 30<br/>for every pair?"}
F -->|"no"| G["Retake that pair<br/>with more overlap"]
F -->|"yes"| H["Chain homographies<br/>to a common frame"]
H --> I["Warp all into the canvas"]
I --> J["Blend seams"]
J --> K["Crop to the largest rectangle"]
Two homographies chain by multiplication. If maps photo 1 to photo 2 and maps photo 2 to photo 3, then photo 1 maps to photo 3 by:
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. .
- The camera rotates but stays roughly in one place
- The scene is effectively flat — a wall, a document, a floor
- You are correcting perspective on a known planar object
- You want a panorama from a hand-held sweep
- The camera moves sideways through a scene with real depth
- You need actual distances or 3D structure
- The overlap between images is under about 25%
- The overlap region is textureless
Practice task
Photograph a bookshelf in four overlapping shots, sweeping left to right.
- Detect ORB keypoints in each. Record how many are found per image.
- Match consecutive pairs raw, without the ratio test. Draw the matches and count the obviously wrong lines.
- Apply the ratio test at 0.75. Draw again and count how many survived.
- Run
findHomographywith RANSAC and record the inlier count per pair. - Repeat steps 3–4 at ratio thresholds 0.6, 0.7, 0.8, and 0.9. Plot inlier count and inlier ratio against threshold.
- Warp image 2 into image 1’s frame and look at the seam.
- 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 and got after dividing by , then measured a reprojection error of 2.69 pixels against the observed — an inlier at a 3-pixel threshold. Always check the inlier count, because findHomography returns a matrix whether or not it means anything.
- Corners make good keypoints because the patch changes in every direction; edges and flat regions do not.
- SIFT gives 128 floats per keypoint, ORB gives 32 bytes — 16x smaller and about 30x faster to match.
- Hamming distance is a bit count: XOR the two binary descriptors and count the ones.
- Always use knnMatch with k=2 plus the ratio test, never a raw distance sort.
- Wrong matches cluster at ratios near 1.0 because a random descriptor is roughly equidistant from everything.
- RANSAC iterations follow N = log(1-p)/log(1-w^s): 72 at w=0.5, but 566 at w=0.3.
- A homography divides by w', which is what makes it perspective rather than affine.
- Reprojection error separates inliers from outliers by hundreds of pixels, so the threshold is not sensitive.
- The inlier count is your only failure signal — findHomography never tells you it failed.
- A homography is exact only for flat scenes or pure camera rotation. Parallax breaks it.
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.