Automating Camera Intrinsic Matrix Extraction

You run a batch of imagery from a mixed UAV fleet through Structure-from-Motion and the reconstruction either refuses to converge or produces an orthomosaic that is visibly stretched, sheared, or scaled wrong — yet the same pipeline worked perfectly last week on a single airframe. The usual culprit is the camera intrinsic matrix (K): a focal length transcribed by hand, a value scraped from an unverified EXIF dump, or a single hardcoded K reused across two payloads with different sensors. When K is wrong, every 2D-to-3D projection is wrong, so bundle adjustment diverges and georeferencing drifts. This page shows how to extract and validate K automatically at ingest, before a single feature is detected, so a swapped gimbal or a firmware revision can no longer silently corrupt a run.

Why intrinsic errors surface downstream, not at ingest

The intrinsic matrix defines the optical projection geometry of the payload — it maps 3D scene rays onto the 2D pixel grid. UAV manufacturers do not store K directly; they store focal length in millimetres and sensor dimensions, and the pipeline is expected to derive pixel-space focal lengths (fx, fy) itself. That derivation is deterministic, but the inputs are fragile, which is why a bad K rarely throws an error at the point it is built and instead detonates later inside the solver.

The conversion from physical millimetres to pixels is:

fx=fmmwsensorWpxfy=fmmhsensorHpxf_x = \frac{f_{\text{mm}}}{w_{\text{sensor}}}\, W_{\text{px}} \qquad f_y = \frac{f_{\text{mm}}}{h_{\text{sensor}}}\, H_{\text{px}}

The principal point (cx,cy)(c_x, c_y) defaults to the image centre (Wpx/2, Hpx/2)(W_{\text{px}}/2,\ H_{\text{px}}/2) unless a measured calibration value is available. Lens distortion coefficients (k1, k2, p1, p2, k3) live separately in EXIF MakerNotes or manufacturer calibration files (.cal, .xml); they are applied during undistortion and must never be folded into the 3×3 intrinsic matrix.

Three input failures account for nearly every corrupted K in a fleet pipeline:

  • Stripped MakerNotes. Aggressive post-processing (DJI Fly exports, Lightroom round-trips, automated cloud uploaders) drops proprietary calibration tags, so the principal point and distortion silently fall back to defaults.
  • 35 mm-equivalent focal length misuse. FocalLengthIn35mmFilm is populated for marketing and is wrong for metric calibration — using it inflates fx/fy by the crop factor and guarantees solver divergence.
  • Non-square pixel artefacts. Older payloads or transcoded video frames can present fx ≠ fy. Modern UAV cameras hold |fx - fy| / max(fx, fy) < 0.02; a larger spread signals corrupted EXIF or a wrong sensor-dimension override.

Because K is consumed by every later stage, it has to be validated at the boundary. This is the calibration dependency that feeds both the feature detection algorithms for drone imagery that match keypoints across frames and the spatial-reference enforcement covered in managing coordinate reference systems in GDAL, where the projected point cloud must land in the correct datum.

Automated extraction and validation of the camera intrinsic matrix K A vertical pipeline reads EXIF focal length and image dimensions from a UAV frame, derives the pixel intrinsics fx, fy, cx, cy, and routes them through a validation gate. Passing frames emit a 3x3 K to a .npy file; failing frames fall through a manufacturer calibration file, a self-calibration seed, and a fleet baseline registry. pass fail UAV JPG / TIFF on disk mixed fleet · per-frame EXIF exifread.process_file() real FocalLength (mm) · image W × H px Derive pixel intrinsics fx, fy = f / sensor × px · cx, cy = centre Validation gate focal symmetry · PP drift · focal plausibility Emit 3×3 K → K_matrix.npy skew = 0 · K[2,2] = 1 fallback · priority order Manufacturer .cal / .xml bundled with flight log Self-calibration seed fx = fy = 0.8 · max(W, H) Fleet baseline registry CameraModel → K lookup The intrinsic matrix and where each entry comes from The three-by-three camera intrinsic matrix K drawn as a grid, with each non-zero entry annotated by its source. The focal lengths in pixels on the diagonal come from the EXIF focal length in millimetres divided by the sensor pixel pitch, which itself must be derived from the sensor width and the image width because EXIF rarely states pitch directly. The principal point entries in the third column default to the image centre but are only correct if the sensor is centred, which is why they are refined during calibration. The bottom row is structural. A caption warns that using the thirty-five millimetre equivalent focal length here is the single most common error, because it silently rescales the whole reconstruction. fx 0 cx 0 fy cy 0 0 1 K — intrinsic matrix fx, fy focal length (mm) ÷ pixel pitch (mm/px) pitch = sensor width ÷ image width cx, cy image centre as a starting value only — refined by self-calibration, never assumed final Do not use the 35 mm equivalent focal length here. It is the physical focal length rescaled to a different sensor, so it rescales the whole reconstruction — a model that looks correct and measures wrong by the crop factor.

