Porting an OpenCV Matcher into an OpenSfM Pipeline
A prototype in OpenCV matches a difficult dataset — a repetitive orchard, a low-texture quarry — noticeably better than the reconstruction engine’s own matcher. The obvious next step is to use it for the real job, and the obvious obstacle is that the engine expects features and matches in its own files, with its own conventions.
The port is not difficult and it has three traps that produce a reconstruction which runs, converges and is wrong: a coordinate convention that differs from OpenCV’s, an index convention in the match records, and a feature ordering that the engine relies on. This page covers the conversion and the tests that catch each trap. It extends the comparison in OpenCV vs OpenSfM for feature matching.
The three conventions that differ
Coordinate normalisation. OpenCV keypoints are in pixels with the origin at the top-left corner. OpenSfM stores features in a normalised frame centred on the image, scaled by the larger dimension, so a pixel coordinate must be converted rather than written directly. Writing pixels into a normalised field produces features that all sit far outside the image, and the engine quietly finds no matches.
Index basis. Match records reference features by index into each image’s feature array. If the arrays are written in a different order from the one the matcher used, every match points at the wrong feature — and because the indices are all valid, nothing errors.
Feature ordering. Some pipelines assume features are sorted by scale or response. An arbitrary order is usually accepted and occasionally interacts with downsampling steps that keep “the first N”.
Figure 1 — The conversion, and what skipping it looks like.
Minimal reproducible solution
import numpy as np
def to_normalised(keypoints_xy: np.ndarray, width: int, height: int) -> np.ndarray:
"""Convert OpenCV pixel coordinates to the centred, scaled convention.
The scale factor is the larger image dimension for both axes, which
preserves the aspect ratio — dividing each axis by its own dimension
would distort the features and produce a subtly wrong reconstruction
rather than an obviously broken one.
"""
size = float(max(width, height))
centred = keypoints_xy.astype(np.float64) - np.array([width / 2.0, height / 2.0])
return centred / size
def to_pixels(normalised_xy: np.ndarray, width: int, height: int) -> np.ndarray:
"""The inverse, for verification."""
size = float(max(width, height))
return normalised_xy * size + np.array([width / 2.0, height / 2.0])
Using the larger dimension for both axes rather than each axis’s own is the subtle half of this. Both conventions are self-consistent; only one matches what the engine expects, and the wrong one produces a reconstruction that converges to a slightly stretched solution — which will pass every internal check and fail against ground control.
Writing the feature and match files
import gzip
import pickle
from pathlib import Path
import numpy as np
def write_features(path: str, keypoints, descriptors, colors,
width: int, height: int) -> dict:
"""Write features in the engine's expected structure.
Order is fixed here and must not change afterwards, because the match
records reference these rows by index. Any later sort invalidates every
match file that was written against this one.
"""
xy = np.array([kp.pt for kp in keypoints], dtype=np.float64)
scale = np.array([kp.size / max(width, height) for kp in keypoints], dtype=np.float64)
angle = np.array([np.radians(kp.angle) for kp in keypoints], dtype=np.float64)
points = np.column_stack([to_normalised(xy, width, height), scale, angle])
payload = {"points": points.astype(np.float32),
"descriptors": np.asarray(descriptors, dtype=np.float32),
"colors": np.asarray(colors, dtype=np.uint8)}
Path(path).parent.mkdir(parents=True, exist_ok=True)
with gzip.open(path, "wb") as fh:
pickle.dump(payload, fh)
return {"features": len(points), "path": path}
def write_matches(path: str, matches_by_image: dict[str, np.ndarray]) -> dict:
"""Write match index pairs for one image against its candidates."""
payload = {name: np.asarray(pairs, dtype=np.int32)
for name, pairs in matches_by_image.items()}
Path(path).parent.mkdir(parents=True, exist_ok=True)
with gzip.open(path, "wb") as fh:
pickle.dump(payload, fh)
return {"images_matched": len(payload),
"total_matches": int(sum(len(v) for v in payload.values()))}
Verifying the port before trusting it
Three tests catch all three traps, and they run in seconds on a handful of images.
import numpy as np
def verify_coordinates(normalised: np.ndarray) -> dict:
"""Normalised features must lie within about half a unit of the origin."""
radius = np.linalg.norm(normalised[:, :2], axis=1)
outside = float((radius > 0.75).mean())
return {"max_radius": float(radius.max()), "fraction_outside": outside,
"ok": outside < 0.001,
"note": ("coordinates look normalised" if outside < 0.001 else
"features lie far outside the image — pixels were written "
"into a normalised field")}
def verify_match_indices(matches: np.ndarray, n_features_a: int,
n_features_b: int) -> dict:
"""Match indices must reference existing features in both images."""
if len(matches) == 0:
return {"ok": True, "note": "no matches to check"}
bad_a = int((matches[:, 0] >= n_features_a).sum())
bad_b = int((matches[:, 1] >= n_features_b).sum())
duplicates = len(matches) - len({tuple(m) for m in matches.tolist()})
return {"out_of_range_a": bad_a, "out_of_range_b": bad_b,
"duplicates": duplicates,
"ok": bad_a == 0 and bad_b == 0 and duplicates == 0}
def verify_round_trip(keypoints_xy: np.ndarray, width: int, height: int,
*, tol_px: float = 1e-6) -> dict:
"""Normalising and un-normalising must return the original pixels."""
back = to_pixels(to_normalised(keypoints_xy, width, height), width, height)
error = float(np.abs(back - keypoints_xy).max())
return {"max_error_px": error, "ok": error < tol_px}
The index check is the one that catches the most damaging trap. Out-of-range indices produce an error the engine reports; in-range but wrong indices do not, and the round-trip test on coordinates plus a visual check of a few matched pairs is what exposes them.
Figure 3 — Four contracts, mostly implicit.
Edge-case matrix
| Situation | Symptom | Handling |
|---|---|---|
| Pixels in a normalised field | Zero matches, no error | Convert; check the radius |
| Per-axis normalisation | Slightly stretched reconstruction | Scale both axes by the larger dimension |
| Features re-sorted after writing | Matches point at wrong features | Fix the order before writing anything |
| Descriptor dtype mismatch | Engine errors or silently misreads | Write float32 for float descriptors |
| Binary descriptors | May not be supported | Check before porting an ORB prototype |
| Angle in degrees | Orientation ignored or wrong | Convert to radians |
| Scale in pixels | Feature scales misinterpreted | Normalise by the same factor |
| Colors absent | Point cloud renders grey | Sample the image at each keypoint |
The angle and scale rows are easy to miss because nothing fails: the reconstruction runs with orientation information that is wrong by a factor of 57, and the effect is a modest loss of matching quality that looks like the imagery being difficult.
Deciding whether the port is worth it
Before porting, it is worth confirming that the prototype’s advantage survives contact with the full pipeline. A matcher that finds more raw matches does not necessarily produce a better reconstruction — the engine’s own matcher may be discarding matches its geometric verification would reject anyway.
def compare_reconstructions(a: dict, b: dict) -> dict:
"""Compare two pipelines on what matters, not on match counts.
More matches is not the goal. More images reconstructed, more tie points
surviving the adjustment and better checkpoint accuracy are.
"""
return {
"images_reconstructed": (a["images"], b["images"]),
"tie_points": (a["tie_points"], b["tie_points"]),
"mean_track_length": (a["mean_track_length"], b["mean_track_length"]),
"checkpoint_rmse_m": (a["checkpoint_rmse_m"], b["checkpoint_rmse_m"]),
"better": ("custom" if (b["images"] >= a["images"]
and b["checkpoint_rmse_m"] <= a["checkpoint_rmse_m"])
else "stock"),
"note": "raw match count is not in this comparison, deliberately",
}
Mean track length is the quiet indicator worth watching. A matcher producing many pairwise matches that do not chain into long tracks is finding correspondences that the adjustment cannot use, and the reconstruction will be no stronger for them.
Figure 2 — The comparison that decides whether a port is worth maintaining.
When to escalate
- The port produces zero matches. Check the coordinate convention first; it accounts for most instances and produces exactly this symptom.
- The reconstruction is stretched. Per-axis normalisation. Scale both axes by the larger dimension.
- The custom matcher wins on matches and loses on checkpoints. It is finding correspondences the adjustment cannot use. Compare mean track length before investigating further.