Stereo Calibration
Calibrate stereo camera pairs for three-component (3C) velocity measurements. Three methods available: Dotboard, ChArUco, and Stepped.
Quick Recipe
opinionated defaults -- full reference below- 1.Note your configuration: same-side (both cameras on one side of the light sheet) or transmission (on opposite sides). If your setup is high-magnification or fragile at single-plane calibration, consider the stepped board instead.
- 2.Pick detection: Stereo Dotboard (circular dot grids) or Stereo ChArUco (occlusion-tolerant). Configure images for both cameras in the calibration tab.
- 3.Click Generate Model. Targets: stereo RMS < 0.5 px, relative angle 30–60°. Verify the baseline matches your physical setup.
- 4.Run PIV on both cameras first (
pivtools-cli instantaneousorensemble). - 5.Click Reconstruct 3D (GUI) or run
pivtools-cli apply-stereoto produceux,uy,uz. - 6.Recommended: run Self-Calibration afterwards to correct for laser-sheet Z-offset and tilt. Adds a few minutes, meaningfully improves accuracy.
Overview
Calibration settings live beside your images, not in config.yaml. The calibration: block in config.yaml is a four-key pointer saying which source and which method are active. Image format, board geometry, and the rig dt live in a per-source sidecar at <source>/calibration/settings.yaml. Create one with pivtools-cli init-settings --source <dir> or by filling in the calibration tab. The image format is required when the settings are read and rig.dt when a model is generated, so a missing value stops the run instead of quietly defaulting.
Stereo calibration computes the geometric relationship between two cameras viewing the same measurement plane. The stereo model contains intrinsic parameters for each camera and extrinsic parameters (rotation and translation) describing their relative geometry.
| Method | Best For | Target Required |
|---|---|---|
| Stereo Dotboard | Standard stereo PIV setups | Circular dot grid, 10-20 positions |
| Stereo ChArUco | Partial target visibility, oblique angles | ChArUco board, multiple positions |
| Stereo Stepped | Transmission rigs where cameras image different board faces | Dual-level (stepped) dot board, 1+ positions (3+ recommended) |
The stereo tabs offer no polynomial camera model. A single-plane polynomial has no out-of-plane sensitivity, so it cannot recover the W component. The model-type dropdown that appears on the planar tabs is absent on the stereo tabs.
Quality Metrics
After calibration, review these metrics to assess quality.
| Metric | Description | Target |
|---|---|---|
| Stereo RMS Error | Joint reprojection error from the stereoCalibrate pose fit (pixels). Dotboard and ChArUco only | < 0.5 px |
| Stereo Views | Number of shared views used in the stereo pose fit (n_stereo_views) | 10+ typical |
| Cam1 / Cam2 RMS Error | Per-camera reprojection error (pixels) | < 0.5 px each |
| Relative Angle | Angle between camera optical axes (degrees) | 30-60 degrees typical |
| Baseline Distance | Physical camera separation (mm) | Verify against setup |
Stereo Model Outputs
Intrinsic Parameters (per camera)
- Camera Matrix: fx and fy (focal length, constrained equal by the default fix_aspect_ratio), cx, cy (principal point)
- Distortion: 4 coefficients -- k1, k2 radial and p1, p2 tangential, with k3 held at zero
Extrinsic Parameters
- R_stereo: 3x3 rotation from Cam1 to Cam2
- T_stereo: Baseline translation (mm)
- self_cal block: Laser-sheet z_offset and tilt from self-calibration (empty until run)
Persistence
The model record is written into the calibration source folder, not the output base path. Beside it, an inputs.mat sidecar caches the detected points, the clicked world frame, and the board geometry, so the model regenerates without re-detecting or re-clicking. Clicks and detections are never stored in config.yaml -- config holds only what you type or select before detecting (image settings, camera pair, dt, geometry seed).
├── model/stereo_model_pinhole.mat (includes the self_cal block)
├── model/inputs.mat (cached detections + clicks)
└── figures/ (proof figures; self_cal/ subfolder after self-calibration)
Calibration Image Setup
Stereo calibration requires synchronised images from both cameras at each target position. Image settings are shared with planar calibration.
Directory Structure
Each entry in calibration_sources is a direct path to a folder of calibration images. Per-camera subfolders are opt-in: set image.use_camera_subfolders in the settings sidecar and name them in image.camera_subfolders. There is no layout-ordering switch.
use_camera_subfolders: true
├── Cam1/
│ └── calib_001.tif
└── Cam2/
└── calib_001.tif
use_camera_subfolders: false
├── calib_001.tif
└── calib_002.tif
(or one IM7 container holding both cameras)
LaVision IM7 files can contain both stereo cameras in a single file. Set use_camera_subfolders: false and PIVTools will extract each camera frame automatically.
Each image index must show the target at the same position for both cameras. Misaligned images produce incorrect stereo geometry.
Stereo Dotboard
Both cameras view a circular dot grid at multiple target positions. Detection uses the same algorithm as planar dotboard (blob detection, neighbour walk, RANSAC filtering) -- grid dimensions are found automatically, so no row/column counts are configured.
Model Fitting
Each camera's intrinsics are fitted independently. Object-point release (calibrateCameraRO) is on by default and falls back to plain calibrateCamera per camera when that camera's views are not identical (partial boards). The cross-camera pose then comes from cv2.stereoCalibrate(CALIB_FIX_INTRINSIC) over all shared views -- a joint estimate that replaced the old compose of two single-view solvePnP poses. The fit reports the stereo RMS (px) and the shared-view count (n_stereo_views).
Parameters
| Parameter | Description | Default |
|---|---|---|
| camera_pair | Camera numbers forming the stereo pair | [1, 2] |
| dot_spacing_mm | Physical spacing between dot centres (mm) | 15.0 |
| datum_frame | Image index defining the world frame (1-based) | 1 |
| dt | Time between frames (seconds) | 1.0 |
| fix_k2 | Pin the r^4 radial term to zero (toggle appears below 3 frames; on by default there) | false |
GUI Workflow
- 1Configure calibration images (format, count, subfolder)
- 2Set camera pair (e.g. Camera 1 and Camera 2) and dot spacing
- 3Browse images to verify both camera views show the target
- 4Detect dots on the datum frame, then click origin / +X / +Y on camera 1 to define the world frame
- 5Click "Generate Model" to compute stereo calibration
- 6Review stereo RMS error, shared-view count, relative angle, and baseline distance
- 7Click "Calibrate Vectors" to apply calibration to PIV data
Output Directory
├── model/stereo_model_pinhole.mat
├── model/inputs.mat
└── figures/
Detection cache: detections persist in inputs.mat beside the model, so Generate reuses them on a reopened tab. The Re-detect button ignores the cache and detects fresh before recalibrating.
Stereo ChArUco
Combines ArUco marker detection with stereo geometry computation. ArUco markers identify which corners are visible, so detection works with partial occlusion and oblique viewing angles. Model fitting is identical to Stereo Dotboard -- independent per-camera intrinsics, then cv2.stereoCalibrate(CALIB_FIX_INTRINSIC) over all shared views, with corners matched across cameras by their global corner ids.
Parameters
| Parameter | Description | Default |
|---|---|---|
| camera_pair | Camera numbers forming the stereo pair | [1, 2] |
| squares_h | Horizontal square count | 10 |
| squares_v | Vertical square count | 9 |
| square_size | Square size in metres | 0.03 |
| marker_ratio | Marker size relative to square | 0.5 |
| aruco_dict | ArUco dictionary type | DICT_4X4_1000 |
| min_corners | Minimum corners to accept a frame | 6 |
| dt | Time between frames (seconds) | 0.0057553 |
ArUco Dictionaries
Available dictionaries: DICT_4X4, DICT_5X5, DICT_6X6, DICT_7X7 -- each with 50, 100, 250, or 1000 markers.
GUI Workflow
Same as Stereo Dotboard: configure images, set camera pair and board parameters, generate model, review quality metrics, and apply to vectors. Detections cache in inputs.mat with the same Re-detect override.
Shared board parameters: ChArUco board settings (squares_h, squares_v, etc.) are read from the calibration.charuco section, shared with the planar ChArUco method.
Stereo Stepped
A stepped (dual-level) board carries dots on two Z planes, so a single view is non-coplanar and constrains depth on its own. The cameras may image different board faces (transmission rigs), which means they share no features and cv2.stereoCalibrate cannot run. Instead, two mono pinhole fits are composed into one rig. This composed pose is the DaVis-matching un-bundled method, not a weaker fallback.
Model Fitting
Each camera is fitted into the shared plate frame independently, then the rig pose is composed as
T_stereo = t2 − R_stereo · t1
Because composition has no joint reprojection step, there is no stereo RMS for this method -- the results card shows the two per-camera RMS values plus the self-calibration residual disparity. Stereo stepped is strictly a two-camera pair; there is no multi-camera stitching on this tab. The camera model is pinhole only.
Parameters
| Parameter | Description | Default |
|---|---|---|
| camera_pair | Camera numbers forming the stereo pair (Camera 1 is the world reference) | [1, 2] |
| dot_spacing_mm | Physical spacing between dot centres (mm) | 15.0 |
| step_height_mm | Height between peak and trough levels (mm) | 3.0 |
| board_thickness_mm | Total thickness of the board (mm) | 14.8 |
| datum_frame | World origin image, 1-based, both cameras | 1 |
| stereo_config | Rig geometry: auto (from clicks), same_side, or transmission | auto |
| dt | Time between frames (seconds) | 1.0 |
| fix_k2 | Pin the r^4 radial term to zero (toggle appears below 3 usable poses) | on when shown |
One near-planar view has no leverage on the r^4 radial term. A free k2 runs away into a degenerate basin, dragging focal length, principal point, stereo angle, and baseline with it. The toggle defaults on below 3 poses. In-plane vectors stay usable either way, but W picks up a bias from the wrong angle -- use 3 or more views for quantitative 3C.
GUI Workflow
- 1Configure calibration images and the camera pair (both cameras, same frame count)
- 2Set the board geometry (dot spacing, step height, thickness) and datum frame
- 3Run detection -- dots on both Z levels are found for every frame of both cameras
- 4Click the three fiducials (origin / +X / +Y) on each camera's datum frame
- 5Verify every usable pose: click one peak or trough dot per pose to label its face
- 6Click "Generate Model" and review the per-camera RMS values
- 7Optionally run self-calibration, then "Calibrate Vectors" for 3C reconstruction
Peak/trough verification is enforced: Generate stays disabled until every usable pose has its face labelled on both cameras. The detection figure colours peaks blue and troughs red so labels can be checked at a glance. Detections, fiducials, and labels persist per-pick to inputs.mat, so a reopened tab regenerates the model with one Generate press.
No polynomial3d: the stereo stepped model is pinhole only. A per-camera polynomial pair builds no usable stereo baseline, so the UI drops the option and the backend rejects it.
3D Velocity Reconstruction
After computing the stereo model, reconstruction converts the 2D PIV velocity fields from each camera into 3C velocity vectors (u, v, w) in world coordinates. The solve is the Willert/Soloff method -- both cameras' projection Jacobians are stacked into a 4x3 least-squares system per grid point and solved for (U, V, W).
Process
2D PIV (Cam 1)
ux, uy in image plane
2D PIV (Cam 2)
ux, uy in image plane
3D Velocity
ux, uy, uz in world coords
Requirements
- Valid stereo calibration model
- 2D PIV vectors from Camera 1
- 2D PIV vectors from Camera 2
- Overlapping camera views on the sheet plane (empty overlap raises)
Output Variables
- ux: x-velocity (m/s)
- uy: y-velocity (m/s)
- uz: out-of-plane velocity (m/s)
- b_mask: validity mask (0 = valid, non-zero = excluded)
- x, y: world coordinates (mm)
Output Grid and Conventions
- Output lands on a regular world-mm grid spanning the two cameras' overlap on the sheet plane. Spacing is automatic -- the median world-space vector pitch of both PIV grids. To change the output resolution, change the PIV window/overlap and re-run.
- Row 0 is the top of the view (y descending down the rows), matching every other calibrated product.
- The frame is right-handed with +Z = +X x +Y, so W shares its axis with the world z coordinate. The legacy "+w toward cameras" flip (z_toward_cameras) has been removed.
- A point is masked in
b_maskwhere either camera's projection leaves its PIV grid or either camera flagged its contributing vector.
The vector reader requires b_mask. Stereo results written before it existed (ux/uy/uz only) raise on load and must be re-reconstructed.
Applying stereo calibration to ensemble results reconstructs the mean velocity (ux/uy/uz/b_mask) per pass -- the 4x3 solve is linear in the displacements, so the ensemble mean reconstructs exactly. Reynolds stresses are not reconstructed through stereo (they transform quadratically and require a separate method).
GUI Workflow
- 1Ensure a stereo model has been generated (Dotboard, ChArUco, or Stepped)
- 2Run PIV processing for both cameras
- 3Select data type (instantaneous or ensemble)
- 4Click "Reconstruct 3D" to start reconstruction
- 5View reconstructed vectors in the Vector Viewer with the Stereo data source
Output Directory
├── B00001.mat ... B0NNNN.mat (ux, uy, uz, b_mask per frame)
└── coordinates.mat (regular world-mm grid)
base_path/stereo_calibrated/{N}/Cam1_Cam2/ensemble/
└── per-pass coordinates.mat + ensemble_result.mat (ux, uy, uz, b_mask)
Note: Reconstruction quality depends on stereo calibration accuracy and proper overlap between the PIV fields from both cameras.
Self-Calibration (Wieneke 2005)
Self-calibration detects and corrects laser-sheet misalignment from the recorded particle images, not the calibration board. The laser sheet may be offset from the calibration plane (z_offset) or tilted relative to it (tilt_x, tilt_y). Self-calibration measures the disparity between camera views and iteratively refines the sheet parameters until the corrections stabilise. It is available on the stereo board tabs and on stereo stepped, for pinhole models only.
Algorithm Steps
- 1Dewarp both camera images to a common reference plane using the existing stereo model
- 2Cross-correlate Camera 1 vs Camera 2 (same time instant, different viewpoints)
- 3Extract the disparity field -- residual displacement between the two dewarped views
- 4Fit the disparity to a plane model, extracting Z-offset and tilt angles
- 5Update the dewarping maps and repeat until the per-iteration corrections (Z-offset and tilt) stabilise below threshold
Parameters
| Parameter | Description | Default |
|---|---|---|
| n_images | Number of image pairs used for disparity estimation | 20 |
| window_size | Correlation window size (pixels) | 64 |
| overlap | Window overlap percentage | 50.0 |
Output Parameters
Estimated Parameters
- z_offset: Laser sheet Z-offset (mm)
- tilt_x: Tilt about X-axis (radians)
- tilt_y: Tilt about Y-axis (radians)
Quality Metrics
- RMS disparity: Residual disparity (pixels)
- Iteration count: Number of refinement iterations
- Convergence status: Whether threshold was reached
GUI Workflow
- 1Complete stereo calibration (Dotboard, ChArUco, or Stepped pinhole)
- 2Open the Self-Calibration panel below the stereo model results
- 3Select the PIV dataset (base path) providing the particle images
- 4Preview the dewarp alignment (red-cyan overlay shows before/after)
- 5Set number of images, window size, and overlap
- 6Click "Run Self-Calibration" to start the iterative process
- 7Review convergence history, final RMS disparity, and the diagnostic figures
Storage and Application
Results are stored in the stereo record's self_cal block inside stereo_model_pinhole.mat -- not a sidecar file and not config.yaml. Subsequent stereo reconstruction applies the stored sheet automatically, so no extra step is needed. Regenerating the stereo model clears the block -- re-run self-calibration after recalibrating.
Diagnostic Output
Seven diagnostic figures plus correlation_planes.mat are written beside the model, shareable with the dataset.
CLI Usage
Stereo calibration uses two CLI steps: detect targets to generate a stereo model, then apply-stereo to reconstruct 3D velocities.
Step 1: Generate Stereo Model
# Stereo ChArUco detection (CLI detection is ChArUco-only --
# dotboard and stepped need interactive clicks, so they are GUI-only)
pivtools-cli detect-stereo
# Specify the camera pair explicitly
pivtools-cli detect-stereo --camera-pair 1,2Step 2: Reconstruct 3D Velocity
# Reconstruct using the ChArUco stereo model
pivtools-cli apply-stereo --board charuco --all-paths
# Specify the camera pair explicitly
pivtools-cli apply-stereo --board charuco --camera-pair 1,2 --all-paths
# Process every configured path (like the GUI)
pivtools-cli apply-stereo --board charuco --all-paths
# Choose the resample kernel
pivtools-cli apply-stereo --board charuco --interpolator lanczos --all-pathsStep 3: Self-Calibration (Optional)
# Run self-calibration to correct laser-sheet misalignment
# (--base-path-idx selects the PIV dataset providing particle images)
pivtools-cli self-calibrate --board dotboard --camera-pair 1,2 \
--base-path-idx 0 --n-images 20 --window-size 64 --overlap 50apply-stereo Options
| Flag | Description | Default |
|---|---|---|
| --board | Stereo board: charuco, dotboard, or stepped | From config |
| --camera-pair | Camera pair as "1,2" | From config |
| --dt | Time between frames (seconds) | From model / config |
| --all-paths | Derive every base_path from config (like the GUI) | off |
| --type-name | PIV result type for --all-paths: instantaneous or ensemble | instantaneous |
| --interpolator | Resample kernel: cubic or lanczos | lanczos |
| --model-type | Which stereo record to load when several exist in the model dir | pinhole |
Complete Workflow
# 1. Generate stereo calibration model (ChArUco; dotboard/stepped via GUI)
pivtools-cli detect-stereo
# 2. Run PIV processing for both cameras
pivtools-cli instantaneous
# 3. Reconstruct 3D velocities
pivtools-cli apply-stereo --board charuco --camera-pair 1,2 --all-paths
# 4. Compute statistics on stereo data
pivtools-cli statistics --source-endpoint stereoComplete YAML Reference
# ============================================================
# config.yaml -- a four-key pointer, nothing more.
# Anything else under calibration: is stripped on the next save.
# ============================================================
calibration:
calibration_sources:
- /data/experiment/calibration
source: ''
source_idx: 0
active: stereo_dotboard # stereo_dotboard | stereo_charuco | stepped_stereo
# ============================================================
# <source>/calibration/settings.yaml
# Seed it with: pivtools-cli init-settings --source <dir>
# ============================================================
image:
image_format: calib_%03d.tif # REQUIRED at read
image_type: standard # REQUIRED at read
n_views: 15 # calibration views (NOT images.num_images)
zero_based_indexing: false
use_camera_subfolders: true
camera_subfolders: ["Cam1", "Cam2"]
rig:
camera_pair: [1, 2]
dt: 0.0006 # REQUIRED before generate; never defaulted
datum_frame: 1 # 1-based
interpolator: lanczos # cubic | lanczos
piv_type: instantaneous
fit:
distortion_model: standard
fix_aspect_ratio: true # forces fx == fy
self_cal_n_images: 20
# One block per physical board, shared by that board's mono and stereo
# flows. The mono/stereo distinction lives in calibration.active.
methods:
dotboard:
dot_spacing_mm: null # REQUIRED at generate
k_neighbors: 9
model_type: pinhole
charuco:
squares_h: null # REQUIRED at generate
squares_v: null # REQUIRED at generate
square_size: null # REQUIRED at generate; metres
marker_ratio: 0.5
aruco_dict: DICT_4X4_1000
min_corners: 6
model_type: pinhole
stepped:
dot_spacing_mm: null
step_height_mm: null
board_thickness_mm: null
level_offset_mm: null # null derives dot_spacing_mm / 2
stepped_stereo:
stereo_config: auto # auto | same_side | transmission
model_type: pinhole
# fix_k2 and use_release_object are deliberately not templated: their
# defaults depend on context, so a stored value would override both.
# Set either explicitly only if you mean to.
# World-frame clicks, detections, and self-calibration results are in
# neither file -- they live in the model record + inputs.mat sidecar.GUI to YAML Field Mapping
| GUI Control | YAML Field | Values |
|---|---|---|
| Active Method | config.yaml -> calibration.active | stereo_dotboard, stereo_charuco, or stepped_stereo |
| Camera 1 | settings.yaml -> rig.camera_pair[0] | Integer (1-based) |
| Camera 2 | settings.yaml -> rig.camera_pair[1] | Integer (1-based) |
| dt | settings.yaml -> rig.dt | Float (seconds); required, never defaulted |
| Datum Frame | settings.yaml -> rig.datum_frame | Integer (1-based) |
| Dot Spacing (mm) | settings.yaml -> methods.dotboard.dot_spacing_mm | Float (mm); required |
| Fix radial k2 = 0 | settings.yaml -> methods.<board>.fix_k2 | Boolean. Not templated: absent means false, and it only appears in the GUI below 3 usable poses |
| Squares H | settings.yaml -> methods.charuco.squares_h | Integer; required |
| Squares V | settings.yaml -> methods.charuco.squares_v | Integer; required |
| Square Size | settings.yaml -> methods.charuco.square_size | Float (metres); required |
| ArUco Dictionary | settings.yaml -> methods.charuco.aruco_dict | DICT_{4-7}X{4-7}_{50-1000} |
| Step Height (mm) | settings.yaml -> methods.stepped.step_height_mm | Float (mm); required |
| Board Thickness (mm) | settings.yaml -> methods.stepped.board_thickness_mm | Float (mm) |
| Stereo Geometry | settings.yaml -> methods.stepped_stereo.stereo_config | auto, same_side, or transmission |
Next: Create Visualisation Videos
Visualise your calibrated velocity fields with animated videos.
Continue to Video Maker