Full Pipeline Tutorial¶
This tutorial demonstrates the complete AquaCal calibration pipeline from start to finish. You’ll learn how to:
Load synthetic or real calibration data
Run all four calibration stages (or load a previous calibration)
Visualize camera rig geometry and calibration quality
Diagnose calibration results with reprojection and 3D error analysis
Prerequisites: Basic Python and OpenCV knowledge. Familiarity with camera calibration concepts is helpful but not required.
[14]:
# Colab setup installs AquaCal from PyPI if running on Google Colab.
# When running locally, this cell is a no-op.
import sys
if "google.colab" in sys.modules:
print("Colab detected — installing AquaCal...")
!pip install -q aquacal
print("Done.")
else:
print("Local environment — skipping install.")
Local environment — skipping install.
Data Source Selection¶
Choose your data source:
``synthetic-small`` (recommended for first run): 4 cameras, 20 frames, ideal conditions (no noise). Fast, no download required. Uses the same “ideal” scenario as the small preset in the synthetic validation tutorial.
``synthetic-large``: 12 cameras matching the real hardware rig, 30 frames, 0.5 px noise. Slower but produces compelling diagnostics. Uses the same “realistic” scenario as the large preset in the synthetic validation tutorial.
``zenodo``: Downloads a real hardware dataset from Zenodo (~164 MB, 13 cameras) and runs the full calibration pipeline from scratch. A reference calibration is included for comparison but is not used. Requires internet on first run. Note that your calibration will probably be worse than the reference calibration, as the zenodo dataset only contains a few calibration images per camera.
[2]:
DATA_SOURCE = "zenodo" # Options: "synthetic-small", "synthetic-large", "zenodo"
Setup and Imports¶
We’ll import the necessary modules and configure matplotlib for inline plotting.
[3]:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
from pathlib import Path
from aquacal.calibration import calibrate_from_detections
from aquacal.core.board import BoardGeometry
from aquacal.datasets import create_scenario, generate_synthetic_detections, load_example
from aquacal.validation.diagnostics import plot_camera_rig, plot_per_camera_error, plot_error_distribution
from aquacal.validation.reprojection import compute_reprojection_errors
# Configure matplotlib
plt.rcParams['figure.figsize'] = (10, 6)
plt.rcParams['font.size'] = 10
OUTPUT_DIR = Path("output")
print("Imports complete!")
Imports complete!
Load Data and Calibrate¶
This cell loads data and produces a CalibrationResult — the central object for all subsequent analysis. The path differs by data source:
Synthetic: generate a scenario with
create_scenario(), produce detections, run Stages 2-3Zenodo (real rig): download the dataset and run the complete pipeline (Stages 1-4, ~15-20 min)
[4]:
# These will be set by the data-source branch below:
# result — CalibrationResult (always)
# scenario — SyntheticScenario with ground truth (synthetic only, else None)
# detections — DetectionResult (synthetic only, else None)
# board — BoardGeometry (always)
scenario = None
detections = None
board_poses = None
reprojection_result = None
pipeline_output_dir = None
if DATA_SOURCE in ("synthetic-small", "synthetic-large"):
# --- Create scenario ---
# "synthetic-small" uses create_scenario("ideal"): 4 cameras, 20 frames, 0 noise
# "synthetic-large" uses create_scenario("realistic"): 12 cameras, 30 frames, 0.5px noise
scenario_name = "ideal" if DATA_SOURCE == "synthetic-small" else "realistic"
scenario = create_scenario(scenario_name, seed=42)
print(f"Created scenario: {scenario.description}")
print(f" Cameras: {len(scenario.intrinsics)}, Frames: {len(scenario.board_poses)}, Noise: {scenario.noise_std} px")
board = BoardGeometry(scenario.board_config)
# --- Generate detections ---
print("Generating synthetic detections...")
detections = generate_synthetic_detections(
intrinsics=scenario.intrinsics,
extrinsics=scenario.extrinsics,
water_zs=scenario.water_zs,
board=board,
board_poses=scenario.board_poses,
noise_std=scenario.noise_std,
seed=42,
)
# --- Run Stages 2-3 ---
print("Running calibration (Stages 2-3)...")
result, board_poses = calibrate_from_detections(
detections, scenario.intrinsics, board,
)
# Compute detailed reprojection errors for later analysis
reprojection_result = compute_reprojection_errors(result, detections, board_poses)
print(f"\nCalibration complete! RMS: {result.diagnostics.reprojection_error_rms:.3f} px")
elif DATA_SOURCE == "zenodo":
import os
from aquacal.calibration.pipeline import run_calibration
print("Downloading real-rig dataset from Zenodo (first run only)...")
dataset = load_example("real-rig")
config_path = dataset.cache_path / "config.yaml"
# chdir so relative paths in config.yaml resolve correctly
original_cwd = os.getcwd()
os.chdir(dataset.cache_path)
print("Running full calibration pipeline (Stages 1-4, ~15-20 min)...")
result = run_calibration(str(config_path), verbose=True)
os.chdir(original_cwd)
pipeline_output_dir = dataset.cache_path / "output"
board = BoardGeometry(result.board)
print(f"\nCalibration complete!")
print(f" Cameras: {len(result.cameras)}")
print(f" RMS: {result.diagnostics.reprojection_error_rms:.3f} px")
print(f" 3D error: {result.diagnostics.validation_3d_error_mean * 1000:.2f} mm (mean)")
else:
raise ValueError(f"Unknown DATA_SOURCE: {DATA_SOURCE!r}")
Downloading real-rig dataset from Zenodo (first run only)...
Running full calibration pipeline (Stages 1-4, ~15-20 min)...
============================================================
AquaCal Calibration Pipeline
============================================================
[Stage 1] Intrinsic calibration (in-air)...
Calibrating e3v8250 (1/13)...
Calibrating e3v829d (2/13)...
Calibrating e3v82e0 (3/13)...
Calibrating e3v82f9 (4/13)...
Calibrating e3v831e (5/13)...
Calibrating e3v832e (6/13)...
Calibrating e3v8334 (7/13)...
Calibrating e3v83e9 (8/13)...
Calibrating e3v83eb (9/13)...
Calibrating e3v83ee (10/13)...
Calibrating e3v83ef (11/13)...
Calibrating e3v83f0 (12/13)...
Calibrating e3v83f1 (13/13)...
e3v8250: RMS 0.503 px
e3v829d: RMS 0.376 px
e3v82e0: RMS 0.479 px
e3v82f9: RMS 0.276 px
e3v831e: RMS 0.570 px
e3v832e: RMS 0.470 px
e3v8334: RMS 0.386 px
e3v83e9: RMS 0.420 px
e3v83eb: RMS 0.477 px
e3v83ee: RMS 0.346 px
e3v83ef: RMS 0.388 px
e3v83f0: RMS 0.397 px
e3v83f1: RMS 0.474 px
Calibrated 13 cameras
[Detection] Detecting ChArUco in underwater videos...
Frame 6/60 (10%)
Frame 12/60 (20%)
Frame 18/60 (30%)
Frame 24/60 (40%)
Frame 30/60 (50%)
Frame 36/60 (60%)
Frame 42/60 (70%)
Frame 48/60 (80%)
Frame 54/60 (90%)
Frame 60/60 (100%)
Found 60 usable frames
[Split] Holdout fraction: 0.2
Calibration frames: 48
Validation frames: 12
[Stage 2] Extrinsic initialization...
Located e3v829d (1/12)
Located e3v82e0 (2/12)
Located e3v82f9 (3/12)
Located e3v832e (4/12)
Located e3v83ee (5/12)
Located e3v83ef (6/12)
Located e3v8334 (7/12)
Located e3v83e9 (8/12)
Located e3v831e (9/12)
Located e3v83f0 (10/12)
Located e3v83f1 (11/12)
Located e3v83eb (12/12)
Averaging poses...
Initialized 12 camera poses
Saved calibration_initial.json
Saved camera_rig_initial.png
[Stage 3] Interface and pose optimization...
Iteration Total nfev Cost Cost reduction Step norm Optimality
0 1 1.5503e+06 5.45e+06
1 7 1.2613e+06 2.89e+05 2.05e-02 6.07e+06
2 8 1.1763e+06 8.50e+04 2.02e-02 5.74e+06
3 9 1.0564e+06 1.20e+05 2.02e-02 6.07e+06
4 10 1.0444e+06 1.20e+04 2.04e-02 5.83e+06
5 11 9.8221e+05 6.22e+04 5.14e-03 5.59e+06
6 12 8.9709e+05 8.51e+04 1.01e-02 1.39e+06
7 13 7.9306e+05 1.04e+05 2.00e-02 1.25e+06
8 14 6.1630e+05 1.77e+05 4.09e-02 8.72e+05
9 15 3.5992e+05 2.56e+05 8.16e-02 1.96e+06
10 16 2.4690e+05 1.13e+05 1.65e-01 3.15e+06
11 18 1.8335e+05 6.35e+04 4.13e-02 2.69e+06
12 19 1.5567e+05 2.77e+04 4.17e-02 2.97e+06
13 20 1.3240e+05 2.33e+04 4.18e-02 9.70e+05
14 21 1.0263e+05 2.98e+04 8.33e-02 2.22e+05
15 22 6.3717e+04 3.89e+04 1.67e-01 5.74e+05
16 23 3.6187e+04 2.75e+04 3.34e-01 2.28e+06
17 24 3.5563e+04 6.24e+02 6.68e-01 3.89e+06
18 25 2.0151e+04 1.54e+04 1.66e-01 3.34e+06
19 26 1.7254e+04 2.90e+03 1.65e-01 2.91e+06
20 27 1.4147e+04 3.11e+03 4.17e-02 1.87e+06
21 28 1.1949e+04 2.20e+03 4.12e-02 6.72e+05
22 29 1.1462e+04 4.87e+02 4.12e-02 1.21e+04
23 30 1.1208e+04 2.54e+02 8.24e-02 4.64e+04
24 31 1.0903e+04 3.05e+02 1.65e-01 1.81e+05
25 32 1.0771e+04 1.32e+02 1.77e-01 1.80e+05
26 33 1.0701e+04 7.06e+01 2.73e-02 4.26e+03
27 34 1.0700e+04 6.54e-01 1.05e-02 5.62e+02
28 35 1.0700e+04 1.42e-03 1.46e-04 8.19e-01
29 36 1.0700e+04 1.33e-06 1.25e-05 4.34e-02
`ftol` termination condition is satisfied.
Function evaluations 36, initial cost 1.5503e+06, final cost 1.0700e+04, first-order optimality 4.34e-02.
Stage 3 RMS: 0.848 pixels (401.3s)
Estimated reference camera tilt: 2.76 degrees
Water surface Z: 1.0127 m
Camera heights above water (h_c):
e3v829d: cam_z=0.0000 h_c=1.0127
e3v82e0: cam_z=-0.0122 h_c=1.0249
e3v82f9: cam_z=-0.0041 h_c=1.0168
e3v831e: cam_z=-0.0160 h_c=1.0287
e3v832e: cam_z=0.0082 h_c=1.0045
e3v8334: cam_z=0.0010 h_c=1.0117
e3v83e9: cam_z=0.0298 h_c=0.9829
e3v83eb: cam_z=0.0111 h_c=1.0016
e3v83ee: cam_z=-0.0159 h_c=1.0286
e3v83ef: cam_z=0.0128 h_c=0.9999
e3v83f0: cam_z=0.0027 h_c=1.0100
e3v83f1: cam_z=-0.0477 h_c=1.0603
Camera height spread: 0.0774 m
[Stage 4] Joint refinement with intrinsics...
Iteration Total nfev Cost Cost reduction Step norm Optimality
0 1 1.0700e+04 1.27e+05
1 5 1.0176e+04 5.24e+02 3.11e+00 9.39e+04
2 7 9.9669e+03 2.10e+02 1.54e+00 7.86e+04
3 8 9.6440e+03 3.23e+02 3.01e+00 4.99e+04
4 11 9.6117e+03 3.23e+01 3.74e-01 4.71e+04
5 12 9.5521e+03 5.96e+01 7.38e-01 4.05e+04
6 15 9.5451e+03 7.00e+00 9.21e-02 3.97e+04
7 17 9.5416e+03 3.46e+00 4.60e-02 3.93e+04
8 19 9.5399e+03 1.72e+00 2.30e-02 3.91e+04
9 20 9.5365e+03 3.43e+00 4.59e-02 3.87e+04
10 24 9.5364e+03 1.07e-01 1.44e-03 3.87e+04
11 26 9.5363e+03 5.34e-02 7.18e-04 3.87e+04
12 28 9.5363e+03 2.67e-02 3.59e-04 3.87e+04
13 30 9.5363e+03 1.33e-02 1.79e-04 3.87e+04
14 31 9.5362e+03 2.67e-02 3.59e-04 3.87e+04
15 34 9.5362e+03 3.33e-03 4.49e-05 3.87e+04
`xtol` termination condition is satisfied.
Function evaluations 34, initial cost 1.0700e+04, final cost 9.5362e+03, first-order optimality 3.87e+04.
Stage 4 RMS: 0.807 pixels (518.6s)
Water surface Z (after refinement): 1.0197 m
Camera heights above water (h_c):
e3v829d: cam_z=0.0000 h_c=1.0197
e3v82e0: cam_z=-0.0117 h_c=1.0314
e3v82f9: cam_z=-0.0043 h_c=1.0240
e3v831e: cam_z=-0.0165 h_c=1.0362
e3v832e: cam_z=0.0084 h_c=1.0112
e3v8334: cam_z=0.0016 h_c=1.0181
e3v83e9: cam_z=0.0305 h_c=0.9892
e3v83eb: cam_z=0.0108 h_c=1.0089
e3v83ee: cam_z=-0.0162 h_c=1.0359
e3v83ef: cam_z=0.0122 h_c=1.0075
e3v83f0: cam_z=0.0033 h_c=1.0164
e3v83f1: cam_z=-0.0485 h_c=1.0682
Camera height spread: 0.0790 m
[Stage 3b] Registering 1 auxiliary camera(s)...
e3v8250: 42 frames, 3584 corners
Iteration Total nfev Cost Cost reduction Step norm Optimality
0 1 4.0486e+04 1.43e+06
1 5 2.7207e+04 1.33e+04 3.39e-02 2.21e+06
2 7 1.5954e+04 1.13e+04 8.47e-03 8.78e+05
3 8 1.3168e+04 2.79e+03 8.47e-03 4.97e+05
4 9 1.1852e+04 1.32e+03 8.47e-03 7.84e+04
5 10 1.0214e+04 1.64e+03 1.69e-02 6.63e+04
6 11 7.1604e+03 3.05e+03 3.39e-02 7.35e+04
7 12 3.1551e+03 4.01e+03 6.78e-02 6.52e+04
8 13 3.0852e+03 6.99e+01 6.30e-03 1.27e+04
9 14 3.0849e+03 3.16e-01 2.96e-04 1.25e+02
10 15 3.0849e+03 2.04e-05 1.26e-06 4.41e-02
`ftol` termination condition is satisfied.
Function evaluations 15, initial cost 4.0486e+04, final cost 3.0849e+03, first-order optimality 4.41e-02.
e3v8250: RMS 1.22 px, interface_d=1.0197m
[Validation] Estimating board poses for held-out frames...
Estimated 12 validation frame poses
[Validation] Computing errors on held-out data...
Primary cameras:
Reprojection RMS: 0.934 pixels
3D distance error: MAE 0.24 mm, RMSE 0.44 mm (0.4% of square size)
Auxiliary cameras:
e3v8250: RMS 25.991 pixels
[Diagnostics] Generating report...
Saved diagnostics to output
[Save] Saving calibration result...
Saved to output\calibration.json
============================================================
Calibration complete!
Primary cameras:
Reprojection RMS: 0.934 pixels
3D error: MAE 0.24 mm, RMSE 0.44 mm (0.4%)
Auxiliary cameras:
e3v8250: RMS 25.991 pixels
============================================================
Calibration complete!
Cameras: 13
RMS: 0.934 px
3D error: 0.24 mm (mean)
Exploring the Result¶
Every calibration produces a CalibrationResult containing per-camera parameters, interface geometry, and diagnostic metrics.
[5]:
cam_names_sorted = sorted(result.cameras.keys())
print(f"Cameras ({len(cam_names_sorted)}): {cam_names_sorted}")
print(f"\nBoard: {result.board.squares_x}x{result.board.squares_y}, "
f"{result.board.square_size * 1000:.1f} mm squares")
print(f"\nInterface: n_air={result.interface.n_air}, n_water={result.interface.n_water}")
print(f"\nDiagnostics:")
print(f" Reprojection RMS: {result.diagnostics.reprojection_error_rms:.3f} px")
if result.diagnostics.validation_3d_error_mean > 0:
print(f" 3D error mean: {result.diagnostics.validation_3d_error_mean * 1000:.2f} mm")
print(f" 3D error std: {result.diagnostics.validation_3d_error_std * 1000:.2f} mm")
print(f"\nWater surface Z per camera:")
for cam in cam_names_sorted:
cc = result.cameras[cam]
C = cc.extrinsics.C
h_c = cc.water_z - C[2]
print(f" {cam}: water_z = {cc.water_z:.4f} m, "
f"C = [{C[0]:.3f}, {C[1]:.3f}, {C[2]:.3f}], h_c = {h_c:.4f} m")
Cameras (13): ['e3v8250', 'e3v829d', 'e3v82e0', 'e3v82f9', 'e3v831e', 'e3v832e', 'e3v8334', 'e3v83e9', 'e3v83eb', 'e3v83ee', 'e3v83ef', 'e3v83f0', 'e3v83f1']
Board: 12x9, 60.0 mm squares
Interface: n_air=1.0, n_water=1.333
Diagnostics:
Reprojection RMS: 0.934 px
3D error mean: 0.24 mm
3D error std: 0.37 mm
Water surface Z per camera:
e3v8250: water_z = 1.0197 m, C = [-0.334, 0.576, 0.015], h_c = 1.0046 m
e3v829d: water_z = 1.0197 m, C = [0.000, 0.000, 0.000], h_c = 1.0197 m
e3v82e0: water_z = 1.0197 m, C = [-0.334, -0.060, -0.012], h_c = 1.0314 m
e3v82f9: water_z = 1.0197 m, C = [0.344, 0.568, -0.004], h_c = 1.0240 m
e3v831e: water_z = 1.0197 m, C = [-0.889, 0.280, -0.016], h_c = 1.0362 m
e3v832e: water_z = 1.0197 m, C = [0.210, 0.238, 0.008], h_c = 1.0112 m
e3v8334: water_z = 1.0197 m, C = [-0.659, 0.007, 0.002], h_c = 1.0181 m
e3v83e9: water_z = 1.0197 m, C = [-0.330, 1.197, 0.031], h_c = 0.9892 m
e3v83eb: water_z = 1.0197 m, C = [-0.876, 0.887, 0.011], h_c = 1.0089 m
e3v83ee: water_z = 1.0197 m, C = [0.016, 1.152, -0.016], h_c = 1.0359 m
e3v83ef: water_z = 1.0197 m, C = [0.236, 0.865, 0.012], h_c = 1.0075 m
e3v83f0: water_z = 1.0197 m, C = [-1.000, 0.570, 0.003], h_c = 1.0164 m
e3v83f1: water_z = 1.0197 m, C = [-0.664, 1.155, -0.049], h_c = 1.0682 m
Camera Rig Geometry¶
A 3D plot of the camera positions and orientations from the calibration result. The water surface plane is shown at the estimated Z-coordinate.
[6]:
%matplotlib inline
fig = plot_camera_rig(result, title="Camera Rig Geometry")
plt.show()
plt.close()
Intrinsic Parameters¶
Focal lengths and principal points for each camera. For synthetic data these are ground truth values passed through to calibration; for real data they come from Stage 1 (in-air) calibration.
[7]:
def plot_intrinsics_summary(result):
"""Side-by-side focal length bar chart and principal point scatter."""
cam_names = sorted(result.cameras.keys())
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
fx_vals = [result.cameras[c].intrinsics.K[0, 0] for c in cam_names]
cx_vals = [result.cameras[c].intrinsics.K[0, 2] for c in cam_names]
cy_vals = [result.cameras[c].intrinsics.K[1, 2] for c in cam_names]
x = np.arange(len(cam_names))
ax1.bar(x, fx_vals, color='steelblue', alpha=0.7)
ax1.set_xlabel('Camera')
ax1.set_ylabel('Focal Length (pixels)')
ax1.set_title('Focal Lengths (fx)')
ax1.set_xticks(x)
ax1.set_xticklabels(cam_names, rotation=45, ha='right')
ax1.grid(axis='y', alpha=0.3)
ax2.scatter(cx_vals, cy_vals, s=100, color='steelblue', alpha=0.7)
for i, cam in enumerate(cam_names):
ax2.annotate(cam, (cx_vals[i], cy_vals[i]), xytext=(5, 5), textcoords='offset points')
ax2.set_xlabel('cx (pixels)')
ax2.set_ylabel('cy (pixels)')
ax2.set_title('Principal Points')
ax2.grid(alpha=0.3)
fig.tight_layout()
return fig
fig = plot_intrinsics_summary(result)
plt.show()
plt.close()
Diagnostics¶
Per-Camera Reprojection Error¶
A bar chart of per-camera RMS errors quickly reveals whether any camera is performing worse than the others. Cameras with high error often have poor intrinsic calibration or insufficient board observations.
[8]:
fig = plot_per_camera_error(result)
plt.show()
plt.close()
print("Cameras with error > 2x the average may need re-calibration.")
Cameras with error > 2x the average may need re-calibration.
Reprojection Error Distribution¶
The histogram shows the overall distribution of per-corner errors across all cameras and frames. A well-calibrated rig produces a tight distribution centered near zero.
For synthetic data, residuals come from recomputing projections against detections. For the Zenodo dataset, residuals are loaded from the saved calibration file (computed on the validation holdout set during the original pipeline run).
[9]:
# Identify auxiliary cameras (if any) for split display
aux_cameras = {name for name, cc in result.cameras.items() if cc.is_auxiliary}
if reprojection_result is not None:
fig = plot_error_distribution(reprojection_result, auxiliary_cameras=aux_cameras or None)
plt.show()
plt.close()
elif result.diagnostics.per_corner_residuals is not None:
# Build a ReprojectionErrors from the saved residuals (e.g. Zenodo reference calibration)
from aquacal.validation.reprojection import ReprojectionErrors
camera_labels = (
np.array(result.diagnostics.per_corner_camera_labels, dtype=object)
if result.diagnostics.per_corner_camera_labels is not None
else None
)
stored = ReprojectionErrors(
rms=result.diagnostics.reprojection_error_rms,
per_camera=result.diagnostics.reprojection_error_per_camera,
per_frame=result.diagnostics.per_frame_errors or {},
residuals=result.diagnostics.per_corner_residuals,
num_observations=len(result.diagnostics.per_corner_residuals),
camera_labels=camera_labels,
)
fig = plot_error_distribution(stored, auxiliary_cameras=aux_cameras or None)
plt.show()
plt.close()
else:
print("Skipped — per-corner residuals not available for this data source.")
print(f"Overall RMS from calibration: {result.diagnostics.reprojection_error_rms:.3f} px")
Error statistics:
Primary cameras:
N: 4,451
Mean: 0.700 px
Median: 0.563 px
95th percentile: 1.642 px
Max: 13.175 px
Auxiliary cameras:
N: 908
Mean: 17.718 px
Median: 10.700 px
95th percentile: 58.351 px
Max: 97.466 px
Interface Distance Recovery (Synthetic Only)¶
The water surface Z-coordinate (water_z) is the key refractive parameter. Comparing estimated values to ground truth tells you how well Stage 3 converged.
[10]:
def plot_interface_recovery(result, scenario):
"""Grouped bar chart comparing estimated vs ground-truth water_z."""
cam_names = sorted(result.cameras.keys())
estimated = [result.cameras[c].water_z for c in cam_names]
ground_truth = [scenario.water_zs[c] for c in cam_names]
fig, ax = plt.subplots(figsize=(10, 5))
x = np.arange(len(cam_names))
width = 0.35
ax.bar(x - width / 2, estimated, width, label='Estimated', color='steelblue', alpha=0.8)
ax.bar(x + width / 2, ground_truth, width, label='Ground Truth', color='orange', alpha=0.8)
ax.set_xlabel('Camera')
ax.set_ylabel('Interface Distance (m)')
ax.set_title('Interface Distance Recovery')
ax.set_xticks(x)
ax.set_xticklabels(cam_names, rotation=45, ha='right')
ax.legend()
ax.grid(axis='y', alpha=0.3)
fig.tight_layout()
mean_err = np.mean([abs(e - g) for e, g in zip(estimated, ground_truth)])
print(f"Mean absolute interface distance error: {mean_err * 1000:.2f} mm")
return fig
if scenario is not None:
fig = plot_interface_recovery(result, scenario)
plt.show()
plt.close()
else:
print("Skipped — ground truth not available for real data.")
print("Estimated water_z values are shown in the result summary above.")
Skipped — ground truth not available for real data.
Estimated water_z values are shown in the result summary above.
3D Reconstruction Error¶
Reprojection error measures 2D fit quality, but 3D reconstruction error is the ultimate accuracy metric. We triangulate board corners from multiple cameras and compare the recovered inter-corner distances to the known board geometry.
[11]:
spatial_measurements = None
if detections is not None:
from aquacal.validation.reconstruction import compute_3d_distance_errors
dist_errors = compute_3d_distance_errors(
calibration=result,
detections=detections,
board=board,
include_per_pair=False,
include_spatial=True,
)
print("3D Reconstruction Quality:")
print(f" Signed mean error: {dist_errors.signed_mean * 1000:.3f} mm")
print(f" RMSE: {dist_errors.rmse * 1000:.3f} mm")
print(f" Comparisons: {dist_errors.num_comparisons}")
if dist_errors.spatial is not None:
spatial_measurements = dist_errors.spatial
fig, ax = plt.subplots(figsize=(8, 5))
ax.hist(
spatial_measurements.signed_errors * 1000,
bins=30, color='steelblue', alpha=0.7, edgecolor='black',
)
ax.axvline(0, color='black', linestyle='--', alpha=0.5)
ax.axvline(
dist_errors.signed_mean * 1000, color='red', linestyle='--',
label=f'Mean: {dist_errors.signed_mean * 1000:.2f} mm',
)
ax.set_xlabel('Signed Distance Error (mm)')
ax.set_ylabel('Frequency')
ax.set_title('3D Reconstruction Error Distribution')
ax.legend()
ax.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.show()
plt.close()
elif pipeline_output_dir is not None:
spatial_csv = pipeline_output_dir / "spatial_measurements.csv"
if spatial_csv.exists():
from aquacal.validation.reconstruction import load_spatial_measurements
spatial_measurements = load_spatial_measurements(spatial_csv)
print("3D Reconstruction Quality (from pipeline spatial measurements):")
print(f" Signed mean error: {np.mean(spatial_measurements.signed_errors) * 1000:.3f} mm")
print(f" RMSE: {np.sqrt(np.mean(spatial_measurements.signed_errors**2)) * 1000:.3f} mm")
print(f" Measurements: {len(spatial_measurements.signed_errors)}")
fig, ax = plt.subplots(figsize=(8, 5))
ax.hist(
spatial_measurements.signed_errors * 1000,
bins=30, color='steelblue', alpha=0.7, edgecolor='black',
)
ax.axvline(0, color='black', linestyle='--', alpha=0.5)
mean_err = np.mean(spatial_measurements.signed_errors) * 1000
ax.axvline(
mean_err, color='red', linestyle='--',
label=f'Mean: {mean_err:.2f} mm',
)
ax.set_xlabel('Signed Distance Error (mm)')
ax.set_ylabel('Frequency')
ax.set_title('3D Reconstruction Error Distribution')
ax.legend()
ax.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.show()
plt.close()
else:
print("3D Reconstruction Quality (from calibration diagnostics):")
print(f" Mean error: {result.diagnostics.validation_3d_error_mean * 1000:.2f} mm")
print(f" Std: {result.diagnostics.validation_3d_error_std * 1000:.2f} mm")
print(f" (spatial_measurements.csv not found — histogram not available)")
else:
print("3D Reconstruction Quality (from calibration diagnostics):")
print(f" Mean error: {result.diagnostics.validation_3d_error_mean * 1000:.2f} mm")
print(f" Std: {result.diagnostics.validation_3d_error_std * 1000:.2f} mm")
3D Reconstruction Quality (from pipeline spatial measurements):
Signed mean error: 0.006 mm
RMSE: 0.441 mm
Measurements: 1817
Common Issues Checklist¶
Symptom |
Likely Cause |
Fix |
|---|---|---|
High error for one camera |
Poor intrinsic calibration |
Re-calibrate intrinsics with more frames, check for motion blur |
High error in image corners |
Distortion model insufficient |
Try rational model (8 coefficients) |
Interface distance not converging |
Initial estimate too far from truth |
Provide better |
Interface distances differ between cameras |
Degenerate board poses |
Ensure board is visible at varied angles and depths |
High 3D reconstruction error |
Systematic bias |
Check that |
Reprojection < 1px but 3D error high |
Overfitting to 2D |
Verify interface distance initialization, add validation frames |
Saving and Loading Results¶
AquaCal provides utilities to save and load calibration results in JSON format.
[12]:
# Save calibration result
# save_calibration(result, "output/calibration.json")
# Load it back later
# loaded = load_calibration("output/calibration.json")
print("Calibration results can be saved to JSON for later use.")
print("See aquacal.io.serialization.save_calibration() and load_calibration().")
Calibration results can be saved to JSON for later use.
See aquacal.io.serialization.save_calibration() and load_calibration().
Exported Data¶
The CSVs below capture the data behind all plots in this notebook, so downstream consumers can reproduce figures without re-running the calibration.
[13]:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
cam_names = sorted(result.cameras.keys())
# --- Camera parameters (backs rig geometry, intrinsics, and interface plots) ---
rows_cameras = []
for cam in cam_names:
cc = result.cameras[cam]
C = cc.extrinsics.C
K = cc.intrinsics.K
rows_cameras.append({
"camera": cam,
"x_m": C[0], "y_m": C[1], "z_m": C[2],
"fx_px": K[0, 0], "fy_px": K[1, 1],
"cx_px": K[0, 2], "cy_px": K[1, 2],
"water_z_m": cc.water_z,
"h_c_m": cc.water_z - C[2],
"reprojection_rms_px": result.diagnostics.reprojection_error_per_camera.get(cam, float("nan")),
})
# Add ground-truth water_z if available
if scenario is not None:
rows_cameras[-1]["gt_water_z_m"] = scenario.water_zs[cam]
df_cameras = pd.DataFrame(rows_cameras)
path_cameras = OUTPUT_DIR / "camera_parameters.csv"
df_cameras.to_csv(path_cameras, index=False)
print(f"Wrote {path_cameras} ({len(df_cameras)} rows)")
# --- Reprojection residuals (backs error distribution histogram) ---
# Available from live computation (synthetic) or saved calibration (Zenodo)
residuals_array = None
camera_labels = None
if reprojection_result is not None:
residuals_array = reprojection_result.residuals
camera_labels = reprojection_result.camera_labels
elif result.diagnostics.per_corner_residuals is not None:
residuals_array = result.diagnostics.per_corner_residuals
if result.diagnostics.per_corner_camera_labels is not None:
camera_labels = np.array(result.diagnostics.per_corner_camera_labels, dtype=object)
if residuals_array is not None:
df_dict = {
"residual_x_px": residuals_array[:, 0],
"residual_y_px": residuals_array[:, 1],
}
if camera_labels is not None and len(camera_labels) == len(residuals_array):
aux_set = {name for name, cc in result.cameras.items() if cc.is_auxiliary}
df_dict["camera"] = camera_labels
df_dict["is_auxiliary"] = [lbl in aux_set for lbl in camera_labels]
df_residuals = pd.DataFrame(df_dict)
path_residuals = OUTPUT_DIR / "reprojection_residuals.csv"
df_residuals.to_csv(path_residuals, index=False)
print(f"Wrote {path_residuals} ({len(df_residuals)} rows)")
# --- 3D reconstruction errors (backs error histogram) ---
if spatial_measurements is not None:
df_3d = pd.DataFrame({
"x_m": spatial_measurements.positions[:, 0],
"y_m": spatial_measurements.positions[:, 1],
"z_m": spatial_measurements.positions[:, 2],
"signed_error_m": spatial_measurements.signed_errors,
"frame_idx": spatial_measurements.frame_indices,
})
path_3d = OUTPUT_DIR / "reconstruction_errors.csv"
df_3d.to_csv(path_3d, index=False)
print(f"Wrote {path_3d} ({len(df_3d)} rows)")
Wrote output\camera_parameters.csv (13 rows)
Wrote output\reprojection_residuals.csv (5359 rows)
Wrote output\reconstruction_errors.csv (1817 rows)
Summary¶
In this tutorial you learned how to:
Load calibration data (synthetic or real hardware from Zenodo)
Run the calibration pipeline (Stages 2-3 for synthetic, Stages 1-4 for real data)
Inspect camera rig geometry, intrinsic parameters, and interface distances
Diagnose calibration quality with per-camera, distribution, and 3D error analysis
Save and load calibration results
Next: Explore why refractive calibration matters with controlled experiments comparing refractive vs non-refractive models.
User Guide: Comprehensive documentation on calibration theory and best practices