Stepped Board Calibration
Calibrate stereo or single-camera setups using a stepped board — a physical target with two Z-levels that provides genuine depth information for robust 3D camera models.
Overview
A stepped calibration board is a physical target with dots printed on two faces at different Z heights. The “peak” face and “trough” face are separated by a known step height (typically 1–5 mm). When a camera images this board, it sees dots at two distinct depths in a single frame — giving genuine 3D point correspondences that break the focal-length / translation ambiguity that plagues single-plane calibration at PIV magnification.
PIVTools supports two stepped calibration modes:
| Method | Config Active Value | Best For | Output |
|---|---|---|---|
| Stepped Stereo | stepped_board | Two-camera 3D velocity (transmission or same-side) | Stereo model + per-camera pinhole |
| Stepped Planar | stepped_planar | Single-camera 3D calibration with real depth | Per-camera pinhole model |
Quality Metrics
| Metric | Description | Target |
|---|---|---|
| Per-camera RMS | Reprojection error per camera (pixels) | < 0.5 px |
| Stereo RMS | Overall stereo reprojection error | < 0.5 px |
| Cross-level consensus | Agreement between peak/trough grid stitching | > 90% |
| Cross-level RMS | Pixel RMS of stitched level overlay | < 2.0 px |
At PIV magnification, a standard single-plane calibration board provides very weak depth information — the camera sees an almost perfectly flat field. This creates a mathematical ambiguity between focal length (fx) and Z-translation (tz) that can cause calibration errors of 3% or more. The stepped board solves this by presenting dots at two known Z-levels in every frame, providing real depth variation that constrains all camera parameters simultaneously.
Board Geometry
Three physical measurements define the board. Enter these in the GUI or config.yaml before running detection.
| Parameter | YAML Key | Description | Example |
|---|---|---|---|
| Dot Spacing | dot_spacing_mm | Centre-to-centre distance between adjacent dots (mm) | 15 |
| Step Height | step_height_mm | Height difference between peak and trough faces (mm) | 3 |
| Board Thickness | board_thickness_mm | Total board thickness including both faces (mm) | 14.8 |
| dt | dt | Time between laser pulses (seconds) | 5.0e-06 |
The step height is critical for accuracy. Measure it with a micrometer, not calipers. A 0.1 mm error in step_height_mm will propagate into all Z-depth estimates and affect stereo reconstruction quality.
How Z-Levels Are Computed
The backend computes world Z coordinates from the board geometry. For a same-side configuration (both cameras on the same side of the board): the peak face is at Z = 0 and the trough face is at Z = -step_height_mm. For transmission (cameras on opposite sides): Z assignments are reversed for one camera. PIVTools auto-detects the correct configuration by trying both assignments and picking whichever gives lower RMS.
Fiducial Setup
Fiducials tell PIVTools how your board's physical axes map onto the image. You click three points per camera on the datum frame (the reference pose):
Origin
Click any dot to define the (0, 0) grid position
X-Axis Point
Click a dot in the positive X direction from origin
Y-Axis Point
Click a dot in the positive Y direction from origin
Each click snaps to the nearest detected blob, so you don't need pixel-perfect accuracy. The origin click also determines which face (peak or trough) you clicked on — this is stored as the clicked_level and establishes the reference convention for all subsequent pose labelling.
Fiducials define the world coordinate system. Origin = (0,0), X-axis point = positive X direction, Y-axis point = positive Y direction. If these are wrong, grid indices will be mirrored or rotated, causing grid stitching to fail. The detection overlay shows indexed dots after fiducials are set — verify the indices increase in the expected directions.
Fiducials in the GUI
In the stepped calibration panel, navigate to the datum frame and use the fiducial click tool. Three clicks per camera: origin, X-axis, Y-axis. The detection overlay updates immediately to show the assigned grid indices. Fiducials persist to config.yaml automatically.
Fiducials for the CLI
The CLI commands require a --fiducials JSON file. You can either export this from the GUI or write it manually:
{
"1": {
"origin": [3311.5, 751.3],
"x_axis": [3904.2, 751.1],
"y_axis": [3307.1, 542.4],
"clicked_level": "peak"
},
"2": {
"origin": [1985.9, 638.5],
"x_axis": [1684.9, 638.5],
"y_axis": [1990.9, 435.2],
"clicked_level": "trough"
}
}Click-to-Label
After setting fiducials on the datum frame, each non-datum pose must be labelled to tell the backend whether dots on that pose are on the peak or trough face. This is the most user-intensive step — but you only need to click one dot per pose per camera.
How It Works
- 1The datum frame is already labelled from the fiducial setup — no action needed
- 2Navigate to a non-datum pose in the calibration image viewer
- 3Click any dot you can identify as being on the peak face (or trough — whichever you recognise)
- 4The backend snaps to the nearest detected blob and reports which level (A or B) it belongs to
- 5The frontend maps A/B to peak/trough using the datum convention, and stores the label
- 6Repeat for every non-datum pose, for every camera
- 7Once all poses are verified, the "Generate Model" button becomes enabled
You don't need to be precise — click anywhere on a dot you recognise. The system snaps to the nearest detected blob automatically. The detection overlay helps: blue dots = peak, red dots = trough. Colours swap in real time when you set a label.
Detection Overlay
The calibration image viewer shows detected dots overlaid on every frame. Dots are colour-coded by level: blue = peak, red = trough. On the datum frame, fiducial markers (origin, X, Y) are also shown. When you set a pose label, the overlay colours update in real time to confirm the assignment.
What Gets Stored
Labels persist to config.yaml as cam1_pose_levels / cam2_pose_levels entries. A pose with no entry is considered unverified. The “Generate Model” button remains disabled until every pose has an entry for every camera.
CLI note: The CLI reads pose labels directly from config.yaml and fails with a clear error if any frame in the sequence is missing its label. Set up labels in the GUI first, then the CLI can process headlessly.
Stereo Stepped Workflow
Stepped stereo calibration builds a complete stereo model from two cameras viewing a stepped board at multiple poses. It works with both same-side (cameras on the same side) and transmission (cameras on opposite sides) setups.
GUI Workflow
- 1Set board parameters: dot spacing, step height, board thickness, dt
- 2Configure calibration images: format, count, source path, camera subfolders
- 3Set camera pair (e.g. Camera 1 and Camera 2)
- 4Browse to the datum frame and click three fiducial points per camera (origin, X-axis, Y-axis)
- 5Navigate to each non-datum pose and click-to-label one dot per camera
- 6Once all poses show verified labels, click "Generate Model"
- 7Review per-camera RMS error, stereo RMS, relative angle, and baseline distance
- 8Click "Calibrate Vectors" to apply stereo calibration to PIV data
- 9Click "Set as Active" to make stepped_board the active calibration method
Stereo Configuration Auto-Detect
PIVTools auto-detects whether your cameras are in same-side or transmission configuration. It fits camera 2 twice (once per configuration) and picks whichever gives lower RMS. The result is surfaced as stereo_config_resolved in the calibration output. You can also force a specific configuration via stereo_config: same_side or stereo_config: transmission.
In a transmission setup, each camera sees a different face of the board at a different Z-plane — there are no common 3D points visible to both cameras. OpenCV's stereoCalibrate requires common points. Instead, PIVTools derives the stereo pose from individual cv2.solvePnP results per camera and computes R_stereo = R2 @ R1.T, T_stereo = t2 - R_stereo @ t1.
Output Directory
├── model/stereo_model.mat
├── Cam1/model/camera_model.mat
├── Cam2/model/camera_model.mat
├── indices/
├── figures/
└── camera_placement.html # Interactive Plotly visualisation
Stepped Planar Workflow
Stepped planar calibration fits a per-camera 3D pinhole model using both Z-levels of the stepped board. Unlike standard planar calibration (which sees a flat field), each pose provides genuine non-coplanar 3D points — dots at two Z-planes give real depth information that breaks the fx/tz ridge without needing a stereo pair.
Standard Planar (1 Z-level)
- All dots at one Z plane per pose
- Depth constrained only by multiple poses
- Fragile at PIV magnification (fx error 1–3%)
Stepped Planar (2 Z-levels)
- Dots at two Z planes in every single pose
- Real depth variation constrains all parameters
- Robust at PIV magnification (fx error < 0.25%)
GUI Workflow
- 1Set board parameters: dot spacing, step height, board thickness, dt
- 2Configure calibration images: format, count, source path
- 3Select target level (peak or trough — which face to use as reference)
- 4Click three fiducial points on the datum frame (origin, X-axis, Y-axis)
- 5Navigate to each non-datum pose and click-to-label one dot
- 6Click "Generate Model" once all poses are verified
- 7Review per-camera RMS reprojection error (target: < 0.5 px)
- 8Click "Calibrate Vectors" to apply
Output Directory
├── model/camera_model.mat
├── indices/
└── figures/
CLI Usage
Both stepped commands require a --fiducials JSON file containing the origin, axis, and clicked_level for each camera. The easiest workflow: set up fiducials and pose labels in the GUI, then run detection headlessly via CLI.
detect-stepped-stereo
# Basic usage (reads board params from config.yaml)
pivtools-cli detect-stepped-stereo --fiducials fiducials.json
# Explicit stereo config and pose count
pivtools-cli detect-stepped-stereo -f fiducials.json --stereo-config transmission -n 11
# Custom calibration source directory
pivtools-cli detect-stepped-stereo -f fiducials.json -cs /path/to/calibration/images| Flag | Short | Description | Default |
|---|---|---|---|
| --fiducials | -f | Path to fiducials JSON file (required) | -- |
| --active-paths | -p | Comma-separated path indices | From config |
| --calibration-source | -cs | Direct path to calibration images | From config |
| --num-frames | -n | Number of poses in the sequence | From config or 11 |
| --start-frame | -s | First frame index | 1 |
| --datum-frame | -d | Datum frame index | Same as start-frame |
| --stereo-config | -- | auto, same_side, or transmission | auto |
detect-stepped-planar
# All cameras in fiducials file
pivtools-cli detect-stepped-planar --fiducials fiducials.json
# Single camera only
pivtools-cli detect-stepped-planar -f fiducials.json --camera 1
# Custom pose count
pivtools-cli detect-stepped-planar -f fiducials.json -n 6| Flag | Short | Description | Default |
|---|---|---|---|
| --camera | -c | Single camera number to process | All in fiducials |
| --fiducials | -f | Path to fiducials JSON file (required) | -- |
| --active-paths | -p | Comma-separated path indices | From config |
| --calibration-source | -cs | Direct path to calibration images | From config |
| --num-frames | -n | Number of poses | From config or 11 |
| --start-frame | -s | First frame index | 1 |
| --datum-frame | -d | Datum frame index | Same as start-frame |
Pose labels from config: The CLI reads stepped_board.cam1_pose_levels and cam2_pose_levels (stereo) or stepped_planar.pose_levels (planar) directly from config.yaml. If any frame in the sequence is missing its label, the CLI exits with a clear error message listing the missing frames.
Complete Stereo Workflow
# 1. Set up board params in config.yaml (or use GUI)
# 2. Set fiducials + pose labels in GUI, or create fiducials.json manually
# 3. Generate stereo model from stepped board
pivtools-cli detect-stepped-stereo -f fiducials.json
# 4. Run PIV processing for both cameras
pivtools-cli instantaneous
# 5. Apply stereo calibration (3D reconstruction)
pivtools-cli apply-stereo --method stepped_board
# 6. Compute statistics on stereo data
pivtools-cli statistics --source-endpoint stereo
# 7. Create visualisation video
pivtools-cli video --data-source stereo -v uzComplete Planar Workflow
# 1. Generate per-camera model from stepped board
pivtools-cli detect-stepped-planar -f fiducials.json
# 2. Run PIV processing
pivtools-cli instantaneous
# 3. Apply calibration
pivtools-cli apply-calibration --method stepped_board
# 4. Compute statistics
pivtools-cli statisticsComplete YAML Reference
calibration:
active: stepped_board
piv_type: instantaneous
# Board geometry
stepped_board:
dot_spacing_mm: 15 # Centre-to-centre dot distance (mm)
step_height_mm: 3 # Peak-to-trough face height (mm)
board_thickness_mm: 14.8 # Total board thickness (mm)
dt: 5.0e-06 # Time between laser pulses (seconds)
# Camera setup
camera_pair: [1, 2]
stereo_config: transmission # auto | same_side | transmission
datum_frame: 1 # Reference pose frame number
datum_camera: 1 # Reference camera
num_calibration_frames: 11 # Total poses in the sequence
# Fiducials (auto-populated by GUI click tool)
cam1_fiducials:
origin: [3311.5, 751.3]
x_axis: [3904.2, 751.1]
y_axis: [3307.1, 542.4]
cam1_clicked_level: peak # Which face the origin click landed on
cam2_fiducials:
origin: [1985.9, 638.5]
x_axis: [1684.9, 638.5]
y_axis: [1990.9, 435.2]
cam2_clicked_level: trough
# Pose labels (set via click-to-label in GUI)
cam1_pose_levels:
'1': peak # Frame 1 verified as peak
'2': peak
cam2_pose_levels:
'1': trough # Frame 1 verified as trough
'2': troughGUI to YAML Field Mapping
| GUI Control | YAML Field | Values |
|---|---|---|
| Active Method | calibration.active | stepped_board or stepped_planar |
| Dot Spacing (mm) | stepped_board.dot_spacing_mm | Float (mm) |
| Step Height (mm) | stepped_board.step_height_mm | Float (mm) |
| Board Thickness (mm) | stepped_board.board_thickness_mm | Float (mm) |
| dt (seconds) | stepped_board.dt | Float (seconds) |
| Camera Pair | stepped_board.camera_pair | [int, int] |
| Stereo Config | stepped_board.stereo_config | auto | same_side | transmission |
| Datum Frame | stepped_board.datum_frame | Integer (1-based) |
| Fiducial Origin Click | stepped_board.cam*_fiducials.origin | [x, y] pixels |
| Clicked Level | stepped_board.cam*_clicked_level | peak | trough |
| Pose Label Click | stepped_board.cam*_pose_levels | { frame: peak|trough } |
Troubleshooting
Grid detection fails on some poses
Check dot spacing parameter matches the actual board. Verify illumination is even across both faces. Try cleaning the board surface. Poses with strong foreshortening (> 18 degrees rotation) may lose dots at the edges.
Cross-level consensus below 50%
This usually means the fiducial axis clicks are inconsistent between the two faces. Verify that the detection overlay shows grid indices increasing in the expected directions. Re-click fiducials if needed.
"Generate Model" button stays disabled
Not all poses have been click-to-labelled. Check the pose list panel — any pose without a label prevents model generation. You need one label per pose per camera.
Stereo RMS error > 1.0 px
Step height measurement may be wrong — verify with a micrometer. Also check that both cameras can resolve individual dots clearly. Blurred or underexposed images increase RMS.
Focal length (fx) error is large
Too few poses. Use at least 5-6 different target positions with varied angles. The multi-image Zhang initialization needs multiple homographies to robustly estimate intrinsics.
CLI fails with "missing pose labels" error
The CLI reads pose labels from config.yaml. Run the GUI click-to-label workflow first, or manually add entries to cam1_pose_levels / cam2_pose_levels in config.yaml.
Transmission auto-detect picks wrong config
Force the correct configuration with stereo_config: transmission (or same_side) in config.yaml, or --stereo-config on the CLI.
Next: View Your Results
After calibration, visualise your velocity fields and compute statistics.