Keypoints, descriptors and feature matching for robotics
How Harris, FAST, SIFT and ORB find and describe image features, how nearest-neighbour matching and the ratio test pair them, and how a SLAM front end turns matches into robot motion. With Python and OpenCV code.
How does a robot know it is looking at the same corner of a table from two different positions? It finds a few distinctive points in each image, describes each one with a short list of numbers, and pairs up points whose numbers are similar. That is feature matching, and it sits at the start of visual SLAM, visual odometry, object recognition and image stitching.
$$ \text{Image} \;\rightarrow\; \underbrace{\text{Keypoints}}_{\text{where?}} \;\rightarrow\; \underbrace{\text{Descriptors}}_{\text{what does it look like?}} \;\rightarrow\; \underbrace{\text{Matching}}_{\text{which is which?}} \;\rightarrow\; \text{Robot motion} $$
An analogy. In a library, the keypoint is where a book stands on the shelf, the descriptor is its title, author and summary, and matching is finding the book in another library whose description is most similar.
1. Keypoints: where to look
A keypoint is a spot in the image that can be found again reliably, even when the camera moves, rotates or the light changes. Corners and blobs are good keypoints; a plain white wall is not. Each keypoint stores:
- its position \((x, y)\) in the image,
- its scale, the size of the region it covers,
- its orientation, the dominant direction of the image gradient around it,
- its response, how strong the detection is.
Harris: corners from image gradients
The Harris detector looks at how the brightness changes in a small window. With image derivatives \(I_x, I_y\) and a Gaussian window \(w\), it builds the structure tensor
$$ \mathbf{M} = \sum_{x,y} w(x,y) \begin{bmatrix} I_x^2 & I_x I_y \\ I_x I_y & I_y^2 \end{bmatrix} $$
and scores each pixel with
$$ R = \det(\mathbf{M}) - k\,\big(\operatorname{trace}\mathbf{M}\big)^2, \qquad k \approx 0.04 \text{ to } 0.06 $$
| Eigenvalues of \(\mathbf{M}\) | Meaning | \(R\) |
|---|---|---|
| Both small | Flat region | Close to 0 |
| One large, one small | Edge | Negative |
| Both large | Corner | Large and positive |
FAST: corners by comparing pixels
FAST looks at the 16 pixels on a circle of radius 3 around a pixel \(p\). If at least \(n\) neighbouring pixels on the circle are all brighter than \(I(p) + t\), or all darker than \(I(p) - t\), then \(p\) is a corner. With \(n = 9\) or \(12\) this needs only a few comparisons per pixel, which makes FAST very fast.
Comparing detectors
| Detector | Principle | Invariant to | Speed | Typical use |
|---|---|---|---|---|
| Harris | Eigenvalues of the structure tensor | Rotation, some lighting change | Medium | Classic corner detection |
| FAST | Brightness test on a circle | Rotation (position only) | Very fast | Real-time tracking |
| SIFT | Difference of Gaussians across scales | Rotation, scale, lighting | Slow | High-accuracy matching |
| ORB | FAST on an image pyramid + orientation | Rotation, scale (via pyramid) | Fast | Real-time SLAM (ORB-SLAM) |
2. Descriptors: what the point looks like
A descriptor is a vector of numbers that summarises the patch around a keypoint. A good descriptor is distinctive (different patches give different vectors), invariant (the same patch seen differently gives a similar vector), compact and robust to noise.
SIFT: 128 numbers from gradient histograms
Around each keypoint, SIFT computes the gradient magnitude and direction at every pixel of the smoothed image \(L\):
$$ m = \sqrt{\big(L_{x+1,y} - L_{x-1,y}\big)^2 + \big(L_{x,y+1} - L_{x,y-1}\big)^2}, \qquad \theta = \operatorname{atan2}\!\big(L_{x,y+1} - L_{x,y-1},\; L_{x+1,y} - L_{x-1,y}\big) $$
The 16 × 16 region is split into a 4 × 4 grid of cells, and each cell gets an 8-bin histogram of gradient directions:
$$ 4 \times 4 \text{ cells} \times 8 \text{ directions} = 128 \text{ numbers} $$
All directions are measured relative to the keypoint's orientation, which is what makes SIFT rotation invariant.
ORB: 256 yes/no answers
ORB's descriptor (rotated BRIEF) compares the brightness of 256 fixed pairs of pixels \((\mathbf{a}_i, \mathbf{b}_i)\) in the smoothed patch, rotated to the keypoint's orientation. Each comparison gives one bit:
$$ \tau_i = \begin{cases} 1 & \text{if } I(\mathbf{a}_i) < I(\mathbf{b}_i) \\ 0 & \text{otherwise} \end{cases} \qquad \text{descriptor} = \tau_1 \tau_2 \ldots \tau_{256} $$
That is 256 bits, or 32 bytes. Binary descriptors are tiny and can be compared extremely fast.
A toy example
Take this 3 × 3 patch of brightness values:
| left | middle | right | |
|---|---|---|---|
| top | 45 | 50 | 48 |
| middle | 52 | 55 | 53 |
| bottom | 49 | 51 | 50 |
A simple four-number descriptor could be:
| Feature | Calculation | Value |
|---|---|---|
| Mean brightness | \(453 / 9\) | 50.33 |
| Horizontal gradient | mean(right column) − mean(left column) \(= 50.33 - 48.67\) | 1.67 |
| Vertical gradient | mean(bottom row) − mean(top row) \(= 50.00 - 47.67\) | 2.33 |
| Variance | \(\tfrac{1}{9}\sum (I - 50.33)^2 = 68 / 9\) | 7.56 |
Descriptor: \([\,50.33,\ 1.67,\ 2.33,\ 7.56\,]\). Real descriptors are longer and normalised, but the idea is the same.
3. Matching: which point is which
To match, we compare each descriptor in image 1 with the descriptors in image 2 and pick the closest one, its nearest neighbour.
Measuring distance
For float descriptors such as SIFT, use the Euclidean distance:
$$ d(\mathbf{a}, \mathbf{b}) = \sqrt{\sum_{i=1}^{n} (a_i - b_i)^2} $$
Example: \(\mathbf{a} = [2, 5, 1]\), \(\mathbf{b} = [4, 3, 2]\) gives \(d = \sqrt{4 + 4 + 1} = 3\).
For binary descriptors such as ORB, use the Hamming distance, the number of bits that differ:
$$ d_H(\mathbf{a}, \mathbf{b}) = \sum_{i=1}^{n} a_i \oplus b_i $$
Example: 10110101 and 10011101 differ in bits 3 and 5, so \(d_H = 2\). Computers do this with one XOR and a bit count, which is why ORB matching is so fast.
The ratio test
The closest match is not always a correct one. In repetitive scenes (windows, tiles) a point may have several look-alikes. Lowe's ratio test accepts a match only if the best candidate is clearly better than the second best:
$$ \frac{d_1}{d_2} < r, \qquad r \approx 0.7 \text{ to } 0.8 $$
Worked example. Query \(Q = [2.0, 5.0, 1.7]\) and three candidates:
| Candidate | Vector | Distance to \(Q\) |
|---|---|---|
| D1 | [2.1, 5.3, 1.8] | 0.332 |
| D2 | [4.2, 3.1, 2.0] | 2.922 |
| D3 | [1.9, 4.8, 1.5] | 0.300 |
The nearest neighbour is D3, but D1 is almost as close: \(0.300 / 0.332 = 0.90 > 0.75\). The match is ambiguous and rejected. Throwing away a few good matches is much better than keeping wrong ones.
Brute force or approximate search
| Method | How | Cost per image pair | Use when |
|---|---|---|---|
| Brute force | Compare every pair | \(O(n \cdot m)\) | Up to a few thousand features; exact |
| FLANN | k-d trees (floats) or LSH (binary) | About \(O(n \log m)\) | Large databases; approximate but much faster |
4. From matches to robot motion
In a visual SLAM front end, matching runs on every camera frame:
$$ \text{New frame} \;\rightarrow\; \text{ORB features} \;\rightarrow\; \text{Match to previous frame or map} \;\rightarrow\; \text{Reject outliers (RANSAC)} \;\rightarrow\; \text{Estimate motion} \;\rightarrow\; \text{Update map} $$
Matching statistics for one frame
| Feature in frame 1 | Best match (Hamming) | 2nd best | Ratio | Keep? |
|---|---|---|---|---|
| F1_1 | 3 | 12 | 0.25 | ✓ |
| F1_2 | 5 | 6 | 0.83 | ✗ ambiguous |
| F1_3 | 0 | 2 | 0.00 | ✓ |
Out of 155 features, about 120 survive the ratio test. RANSAC then removes the remaining wrong matches by keeping only those consistent with a single camera motion.
Worked example: how far did the robot move?
Suppose a robot measures three landmarks (in metres, in its own frame) before and after a move. We want the rotation \(\theta\) and translation \(\mathbf{t}\) that map the new view onto the old one:
$$ \mathbf{p}^{(0)}_i = \mathbf{R}(\theta)\,\mathbf{p}^{(1)}_i + \mathbf{t}, \qquad \mathbf{R}(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{bmatrix} $$
| Landmark | Before \(\mathbf{p}^{(0)}\) | After \(\mathbf{p}^{(1)}\) |
|---|---|---|
| A | (2.00, 1.00) | (1.63, 0.63) |
| B | (3.00, −1.00) | (2.27, −1.52) |
| C | (4.00, 0.50) | (3.52, −0.21) |
Step 1, centroids. \(\bar{\mathbf{p}}^{(0)} = (3.00, 0.17)\) and \(\bar{\mathbf{p}}^{(1)} = (2.47, -0.37)\).
Step 2, centre the points: \(\mathbf{a}_i = \mathbf{p}^{(0)}_i - \bar{\mathbf{p}}^{(0)}\) and \(\mathbf{b}_i = \mathbf{p}^{(1)}_i - \bar{\mathbf{p}}^{(1)}\).
Step 3, rotation. The best 2D rotation in the least-squares sense is
$$ \theta = \operatorname{atan2}\!\Big(\sum_i (b_{i,x} a_{i,y} - b_{i,y} a_{i,x}),\; \sum_i (b_{i,x} a_{i,x} + b_{i,y} a_{i,y})\Big) = \operatorname{atan2}(0.72,\; 4.12) \approx 10^\circ $$
Step 4, translation. \(\mathbf{t} = \bar{\mathbf{p}}^{(0)} - \mathbf{R}(\theta)\,\bar{\mathbf{p}}^{(1)} \approx (0.50,\ 0.10)\) m.
So the robot drove about half a metre forward, slightly to the left, and turned 10° to the left. In 3D the same idea, with an SVD instead of atan2, is the Kabsch / Umeyama method; with a single camera and no depth, OpenCV estimates the essential matrix instead and recovers the rotation and the direction of travel.
5. Code: matching two images with OpenCV
import cv2
def match(img1_path, img2_path, ratio=0.75):
img1 = cv2.imread(img1_path, cv2.IMREAD_GRAYSCALE)
img2 = cv2.imread(img2_path, cv2.IMREAD_GRAYSCALE)
orb = cv2.ORB_create(nfeatures=1000)
kp1, des1 = orb.detectAndCompute(img1, None)
kp2, des2 = orb.detectAndCompute(img2, None)
matcher = cv2.BFMatcher(cv2.NORM_HAMMING) # Hamming for binary ORB descriptors
pairs = matcher.knnMatch(des1, des2, k=2) # two nearest neighbours each
good = [m for m, n in (p for p in pairs if len(p) == 2)
if m.distance < ratio * n.distance] # Lowe's ratio test
print(f"{len(kp1)} and {len(kp2)} keypoints, {len(good)} good matches")
out = cv2.drawMatches(img1, kp1, img2, kp2, good[:50], None,
flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS)
cv2.imwrite("matches.png", out)
return kp1, kp2, good
kp1, kp2, good = match("frame1.png", "frame2.png")For SIFT, use cv2.SIFT_create() and cv2.BFMatcher(cv2.NORM_L2) instead.
6. Code: camera motion between two frames
With the camera matrix \(\mathbf{K}\) from calibration, the matches give the relative motion directly:
import numpy as np
import cv2
K = np.array([[525.0, 0.0, 319.5], # fx, 0, cx
[ 0.0, 525.0, 239.5], # 0, fy, cy
[ 0.0, 0.0, 1.0]])
pts1 = np.float32([kp1[m.queryIdx].pt for m in good])
pts2 = np.float32([kp2[m.trainIdx].pt for m in good])
# RANSAC keeps only matches that agree with one camera motion
E, inliers = cv2.findEssentialMat(pts1, pts2, K, method=cv2.RANSAC,
prob=0.999, threshold=1.0)
n, R, t, _ = cv2.recoverPose(E, pts1, pts2, K, mask=inliers)
angle = np.degrees(np.arccos(np.clip((np.trace(R) - 1) / 2, -1, 1)))
print(f"{n} inliers, rotation {angle:.1f} deg, direction of travel {t.ravel()}")With one camera, t has length 1: the direction is known but not the distance. A stereo or depth camera, an IMU or a known object size supplies the missing scale.
7. Code: nearest neighbour and ratio test from scratch
import numpy as np
def match_ratio(query, database, ratio=0.75):
"""Return (query index, database index) pairs that pass the ratio test."""
matches = []
for i, q in enumerate(query):
d = np.linalg.norm(database - q, axis=1) # Euclidean distance to every entry
best, second = np.argsort(d)[:2]
if d[best] < ratio * d[second]:
matches.append((i, int(best)))
return matches
Q = np.array([[2.0, 5.0, 1.7]])
DB = np.array([[2.1, 5.3, 1.8], [4.2, 3.1, 2.0], [1.9, 4.8, 1.5]])
print(match_ratio(Q, DB)) # [] : rejected as ambiguous, as in the worked exampleCommon problems and fixes
| Problem | Fix |
|---|---|
| Wrong matches in repetitive scenes | Ratio test, then RANSAC |
| Large change in scale | Scale-aware detectors: SIFT, or ORB with an image pyramid |
| Lighting changes | Gradient-based or binary descriptors, histogram equalisation |
| Too slow | FAST/ORB, fewer features, FLANN, or a GPU |
| Moving people and objects | Remove them with segmentation, as in dynamic SLAM |
Further reading
- OpenCV feature detection tutorials
- Multiple View Geometry, Hartley and Zisserman
- ORB-SLAM3, a complete feature-based SLAM system
- The Visual SLAM guide on this site, which continues where this post ends
Try it yourself
- Implement the Harris response \(R\) with NumPy and compare it with
cv2.cornerHarris. - Match two photos of your desk with ORB and with SIFT. Which gives more good matches, and which is faster?
- Change the ratio threshold from 0.6 to 0.9 and count the wrong matches.
- Simulate a robot moving in 2D, add noise to the landmark positions and recover its motion with the method from section 4.