Correspondence eps0 Semantics
eps0: epipolar-band tolerance — semantics differ between openptv2/liboptv and 3dptv.exe¶
eps0 is a correspondence-matching parameter (the criteria.eps0 YAML field,
VolumePar.eps0 in code) that controls how far off the epipolar line a
candidate point in another camera is still accepted as a possible match. Its
name and legacy .par file slot are identical between openptv2 and
3dptv.exe, but its units and meaning are not — copying a 3dptv criteria.par
eps0 value verbatim into an openptv2 YAML silently changes matching
behavior. This was found and root-caused during the wp1 dataset investigation
(see docs/plans/2026-08-27-track3d-beat-gt-plan.md) and is a specific
instance of a general risk: legacy .par parameters with the same name as
an openptv2 YAML field are not guaranteed to have the same definition.
What openptv2 does¶
openptv2.algorithms.epi.find_candidate and the production matching kernel
openptv2.algorithms.correspondences._build_adjacency_for_pair both use
eps0 as a flat millimeter tolerance, applied identically to every
candidate regardless of particle size:
This is liboptv's mainline algorithm (find_candidate in liboptv's classic
correspondences.c), correctly ported.
What 3dptv.exe does¶
3dptv.exe does not run mainline liboptv's find_candidate. It runs a locally
patched variant, find_candidate_plus (3dptv/src_c/epi.c, patch by Beat,
April 2010), where eps0 is a dimensionless multiplier scaled per
candidate by pixel size and the source particle's own detected blob size:
// 3dptv/src_c/epi.c, find_candidate_plus
if (nx > ny) particle_size = nx; else particle_size = ny; // px, source blob's own nx/ny
tol_band_width = eps0 * 0.5*(pix_x + pix_y) * particle_size;
if (tol_band_width < 0.06) tol_band_width = 0.06; // mm, hard floor
So the actual mm tolerance 3dptv used varies per particle, and is never
smaller than 0.06mm regardless of how small eps0 or the particle are.
Converting a legacy eps0 to openptv2's flat mm value¶
openptv2 has no per-particle dynamic scaling (see "Should we port the dynamic formula?" below), so converting a legacy value means picking one representative flat mm number that approximates what 3dptv's per-particle formula would have produced across the dataset:
where:
- eps0_raw — the value read straight from the legacy criteria.par's
eps0 line (last field, dimensionless).
- pix_x, pix_y — mm/pixel, from ptv.par / YAML ptv.pix_x/pix_y.
- P — a representative particle size in pixels (max(nx, ny) of a
typical detected blob in this dataset). Use the median of max(nx, ny)
over the dataset's own detected targets (already available from a RunStore
via read_targets), not a guess — particle size is dataset- and
camera-lens-dependent.
Before trusting the result, check whether the flat approximation is even valid for this dataset — compute what fraction of detected particles would have their true per-particle tolerance dominated by the 0.06mm floor:
tol = eps0_raw * 0.5 * (pix_x + pix_y) * particle_sizes # array, one per target
floor_fraction = (tol < 0.06).mean()
floor_fraction≈ 1.0 (small, uniform tracers — this was true for wp1, where every particle measuredmax(nx,ny)in the 2–8px range): the flat approximation is essentially exact. Just setflat_eps0_mm = 0.06; the nominaleps0_rawandPbarely matter because the floor absorbs them.floor_fractionwell below 1.0 (larger or size-varied particles — dumbbells, big tracers, mixed populations): the flat approximation is lossy. A single flat value cannot reproduce a formula that scales per-particle; large particles get an unfairly tight window (ifPwas picked from the smaller population) or small particles get an unfairly loose one (ifPwas picked from the larger population) — you have to pick which class of match error you're willing to accept. This is the case where porting the real per-particle formula (see below) stops being optional polish and starts being the correct fix.
Should openptv2 port the dynamic per-particle formula?¶
Not done yet — tracked as a follow-up
(docs/plans/2026-08-27-eps0-dynamic-band-study-plan.md). Short version:
- Why the flat approximation was acceptable for wp1: this dataset's
particles are small and size-uniform enough that
floor_fraction = 1.0— the dynamic formula and a flat0.06mmare numerically the same thing here. - Why porting the real formula anyway is worth studying: the inputs
(
nx,nyof the source particle,pix_x/pix_y) are already threaded through bothfind_candidateand_build_adjacency_for_pair— this isn't a new API, and it removes the entire class of "guess a flat mm value and hope" bugs this doc exists to warn about, for every other legacy 3dptv dataset with larger or mixed particle sizes wherefloor_fraction < 1.0. - Why it's not a trivial drop-in: it changes matching behavior for every existing openptv2 dataset unless gated behind an explicit opt-in mode (liboptv-parity flat behavior must stay the default), it adds a multiply+branch to the innermost O(N₁×N₂) matching loop in a compiled Cython kernel (needs a perf check), and it needs a rebuild plus a run of the existing correspondence/parity test suite before it can be trusted.
Practical recommendation¶
- Never copy a legacy
criteria.pareps0value into an openptv2 YAML verbatim. Convert it using the formula above. - Always compute
floor_fractionfor the target dataset first. If it's ≈1.0, useflat_eps0_mm = 0.06and stop — no further tuning needed. If it's well below 1.0, treat the flat value as an approximation, validate its effect (candidate/point counts vs. any available ground truth) before trusting it, and consider requesting the dynamic-scaling mode once it exists. - When in doubt, check the effect empirically, not just the formula:
rerun the correspondence stage and compare per-frame point counts against
a trusted reference (ground truth, or a known-good prior run) — a flat
eps0guessed too high inflates false 3D points (more candidates pass the epipolar-distance check); guessed too low starves recall (real matches fall outside the band). Seewp1_10_images/scripts/gate_sweep_gt_cloud.pyandclassify_by_level.pyin the wp1 case study for the pattern.