Fitting a Brown–Conrady Model from a Calibration Flight
The classic camera calibration uses a printed checkerboard held at various angles a metre from the lens. At survey altitude that is useless: a target large enough to fill the frame at 80 m would be the size of a tennis court, and a camera focused at infinity behaves differently from one focused at a metre anyway.
Calibrating a survey camera therefore means solving the intrinsics from aerial imagery, using tie points from a reconstruction as the correspondences. This page covers doing that, choosing which parameters to solve for, and — most importantly — recognising when the result is unconstrained rather than wrong. It supports the policy decisions in camera calibration and lens models in Python.
What makes an aerial calibration different
The correspondences are tie points, not targets. They come from the matching stage and carry its errors, which means a calibration is only as good as the reconstruction that produced its input.
Scale comes from outside. A checkerboard has known dimensions; a landscape does not. Without ground control or accurate camera positions, the whole solution is scale-free and the focal length is undetermined.
Depth variation is small. A flat site at constant altitude gives every point almost the same depth, which is the geometry in which focal length and camera height are least separable.
All three point the same way: the calibration flight’s job is to introduce the variation that a mapping flight lacks, and without that variation the solve is ill-conditioned regardless of how many images it has.
Figure 1 — Why the calibration pattern has four parts rather than one.
Minimal reproducible solution
import cv2
import numpy as np
def calibrate_from_reconstruction(object_points: list[np.ndarray],
image_points: list[np.ndarray],
image_size: tuple[int, int],
*, solve_k3: bool = False,
solve_tangential: bool = False) -> dict:
"""Solve Brown–Conrady intrinsics from reconstruction tie points.
Parameters are disabled rather than solved by default. Every free
parameter is one more thing that can absorb error from the others, and on
a modern drone lens k3 and the tangential terms genuinely are near zero.
"""
flags = 0
if not solve_k3:
flags |= cv2.CALIB_FIX_K3
if not solve_tangential:
flags |= cv2.CALIB_ZERO_TANGENT_DIST
rms, K, dist, rvecs, tvecs = cv2.calibrateCamera(
object_points, image_points, image_size, None, None, flags=flags,
criteria=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 200, 1e-7))
per_view = []
for i, (obj, img) in enumerate(zip(object_points, image_points)):
projected, _ = cv2.projectPoints(obj, rvecs[i], tvecs[i], K, dist)
err = np.linalg.norm(projected.reshape(-1, 2) - img.reshape(-1, 2), axis=1)
per_view.append(float(np.mean(err)))
return {"rms_px": float(rms), "K": K, "dist": dist,
"focal_px": (float(K[0, 0]), float(K[1, 1])),
"principal_point": (float(K[0, 2]), float(K[1, 2])),
"k": [float(v) for v in dist.ravel()[:3]],
"per_view_error_px": per_view,
"views": len(object_points)}
Disabling parameters by default rather than solving everything is the choice that most improves a calibration’s stability. A solve with every term free will always report a lower RMS, because it has more freedom to fit; whether the extra terms describe the lens or the noise is a different question, and on aerial correspondences it is usually the noise.
Judging whether the solve is constrained
import cv2
import numpy as np
def parameter_uncertainty(object_points, image_points, image_size,
*, flags: int = 0) -> dict:
"""Standard deviations of the solved intrinsics, which OpenCV can report.
A calibration with a low RMS and a huge focal-length uncertainty is not a
good calibration; it is an underdetermined one that happened to fit. The
uncertainties are what distinguish the two, and they are free.
"""
rms, K, dist, _, _, std_int, _, _ = cv2.calibrateCameraExtended(
object_points, image_points, image_size, None, None, flags=flags)
labels = ["fx", "fy", "cx", "cy", "k1", "k2", "p1", "p2", "k3"]
stds = {name: float(v) for name, v in zip(labels, std_int.ravel())}
problems = []
if stds["fx"] / float(K[0, 0]) > 0.01:
problems.append(f"focal length is uncertain by {stds['fx'] / K[0, 0]:.1%} — "
"add a second altitude")
if stds["cx"] > 0.02 * image_size[0]:
problems.append("principal point is poorly determined — add a cross pattern")
if stds["k1"] > 0.01:
problems.append("k1 is poorly determined — add oblique frames")
return {"rms_px": float(rms), "std": stds, "problems": problems,
"constrained": not problems}
Reporting the uncertainties alongside the values is the practice that distinguishes a usable calibration from a plausible one, and it costs one different function call.
Figure 3 — Five stages; the fourth is where a bad fit is caught.
Edge-case matrix
| Situation | Symptom | Handling |
|---|---|---|
| Single altitude | Focal length uncertain | Add a second altitude |
| Nadir only | k1 uncertain | Add obliques |
| One flight direction | Principal point uncertain | Add a cross |
| Few tie points near the corners | Distortion extrapolated | Choose views with corner coverage |
| All terms solved | Low RMS, unstable values | Fix k3 and tangential |
| No control or camera positions | Scale-free, focal undetermined | Supply either |
| Tie points from a poor reconstruction | Calibration inherits the errors | Fix the matching first |
| Fisheye lens | Divergent radial terms | Use the fisheye model |
The corner-coverage row deserves attention because it is easy to satisfy and easy to miss. Distortion is determined almost entirely by points far from the image centre, so a set of views whose tie points cluster in the middle produces a distortion model that is extrapolated everywhere it matters.
import numpy as np
def corner_coverage(image_points: list[np.ndarray],
image_size: tuple[int, int]) -> dict:
"""What share of the frame's outer region carries correspondences."""
w, h = image_size
cx, cy = w / 2, h / 2
max_r = float(np.hypot(cx, cy))
radii = []
for pts in image_points:
p = pts.reshape(-1, 2)
radii.extend(np.hypot(p[:, 0] - cx, p[:, 1] - cy) / max_r)
r = np.asarray(radii)
outer = float((r > 0.7).mean())
return {"points": int(r.size), "outer_region_fraction": outer,
"adequate": outer > 0.15,
"note": ("corner coverage is adequate" if outer > 0.15 else
"few correspondences beyond 70 % of the frame radius — "
"the distortion model is extrapolated at the edges")}
Verification snippet
import cv2
import numpy as np
def holdout_check(object_points, image_points, image_size,
*, holdout: int = 5) -> dict:
"""Calibrate on most views and test on the rest.
A calibration that fits its own views and fails on held-out ones has
fitted noise, which is exactly what over-parameterisation produces and
exactly what an in-sample RMS cannot show.
"""
n = len(object_points)
if n <= holdout + 5:
return {"note": "not enough views for a holdout"}
train_obj, train_img = object_points[:-holdout], image_points[:-holdout]
test_obj, test_img = object_points[-holdout:], image_points[-holdout:]
rms, K, dist, _, _ = cv2.calibrateCamera(train_obj, train_img, image_size, None, None,
flags=cv2.CALIB_FIX_K3 | cv2.CALIB_ZERO_TANGENT_DIST)
errors = []
for obj, img in zip(test_obj, test_img):
ok, rvec, tvec = cv2.solvePnP(obj, img, K, dist)
if not ok:
continue
proj, _ = cv2.projectPoints(obj, rvec, tvec, K, dist)
errors.append(float(np.mean(np.linalg.norm(
proj.reshape(-1, 2) - img.reshape(-1, 2), axis=1))))
test_rms = float(np.mean(errors)) if errors else float("nan")
return {"train_rms_px": float(rms), "test_rms_px": test_rms,
"ratio": test_rms / max(float(rms), 1e-9),
"overfitted": test_rms > 2 * float(rms)}
Figure 2 — The reason to disable parameters rather than solve them.
Getting the correspondences in the first place
The calibration above needs object points and image points, and on an aerial flight those come from a reconstruction rather than from a target. Two sources work, with different trade-offs.
Tie points from a completed reconstruction are plentiful and free. Their object coordinates come from the same solution whose intrinsics are being estimated, which sounds circular and is acceptable in practice provided the reconstruction was well controlled: the geometry constrains the solution, and re-solving the intrinsics against the resulting points refines rather than invents them.
Surveyed points visible in the imagery — control targets, or any identifiable feature whose coordinates were measured — are independent and far fewer. A handful of them scattered across the frame is worth more than thousands of tie points clustered in the middle, because they break the circularity.
The arrangement that works uses both: tie points for coverage and surveyed points as an independent check on the result, which is exactly the holdout above with better data.
When to escalate
- The uncertainties are large despite a full calibration pattern. The tie points may be poor. Look at the reconstruction before the calibration.
- The lens is fisheye or strongly wide-angle. Use the fisheye model; radial terms fitted to it will fit the views and diverge outside them.
- Calibrations vary substantially between flights of the same camera. Beyond the expected thermal drift, that means the geometry is not constraining the solve. Compare the reported uncertainties across the flights.