Feature matching with SIFT and ORB
Feature matching finds the same physical spot in two photos by describing a patch around each corner in a way that survives rotation, scale and lighting changes.
- 15 min read
- 3 reading levels
- Updated
Read these first
On this page 10
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
The short answer
Feature matching finds the same real-world spot in two different photos.
The analogy you have already lived
Think of finding your bag on an airport carousel. You do not compare every bag pixel by pixel. You look for the red ribbon you tied on the handle.
The ribbon works because it is distinctive. It also still looks like your ribbon from any angle, near or far, in any light.
Feature matching picks its own ribbons. Thousands of them, automatically, on every photo.
Why it exists
Almost everything in 3D vision starts here. To work out how a camera moved, you first need matching bits in the two photos.
Panorama stitching, augmented reality, robot navigation, 3D reconstruction from a phone video. Every one of them begins by matching points.
What makes a good ribbon
Two properties.
It must be findable again. A point in the middle of a blank wall is useless. So is a point on a long straight edge, because you cannot tell where along the edge you are. Corners are what work.
It must be describable in a way that survives change. The same corner will be bigger, rotated, and lit differently in the second photo. The description has to come out roughly the same anyway.
photo 1 photo 2
find corners find corners
describe each one describe each one
\ /
\ /
for each description in 1,
find the closest one in 2The two famous methods
SIFT is the careful one, from 2004. It searches for corners at many sizes and works out which way each one points. Then it writes a rich description of 128 numbers.
ORB is the fast one, from 2011. It writes a description of only 32 bytes. It compares two of them by counting differing bits, which a computer does extremely quickly.
Below, on the same pair of pictures, SIFT gets 93% of its matches right and ORB gets 84%. ORB gives more matches and finds them faster. Neither is better; they trade accuracy for speed.
The trick that removes most bad matches
For each point in photo 1, look at its two closest candidates in photo 2, not one.
If the best is much closer than the second best, the match is probably correct. If the two are about equally close, the point was ambiguous, so throw it away.
This is called the ratio test, and it is one line of code that removes most rubbish. It also explains why matching fails on repeating patterns. On a tiled floor or a brick wall, every candidate looks equally good.
The honest part
Even after the ratio test, some matches are still wrong. In the run below, 7% of SIFT's surviving matches and 16% of ORB's are wrong by more than three pixels.
You cannot fix this by being cleverer about descriptions. Everything downstream has to survive some wrong matches, which is the subject of RANSAC for robust fitting.
Where you have already seen it
- Panorama mode joining photos seamlessly.
- Google Lens recognising a landmark from a different angle.
- AR apps keeping a virtual object glued to your table.
- Phone photo apps grouping burst shots for a sharper picture.
Remember this
- Matching finds the same physical spot in two photos, using corners.
- A description must survive rotation, scale and lighting change.
- The ratio test drops ambiguous matches, and some wrong ones still get through.
What to learn next
- RANSAC for robust fitting — surviving the wrong matches this lesson measured.
- Structure from motion — turning matches across many photos into a 3D model.
- Visual SLAM — the same matching, running live on a moving robot.
Developer — Code and libraries.
Setup
pip install opencv-python==4.10.0.84 numpy==1.26.4SIFT is patent-free since 2020 and ships in the main opencv-python package. No contrib build is needed.
We build a textured picture, warp it by a homography we chose, then match. Because we know the true warp, every match can be scored instead of counted.
Matching, and scoring the matches
import cv2
import numpy as np
print("OpenCV", cv2.__version__)
rng = np.random.default_rng(0)
# A textured "poster": random blobs and rectangles give plenty of corners.
img = np.full((360, 480), 235, np.uint8)
for _ in range(60):
x, y = rng.integers(20, 460), rng.integers(20, 340)
r = int(rng.integers(6, 22))
cv2.circle(img, (int(x), int(y)), r, int(rng.integers(0, 120)), -1)
for _ in range(25):
x, y = int(rng.integers(10, 420)), int(rng.integers(10, 300))
cv2.rectangle(img, (x, y), (x + int(rng.integers(15, 55)), y + int(rng.integers(15, 45))),
int(rng.integers(0, 90)), -1)
# The second photo: the same poster, rotated, scaled and seen at an angle.
src = np.float32([[0, 0], [480, 0], [480, 360], [0, 360]])
dst = np.float32([[60, 40], [430, 15], [470, 330], [20, 300]])
H_true = cv2.getPerspectiveTransform(src, dst)
img2 = cv2.warpPerspective(img, H_true, (480, 360), borderValue=235)
def transfer(pts, H):
p = np.hstack([pts, np.ones((len(pts), 1))])
q = (H @ p.T).T
return q[:, :2] / q[:, 2:3]
def run(name, detector, norm):
kp1, des1 = detector.detectAndCompute(img, None)
kp2, des2 = detector.detectAndCompute(img2, None)
matcher = cv2.BFMatcher(norm)
pairs = matcher.knnMatch(des1, des2, k=2)
good = [m for m, n in pairs if m.distance < 0.75 * n.distance] # Lowe's ratio test
p1 = np.float32([kp1[m.queryIdx].pt for m in good])
p2 = np.float32([kp2[m.trainIdx].pt for m in good])
err = np.linalg.norm(transfer(p1, H_true) - p2, axis=1) if len(good) else np.array([])
correct = int((err < 3).sum())
print(f"\n{name}")
print(f" descriptor: {des1.shape[1]} numbers per keypoint, dtype {des1.dtype}")
print(f" keypoints: {len(kp1)} in image 1, {len(kp2)} in image 2")
print(f" survived the ratio test: {len(good)}")
print(f" of those, within 3 px of the truth: {correct}"
f" ({100 * correct / max(len(good), 1):.0f}% correct)")
if len(err):
print(f" median error of the good matches: {np.median(err):.2f} px")
return p1, p2, good
p1, p2, good = run("SIFT (Lowe 2004, float descriptors)", cv2.SIFT_create(), cv2.NORM_L2)
run("ORB (Rublee 2011, binary descriptors)", cv2.ORB_create(1500), cv2.NORM_HAMMING)
print("\nwhy two descriptor types exist at all:")
print(" SIFT: 128 floats = 512 bytes, compared with Euclidean distance")
print(" ORB: 32 bytes = 32 bytes, compared by counting differing bits")
print(" ORB is ~16x smaller and its comparison is a XOR plus a popcount")
print("\nthe ratio test, in one line:")
print(" keep a match only if the best neighbour is much closer than the second best")
print(" a repeated pattern gives two equally good matches, and both get dropped")
H_est, mask = cv2.findHomography(p1, p2, cv2.RANSAC, 3.0)
inliers = int(mask.sum())
corners = np.float32([[0, 0], [480, 0], [480, 360], [0, 360]])
drift = np.linalg.norm(transfer(corners, H_est) - transfer(corners, H_true), axis=1)
print(f"\nfitting a homography to the SIFT matches: {inliers} of {len(good)} kept as inliers")
print(f" corner positions recovered to within {drift.max():.2f} px of the truth")OpenCV 4.10.0 SIFT (Lowe 2004, float descriptors) descriptor: 128 numbers per keypoint, dtype float32 keypoints: 283 in image 1, 418 in image 2 survived the ratio test: 170 of those, within 3 px of the truth: 158 (93% correct) median error of the good matches: 0.43 px ORB (Rublee 2011, binary descriptors) descriptor: 32 numbers per keypoint, dtype uint8 keypoints: 1503 in image 1, 1500 in image 2 survived the ratio test: 551 of those, within 3 px of the truth: 461 (84% correct) median error of the good matches: 0.96 px why two descriptor types exist at all: SIFT: 128 floats = 512 bytes, compared with Euclidean distance ORB: 32 bytes = 32 bytes, compared by counting differing bits ORB is ~16x smaller and its comparison is a XOR plus a popcount the ratio test, in one line: keep a match only if the best neighbour is much closer than the second best a repeated pattern gives two equally good matches, and both get dropped fitting a homography to the SIFT matches: 156 of 170 kept as inliers corner positions recovered to within 0.63 px of the truth
Reading the output carefully
128 numbers, float32 against 32 numbers, uint8. This one line is the whole SIFT-versus-ORB decision. SIFT's descriptor is 512 bytes and needs a Euclidean distance, which is 128 multiply-accumulates. ORB's is 32 bytes and needs a Hamming distance, which on modern hardware is four XOR instructions and four popcounts. On an embedded board or a drone, that difference decides whether the system runs.
SIFT found 283 keypoints, ORB found 1503. ORB's count is a parameter — we asked for 1500. SIFT's is not; it returns whatever survives its contrast and edge tests. This is a real usability difference: ORB gives you a budget knob, SIFT gives you what the image supports.
93% correct against 84%. Both after the same ratio test. SIFT's richer descriptor is more discriminative, and the gap widens on harder pairs with larger viewpoint changes.
Median error 0.43 px against 0.96 px. Even among the correct matches, SIFT localises better. SIFT refines keypoint positions to sub-pixel accuracy by fitting a quadratic to the scale-space response; ORB's FAST detector works at integer pixel positions with a coarser refinement. For triangulation, where errors propagate into depth, that factor of two matters.
283 keypoints in image 1 but 418 in image 2. The warp stretched part of the poster, and stretched regions expose blobs at scales the detector likes. Feature counts are not stable under viewpoint change, which is one reason matching is asymmetric — matching 1 to 2 does not give the same set as matching 2 to 1. Cross-checking both directions is a cheap extra filter.
156 of 170 inliers, corners to 0.63 px. Even with 12 wrong matches in the input, the fitted homography reproduces the true corner positions to under a pixel. That robustness is not from the matcher; it is from RANSAC inside findHomography.
Choosing, in practice
| SIFT | ORB | Learned (SuperPoint + LightGlue) | |
|---|---|---|---|
| Descriptor | 128 float | 32 byte binary | 256 float, typically |
| Speed | Moderate | Fast | GPU-fast, CPU-slow |
| Accuracy under viewpoint change | Strong | Moderate | Strongest |
| Licence | Free since 2020 | Always free | Varies; check per model |
| Dependencies | opencv-python | opencv-python | PyTorch plus weights |
For a laptop-scale reconstruction, SIFT is still the sensible default and is what COLMAP uses. For real-time on a CPU or a microcontroller, ORB is why ORB-SLAM is named that. For hard wide-baseline pairs where classical matching collapses, learned matchers are a genuine step change and cost a GPU.
Common mistakes
Using NORM_L2 for ORB. Binary descriptors need NORM_HAMMING. Using L2 runs without error and matches badly.
Skipping the ratio test. Taking the single nearest neighbour typically leaves 40 to 60% wrong matches on a real pair. The threshold of 0.75 comes from Lowe's paper; 0.7 is stricter and 0.8 more permissive.
Matching without checking des is not None. On a blank or very dark image, detectAndCompute returns None for descriptors and knnMatch then raises. Guard it.
Assuming matches are ordered or unique. Several points in image 1 can match the same point in image 2. Add a cross-check, or let RANSAC handle it.
Expecting features on textureless surfaces. A white wall, a clear sky, a plain floor — no corners means no features means no matching. This is the standing failure mode of every classical 3D pipeline, and it is why depth sensors and learned methods exist.
Comparing feature counts across OpenCV versions. They change. Compare inlier ratios or downstream accuracy instead.
Try it yourself
Replace the random poster with a regular grid — cv2.line every 40 pixels in both directions. Rerun. The ratio test will discard almost everything, because every grid intersection looks identical to every other. That is the repeated-pattern failure, and it is why matching struggles on brick walls, tiled floors and office ceilings.
What to learn next
- RANSAC for robust fitting — surviving the wrong matches this lesson measured.
- Structure from motion — turning matches across many photos into a 3D model.
- Visual SLAM — the same matching, running live on a moving robot.
Researcher — Mathematics and papers.
SIFT
Lowe (2004), Distinctive image features from scale-invariant keypoints, IJCV 60(2). Four stages:
Scale-space extrema detection. Build a Gaussian pyramid and take differences of adjacent blur levels, approximating the scale-normalised Laplacian:
$$ D(x, y, \sigma) = \bigl(G(x,y,k\sigma) - G(x,y,\sigma)\bigr) * I(x,y) \approx (k-1)\,\sigma^2 \nabla^2 G * I $$
Keypoints are local extrema of $D$ over the 3x3x3 neighbourhood in $(x, y, \sigma)$. Detecting at the scale of the extremum is what gives scale invariance.
Keypoint localisation. Fit a 3D quadratic to $D$ around each extremum and take its analytic extremum, giving sub-pixel and sub-scale position. Reject low-contrast points, and reject edge responses by thresholding the ratio of principal curvatures of the 2x2 Hessian — the same Harris criterion, applied in scale space.
Orientation assignment. Build a 36-bin histogram of gradient orientations in a neighbourhood, weighted by gradient magnitude and a Gaussian. The dominant peak defines the keypoint's orientation; peaks within 80% of it spawn duplicate keypoints. Describing relative to this orientation is what gives rotation invariance.
Descriptor. A 4x4 grid of 8-bin orientation histograms, computed in the rotated frame, gives $4 \times 4 \times 8 = 128$ values, normalised to unit length, clipped at 0.2, and renormalised. The clipping suppresses the effect of large gradient magnitudes from non-linear illumination change.
ORB
Rublee et al. (ICCV 2011), ORB: an efficient alternative to SIFT or SURF. Two contributions on top of existing parts.
oFAST. FAST corners (Rosten and Drummond, 2006) have no orientation. ORB adds one from the intensity centroid: with moments $m_{pq} = \sum_{x,y} x^p y^q I(x,y)$, the orientation is $\theta = \operatorname{atan2}(m_{01}, m_{10})$. It is a single pass over the patch.
rBRIEF. BRIEF (Calonder et al., 2010) is a binary string whose bits are intensity comparisons at 256 point pairs. It is not rotation invariant, so ORB rotates the sampling pattern by $\theta$. Rotating a randomly-chosen pattern destroys BRIEF's carefully high variance and low correlation, so ORB learns the 256 pairs greedily from a training set, selecting for variance near 0.5 and low pairwise correlation. That learned pattern is baked into the implementation.
Scale invariance comes from running oFAST over an image pyramid, which is coarser than SIFT's continuous scale-space search — one reason ORB's localisation error is roughly double SIFT's in the run above.
Why the ratio test works
Lowe's ratio test keeps a match when
$$ \frac{d_1}{d_2} < \tau, \qquad \tau \approx 0.75 $$
where $d_1$ and $d_2$ are the distances to the nearest and second-nearest descriptors.
The justification is a likelihood ratio. In a high-dimensional descriptor space, the distance from a query to a random descriptor is tightly concentrated — the concentration-of-measure effect. So $d_2$ is an estimate of "how far away things are when they are not the answer". If $d_1$ is not much smaller than that reference, the nearest neighbour carries no evidence. The test is a per-query normalisation, which is why a single global distance threshold works so much worse.
Lowe's original figure shows the test rejecting about 90% of false matches while discarding about 5% of correct ones.
Cost
Descriptor matching by brute force is $O(n_1 n_2 d)$. For $n = 2000$ and $d = 128$, that is around $5\times10^8$ operations per pair — the dominant cost in a classical SfM pipeline, and why approximate nearest neighbour (FLANN, or a k-d tree with a bounded search) is standard beyond a few thousand features.
For binary descriptors, LSH with multi-probe is the usual index, and Hamming distance makes the brute-force baseline much cheaper than for floats.
What replaced them
SuperPoint (DeTone et al., CVPRW 2018) learns detection and description jointly, self-supervised by Homographic Adaptation: apply random homographies to unlabelled images and keep points detected consistently across them.
SuperGlue (Sarlin et al., CVPR 2020) reframes matching as optimal transport between the two keypoint sets, using attention over both intra- and inter-image keypoints and a Sinkhorn solver, with an explicit dustbin for unmatched points. This replaced the nearest-neighbour-plus-ratio-test pipeline entirely, and it uses context: a keypoint's match is decided partly by where its neighbours matched.
LightGlue (Lindenberger et al., ICCV 2023) makes it adaptive — easier pairs exit early, and confidently-unmatchable points are pruned — reaching comparable accuracy at a fraction of the cost.
Detector-free matching. LoFTR (Sun et al., CVPR 2021) and RoMa skip keypoints entirely, producing dense coarse-to-fine correspondences with transformers. These dominate on low-texture and wide-baseline pairs where no detector fires at all — the failure mode classical methods cannot escape.
Classical features remain in wide use because they need no GPU, no weights and no licence review. On well-textured scenes with moderate baselines the accuracy gap is also small. COLMAP still defaults to SIFT; ORB-SLAM3 still uses ORB.
What to learn next
- RANSAC for robust fitting — surviving the wrong matches this lesson measured.
- Structure from motion — turning matches across many photos into a 3D model.
- Visual SLAM — the same matching, running live on a moving robot.