Figure 2 — Every entry in K is derived, not read. The two that come straight from EXIF are the two most often taken from the wrong tag.

Minimal reproducible solution

The function below is the core of the extractor: it reads the EXIF focal length and image dimensions, derives fx/fy/cx/cy, applies the validation thresholds, and returns an OpenCV-compatible K plus a structured report. Keep it under one screen; the production wrapper (CLI flags, directory walking, fallback loading) is layered around this without changing the maths.

import os
from typing import Dict, Optional, Tuple

import numpy as np
import exifread
from PIL import Image


def extract_intrinsic_matrix(
    image_path: str,
    sensor_width_mm: float,
    sensor_height_mm: float,
    max_focal_dev_pct: float = 15.0,   # focal symmetry tolerance
    pp_tol_frac: float = 0.05,         # principal-point drift tolerance
    principal_point: Optional[Tuple[float, float]] = None,
) -> Tuple[np.ndarray, Dict]:
    """Derive and validate the 3x3 camera intrinsic matrix from EXIF."""
    with open(image_path, "rb") as f:
        tags = exifread.process_file(f, details=False)

    width_px, height_px = Image.open(image_path).size

    focal_tag = tags.get("EXIF FocalLength")          # real focal, NOT 35mm-equiv
    if focal_tag is None:
        raise ValueError(f"Missing EXIF FocalLength in {os.path.basename(image_path)}")
    focal_mm = float(focal_tag.values[0].num) / float(focal_tag.values[0].den)

    fx = (focal_mm / sensor_width_mm) * width_px      # pixel focal lengths
    fy = (focal_mm / sensor_height_mm) * height_px

    # Use a measured principal point when calibration supplies one, else centre.
    cx, cy = principal_point if principal_point else (width_px / 2.0, height_px / 2.0)

    focal_dev = abs(fx - fy) / max(fx, fy) * 100.0    # validation metrics
    pp_dev = max(abs(cx - width_px / 2.0) / width_px,
                 abs(cy - height_px / 2.0) / height_px)

    report = {
        "image": os.path.basename(image_path),
        "fx": fx, "fy": fy, "cx": cx, "cy": cy,
        "focal_deviation_pct": focal_dev,
        "pp_drift_frac": pp_dev,
        "passed": focal_dev <= max_focal_dev_pct and pp_dev <= pp_tol_frac,
    }

    K = np.array([[fx, 0.0, cx],
                  [0.0, fy, cy],
                  [0.0, 0.0, 1.0]], dtype=np.float64)
    return K, report

The validation thresholds baked into the defaults are the ones that matter in production UAV mapping:

  • Focal symmetry: |fx - fy| / max(fx, fy) ≤ 0.15. Exceeding this means non-square pixels, EXIF corruption, or a wrong sensor-dimension override.
  • Principal-point drift: |cx - W/2| / W ≤ 0.05 and |cy - H/2| / H ≤ 0.05. Larger offsets indicate severe lens decentering or gimbal misalignment.
  • Focal plausibility: fx should fall between 0.8 × W and 1.5 × W. Values outside this band almost always trace back to FocalLengthIn35mmFilm leaking into the calculation.

For automation, wrap this function in an argparse CLI so thresholds and fallbacks are version-controlled rather than edited inline:

