Feature detection and matching

ORB and SIFT keypoints, brute-force and FLANN matching, filtering with Lowe's ratio test, and finding a homography with RANSAC.

Detectors and descriptors

import cv2
import numpy as np

query = cv2.imread("object.jpg", cv2.IMREAD_GRAYSCALE)
train = cv2.imread("scene.jpg", cv2.IMREAD_GRAYSCALE)

# ORB: fast, patent-free, binary descriptors
orb = cv2.ORB_create(nfeatures=2000, scaleFactor=1.2, nlevels=8,
                     edgeThreshold=31, fastThreshold=20)
kps_q, des_q = orb.detectAndCompute(query, None)
kps_t, des_t = orb.detectAndCompute(train, None)
print("ORB keypoints", len(kps_q), len(kps_t), des_q.dtype, des_q.shape)

# SIFT: slower, more robust to scale and rotation, float descriptors
sift = cv2.SIFT_create(nfeatures=2000, contrastThreshold=0.04, edgeThreshold=10)
kps_s, des_s = sift.detectAndCompute(query, None)
print("SIFT keypoints", len(kps_s), des_s.dtype, des_s.shape)

# AKAZE and BRISK are the other common choices
akaze = cv2.AKAZE_create()
kps_a, des_a = akaze.detectAndCompute(query, None)
print("AKAZE", len(kps_a), des_a.shape)

# a mask restricts detection to a region
mask = np.zeros(query.shape, dtype="uint8")
mask[100:500, 100:600] = 255
kps_m, des_m = orb.detectAndCompute(query, mask)
print("masked ORB", len(kps_m))
DetectorDescriptor typeRotationScaleNote
ORBBinaryYesYesFastest practical general choice
SIFTFloatYesYesMost robust, slower, historically patented
AKAZEBinaryYesYesNonlinear scale space, good on texture
BRISKBinaryYesYesVery fast, less repeatable
FASTNone (keypoints only)NoNoCorner detection only, no matching
HarrisNone (keypoints only)NoNoClassic corner response, no descriptor
  • ORB is the default: roughly an order of magnitude faster than SIFT, with binary descriptors that match quickly by Hamming distance.
  • nfeatures caps the count. More features means a slower match and more chance of a wrong correspondence, so do not simply set it to the maximum.
  • A descriptor is only useful if it is repeatable: the same physical point should be detected in both images. Test repeatability on your own images rather than trusting a comparison table.
  • Keypoints are detected on grayscale. Convert once, and use a mask to exclude regions with no useful structure.

Matching and filtering

# brute force with a Hamming distance, correct for binary descriptors
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False)
matches = bf.knnMatch(des_q, des_t, k=2)
print("raw matches", len(matches))

# Lowe's ratio test: keep a match only if it is clearly better than the next best
good = []
for pair in matches:
    if len(pair) < 2:
        continue
    first, second = pair
    if first.distance < 0.75 * second.distance:
        good.append(first)
print("after ratio test", len(good))

# for float descriptors, use the L2 norm and either crossCheck or the ratio test
bf_l2 = cv2.BFMatcher(cv2.NORM_L2)
matches_l2 = bf_l2.knnMatch(des_s, des_s, k=2)
ratio_filtered = [m for m, n in matches_l2 if m.distance < 0.75 * n.distance]

# FLANN is faster than brute force for large descriptor sets
index_params = dict(algorithm=6,                # FLANN_INDEX_LSH for binary
                    table_number=12, key_size=20, multi_probe_level=2)
search_params = dict(checks=50)
flann = cv2.FlannBasedMatcher(index_params, search_params)
flann_matches = flann.knnMatch(des_q, des_t, k=2) if des_t is not None else []
print("FLANN matches", len(flann_matches))

# the ratio test is the single most effective filter; draw to confirm
vis = cv2.drawMatches(query, kps_q, train, kps_t, good[:40], None,
                      matchColor=(0, 255, 0), singlePointColor=(255, 0, 0))
cv2.imwrite("matches.png", vis)
  • The ratio test keeps a match only when the best candidate is distinctly better than the second best. It removes most ambiguous matches at the cost of dropping some correct ones.
  • Use crossCheck=True only with 1-nearest-neighbour matching. Passing both crossCheck and k=2 to knnMatch is a common mistake.
  • FLANN for binary descriptors needs the LSH index (algorithm 6) and for float descriptors the KD-tree. Mixing them up gives either terrible results or an exception.
  • Always look at the drawn matches. A hundred matches that connect unrelated regions look fine in a count and obviously wrong in an image.

Homography and RANSAC

if len(good) >= 10:
    src_pts = np.float32([kps_q[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
    dst_pts = np.float32([kps_t[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)

    # RANSAC finds the largest consistent set and rejects the outliers
    homography, inlier_mask = cv2.findHomography(src_pts, dst_pts,
                                                 cv2.RANSAC, ransacReprojThreshold=5.0)
    inliers = int(inlier_mask.sum())
    print(f"homography found with {inliers}/{len(good)} inliers")

    # reject a homography built from too few consistent points
    if homography is not None and inliers >= 15:
        h, w = query.shape
        corners = np.float32([[0, 0], [w, 0], [w, h], [0, h]]).reshape(-1, 1, 2)
        projected = cv2.perspectiveTransform(corners, homography)

        annotated = cv2.polylines(cv2.cvtColor(train, cv2.COLOR_GRAY2BGR),
                                  [np.int32(projected)], True, (0, 255, 0), 3)
        cv2.imwrite("detected.png", annotated)

        # a sanity check on the geometry: the projected area should be plausible
        area = cv2.contourArea(np.int32(projected))
        ratio = area / (w * h)
        print("projected area ratio", round(float(ratio), 3))
        if ratio < 0.05 or ratio > 3.0:
            print("rejected: implausible geometry")
    else:
        print("rejected: too few inliers")
else:
    print("not enough matches to attempt a homography")
⚠️
A homography is just a matrix, and RANSAC will happily fit one to a set of consistently wrong matches. Gate on the inlier count and on the plausibility of the projected shape before acting on a result. For anything that triggers a real-world action, verify with a second independent signal.

FAQ

ORB or SIFT?
ORB for speed and most engineering work, SIFT when matching has to survive large scale or viewpoint changes and the latency budget allows it. Test both on your own image pairs; results vary far more by scene than by the published comparison.
What ratio threshold should I use?
0.75 is the standard starting point. Lower it to 0.6 or 0.65 for stricter matching when you need very reliable correspondences; raise it when too few matches survive and you can tolerate some wrong ones.

Geometric transformations Performance for real-time vision

Last refreshed 2026-09-18.