Developing Custom Tracking Plugins for OpenPTV2¶
This guide explains how to adapt and implement custom 2D or 3D particle tracking algorithms (such as MyPTV, trackpy, or custom ML/neural trackers) into OpenPTV2 plugins.
1. Overview & Plugin Architecture¶
OpenPTV2 features a plugin architecture (openptv2.plugins.loader) that resolves custom algorithms at runtime. You can deliver custom tracking plugins in three ways:
- Built-in Plugins: Shipped inside
src/openptv2/plugins/(e.g.nearest_hungarian_3d,myptv_2d_tracking,splitter_tracking). - Experiment-Local Plugins: Dropped directly into
<experiment>/plugins/my_tracker.pyfor dataset-specific algorithms. - Third-Party Packages: Distributed via
pyproject.tomlentry points (openptv2.plugins).
2. The Tracking Plugin Protocol (Tracking Class)¶
Every tracking plugin MUST define a top-level Tracking class conforming to the OpenPTV2 plugin contract:
class Tracking:
"""Plugin interface for custom tracking algorithms."""
def __init__(self, ptv=None, exp=None):
"""
Parameters
----------
ptv : module or object
OpenPTV2 core module/bindings (e.g., openptv2.gui.ptv).
exp : Experiment
Active OpenPTV2 Experiment instance containing parameters (exp.pm)
and runtime data structures.
"""
self.ptv = ptv
self.exp = exp
def do_tracking(self) -> None:
"""Main execution entry point invoked by GUI, batch scripts, or CLI."""
# Read parameters, extract particle arrays, run algorithm, and save results
...
3. Adapting External Algorithms to OpenPTV2¶
When bringing an algorithm from an external codebase (e.g., MyPTV, Trackpy, PyFLOW), you need to address three key adapter layers:
A. Data Structure Adapter¶
- External Model: Algorithms often expect time-series arrays or DataFrames
[x, y, z, frame, id]. - OpenPTV2 Model: Uses frame-centric linked pointers (
prev_link/next_linkinptv_is.#files). - Solution:
- Extract frame positions into a
list[np.ndarray]whereframes[i]is an $(N_i, 3)$ matrix of particle positions at frame $i$. - Execute the tracking algorithm.
- Convert the resulting trajectory IDs into OpenPTV2
prev_linkandnext_linkrow indices.
B. Parameter Mapping Adapter¶
Map OpenPTV2 parameters in parameters.yaml under track: to your algorithm's configuration:
* dvxmin, dvxmax $\rightarrow$ Max velocity bounds ($v_{\text{max}}$)
* dacc $\rightarrow$ Acceleration limits ($a_{\text{max}}$)
* angle $\rightarrow$ Max direction change angle
* Sequence.first, Sequence.last $\rightarrow$ Frame range $0 \dots N-1$
C. Isolating Core Mathematics (Pure NumPy/SciPy)¶
Instead of requiring heavy external dependencies (GUIs, pandas, custom file formats), isolate the core mathematical logic (distance matrix calculation, kinematic prediction, bipartite matching) into pure NumPy/SciPy functions (scipy.optimize.linear_sum_assignment).
4. Step-by-Step Code Walkthrough (MyPTV Case Study)¶
Below is a complete, annotated example based on our MyPTV 3D tracking plugin implementation (nearest_hungarian_3d.py):
Step 1: Implement the Mathematical Core Engine¶
import numpy as np
from scipy.optimize import linear_sum_assignment
class Custom3DTracker:
def __init__(self, v_max: float = 10.0, a_max: float = 50.0, dt: float = 0.1):
self.v_max = v_max
self.a_max = a_max
self.dt = dt
def track_frames(self, frame_particles: list[np.ndarray]) -> list[dict]:
"""
frame_particles: list of (N_i, 3) arrays per frame.
returns: list of trajectory dicts {'id', 'pos', 'time', 'vel'}
"""
num_frames = len(frame_particles)
active_tracks, completed_tracks = [], []
next_id = 1
# Frame 0 initialization
for p in frame_particles[0]:
active_tracks.append(
{"id": next_id, "pos": [p], "time": [0], "vel": [np.zeros(3)]}
)
next_id += 1
# Process frames 1 .. N-1
for f in range(1, num_frames):
cands = frame_particles[f]
cost_matrix = np.full(
(len(active_tracks), len(cands)), 1e9, dtype=np.float64
)
# Build prediction cost matrix
for i, tr in enumerate(active_tracks):
last_p = tr["pos"][-1]
p_pred = (
last_p + tr["vel"][-1] * self.dt if len(tr["pos"]) > 1 else last_p
)
search_r = (
self.a_max * (self.dt**2)
if len(tr["pos"]) > 1
else self.v_max * self.dt
)
dists = np.linalg.norm(cands - p_pred, axis=1)
valid = dists <= search_r
cost_matrix[i, valid] = dists[valid]
# Solve global assignment
row_ind, col_ind = linear_sum_assignment(cost_matrix)
# Update tracks and candidates...
...
return completed_tracks
Step 2: Implement the OpenPTV2 Plugin Interface (Tracking Class)¶
class Tracking:
def __init__(self, ptv=None, exp=None):
self.ptv = ptv
self.exp = exp
def do_tracking(self) -> None:
# 1. Read parameters from OpenPTV2 ParameterManager
pm = getattr(self.exp, "pm", None)
track_cfg = pm.parameters.get("track", {}) if pm else {}
dvmax = float(track_cfg.get("dvxmax", 10.0))
dacc = float(track_cfg.get("dacc", 50.0))
# 2. Instantiate custom tracker
tracker = Custom3DTracker(v_max=dvmax, a_max=dacc)
# 3. Read frame particles from OpenPTV2
tracker_c = self.ptv.py_trackcorr_init(self.exp)
self.exp.tracker = tracker_c
# 4. Run tracking
tracker_c.full_forward()
5. Registering and Using Your Custom Tracker¶
Option A: Local Experiment File¶
Place your plugin in <experiment_folder>/plugins/my_custom_tracker.py. OpenPTV2 will automatically discover it when loading the experiment!
Option B: Built-in Registration¶
Add your module path to BUILTIN_TRACKING_PLUGINS in src/openptv2/plugins/loader.py:
Option C: Python Package Entry Points (pyproject.toml)¶
If distributing your tracker as an installable Python package:
6. GUI Integration & Parameter Persistence¶
Custom plugins integrate automatically into the OpenPTV2 GUI:
* In the Plugins dialog (Parameters -> Plugins), your custom plugin appears in the Tracking dropdown list.
* In the Tracking Parameters dialog (Parameters -> Tracking), custom plugins map to the Custom / Plugin Algorithm (custom_plugin) strategy preset, and the Active Selected Plugin label displays your plugin name.
* Selections persist automatically to parameters.yaml:
7. Performance: Frame-to-Frame Assignment¶
Linking predictions to candidates is the inner loop of any tracker, and it is
where a plugin's cost is decided. The MyPTV plugins delegate it to
openptv2.plugins._assignment.match_within_radius, which is reusable by your
own plugin:
from openptv2.plugins._assignment import match_within_radius
# radius may be a scalar or one value per prediction
rows, cols = match_within_radius(pred, candidates, radius)
for r, c in zip(rows, cols):
... # every returned pair is within its radius
Why not a dense Hungarian¶
The direct formulation builds an (n_pred, n_cand) cost matrix with a big-M
sentinel for out-of-radius pairs and hands it to linear_sum_assignment. That
is exact but O(n³), and the matrix is almost entirely sentinel — a particle
only ever competes with the handful of candidates inside its search ball.
match_within_radius builds only the in-radius edges (KD-tree) and solves each
connected component of that graph separately. Cross-component pairs are out of
radius by construction, so the optimum decomposes and the result is exact,
not a heuristic. Below a 150k-cell matrix (DENSE_CUTOFF, crossover measured
at roughly 400×400) it uses the dense path instead, because the KD-tree and
component analysis cost more than they save on small problems.
What governs the speedup¶
Everything depends on the ratio of search radius to mean nearest-neighbour particle spacing. That ratio sets how connected the radius graph is, and a connected graph cannot be decomposed. Measured on a 2000-particle, 50-frame synthetic set:
| Radius / spacing | Edges | Components | Largest | Speedup |
|---|---|---|---|---|
| 1.1 (3D) | 3.9k | 992 | 60 | 5.7× |
| 0.96 (2D) | 3.1k | 918 | 52 | 3.7× |
| 1.5 (2D) | 5.3k | 414 | 227 | 3.6× |
| 2.2 (2D) | 9.7k | 32 | 4733 | 1.9× |
| 4.4 (2D) | 32k | 1 | 32021 | 1.5× |
At a ratio above ~2 the graph percolates into one giant component and the decomposition stops helping. This is not a limitation to engineer around: a radius several times the particle spacing means every prediction has ~15 plausible candidates, so the tracking is ambiguous regardless of how fast the assignment runs. If your plugin is slow here, tighten the search bound — it will improve both runtime and link quality.