Flag Type Default Purpose
--image-dir str ./input/ Directory of UAV imagery to scan
--sensor-width-mm float 13.2 Physical sensor width (1-inch sensor)
--sensor-height-mm float 8.8 Physical sensor height (1-inch sensor)
--max-focal-deviation-pct float 15.0 Max % spread between fx and fy
--principal-point-tolerance-px float 0.05 Max optical-centre drift (fraction of dimension)
--fallback-k str None Path to a pre-calibrated K .npy
--output-matrix str K_matrix.npy Output path for the validated matrix
--strict-validation flag False Abort on the first threshold breach

Edge-case matrix

Input variant Symptom in raw EXIF Expected handling
MakerNotes stripped by post-processing No principal point, no distortion tags Fall back to image-centre (W/2, H/2); flag run as uncalibrated
FocalLengthIn35mmFilm present, real FocalLength absent fx ≈ 1.5–2.7× plausible Reject: never substitute 35 mm-equivalent; require true focal
Transcoded video frame (fx ≠ fy) focal_deviation_pct > 15 Mark passed=False; route to fallback K
Zero denominator in focal rational ZeroDivisionError on num/den Guard the division; raise a typed ValueError
Mixed sensors in one --image-dir Per-image fx values diverge Group by EXIF Model; emit one K per model, not a fleet average
Cropped / digitally zoomed frame Dimensions disagree with sensor mm Recompute against the true output resolution, not the native sensor

When validation fails, route to fallback strategies in priority order: first a manufacturer calibration file (.cal/.xml bundled with the flight log), then a self-calibration seed (fx = fy = 0.8 × max(W, H), cx = W/2, cy = H/2) handed to cv2.calibrateCamera or a COLMAP PINHOLE model with --ImageReader.single_camera 1, and finally a version-controlled fleet registry mapping CameraModel → K for known payloads.

How an unmodelled radial distortion term shows up in the survey Two panels over the same square grid of ground targets. On the left, the grid as reconstructed with the correct radial distortion coefficients: straight rows, uniform spacing, and residual arrows that are short and randomly oriented. On the right, the same grid reconstructed with the distortion left at zero: the rows bow outward toward the edges of the frame and the residual arrows grow with radius, all pointing away from the centre. A note states that the centre of the block is unaffected, which is why a check based only on central control passes. Distortion modelled residuals short and randomly oriented Distortion left at zero error grows with radius from the centre The block centre is right in both panels. Control placed only near the launch point therefore validates a reconstruction that is decimetres out at the edges.

Figure 3 — Radial distortion is a radius-dependent error, so it hides exactly where surveyors are most tempted to put their control. Checking the corners is not optional here; it is the only place the fault is visible.

Verify the fix worked

After extraction, assert the matrix is structurally sound and physically plausible before letting it reach the solver. This block fails loudly rather than passing a quietly-wrong K downstream:

import numpy as np

K, report = extract_intrinsic_matrix("input/DJI_0001.JPG", 13.2, 8.8)
W = 5472  # native sensor width in px for this payload

assert K.shape == (3, 3) and K[2, 2] == 1.0, "K is not a normalized 3x3"
assert K[0, 1] == 0.0 and K[1, 0] == 0.0, "skew must be zero for UAV pinhole"
assert 0.8 * W <= K[0, 0] <= 1.5 * W, f"fx={K[0,0]:.1f} implausible vs width {W}"
assert report["passed"], f"validation failed: {report}"
print(f"OK fx={K[0,0]:.1f} fy={K[1,1]:.1f} drift={report['pp_drift_frac']:.3%}")

A passing run prints fx/fy within a few percent of each other and a sub-5% principal-point drift; group the per-image reports by EXIF Model and confirm each model resolves to a single stable K before submitting the batch.

One more sanity check is worth running on every new camera model added to a fleet: compute the implied field of view from the extracted focal length and sensor size, and compare it with the manufacturer’s published figure. A discrepancy of more than a couple of degrees almost always means the sensor dimensions were guessed rather than read, and it is far easier to catch here than in a reprojection error weeks later.

When to escalate

Managing Coordinate Reference Systems in GDAL