|
#!/usr/bin/env python3 |
|
"""Standalone 3D Particle Tracking Velocimetry (PTV) Pipeline. |
|
|
|
Uses openptv2 and postptv (flowtracks) to process raw PTV experiment folders |
|
into 3D trajectories, velocity/acceleration fields, and Eulerian statistics. |
|
|
|
Usage: |
|
python ptv_pipeline.py <experiment_dir> [options] |
|
|
|
Options: |
|
--first <int> First frame to process |
|
--last <int> Last frame to process |
|
--params <filename> Target parameters YAML file |
|
--frame-rate <float> Camera acquisition frame rate in Hz (default: 1.0) |
|
--eulerian-cell <float> Eulerian voxel cell size in mm (optional) |
|
--min-length <int> Minimum trajectory frame length (default: 3) |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import sys |
|
from pathlib import Path |
|
from typing import Any |
|
|
|
import numpy as np |
|
import yaml |
|
|
|
INPUT_PATH_KEYS = [ |
|
("cal_ori", "img_ori", True), |
|
("cal_ori", "img_cal_name", True), |
|
("cal_ori", "fixp_name", False), |
|
] |
|
|
|
|
|
def is_placeholder(val: str) -> bool: |
|
"""Check if a calibration string is an image-splitter placeholder like '---'.""" |
|
s = val.strip() |
|
return not s or set(s) <= {"-"} |
|
|
|
|
|
def find_params_file(data_dir: Path, requested_name: str | None = None) -> Path: |
|
"""Locate the parameters_*.yaml file in the experiment directory.""" |
|
if requested_name: |
|
p = data_dir / requested_name |
|
if p.is_file(): |
|
return p |
|
matches = list(data_dir.glob(requested_name)) |
|
if matches: |
|
return matches[0] |
|
raise FileNotFoundError( |
|
f"Parameters file '{requested_name}' not found in {data_dir}" |
|
) |
|
|
|
candidates = sorted(data_dir.glob("parameters_*.yaml")) |
|
if not candidates: |
|
candidates = sorted(data_dir.glob("parameters*.yaml")) |
|
if not candidates: |
|
raise FileNotFoundError(f"No parameters_*.yaml found in {data_dir}") |
|
|
|
# Prefer batch over sample if multiple exist |
|
batches = [c for c in candidates if "_batch" in c.stem] |
|
return batches[0] if batches else candidates[0] |
|
|
|
|
|
def check_inputs( |
|
data_dir: Path, params_file: Path, first: int, last: int |
|
) -> dict[str, Any]: |
|
"""Verify that all calibration files and frame images referenced in YAML exist.""" |
|
params = yaml.safe_load(params_file.read_text(encoding="utf-8")) or {} |
|
print(f"[pipeline] verifying inputs from {params_file.name}...") |
|
|
|
missing = [] |
|
for section, key, is_list in INPUT_PATH_KEYS: |
|
val = (params.get(section) or {}).get(key) |
|
if val is None: |
|
continue |
|
items = val if is_list else [val] |
|
for item in items: |
|
if isinstance(item, str) and item.strip() and not is_placeholder(item): |
|
resolved = (data_dir / item).resolve() |
|
if not resolved.exists(): |
|
missing.append(f" MISSING: {item} -> {resolved}") |
|
else: |
|
print(f" [ok] {item}") |
|
|
|
# Verify endpoint images |
|
base_names = ((params.get("sequence") or {}).get("base_name")) or [] |
|
for pattern in base_names: |
|
if ( |
|
not isinstance(pattern, str) |
|
or "%" not in pattern |
|
or is_placeholder(pattern) |
|
): |
|
continue |
|
for frame in {first, last}: |
|
rel = pattern % frame |
|
resolved = (data_dir / rel).resolve() |
|
if not resolved.exists(): |
|
missing.append(f" MISSING image: {rel} -> {resolved}") |
|
else: |
|
print(f" [ok] image frame {frame}: {rel}") |
|
|
|
if missing: |
|
raise FileNotFoundError("Missing required input files:\n" + "\n".join(missing)) |
|
return params |
|
|
|
|
|
def compute_derivatives( |
|
pos: np.ndarray, time: np.ndarray, dt: float |
|
) -> tuple[np.ndarray, np.ndarray]: |
|
"""Compute central difference velocity and acceleration for a single trajectory.""" |
|
n = len(pos) |
|
vel = np.zeros_like(pos, dtype=np.float64) |
|
accel = np.zeros_like(pos, dtype=np.float64) |
|
|
|
if n < 2: |
|
return vel, accel |
|
|
|
if n == 2: |
|
d_time = (time[1] - time[0]) * dt |
|
if d_time > 0: |
|
vel[:] = (pos[1] - pos[0]) / d_time |
|
return vel, accel |
|
|
|
# First point (forward difference) |
|
dt0 = (time[1] - time[0]) * dt |
|
if dt0 > 0: |
|
vel[0] = (pos[1] - pos[0]) / dt0 |
|
|
|
# Interior points (central difference) |
|
dt_mid = (time[2:] - time[:-2]) * dt |
|
valid_mid = dt_mid > 0 |
|
if np.any(valid_mid): |
|
vel[1:-1][valid_mid] = ( |
|
pos[2:][valid_mid] - pos[:-2][valid_mid] |
|
) / dt_mid[valid_mid, None] |
|
|
|
# Last point (backward difference) |
|
dtn = (time[-1] - time[-2]) * dt |
|
if dtn > 0: |
|
vel[-1] = (pos[-1] - pos[-2]) / dtn |
|
|
|
# Acceleration from velocities (central difference) |
|
if n >= 3: |
|
if dt0 > 0: |
|
accel[0] = (vel[1] - vel[0]) / dt0 |
|
accel[1:-1][valid_mid] = ( |
|
vel[2:][valid_mid] - vel[:-2][valid_mid] |
|
) / dt_mid[valid_mid, None] |
|
if dtn > 0: |
|
accel[-1] = (vel[-1] - vel[-2]) / dtn |
|
|
|
return vel, accel |
|
|
|
|
|
def run_openptv_tracking( |
|
data_dir: Path, params_file: Path, first: int, last: int |
|
) -> None: |
|
"""Execute OpenPTV2 tracking on the experiment folder.""" |
|
try: |
|
from openptv2.main import main as optv_main |
|
|
|
print( |
|
f"[pipeline] running openptv2 tracking on {data_dir.name} " |
|
f"(frames {first}..{last})..." |
|
) |
|
optv_main([str(data_dir), str(params_file.name), str(first), str(last)]) |
|
except ImportError: |
|
import subprocess |
|
|
|
print("[pipeline] calling openptv2 via subprocess...") |
|
res = subprocess.run( |
|
[ |
|
sys.executable, |
|
"-m", |
|
"openptv2", |
|
str(data_dir), |
|
str(params_file.name), |
|
str(first), |
|
str(last), |
|
], |
|
cwd=str(data_dir), |
|
capture_output=True, |
|
text=True, |
|
) |
|
if res.returncode != 0: |
|
raise RuntimeError(f"openptv2 failed:\n{res.stderr}\n{res.stdout}") |
|
|
|
|
|
def build_trajectories_table( |
|
res_dir: Path, frame_rate: float = 1.0 |
|
) -> dict[str, np.ndarray]: |
|
"""Read ptv_is linkage from res/ and build structured trajectory arrays.""" |
|
from flowtracks.io import trajectories_ptvis |
|
|
|
ptv_files = sorted(res_dir.glob("ptv_is.*")) |
|
if not ptv_files: |
|
raise FileNotFoundError(f"No ptv_is.* output files found in {res_dir}") |
|
|
|
first_frame = int(ptv_files[0].suffix.lstrip(".")) |
|
last_frame = int(ptv_files[-1].suffix.lstrip(".")) |
|
print( |
|
f"[pipeline] reading trajectories from ptv_is files " |
|
f"({first_frame}..{last_frame})..." |
|
) |
|
|
|
# Read trajectories using flowtracks |
|
trajects = trajectories_ptvis(str(res_dir), first=first_frame, last=last_frame) |
|
dt = 1.0 / frame_rate if frame_rate > 0 else 1.0 |
|
|
|
all_pos, all_vel, all_accel, all_time, all_trajid = [], [], [], [], [] |
|
|
|
for tr in trajects: |
|
tr_id = int(tr.trajid()) |
|
pos = np.asarray(tr.pos(), dtype=np.float64) |
|
time = np.asarray(tr.time(), dtype=np.int64) |
|
|
|
if tr_id == 0 or len(pos) == 0: |
|
# Unlinked particles: no valid derivatives |
|
vel = np.zeros_like(pos) |
|
accel = np.zeros_like(pos) |
|
else: |
|
sort_idx = np.argsort(time) |
|
pos = pos[sort_idx] |
|
time = time[sort_idx] |
|
vel, accel = compute_derivatives(pos, time, dt) |
|
|
|
all_pos.append(pos) |
|
all_vel.append(vel) |
|
all_accel.append(accel) |
|
all_time.append(time) |
|
all_trajid.append(np.full(len(pos), tr_id, dtype=np.int64)) |
|
|
|
if not all_pos: |
|
raise ValueError("No particle data was recovered from tracking output.") |
|
|
|
pos_cat = np.concatenate(all_pos, axis=0) |
|
vel_cat = np.concatenate(all_vel, axis=0) |
|
accel_cat = np.concatenate(all_accel, axis=0) |
|
time_cat = np.concatenate(all_time, axis=0) |
|
trajid_cat = np.concatenate(all_trajid, axis=0) |
|
|
|
return { |
|
"pos": pos_cat, |
|
"vel": vel_cat, |
|
"accel": accel_cat, |
|
"time": time_cat, |
|
"trajid": trajid_cat, |
|
} |
|
|
|
|
|
def save_zarr(res_dir: Path, data: dict[str, np.ndarray]) -> Path: |
|
"""Save structured trajectory data into a Zarr store.""" |
|
import zarr |
|
|
|
zarr_path = res_dir / "run.zarr" |
|
store = zarr.open_group(str(zarr_path), mode="a") |
|
traj_grp = store.require_group("trajectories") |
|
|
|
for key, arr in data.items(): |
|
if key in traj_grp: |
|
del traj_grp[key] |
|
traj_grp.create_dataset(key, data=arr, overwrite=True) |
|
|
|
print(f"[pipeline] saved structured trajectories to {zarr_path}/trajectories") |
|
return zarr_path |
|
|
|
|
|
def compute_eulerian_grid( |
|
pos: np.ndarray, |
|
vel: np.ndarray, |
|
cell_size: float, |
|
min_samples: int = 3, |
|
) -> dict[str, Any]: |
|
"""Bin 3D velocities onto a regular Eulerian Cartesian grid.""" |
|
# Use percentiles to avoid outlier explosion |
|
p_low = np.percentile(pos, 0.5, axis=0) |
|
p_high = np.percentile(pos, 99.5, axis=0) |
|
|
|
x_edges = np.arange(p_low[0], p_high[0] + cell_size, cell_size) |
|
y_edges = np.arange(p_low[1], p_high[1] + cell_size, cell_size) |
|
z_edges = np.arange(p_low[2], p_high[2] + cell_size, cell_size) |
|
|
|
nx, ny, nz = len(x_edges) - 1, len(y_edges) - 1, len(z_edges) - 1 |
|
counts = np.zeros((nx, ny, nz), dtype=np.int32) |
|
u_sum = np.zeros((nx, ny, nz, 3), dtype=np.float64) |
|
u_sq_sum = np.zeros((nx, ny, nz, 3), dtype=np.float64) |
|
|
|
ix = np.clip(np.digitize(pos[:, 0], x_edges) - 1, 0, nx - 1) |
|
iy = np.clip(np.digitize(pos[:, 1], y_edges) - 1, 0, ny - 1) |
|
iz = np.clip(np.digitize(pos[:, 2], z_edges) - 1, 0, nz - 1) |
|
|
|
for i in range(len(pos)): |
|
cx, cy, cz = ix[i], iy[i], iz[i] |
|
counts[cx, cy, cz] += 1 |
|
v = vel[i] |
|
u_sum[cx, cy, cz] += v |
|
u_sq_sum[cx, cy, cz] += v * v |
|
|
|
valid = counts >= min_samples |
|
u_mean = np.full((nx, ny, nz, 3), np.nan) |
|
u_mean[valid] = u_sum[valid] / counts[valid, None] |
|
|
|
# Reynolds stresses: <u'u'> = <u^2> - <u>^2 |
|
reynolds_diag = np.full((nx, ny, nz, 3), np.nan) |
|
reynolds_diag[valid] = (u_sq_sum[valid] / counts[valid, None]) - ( |
|
u_mean[valid] ** 2 |
|
) |
|
reynolds_diag[reynolds_diag < 0] = 0.0 |
|
|
|
# TKE = 0.5 * (u'^2 + v'^2 + w'^2) |
|
tke = np.full((nx, ny, nz), np.nan) |
|
tke[valid] = 0.5 * np.sum(reynolds_diag[valid], axis=1) |
|
|
|
occupancy = float(np.sum(valid) / counts.size * 100.0) |
|
print( |
|
f"[eulerian] grid ({nx}, {ny}, {nz}) -> " |
|
f"{occupancy:.1f}% occupied voxels (>= {min_samples} samples)" |
|
) |
|
|
|
return { |
|
"x": 0.5 * (x_edges[:-1] + x_edges[1:]), |
|
"y": 0.5 * (y_edges[:-1] + y_edges[1:]), |
|
"z": 0.5 * (z_edges[:-1] + z_edges[1:]), |
|
"counts": counts, |
|
"mean_velocity": u_mean, |
|
"reynolds_diag": reynolds_diag, |
|
"tke": tke, |
|
} |
|
|
|
|
|
def verify_results(data: dict[str, np.ndarray]) -> None: |
|
"""THE FIRST RULE: Assert on actual numerical values produced by the pipeline.""" |
|
pos = data["pos"] |
|
vel = data["vel"] |
|
trajid = data["trajid"] |
|
|
|
if len(pos) == 0: |
|
raise ValueError("Pipeline produced 0 total particle points!") |
|
|
|
n_trajs = len(np.unique(trajid[trajid > 0])) |
|
n_unlinked = int((trajid == 0).sum()) |
|
speeds = np.linalg.norm(vel[trajid > 0], axis=1) if n_trajs > 0 else np.array([]) |
|
valid_speeds = speeds[speeds > 0] |
|
|
|
print("\n" + "=" * 50) |
|
print(" PIPELINE QUALITY AUDIT ") |
|
print("=" * 50) |
|
print(f"Total points : {len(pos):,}") |
|
print(f"Linked trajectories: {n_trajs:,}") |
|
print( |
|
f"Unlinked fraction : {n_unlinked / len(pos) * 100:.1f}% " |
|
f"({n_unlinked:,} rows)" |
|
) |
|
|
|
if len(valid_speeds) == 0: |
|
raise ValueError("CRITICAL BUG: All trajectory velocities are exactly 0.0!") |
|
|
|
print(f"Mean speed (|v|) : {valid_speeds.mean():.3f}") |
|
print(f"Max speed (|v|) : {valid_speeds.max():.3f}") |
|
print(f"Coordinate extent : X=[{pos[:, 0].min():.1f}, {pos[:, 0].max():.1f}]") |
|
print(f" Y=[{pos[:, 1].min():.1f}, {pos[:, 1].max():.1f}]") |
|
print(f" Z=[{pos[:, 2].min():.1f}, {pos[:, 2].max():.1f}]") |
|
print(f"Finite values check: {np.isfinite(vel).all()}") |
|
print("=" * 50 + "\n") |
|
|
|
|
|
def main() -> None: |
|
parser = argparse.ArgumentParser(description="Standalone 3D OpenPTV Pipeline") |
|
parser.add_argument("path", type=Path, help="Experiment directory path") |
|
parser.add_argument("--first", type=int, help="First frame number") |
|
parser.add_argument("--last", type=int, help="Last frame number") |
|
parser.add_argument("--params", type=str, help="Parameters YAML filename") |
|
parser.add_argument( |
|
"--frame-rate", |
|
type=float, |
|
default=1.0, |
|
help="Camera acquisition frame rate in Hz", |
|
) |
|
parser.add_argument( |
|
"--eulerian-cell", type=float, help="Eulerian cell voxel size in mm" |
|
) |
|
args = parser.parse_args() |
|
|
|
data_dir = args.path.resolve() |
|
if not data_dir.is_dir(): |
|
raise SystemExit(f"Directory not found: {data_dir}") |
|
|
|
params_file = find_params_file(data_dir, args.params) |
|
params = yaml.safe_load(params_file.read_text(encoding="utf-8")) or {} |
|
|
|
first = ( |
|
args.first |
|
if args.first is not None |
|
else int((params.get("sequence") or {}).get("first", 1)) |
|
) |
|
last = ( |
|
args.last |
|
if args.last is not None |
|
else int((params.get("sequence") or {}).get("last", 10)) |
|
) |
|
|
|
# 1. Preflight check |
|
check_inputs(data_dir, params_file, first, last) |
|
|
|
# 2. Tracking execution |
|
run_openptv_tracking(data_dir, params_file, first, last) |
|
|
|
# 3. Post-process trajectories and compute derivatives |
|
res_dir = data_dir / "res" |
|
traj_data = build_trajectories_table(res_dir, frame_rate=args.frame_rate) |
|
|
|
# 4. Save to Zarr |
|
save_zarr(res_dir, traj_data) |
|
|
|
# 5. Eulerian Grid (optional) |
|
if args.eulerian_cell: |
|
linked_mask = traj_data["trajid"] > 0 |
|
grid_data = compute_eulerian_grid( |
|
traj_data["pos"][linked_mask], |
|
traj_data["vel"][linked_mask], |
|
cell_size=args.eulerian_cell, |
|
) |
|
import zarr |
|
|
|
store = zarr.open_group(str(res_dir / "run.zarr"), mode="a") |
|
eul_grp = store.require_group("eulerian") |
|
for k, v in grid_data.items(): |
|
if k in eul_grp: |
|
del eul_grp[k] |
|
eul_grp.create_dataset(k, data=v, overwrite=True) |
|
print(f"[pipeline] saved Eulerian grid to {res_dir / 'run.zarr'}/eulerian") |
|
|
|
# 6. Quality self-check |
|
verify_results(traj_data) |
|
print("[pipeline] SUCCESS: 3D PTV pipeline completed cleanly.") |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |