Tutorial: Choosing Tracking Parameters from Data Statistics¶
Tracking parameters (velocity, acceleration, angle limits) can be chosen systematically using simple statistics from your dataset. This approach minimizes trial-and-error and ensures robust, unambiguous tracking.
Step-by-step workflow¶
-
Run a quick probe script to compute:
- Maximum observed displacement per frame (velocity)
- Maximum observed acceleration
- Typical interparticle distance (spacing between particles in a frame)
-
Set parameters:
- Velocity window (
velocity_lims):- Set just above the maximum observed displacement, but below the typical interparticle distance to avoid ambiguity.
- Acceleration limit (
accel_lim):- Set just above the maximum observed acceleration.
- Angle limit (
angle_lim):- Set to a typical value for smooth motion (e.g., 20 gon ≈ 18°), or just above the maximum observed angle change if available.
- Velocity window (
Example (from Burgers dataset):¶
| Statistic | Value (example) |
|---|---|
| Max displacement | 0.08 mm/frame |
| Max acceleration | 0.09 mm/frame² |
| Interparticle distance | 1.53 mm |
Parameter selection:
- velocity_lims = [[-0.088, 0.088], [-0.088, 0.088], [-0.088, 0.088]] (10% above max displacement, but < interparticle distance)
- accel_lim = 0.099 (10% above max acceleration)
- angle_lim = 20 (gon)
Measured recipe for trackcorr (openptv2.tracking_params)¶
For noisy, slowly moving tracers (kHz frame rates) the limits above are better derived from the noise than from observed maxima, because the observed "acceleration" and "turning angle" between frames are almost entirely position noise:
- Track a short stretch (e.g. 20 frames) with loose limits:
angle: 270(off) and a generousdacc. params, stats, reasons = recommend_from_store(store, first, last)measures the per-axis position noise from the lag-1 covariance of second differences (-4 sigma^2for white noise) and the step statistics, and returns:dvper axis = p99.9 of |step| + 3·√2·σ,dacc= 1.2 × p99.5 of the noise acceleration magnitude (per-axis std √6·σ),angle= 270 (off) when the median step is below 10× the step noise (√2·|σ|): the turning angle is then noise and an angle limit only rejects correct links.- Keep
ptv.pair_flag: false(the default): on a 4-camera rig only ~37% of 2-camera correspondences were confirmed by a third camera.
On a 4-camera, 5005-frame experiment (σ = 9/8/42 µm, median step 0.125 mm)
this gave angle: 270, dacc: 0.4, dv ±1.4/1.7/1.8 mm; points in tracks of
10+ frames rose from 52.9% (angle 100, dacc 0.8) to 71.8% on the first 20
frames and to 84.5% over the full run, and on synthetic ground truth matched to
that experiment link recall went from 93.3% to 99.8% with precision 99.9%.
Parallel chunked tracking (track_sequence_chunked_parallel) runs trackcorr
forward-only per window; its optional global passes
(run_postprocess_passes: cold-start seeding, gap relinking, reciprocity)
now print their stats and warn when a pass fails instead of skipping silently.
Reference test¶
See the tracking parameter sensitivity tests in tests/batch/test_pyptv_batch.py and tests/batch/test_apply_optimizations.py,
and tests/unit/test_tracking_params.py for the measured recipe.
These tests demonstrate the full workflow and can be used as a template for your own datasets.
Tracking Algorithms¶
OpenPTV implements two tracking strategies for particle trajectory reconstruction:
Case Study: Burgers Dataset Gap Relinking¶
For a detailed analysis of how these algorithms differ when particles re-appear after a gap, including: - 7 trajectories vs 6 trajectories from the same 5-frame dataset - Root cause analysis of the algorithmic divergence - Backward tracking recovery capabilities and limitations - Numerical insights for designing better tracking methods
See: Burgers Gap Relinking Case Study
Overview¶
| Feature | track.c |
track3d.c |
|---|---|---|
| Dimensionality | Multi-camera 2D→3D | Direct 3D |
| Candidate search | Projects 3D box to 2D per camera | Direct 3D box search |
| Candidate sorting | Frequency across cameras | Acceleration metric |
| Linking metric | Angle + acceleration (gon) | Acceleration (second derivative) |
| Particle addition | Yes | No |
| Backward tracking | Yes | No |
| Lines of code | ~1275 | ~203 |
track.c — Multi-Camera Tracking¶
Full pipeline for calibrated multi-camera setups. Handles the complete workflow from 2D image targets to 3D particle trajectories.
Key Functions¶
trackcorr_c_loop()— Main forward tracking looptrackback_c()— Backward tracking to fill gapssearchquader()— Projects 3D search volume to 2D image regions per cameracandsearch_in_pix()— Finds up to 4 nearest candidates in pixel spacesort_candidates_by_freq()— Ranks candidates by camera visibility countangle_acc()— Computes angle (gon) and acceleration between velocity vectorsadd_particle()— Inserts new particles from unmatched targets
Algorithm Flow¶
- For each particle in current frame:
- Predict next position using
2*curr - prev(linear extrapolation) - Project 3D search cuboid to 2D per camera via
searchquader() - Find candidates in pixel space via
candsearch_in_pix() - Sort candidates by frequency across cameras
- Evaluate candidates using angle + acceleration metric
- Link to best candidate if within thresholds (
dacc,dangle) - Optionally add new particles from unmatched targets
Use Case¶
Real experiments with 2–4 calibrated cameras. Robust to occlusions because it leverages multi-camera redundancy.
track3d.c — Direct 3D Tracking¶
Simplified tracking for pre-reconstructed 3D data. No camera projection needed.
Key Functions¶
track3d_loop()— Main tracking loopfind_candidates_in_3d()— Finds particles within 3D search box
Algorithm Flow¶
Three-level linking strategy:
- Level 1 — Particles with previous links:
- Predict:
2*curr - prev -
Search in 3D box defined by
dvxmax,dvymax,dvzmax -
Level 2 — No previous link, but neighbors have links:
- Compute average velocity from linked neighbors
-
Predict:
curr + avg_velocity -
Level 3 — No previous link, no neighbor links:
- Use current position as prediction
For each level: - Find candidates within 3D box - Sort by acceleration (second derivative) - Link to best unlinked candidate
Use Case¶
Pre-computed 3D data (e.g., rt_is.* files). Faster and simpler than full multi-camera tracking.
Candidate Selection Comparison¶
track.c — Multi-Camera Frequency¶
// Search in each camera's image space
for (cam = 0; cam < num_cams; cam++) {
register_closest_neighbs(targets[cam], ...);
}
// Sort by how many cameras see each candidate
num_cands = sort_candidates_by_freq(points, num_cams);
track3d.c — Direct 3D Box¶
// Simple box search in 3D
for (i = 0; i < frm->num_parts; i++) {
if (fabs(x - pos[0]) < dx &&
fabs(y - pos[1]) < dy &&
fabs(z - pos[2]) < dz) {
indices[count++] = i;
}
}
Linking Quality Metrics¶
track.c — Angle + Acceleration¶
Uses angle between velocity vectors in gon plus acceleration magnitude:
void angle_acc(vec3d start, vec3d pred, vec3d cand,
double *angle, double *acc)
{
vec3d v0, v1;
vec_subt(pred, start, v0); // predicted velocity
vec_subt(cand, start, v1); // actual velocity
*acc = vec_diff_norm(v0, v1);
*angle = (200./M_PI) * acos(vec_dot(v0, v1) / (vec_norm(v0) * vec_norm(v1)));
}
Decision: link if (acc < dacc AND angle < dangle) OR (acc < dacc/10).
track3d.c — Acceleration Only¶
Uses second derivative (acceleration) as the sole metric:
// Acceleration = |curr - 2*next + prev|
float acc = 0.0;
for (d = 0; d < 3; d++) {
float diff = curr[d] - 2*next[d] + prev[d];
acc += diff * diff;
}
decis[k] = sqrtf(acc);
Output Format¶
Both algorithms print per-step statistics:
track.c: step: 10000, curr: 998, next: 1043, links: 453, lost: 545, add: 1
track3d.c: track3d step: 10001, curr: 1, next: 1, links: 0
Seal: Linkage → Flat Trajectories (src/openptv2/storage/seal.py:73)¶
Tracking writes per-frame linkage/ptv_is/frame_*/{prev,next,pos} (RunStore.write_linkage, run_store.py:364). seal() materializes the flat cache:
- Walks
linkagewithhistorywindow (seal.py:92) to resolveprev(gap-bridgingMAX_LINK_STEP), assigns contiguoustrajid, handles multi-claim disambiguation. - Concatenates all frames,
lexsortby(trajid,time),np.uniqueto gettrajid/length/first_row(seal.py:179). - Filters
length < min_trajectory_length(default 5 fromtrack.min_trajectory_length,batch/pyptv_batch.py:246) before writing. - Writes
trajectories/{pos,vel,accel,time,trajid}(run_store.py:530,posin meters) +traj/{trajid,length,first,last,first_row}(run_store.py:480). - Memoizes via
source_hash— skips if linkage unchanged unlessforce=True; setsmeta/sealed.
traj/first_row enables O(1) pos[lo:hi] without loading 66 MB trajid (notebooks/marimo_trajectory_viewer.py:33). copy_trajectories.py filesystem-copies run.zarr/traj → traj.zarr and run.zarr/trajectories → trajectories.zarr.
When to Use Which¶
| Scenario | Use |
|---|---|
| Raw 2D images + calibration | track.c |
| Pre-reconstructed 3D positions | track3d.c |
| Need backward gap filling | track.c |
| Fast iteration on 3D data | track3d.c |
| Multi-camera redundancy required | track.c |
How to Choose/Enable 3D Segment Tracking (track3d)¶
You can select and run the direct 3D segment tracking algorithm (track3d) over the standard multi-camera epipolar tracking algorithm (trackcorr) in three different ways:
1. Through the Desktop GUI¶
- Open the Parameter Editor in the GUI.
- Navigate to the Track Parameters (Tracking) tab.
- Locate the Tracking mode (0=Standard, 1=3D Seg): parameter.
- Set the value to:
0for Standard Epipolar tracking (trackcorr).1for 3D Segment tracking (track3d).- Save the parameters. The GUI and its interactive step-by-step previewers/visualizers (
tracking_previewandtracking_viz_panel) will conditionally load and execute the selected algorithm automatically.
2. Through Parameter Files (YAML & Legacy .par)¶
- YAML configuration: In your active YAML parameters, set the
track_modekey in thetracksection: - Legacy
.parconfiguration: Inparameters/track.par, the 10th line (if present) represents the tracking mode. Set it to1to enable 3D segment tracking: (Note: If the 10th line is absent, the system gracefully defaults to0/ Standard mode).
3. Via the Command-Line Batch Utility (pyptv_batch.py)¶
When executing batch processing from the CLI, you can force the use of 3D tracking using the --track3d option:
--track3d is not specified, the utility will fall back to reading the track_mode setting from the active parameter file.
Python Translation Status¶
Both track.c and track3d.c have been fully translated to Python in algorithms/track.py and algorithms/track3d.py. The Python implementations include Numba JIT-compiled fast paths.
Parity with C/Cython¶
| Dataset | track3d | trackcorr |
|---|---|---|
| Burgers (5 frames, 5 particles) | Exact match | Exact match |
| Cavity (4 frames, ~700 particles) | Exact match | Python produces more links (see below) |
| Synthetic (8 frames, 15 particles) | 99% recovery, 0 wrong | 100% recovery, 0 wrong |
Python Improvements Over C¶
The Python trackcorr_c_loop includes two improvements not present in the C code:
1. Phase 3: Losers Retry¶
When two particles compete for the same target in conflict resolution, C drops the loser permanently. Python lets the loser try its fallback candidates (2nd, 3rd best matches) if they're still unclaimed. On the cavity dataset, this recovers ~27 additional correct links.
# Phase 3: Losers retry with fallback candidates (claim unclaimed only)
for h in range(fb.buf[1].num_parts):
curr_path_inf = fb.buf[1].path_info[h]
if curr_path_inf.inlist > 1 and curr_path_inf.next == NEXT_NONE:
for ti in range(1, curr_path_inf.inlist):
cand = curr_path_inf.linkdecis[ti]
if fb.buf[2].path_info[cand].prev == PREV_NONE:
curr_path_inf.next = cand
fb.buf[2].path_info[cand].prev = h
break
2. Stale Buffer Fix¶
When step >= last - 2, no new frame can be loaded into the last buffer slot after rotation. C leaves stale data from a previous frame in that slot, which assess_new_position may search and produce spurious links. Python clears the slot:
fb.fb_next()
fb.write_frame_from_start(step)
if step < run_info.seq_par.last - 2:
fb.read_frame_at_end(step + 3, read_links=False)
else:
fb.buf[fb.buf_len - 1].num_parts = 0 # clear stale data
C count1 Overcounting Bug¶
The C code increments count1 inside the conflict resolution loop. When particle B loses a conflict to particle A (B processed first, B.next set to NEXT_NONE), B has already been counted. The final count1 is inflated. Python counts in a separate loop after all conflicts are resolved, producing the correct count. This explains why C's printf reports more links than actually appear in the output files.
Synthetic Test Suite¶
A synthetic test case (tests/unit/test_synthetic_tracking.py) validates both algorithms against known ground truth:
- 15 particles with diverse trajectories:
- 5 constant-velocity straight lines
- 3 constant-acceleration curved paths
- 2 near-miss paths (close approach)
- 2 parallel neighbors (3-unit separation)
- 2 actual crossing paths
- 1 late entry (appears at frame 3)
- 8 frames (10001–10008)
- 5 test cases: link correctness, recovery rate, trajectory distance validation, and trackcorr >= track3d comparison
High-Performance Parallelization (OpenMP & Cython)¶
To support high-throughput processing on large experimental datasets (e.g., thousands of frames with 1,000+ particles), OpenPTV2 includes two major OpenMP-parallelized optimization pipelines implemented under standard Cython 3 pure-Python mode:
1. Parallel Image Preprocessing (Approach C)¶
The preprocessing pipeline (including highpass filtering, thresholding, background subtraction, and image segmentation/detection) is parallelized across frames. * Mechanism: Rather than processing each camera sequence sequentially, OpenPTV2 chunks and distributes the image-processing workload across threads using OpenMP. * Speedup: Provides a 2.4x to 4x speedup on multi-core systems, greatly reducing batch initialization times. * Integration: Seamlessly integrated into both the Python CLI/batch processing backend and the modern Tkinter/ttkbootstrap GUI.
2. Particle-Level Parallel Tracking (Approach B)¶
The forward multi-camera tracking loop (trackcorr_loop_fast) is parallelized at the particle level, distributing calculations concurrently across OpenMP threads.
Algorithmic Design & Thread-Safety¶
Parallelizing a complex, stateful tracking pipeline requires careful handling of shared states, coordinate buffers, and memory management:
[ Parallel Particle Loop (prange schedule='guided', nogil) ]
├── 1. Linear prediction per particle
├── 2. 3D search box projection to 2D per camera
├── 3. Pixel candidate searches
├── 4. Candidate frequency sorting
└── 5. Log particle additions to Thread-Local buffers
│
▼
[ Sequential Post-Loop Phase (Single-Threaded) ]
├── 1. Gather & append additions deterministically
├── 2. Resolve link conflicts (Phase 1 & 2)
└── 3. Losers retry fallback candidates (Phase 3)
-
Lock-Free Parallel Computation: The tracking prediction, 2D epipolar-box projection (
searchquader), candidate coordinate filtering, candidate sorting, and cost-metric evaluation all run concurrently without lock overhead or the Python GIL. -
Zero-Allocation Thread-Local Scratch Space: Memory allocation inside parallel
nogilblocks triggers expensive runtime allocations or requires acquiring the GIL. To prevent this, we pre-allocate thread-local scratch arrays (such as ray-tracing coordinate arrays, pixel coordinates, and temporary lists) during initiation. -
Dynamic Thread-ID Safety & Defensive Clamping: Because OpenMP's
threadid()can occasionally return indices greater than the requested thread count due to runtime thread-pooling, OpenPTV2 dynamically pads scratch buffer allocations tomax_threads_alloc = max(num_threads, 64)and clamps indexing bounds defensively: -
Absolute Mathematical Determinism: Adding new particles or modifying candidate tables directly in parallel threads would lead to non-deterministic race conditions (and require locks). Instead, each thread writes its added particles and candidate tables into dedicated thread-local buffers. A single-threaded post-loop sequential block then collects and appends these records in a fixed, thread-independent order. This guarantees 100% mathematical parity and identical tracking results across any thread configuration.
-
Robust Bounds Checking: All candidate array insertions verify array bounds (e.g. checking if
0 <= cand_idx < targ_tnr.shape[1]) before performing any writes, preventing memory corruption orfree(): invalid next sizeerrors.