Deciding When to Fix Versus Optimise Intrinsics
Every reconstruction engine offers a choice: solve the camera parameters along with everything else, or hold them at supplied values. Most default to solving them, which is correct for the varied imagery the algorithms were developed on and incorrect for a nadir mapping grid — the geometry in which the parameters are least observable and most able to absorb a surface error.
The decision is not a preference. It follows from two properties of the survey that are known before processing starts, and getting it right costs nothing while getting it wrong costs a re-flight. This page sets out the rule, the middle position, and how to check the choice afterwards. It applies the analysis in camera calibration and lens models in Python.
The two properties that decide it
Observability is whether the flight geometry can distinguish a lens error from a scene error. Varied altitude separates focal length from distance; oblique views separate radial distortion from surface curvature; a cross pattern separates the principal point from a tilt. A single-altitude nadir grid has none of these.
Constraint is whether ground control pins the solution independently of the camera model. Control distributed across the interior of the site constrains the surface directly, so a self-calibration cannot bend it. Control only around the perimeter leaves the middle free, which is precisely where a dome lives.
High on both, self-calibration wins: it captures the lens as flown, including thermal drift, better than any prior calibration. Low on both, fixed intrinsics win by a wide margin. In between, a partial freedom is the right answer.
Figure 1 — The rule. Both properties are known before processing, which is why the decision does not need a trial run.
Minimal reproducible solution
def intrinsics_policy(flight: dict, control: dict, calibration: dict | None) -> dict:
"""Decide which intrinsics to free for this survey.
Observability and constraint are assessed separately because they fail
separately: a well-controlled nadir survey and a poorly controlled
oblique one both land in the middle position for different reasons.
"""
observable = (flight.get("oblique_fraction", 0.0) > 0.05
or flight.get("altitude_range_m", 0.0) > 0.2 * flight.get("altitude_m", 1)
or flight.get("cross_pattern", False))
constrained = (control.get("gcp_count", 0) >= 5
and control.get("interior_gcp_count", 0) >= 2)
if observable and constrained:
return {"free": ["focal", "cx", "cy", "k1", "k2"],
"reason": "geometry and control both constrain the solve"}
if observable or constrained:
return {"free": ["focal", "k1"],
"reason": "partial constraint — free only the terms that drift"}
if calibration is None:
return {"free": ["focal", "k1"],
"reason": "no prior calibration available; partial freedom is the "
"least bad option, and flag the survey for checkpoints"}
return {"free": [], "prior": calibration,
"reason": "nadir-only with weak control — fix everything"}
The no-calibration branch matters because it is common and the temptation is to fall back on full self-calibration. Partial freedom with a flagged survey is safer: it constrains the terms that cannot absorb a dome while leaving the ones that genuinely vary.
Applying it to the engine
Most engines accept the policy as configuration, and the names differ.
def odm_options(policy: dict) -> dict:
"""Translate a policy into reconstruction engine options."""
free = set(policy.get("free", []))
if not free:
return {"use-fixed-camera-params": True,
"cameras": policy["prior"]}
if free == {"focal", "k1"}:
return {"camera-lens": "brown",
"optimize-disabled-params": "cx,cy,k2,k3,p1,p2"}
return {"camera-lens": "brown"}
def verify_applied(reconstruction_cameras: dict, policy: dict,
prior: dict | None, *, tol: float = 1e-6) -> dict:
"""Confirm the engine actually honoured the policy.
Supplying a calibration and fixing it are separate settings in most
software, and forgetting the second produces a self-calibration with a
good initial estimate — which looks like success and is not.
"""
if policy.get("free"):
return {"checked": False, "note": "parameters were intended to be free"}
problems = []
for key in ("focal", "k1", "k2", "cx", "cy"):
before = (prior or {}).get(key)
after = reconstruction_cameras.get(key)
if before is None or after is None:
continue
if abs(before - after) > tol:
problems.append(f"{key} moved from {before:.6f} to {after:.6f} — "
"the parameters were not fixed")
return {"checked": True, "problems": problems, "honoured": not problems}
That verification is worth running on every job with a fixed-intrinsics policy. The failure mode — supplied but not fixed — is silent, produces a plausible result, and is exactly what the policy was meant to prevent.
Figure 3 — Three policies, selected by geometry rather than by preference.
Edge-case matrix
| Situation | Policy | Reason |
|---|---|---|
| Nadir grid, perimeter control | Fix | Neither constraint present |
| Nadir grid, interior control | Free focal and k1 | Surface is pinned |
| Cross plus obliques, no control | Free focal and k1 | Geometry helps, scale does not |
| Cross plus obliques, good control | Free all | Best possible case |
| Corridor survey | Fix, or free focal only | Cross-geometry is weak by construction |
| Very small site | Fix | Too little geometry to separate anything |
| Camera changed mid-project | Per-camera policy | Two cameras, two calibrations |
| RTK positions, no ground control | Free focal and k1 | Positions constrain scale, not the surface |
The RTK row is a common misconception. Accurate camera positions fix the scale and the absolute placement of a survey and do very little to prevent doming, because a dome is a deformation of the surface between the cameras rather than of the camera positions themselves.
Verification snippet
import numpy as np
def compare_policies(results: dict[str, dict]) -> dict:
"""Run the same survey under two policies and compare against checkpoints.
The comparison that matters is checkpoint RMSE and residual curvature,
not the reconstruction's own reprojection error — which will always
favour the policy with more free parameters.
"""
rows = {}
for name, r in results.items():
rows[name] = {
"reprojection_px": r["reprojection_px"],
"checkpoint_rmse_m": r["checkpoint_rmse_m"],
"sag_m": r["sag_m"],
}
best = min(rows, key=lambda k: (abs(rows[k]["sag_m"]), rows[k]["checkpoint_rmse_m"]))
return {"policies": rows, "best_by_checkpoints": best,
"note": ("the policy with the lower reprojection error is not "
"necessarily the better survey")}
Figure 2 — Why the engine’s own quality metric is the wrong one to choose a policy by.
Making the policy part of the job definition
A policy applied by whoever processed a survey is a policy that will be applied differently next time. Putting it in the job definition — alongside the survey identifier, the parameters and the control set — makes it an attribute of the work rather than of the operator.
Two fields are enough: the policy name and the calibration reference it depends on. A job that says “fixed, camera SN-4471 calibration of 2026-03-02” is one that can be re-run identically and audited without asking anybody.
def job_intrinsics_block(policy: dict, calibration_record: dict | None) -> dict:
"""The intrinsics section of a job definition."""
return {
"policy": "fixed" if not policy.get("free") else
("partial" if set(policy["free"]) == {"focal", "k1"} else "full"),
"free_parameters": policy.get("free", []),
"calibration": {
"camera_serial": (calibration_record or {}).get("camera_serial"),
"measured_on": (calibration_record or {}).get("measured_on"),
} if calibration_record else None,
"reason": policy.get("reason"),
}
Carrying the reason as well as the decision is a small habit with a real payoff. Six months later the question is never “what policy was used” — that is in the file — but “why”, and a sentence written at the time answers it better than any reconstruction of the reasoning.
A programme that records this consistently also gains something unexpected: a dataset relating policy, geometry and checkpoint accuracy across dozens of surveys, which is far more convincing evidence for the rule at the top of this page than any single comparison.
When to escalate
- The policy says fix and no calibration exists. Fly the calibration pattern, or accept partial freedom and insist on checkpoints.
- Two policies give similar checkpoints and different lens parameters. The survey does not constrain the lens; prefer the fixed policy for consistency across the programme.
- A client requires the engine’s default. Provide it and the checkpoint comparison together. The number is more persuasive than the argument.