Bundle adjustment in OpenPTV calibration¶
This page explains how OpenPTV calibrates today, why that leaves a cross-camera gap (RCM), and how the joint bundle adjustment closes it — plus the roadmap to a full self-calibration.
[!TIP] For a practical step-by-step playbook covering coarse-to-fine
sortgridsearch radii, 4-view optical splitters, and target detection, see the Calibration Best Practices & Troubleshooting Guide.
1. What OpenPTV does today: per-camera resection¶
The default calibration (orient / full_calibration, driven headlessly by
autocalibration.calibrate_dataset) is space resection, one camera at a time:
for each camera independently:
known 3D calibration points Xᵢ (fixed, from the calblock file)
detected pixels xᵢ (matched to Xᵢ by sortgrid)
solve the camera's exterior (position, angles) + interior/distortion
that minimize Σᵢ ‖ xᵢ − project(Xᵢ) ‖² ← this camera's RMS only
Each camera is fit against the same, fixed 3D points, but separately. The
distortion flag-set is chosen greedily by lowest RMS (CANDIDATE_FLAGS:
cc,xh,yh → +k1,k2 → +k3,p1,p2 → +interf).
The gap: there is no term in the cost that couples camera A to camera B. Nothing asks whether their rays actually meet in 3D. So every camera can reach sub-pixel RMS while the set triangulates poorly — high RCM, low RMS (the RMS-vs-RCM divergence).
Why "just bundle-adjust the plate" is subtle¶
The instinct is: optimize all cameras together against the plate. But if the plate 3D points stay fixed, a joint fit is mathematically identical to per-camera resection — each camera's residuals depend only on its own parameters, so nothing is coupled and RCM cannot change. A joint fit only helps when there is a shared unknown linking the cameras. That shared unknown is the 3D points themselves.
2. Joint plate bundle adjustment (joint_plate_bundle_adjust)¶
The coupling is created by making the 3D plate points free, shared parameters:
free parameters: every camera's exterior (position + angles)
+ one shared 3D position per plate point ← couples the cameras
minimize: Σ_cameras Σ_points ‖ detected − project(point, camera) ‖²
+ reg_weight · Σ_points ‖ point − nominal_calblock ‖² ← anchor
Now moving a shared point changes every camera's residual at once, so the optimizer trades camera poses against a common geometry — it can only lower total reprojection by making the cameras mutually consistent. That is exactly what lowers RCM.
- The anchor term (
reg_weight · ‖point − nominal‖) is not optional. Without it the problem has a 7-DOF gauge freedom: you can translate/rotate/scale the whole cloud-plus-cameras with zero reprojection change, and the solver drifts. Anchoring every point to its manufactured calblock coordinate both fixes the gauge and encodes trust in the known plate geometry. (reg_weight ≤ 0is rejected.) - Distortion is held fixed at the per-camera-fit values in this first cut — only exteriors and points move (see §4 for why distortion is added later, and one group at a time).
- It reuses the same machinery as the dumbbell bundle adjustment
(
ptv_calibration.dumbbell_ba_residuals), generalized from 2 dumbbell points to the N-point plate.
Usage¶
uv run python skills/openptv-calibrate/scripts/calib.py run <dataset> \
--output report.json --joint-ba # preview: prints RCM before -> after
uv run python skills/openptv-calibrate/scripts/calib.py run <dataset> \
--output report.json --joint-ba --shake-distortion # + greedy distortion shaking
uv run python skills/openptv-calibrate/scripts/calib.py run <dataset> \
--output report.json --joint-ba --write # writes refined .ori/.addpar IF improved
--write only overwrites when RCM actually drops (backups: *.jointba-bck).
Distortion shaking (--shake-distortion)¶
By default the joint BA holds distortion fixed and frees only exteriors + points.
Freeing all of k1,k2,k3 / p1,p2 / scale,shear / glass at once alongside the
exterior is ill-conditioned — the parameters trade off against each other
(cc/xh/yh vs depth, k-terms vs principal point, glass vs exterior) and the fit
goes unstable. --shake-distortion introduces them the safe way: one group at a
time, in least- to most-entangled order, re-solving after each and accepting a
group only if cross-camera RCM strictly improves — otherwise rolling it back.
The report's joint_ba block records shaken_groups (which were accepted) and
rcm_trace (per-group trial RCM + accept/reject), plus rcm_exterior_only so you
can see the exterior-BA vs +distortion contributions separately.
new_results, info = joint_plate_bundle_adjust(results, cpar, shake_distortion=True)
print(info["rcm_exterior_only"], "->", info["rcm_after"], info["shaken_groups"])
On aorta shaking accepted p1p2 + glass but moved RCM only
0.076 → 0.074 mm — confirming that rig's RCM is a shallow-parallax + detection-noise
floor the plate can't beat (neither exterior BA nor distortion helps). The
gate/rollback is the point: it pays off on rigs where distortion genuinely is the
ceiling, and it never destabilizes the fit chasing a group that doesn't help.
from openptv2.autocalibration import joint_plate_bundle_adjust
new_results, info = joint_plate_bundle_adjust(results, cpar)
print(info["rcm_before"], "->", info["rcm_after"]) # median mm
When it helps — and when it doesn't¶
On a rig whose cameras are misaligned relative to each other (exterior inconsistency), the joint BA pulls them back together and RCM drops sharply — on a perturbed synthetic rig it took median RCM 0.77 mm → ~0.
But on aorta the joint BA moved RCM only 0.076 → 0.075 mm (~1%). That
near-zero gain is itself the diagnosis: the residual RCM there is not exterior
misalignment — it is shallow parallax + detection noise + the distortion model's
ceiling, none of which exterior+point BA can fix. A BA that doesn't help tells
you where the error is coming from. The remaining error needs §3/§4, not more of
§2.
3. Roadmap — driving RCM down further¶
The calibration plans live in docs/plans/ -- see
2026-08-30-calibration-hub-multi-source.md and
2026-08-30-illmenau-dots-plate-pipeline.md. (This used to point at a
master-plan.md that was never written.)
Status: RCM reporting done, joint-plate BA first cut done, the rest below
TODO.
The full-blown self-calibration (target design)¶
Today we use forward reprojection only. The full version treats the calibration body as a cloud of 3D dots and closes the loop both ways:
- Forward + backward residual. Keep forward reprojection (3D→image), and add
the backward term: back-project each detection to a ray (
ray_tracing) and penalize the ray-to-ray / ray-to-point miss — i.e. make RCM itself a residual, not just a report. - Epipolar-distance term. For each point seen by ≥2 cameras, add the
point-to-epipolar-line distance (
epi.py) as a cheap, stabilizing image-space check — keeping the fit consistent with the correspondence geometry that tracking/stereo-matching later rely on. - Full "shaking" of the calibration files. Let exteriors, the glass/interface vector, the distortion terms, and the 3D cloud all float, minimizing (1)+(2) jointly, with the plate nominal as a soft prior. Real tracer particles (below) extend the cloud into the depth the plate can't reach.
- Add parameters one group at a time. (Shipped —
--shake-distortion, see above.) Freeing exterior + glass + all ofk1,k2,k3,p1,p2,scale,shear+ 3D points at once is ill-conditioned — parameters trade off against each other (ccvs depth,k-terms vs principal point) and the fit goes unstable. Instead greedily: free one new group, re-solve, and accept it only if RCM improves — else roll back. Same idea as the existing greedyCANDIDATE_FLAGS, but gated on cross-camera RCM instead of single-camera RMS. (A held-out fit/eval split for the RCM gate — to prevent overfitting distortion to the plate — is the next refinement.)
#2 — tracer self-calibration ("shaking"), iterated¶
The highest-value fix for shallow-parallax rigs, because the plate can't reach the measurement depth but the particles can:
plate-calibrate → track particles → self-calibrate on triangulation disparity/RCM
at real depth → re-track → repeat (Shake-the-Box lineage)
Formalize the existing calib.py snapshot-refine into an iterated stage, reusing
the §2 residual with the tracer cloud in place of (or alongside) the plate cloud;
RCM is the convergence/stopping criterion.
4. Why this matters downstream¶
Lower RCM ⇒ tighter epipolar bands ⇒ fewer false correspondences ⇒ better stereo-matching ⇒ longer, cleaner tracks and more accurate 3D positions/velocities/accelerations. The calibration error propagates into every Lagrangian and Eulerian result, so it is worth measuring (RCM) and minimizing (BA / self-calibration) rather than trusting RMS alone.
Summary¶
| step | couples cameras? | frees 3D points? | lowers RCM? | status |
|---|---|---|---|---|
| per-camera resection (default) | no | no | no | shipped |
joint plate BA (--joint-ba) |
yes (shared points) | yes | yes, if exterior-limited | shipped (first cut) |
+ one-at-a-time distortion shaking (--shake-distortion) |
yes | yes | yes, if distortion-limited | shipped |
| + backward/epipolar residual + held-out RCM gate | yes | yes | further | TODO |
| tracer self-calibration (#2) | yes | yes (real particles at depth) | most, on shallow rigs | TODO |