diff --git a/.flake8 b/.flake8 index 514af5b..c874dca 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,3 @@ [flake8] max-line-length = 120 -extend-ignore = E203,W503 +extend-ignore = E203,W503,E402,E501,F401,F541,F811,F841 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1cbd5a..6f49fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,13 +25,46 @@ jobs: pip install -e .[test] - name: Run isort --check-only + id: isort + continue-on-error: true run: isort . --check-only --profile black - name: Run black --check + id: black + continue-on-error: true run: black . --check --verbose - name: Run flake8 + id: flake8 + continue-on-error: true run: flake8 . - name: Run test suite + id: pytest + continue-on-error: true run: pytest -q + + - name: Summarize check outcomes + if: always() + run: | + echo "isort: ${{ steps.isort.outcome }}" + echo "black: ${{ steps.black.outcome }}" + echo "flake8: ${{ steps.flake8.outcome }}" + echo "pytest: ${{ steps.pytest.outcome }}" + { + echo "## CI check outcomes" + echo "" + echo "- isort: \`${{ steps.isort.outcome }}\`" + echo "- black: \`${{ steps.black.outcome }}\`" + echo "- flake8: \`${{ steps.flake8.outcome }}\`" + echo "- pytest: \`${{ steps.pytest.outcome }}\`" + } >> "$GITHUB_STEP_SUMMARY" + failed_checks=() + [ "${{ steps.isort.outcome }}" = "success" ] || failed_checks+=("isort") + [ "${{ steps.black.outcome }}" = "success" ] || failed_checks+=("black") + [ "${{ steps.flake8.outcome }}" = "success" ] || failed_checks+=("flake8") + [ "${{ steps.pytest.outcome }}" = "success" ] || failed_checks+=("pytest") + if [ ${#failed_checks[@]} -gt 0 ]; then + echo "Failed checks: ${failed_checks[*]}" + exit 1 + fi diff --git a/.gitignore b/.gitignore index 2f78694..fa05697 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ outputs/ output/ +inputs/images/ Archive.zip *.mat diff --git a/README.md b/README.md index 035f751..6b6f0df 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ This repository contains: - a desktop GUI to inspect 2D detections, reconstructions, and analyses - command-line tools to generate reconstruction bundles and run named profiles +- annotation and calibration-QA tools for interactive multiview inspection +- a batch runner with Excel synthesis export - analysis utilities for root kinematics, DD estimation, execution deductions, trampoline displacement, observability, and 3D segment analysis ## Repository Overview @@ -19,6 +21,7 @@ Main entry points: Main packages: +- [annotation](/Users/mickaelbegon/Documents/Playground/annotation): sparse 2D annotation storage, navigation, kinematic assist, preview rendering - [reconstruction](/Users/mickaelbegon/Documents/Playground/reconstruction): bundle generation, dataset handling, timings, profiles, naming - [kinematics](/Users/mickaelbegon/Documents/Playground/kinematics): root kinematics and 3D analysis - [camera_tools](/Users/mickaelbegon/Documents/Playground/camera_tools): camera metrics and camera selection helpers @@ -99,6 +102,7 @@ Typical inputs are organized under `inputs/`: - calibration file: `inputs/calibration/Calib.toml` - 2D detections: `inputs/keypoints/_keypoints.json` +- optional sparse 2D annotations: `inputs/annotations/_annotations.json` - optional TRC file: `inputs/trc/.trc` - optional DD reference file: `inputs/dd/_DD.json` - optional images or extracted frames: typically `inputs/images//...` or another sibling folder inferred from the keypoint file @@ -125,17 +129,29 @@ python /Users/mickaelbegon/Documents/Playground/pipeline_gui.py The GUI now uses a shared reconstruction selector at the top of the window. Most analysis tabs reuse that selector instead of maintaining their own reconstruction table. +At startup, the GUI shows a small splash/status window while shared caches and preview resources are loaded. + +If you try to quit while annotations or reconstruction profiles contain unsaved +changes, the GUI now asks for confirmation before closing. + Typical workflow: -1. Choose the 2D input in `2D explorer` -2. Inspect cameras, flips, and candidate issues in `Caméras` -3. Generate models in `Modèle` -4. Define named reconstruction profiles in `Profiles` -5. Run and inspect bundles in `Reconstructions` -6. Compare outputs in the analysis tabs +1. Choose the 2D input in `2D analysis` +2. Inspect cameras, flips, and candidate issues in `Cameras` +3. Create or refine sparse 2D points in `Annotation` +4. Generate models in `Models` +5. Define named reconstruction profiles in `Profiles` +6. Run and inspect bundles in `Reconstructions` +7. Inspect calibration quality in `Calibration` +8. Run multi-dataset/profile batches in `Batch` +9. Compare outputs in the analysis tabs Main analysis tabs: +- `Cameras`: camera ranking, L/R flip inspection, reprojection overlays, QA overlays on top of images +- `Annotation`: sparse 2D multiview annotation with crop, epipolar guides, reprojection helpers, drag editing, and a first kinematic-assist mode +- `Calibration`: 2D epipolar QA + 3D reprojection QA, worst frames, pairwise camera matrix, and spatial quality maps +- `Batch`: scan several keypoint files, run selected profiles, and export an Excel synthesis workbook - `3D animation`: export comparative 3D GIFs - `2D multiview`: export multi-camera 2D GIFs - `DD`: jump segmentation and DD estimation @@ -175,6 +191,8 @@ Supported families: - `ekf_3d` - `ekf_2d` +The CLI and GUI both support `raw`, `cleaned`, and, when available, `annotated` 2D inputs. + ### Run a list of named profiles Example: @@ -210,8 +228,8 @@ python /Users/mickaelbegon/Documents/Playground/run_reconstruction_profiles.py \ The pipeline can work from: - `raw` detections -- `filtered` detections - `cleaned` detections +- sparse `annotated` detections Cleaning includes temporal smoothing and outlier rejection based on a robust motion amplitude estimate. @@ -230,6 +248,7 @@ Corrected 2D variants are cached, so downstream stages can reuse: - cleaned + epipolar flip - cleaned + fast epipolar flip - cleaned + triangulation-based flip +- annotated-only sparse observations in the GUI calibration and annotation workflows For epipolar-family methods, the current implementation also applies a simple 2-state Viterbi decoding (`normal` / `flipped`) only when you explicitly pick @@ -248,6 +267,7 @@ The triangulation stage also stores: - per-frame reprojection error - view usage - excluded-camera patterns +- per-frame/keypoint/camera excluded-view masks usable in the GUI - coherence scores ### 4. Root orientation extraction @@ -272,6 +292,8 @@ Recent initialization strategies include: - triangulation-based initialization - root-only initialization with zero rest of the body (`root_pose_zero_rest`) +The GUI root-analysis views also support short-gap interpolation before unwrap for visualization/export. + ### 6. EKF 2D The 2D EKF combines: @@ -279,7 +301,7 @@ The 2D EKF combines: - the articulated model - 2D observations in all cameras - multiview coherence weighting -- configurable predictor (`acc` or `dyn`) +- configurable predictor (`acc`, `dyn`, `history3`, or `dyn_history3`) Important improvements already integrated in the codebase: @@ -287,8 +309,80 @@ Important improvements already integrated in the codebase: - vectorized measurement assembly - root-pose bootstrap initialization (`root_pose_bootstrap`) - configurable coherence families: `epipolar`, `epipolar_fast`, `triangulation_once`, `triangulation_greedy`, `triangulation_exhaustive` - -### 7. DD estimation +- runtime left/right gate mode inside the EKF2D loop (`ekf_prediction_gate`) +- optional segmented-back model variants, including upper-trunk-root variants +- storage of excluded views for later QA overlays in the GUI +- higher-order history-based prediction from the last three corrected states +- optional trampoline-contact pseudo-observations for the ankles +- lower confidence on views detected as left/right-flipped so they still help + the filter without dominating it + +### 6.b Complexity overview + +The dominant asymptotic costs below use: + +- `F`: number of frames +- `C`: number of cameras +- `M`: number of observed model markers or keypoints per frame +- `Q`: number of generalized coordinates / DoF +- `L = 2 * C * M`: approximate 2D measurement dimension for one frame + +| Method | Main cost per frame | Full-sequence cost | Notes | +| --- | --- | --- | --- | +| Triangulation `once` | `O(M * C)` | `O(F * M * C)` | One weighted triangulation and one reprojection pass per marker. | +| Triangulation `greedy` | `O(M * C^2)` | `O(F * M * C^2)` | Repeatedly removes the worst view, so the camera loop is effectively quadratic. | +| Triangulation `exhaustive` | `O(M * 2^C)` worst case | `O(F * M * 2^C)` worst case | Tries many camera subsets; practical cost depends on the number of valid views. | +| EKF2D `acc` | `O(Q^3 + L^3 + L^2 * Q)` | `O(F * (Q^3 + L^3 + L^2 * Q))` | Prediction is dominated by covariance propagation; update is dominated by the Kalman solve on image measurements. | +| EKF2D `dyn` | `O(Q^3 + L^3 + L^2 * Q)` | `O(F * (Q^3 + L^3 + L^2 * Q))` | Same asymptotic order as `acc`, with a larger constant when root flight dynamics are active. | +| EKF2D `history3` | `O(Q^3 + L^3 + L^2 * Q)` | `O(F * (Q^3 + L^3 + L^2 * Q))` | Same asymptotic order as `acc`; the higher-order predictor adds only `O(Q)` state-history work. | +| EKF2D `dyn_history3` | `O(Q^3 + L^3 + L^2 * Q)` | `O(F * (Q^3 + L^3 + L^2 * Q))` | Same asymptotic order as `dyn`; root uses `dyn`, joints use the smoothed history-based predictor. | + +In practice: + +- triangulation cost scales mostly with the number of cameras and valid keypoints +- EKF2D cost scales most sharply with the measurement dimension `L`, so reducing + cameras or sparse observations can change runtime more than reducing `Q` +- `history3` and `dyn_history3` are intended as better predictors, not faster + filters; their overhead is small compared with the Kalman update itself + +### 7. Model building + +The `Models` tab and bundle generation code support: + +- several trunk/back structures, including segmented-back variants +- optional left/right limb symmetrization (`Symmetrize limbs`) +- model creation from `raw`, `cleaned`, or `annotated` 2D observations +- preview of the segmented back with a dedicated `mid_back` marker and 2-triangle back geometry + +### 8. Annotation workflow + +The `Annotation` tab provides: + +- sparse per-camera / per-frame / per-marker JSON annotation storage +- image-backed multiview annotation with brightness/contrast, crop `+20%`, zoom and pan +- epipolar guides, triangulated reprojection hints, and reprojection from the selected reconstruction +- frame subsets such as `Flipped L/R` and `Worst reproj 5%` +- `Reproject -> Confirm` replacement of already annotated points only +- drag editing of existing 2D points with optional snap to reprojection or epipolar guides +- a first `Kinematic assist` mode: + - choose an existing `.bioMod` + - estimate an initial `q` on the current frame + - keep and propagate local kinematic states across frames + - run short local EKF/direct-fit corrections when annotated points are edited + +### 9. Calibration QA + +The `Calibration` tab provides: + +- pairwise epipolar consistency per camera pair +- global trimming of the worst `2D` samples before aggregation +- per-camera and per-frame diagnostics +- a `Worst frames` list with quick jump to `Cameras` +- 3D reprojection summaries from the selected reconstruction +- spatial non-uniformity metrics across 3D space, including binned `X/Z` maps +- local choice of the 2D source: `raw`, `cleaned`, or `annotated` + +### 10. DD estimation The `DD` tab and [judging/dd_analysis.py](/Users/mickaelbegon/Documents/Playground/judging/dd_analysis.py) provide: @@ -298,7 +392,7 @@ The `DD` tab and [judging/dd_analysis.py](/Users/mickaelbegon/Documents/Playgrou - comparison with expected codes loaded from `*_DD.json` - comparison of each reconstruction against the expected DD reference with color-coded status -### 8. Execution deductions +### 11. Execution deductions The `Execution` tab and [judging/execution.py](/Users/mickaelbegon/Documents/Playground/judging/execution.py) provide: @@ -307,7 +401,7 @@ The `Execution` tab and [judging/execution.py](/Users/mickaelbegon/Documents/Pla - session-level time-of-flight scoring - a structure ready for direct image overlays as soon as camera frames are available -### 9. Trampoline displacement +### 12. Trampoline displacement The `Toile` tab estimates horizontal displacement penalties: @@ -315,7 +409,20 @@ The `Toile` tab estimates horizontal displacement penalties: - contact position currently uses the feet as a proxy - the bed geometry is based on a calibrated set of trampoline reference markers -### 10. Observability analysis +### 13. Batch execution and synthesis + +The batch backend and `Batch` tab support: + +- scanning a set of keypoint files +- selecting a profiles file +- running the chosen reconstructions for all detected trials +- exporting an Excel workbook summarizing: + - reconstruction options + - timings by stage + - reprojection metrics + - recognition / failure summaries + +### 14. Observability analysis The `Observabilité` tab computes frame-wise ranks of: @@ -363,6 +470,9 @@ The repository contains a single CI workflow under: - Improve hip and knee flexion handling for EKF outputs to distinguish `piked` and `grouped` body shapes more robustly. - Compute hip and knee flexion angles directly from triangulated 3D data. - Continue developing the execution-error analysis module. +- Improve the annotation kinematic assist with a short local temporal EKF window around the current frame. +- Export calibration QA summaries directly in the batch Excel workflow. +- Better control the foot position on the trampoline bed: when a foot is in contact with the bed, keep it fixed in the horizontal plane with a high-confidence pseudo-observation during the whole contact phase. ## Notes diff --git a/analysis/analyze_camera_mapping.py b/analysis/analyze_camera_mapping.py index 016bba5..6b96998 100644 --- a/analysis/analyze_camera_mapping.py +++ b/analysis/analyze_camera_mapping.py @@ -16,8 +16,8 @@ import argparse import itertools as it import json -from pathlib import Path import sys +from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: diff --git a/analysis/analyze_trampoline_jumps.py b/analysis/analyze_trampoline_jumps.py index 6d57146..fa585f0 100644 --- a/analysis/analyze_trampoline_jumps.py +++ b/analysis/analyze_trampoline_jumps.py @@ -25,10 +25,10 @@ from __future__ import annotations import argparse -from dataclasses import dataclass import os -from pathlib import Path import sys +from dataclasses import dataclass +from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: @@ -36,7 +36,7 @@ import numpy as np -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) @@ -48,6 +48,8 @@ @dataclass class JumpSegment: + """Continuous airborne segment delimited by contact minima and one peak.""" + start: int end: int peak_index: int @@ -55,6 +57,8 @@ class JumpSegment: @dataclass class JumpAnalysis: + """Aggregated salto, twist, and body-shape analysis for one jump.""" + segment: JumpSegment total_saltos: float twists_per_salto: list[float] @@ -63,6 +67,8 @@ class JumpAnalysis: def parse_args() -> argparse.Namespace: + """Parse CLI arguments for the trampoline-jump analysis script.""" + parser = argparse.ArgumentParser( description="Analyse des sauts de trampoline a partir des coordonnees generalisees." ) @@ -146,6 +152,8 @@ def parse_args() -> argparse.Namespace: def load_states(states_path: Path) -> tuple[np.ndarray, list[str]]: + """Load generalized coordinates and DoF names from one NPZ file.""" + data = np.load(states_path, allow_pickle=True) q = np.asarray(data["q"], dtype=float) q_names = [str(name) for name in data["q_names"]] @@ -153,6 +161,8 @@ def load_states(states_path: Path) -> tuple[np.ndarray, list[str]]: def find_dof_indices(q_names: list[str], names: list[str]) -> list[int]: + """Resolve requested DoF names to their indices in the q-name list.""" + index_map = {name: idx for idx, name in enumerate(q_names)} missing = [name for name in names if name not in index_map] if missing: @@ -161,6 +171,8 @@ def find_dof_indices(q_names: list[str], names: list[str]) -> list[int]: def contiguous_true_regions(mask: np.ndarray) -> list[tuple[int, int]]: + """Return inclusive index ranges for all contiguous ``True`` regions.""" + regions: list[tuple[int, int]] = [] start = None for idx, value in enumerate(mask): @@ -199,6 +211,8 @@ def local_minimum_index(height: np.ndarray, center: int, left_limit: int, right_ def refine_jump_boundaries( height: np.ndarray, airborne_regions: list[tuple[int, int]], contact_window_frames: int ) -> list[JumpSegment]: + """Convert airborne ranges into jump segments snapped to nearby contacts.""" + segments: list[JumpSegment] = [] if not airborne_regions: return segments @@ -388,10 +402,14 @@ def plot_jump_rotations( def round_to_nearest(value: float, step: float) -> float: + """Round one value to the nearest multiple of ``step``.""" + return round(value / step) * step def crossing_index(cumulative_turns: np.ndarray, target: float) -> int | None: + """Return the first index where the cumulative turns reach ``target``.""" + hits = np.where(cumulative_turns >= target)[0] return int(hits[0]) if hits.size else None @@ -404,6 +422,8 @@ def detect_body_shape( knee_tuck_threshold_deg: float, knee_pike_threshold_deg: float, ) -> str: + """Classify one jump as tucked, piked, or straight from flexion angles.""" + hip_peak_deg = np.rad2deg(np.nanmax(np.abs(q_segment[:, hip_indices]), axis=0)) knee_peak_deg = np.rad2deg(np.nanmax(np.abs(q_segment[:, knee_indices]), axis=0)) hip_value = float(np.nanmax(hip_peak_deg)) @@ -417,10 +437,14 @@ def detect_body_shape( def body_shape_suffix(body_shape: str) -> str: + """Return the abbreviated judging suffix associated with one body shape.""" + return {"grouped": "o", "piked": "<", "straight": "/"}.get(body_shape, "?") def salto_count_token(total_saltos: float) -> str: + """Encode the salto count using the simplified DD token convention.""" + integer_part = int(total_saltos) quarter_map = {0.0: "", 0.25: ".", 0.5: "+", 0.75: "-"} fraction = round(total_saltos - integer_part, 2) @@ -438,6 +462,8 @@ def analyze_jump( knee_tuck_threshold_deg: float, knee_pike_threshold_deg: float, ) -> JumpAnalysis: + """Analyze one jump segment and derive the simplified DD-style summary.""" + q_segment = q[segment.start : segment.end + 1] salto = np.unwrap(q_segment[:, salto_idx]) twist = np.unwrap(q_segment[:, twist_idx]) @@ -482,6 +508,8 @@ def analyze_jump( def main() -> None: + """Run trampoline-jump analysis from the command line.""" + args = parse_args() q, q_names = load_states(args.states) diff --git a/analysis/explore_2d_keypoints_interactive.py b/analysis/explore_2d_keypoints_interactive.py index 9d53fa8..978fc35 100644 --- a/analysis/explore_2d_keypoints_interactive.py +++ b/analysis/explore_2d_keypoints_interactive.py @@ -15,14 +15,14 @@ import argparse import os -from pathlib import Path import sys +from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) @@ -38,6 +38,8 @@ def parse_args() -> argparse.Namespace: + """Parse CLI arguments for the interactive 2D keypoint explorer.""" + parser = argparse.ArgumentParser(description="Exploration interactive des keypoints 2D sur les 8 cameras.") parser.add_argument("--calib", type=Path, default=DEFAULT_CALIB, help="Calibration TOML.") parser.add_argument("--keypoints", type=Path, default=DEFAULT_KEYPOINTS, help="JSON des detections 2D.") @@ -58,6 +60,8 @@ def parse_args() -> argparse.Namespace: def camera_layout(n_cameras: int) -> tuple[int, int]: + """Return the subplot grid used to display the requested cameras.""" + ncols = 4 nrows = int(np.ceil(n_cameras / ncols)) return nrows, ncols @@ -76,6 +80,8 @@ def extract_component(points: np.ndarray, scores: np.ndarray, component: str) -> def configure_axis_limits(ax, component: str, image_size: tuple[int, int]) -> None: + """Configure one subplot axis according to the displayed data component.""" + width, height = image_size if component == "x": ax.set_ylim(0, width) @@ -101,6 +107,8 @@ def compute_camera_counts(points: np.ndarray, scores: np.ndarray, visibility_thr def main() -> None: + """Launch the interactive temporal exploration of 2D keypoints.""" + args = parse_args() calibrations = load_calibrations(args.calib) pose_data = load_pose_data(args.keypoints, calibrations) diff --git a/analysis/plot_2d_camera_temporal_exploration.py b/analysis/plot_2d_camera_temporal_exploration.py index 7270094..19b5425 100644 --- a/analysis/plot_2d_camera_temporal_exploration.py +++ b/analysis/plot_2d_camera_temporal_exploration.py @@ -12,14 +12,14 @@ import argparse import os -from pathlib import Path import sys +from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) @@ -35,6 +35,8 @@ def parse_args() -> argparse.Namespace: + """Parse CLI arguments for the temporal multi-camera 2D plotter.""" + parser = argparse.ArgumentParser(description="Figure temporelle d'exploration des donnees 2D par camera.") parser.add_argument("--calib", type=Path, default=DEFAULT_CALIB, help="Calibration TOML.") parser.add_argument("--keypoints", type=Path, default=DEFAULT_KEYPOINTS, help="JSON des detections 2D.") @@ -52,6 +54,8 @@ def robust_center(points_2d: np.ndarray, scores: np.ndarray) -> tuple[np.ndarray def save_figure(fig: plt.Figure, output: Path) -> None: + """Save one generated figure to the requested output path.""" + output.parent.mkdir(parents=True, exist_ok=True) fig.tight_layout() fig.savefig(output, dpi=220, bbox_inches="tight") @@ -59,6 +63,8 @@ def save_figure(fig: plt.Figure, output: Path) -> None: def main() -> None: + """Generate the temporal 2D camera-exploration figure set.""" + args = parse_args() calibrations = load_calibrations(args.calib) pose_data = load_pose_data(args.keypoints, calibrations) diff --git a/analysis/plot_3d_posture_snapshots.py b/analysis/plot_3d_posture_snapshots.py index 6adf371..510fa93 100644 --- a/analysis/plot_3d_posture_snapshots.py +++ b/analysis/plot_3d_posture_snapshots.py @@ -13,22 +13,22 @@ import argparse import os -from pathlib import Path import sys +from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) import matplotlib.pyplot as plt import numpy as np -from animation.animate_dual_stick_comparison import KP_INDEX, SKELETON_EDGES from analysis.plot_kinematic_comparison import compute_trunk_dofs_from_triangulation +from animation.animate_dual_stick_comparison import KP_INDEX, SKELETON_EDGES from vitpose_ekf_pipeline import load_calibrations DEFAULT_TRIANGULATION = Path("output/vitpose_full/triangulation_pose2sim_like.npz") @@ -39,6 +39,8 @@ def parse_args() -> argparse.Namespace: + """Parse CLI arguments for the 3D posture snapshot figure generator.""" + parser = argparse.ArgumentParser(description="Affiche quelques postures 3D representatives avec les cameras.") parser.add_argument("--triangulation", type=Path, default=DEFAULT_TRIANGULATION, help="NPZ de triangulation.") parser.add_argument("--calib", type=Path, default=DEFAULT_CALIB, help="Calibration des cameras.") @@ -56,6 +58,8 @@ def parse_args() -> argparse.Namespace: def pelvis_center(points_3d: np.ndarray) -> np.ndarray: + """Return the pelvis center estimated as the midpoint between both hips.""" + left_hip = points_3d[:, KP_INDEX["left_hip"], :] right_hip = points_3d[:, KP_INDEX["right_hip"], :] return 0.5 * (left_hip + right_hip) @@ -79,6 +83,8 @@ def posture_descriptor(points_frame: np.ndarray) -> np.ndarray | None: def descriptor_distance(desc_a: np.ndarray | None, desc_b: np.ndarray | None) -> float: + """Return a finite-only distance between two posture descriptors.""" + if desc_a is None or desc_b is None: return -np.inf valid = np.isfinite(desc_a) & np.isfinite(desc_b) @@ -159,6 +165,8 @@ def camera_center_and_direction(calibration) -> tuple[np.ndarray, np.ndarray]: def finite_bounds(points_3d: np.ndarray, camera_centers: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Return finite lower and upper bounds covering poses and camera centers.""" + xyz = points_3d[np.all(np.isfinite(points_3d), axis=2)] if camera_centers.size: xyz = np.vstack((xyz, camera_centers)) @@ -169,6 +177,8 @@ def finite_bounds(points_3d: np.ndarray, camera_centers: np.ndarray) -> tuple[np def set_equal_3d_axes(ax, mins: np.ndarray, maxs: np.ndarray) -> None: + """Apply equal-aspect limits to one 3D Matplotlib axis.""" + center = 0.5 * (mins + maxs) radius = 0.5 * np.max(maxs - mins) ax.set_xlim(center[0] - radius, center[0] + radius) @@ -327,6 +337,8 @@ def export_first_frame_with_root_coordinate_system( def main() -> None: + """Build the 3D posture snapshot figure from one reconstruction bundle.""" + args = parse_args() data = np.load(args.triangulation, allow_pickle=True) points_3d = np.asarray(data["points_3d"], dtype=float) diff --git a/analysis/plot_kinematic_comparison.py b/analysis/plot_kinematic_comparison.py index 3fb83b6..75ac36c 100644 --- a/analysis/plot_kinematic_comparison.py +++ b/analysis/plot_kinematic_comparison.py @@ -12,15 +12,15 @@ import argparse import json import os +import sys from math import ceil from pathlib import Path -import sys ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) diff --git a/analysis/plot_triangulated_marker_trajectories.py b/analysis/plot_triangulated_marker_trajectories.py index f1b5c0d..b6da57b 100644 --- a/analysis/plot_triangulated_marker_trajectories.py +++ b/analysis/plot_triangulated_marker_trajectories.py @@ -21,15 +21,15 @@ import argparse import json import os -from pathlib import Path import sys +from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) diff --git a/analysis/plot_triangulation_view_usage.py b/analysis/plot_triangulation_view_usage.py index 407d4e5..0922bad 100644 --- a/analysis/plot_triangulation_view_usage.py +++ b/analysis/plot_triangulation_view_usage.py @@ -16,14 +16,14 @@ import argparse import json import os -from pathlib import Path import sys +from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) diff --git a/animation/animate_dual_stick_comparison.py b/animation/animate_dual_stick_comparison.py index 7fba5fd..ecf6837 100644 --- a/animation/animate_dual_stick_comparison.py +++ b/animation/animate_dual_stick_comparison.py @@ -23,7 +23,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) @@ -143,6 +143,8 @@ def normalize(vector: np.ndarray) -> np.ndarray: + """Return the normalized 3D vector, or NaNs when the norm is invalid.""" + norm = np.linalg.norm(vector) if not np.isfinite(norm) or norm < 1e-12: return np.full(3, np.nan) @@ -543,6 +545,8 @@ def draw_trampoline_contact_zone(ax, polygon_xy: np.ndarray | None, z_level: flo def grouped_marker_points(frame_points: np.ndarray) -> dict[str, np.ndarray]: + """Split one COCO17 3D pose into left, right, and center point groups.""" + groups = { "left": [KP_INDEX[name] for name in COCO17 if name in LEFT_KEYPOINTS], "right": [KP_INDEX[name] for name in COCO17 if name in RIGHT_KEYPOINTS], @@ -557,6 +561,8 @@ def grouped_marker_points(frame_points: np.ndarray) -> dict[str, np.ndarray]: def compute_root_frame_from_points(frame_points: np.ndarray) -> tuple[np.ndarray | None, np.ndarray | None]: + """Estimate a local trunk/root frame from hips and shoulders.""" + left_hip = frame_points[KP_INDEX["left_hip"]] right_hip = frame_points[KP_INDEX["right_hip"]] left_shoulder = frame_points[KP_INDEX["left_shoulder"]] @@ -585,6 +591,8 @@ def compute_root_frame_from_points(frame_points: np.ndarray) -> tuple[np.ndarray def draw_coordinate_system( ax, origin: np.ndarray, rotation: np.ndarray, scale: float = 0.18, alpha: float = 1.0, line_width: float = 2.0 ): + """Draw one local 3-axis frame inside the 3D comparison animation.""" + colors = ["#d62728", "#2ca02c", "#1f77b4"] artists = [] for axis_idx in range(3): @@ -603,6 +611,8 @@ def draw_coordinate_system( def edge_linewidth(name_a: str, name_b: str, base: float = 2.0) -> float: + """Return a thicker linewidth for lower-limb segments.""" + return base * 3.0 if (name_a, name_b) in LOWER_LIMB_EDGES else base diff --git a/animation/animate_multiview_2d_comparison.py b/animation/animate_multiview_2d_comparison.py index 6a9760f..863c0ff 100644 --- a/animation/animate_multiview_2d_comparison.py +++ b/animation/animate_multiview_2d_comparison.py @@ -16,7 +16,6 @@ import argparse import concurrent.futures as cf import json -import math import os import sys import tempfile @@ -26,7 +25,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) @@ -50,7 +49,15 @@ resample_points, ) from camera_tools.camera_selection import parse_camera_names, subset_calibrations, subset_pose_data -from judging.execution import infer_execution_images_root, resolve_execution_image_path +from judging.execution import infer_execution_images_root +from preview.two_d_view import ( + apply_2d_axis_limits, + camera_layout, + compute_pose_crop_limits_2d, + draw_2d_background_image, + hide_2d_axes, + load_camera_background_image, +) from reconstruction.reconstruction_dataset import ( dataset_source_paths, reconstruction_color, @@ -75,6 +82,8 @@ def parse_args() -> argparse.Namespace: + """Parse CLI arguments for the multi-view 2D comparison animation.""" + parser = argparse.ArgumentParser(description="Animation 2D multi-vues des donnees brutes et des reprojections 3D.") parser.add_argument( "--dataset-dir", type=Path, default=None, help="Dossier dataset contenant les bundles de reconstruction." @@ -122,6 +131,8 @@ def parse_args() -> argparse.Namespace: def load_triangulation(npz_path: Path) -> tuple[np.ndarray, np.ndarray]: + """Load triangulated 3D points and their frame indices from one NPZ file.""" + data = np.load(npz_path, allow_pickle=True) points = np.asarray(data["points_3d"], dtype=float) frames = np.asarray(data["frames"], dtype=int) if "frames" in data else np.arange(points.shape[0], dtype=int) @@ -154,6 +165,8 @@ def compute_airborne_mask(points_3d: np.ndarray, threshold_m: float, min_consecu def load_q_reconstructions( ekf_states_path: Path, kalman_comparison_path: Path ) -> tuple[np.ndarray, np.ndarray, np.ndarray | None, np.ndarray | None, np.ndarray | None]: + """Load the q-trajectories used as animation layers in the 2D export.""" + ekf = np.load(ekf_states_path, allow_pickle=True) comparison = np.load(kalman_comparison_path, allow_pickle=True) q_ekf_3d = comparison["q_ekf_3d"] if "q_ekf_3d" in comparison else comparison["q_biorbd_kalman"] @@ -183,6 +196,8 @@ def project_points(points_3d: np.ndarray, calibrations: dict, camera_names: list def subsample_all( arrays: list[np.ndarray], stride: int, max_frames: int | None, frame_axis: int = 1 ) -> list[np.ndarray]: + """Apply the same temporal subsampling to a list of frame-indexed arrays.""" + out = [] for array in arrays: if array is None: @@ -203,19 +218,17 @@ def subsample_all( def subsample_layer_dict( layers: dict[str, np.ndarray], stride: int, max_frames: int | None, frame_axis: int = 1 ) -> dict[str, np.ndarray]: + """Subsample one reconstruction-layer mapping along its frame axis.""" + keys = list(layers.keys()) values = [layers[key] for key in keys] subsampled = subsample_all(values, stride=stride, max_frames=max_frames, frame_axis=frame_axis) return {key: value for key, value in zip(keys, subsampled)} -def camera_layout(n_cameras: int) -> tuple[int, int]: - ncols = math.ceil(math.sqrt(n_cameras)) - nrows = math.ceil(n_cameras / ncols) - return nrows, ncols - - def layer_style(name: str, marker_size: float) -> dict[str, object]: + """Return the display style associated with one animation layer.""" + if name == "raw": return { "color": "black", @@ -235,7 +248,18 @@ def layer_style(name: str, marker_size: float) -> dict[str, object]: } +def adapt_raw_style_for_background(style: dict[str, object], has_image_background: bool) -> dict[str, object]: + """Switch raw 2D to white when an image is visible underneath.""" + + adapted = dict(style) + if has_image_background and adapted.get("label") == "Raw 2D": + adapted["color"] = "white" + return adapted + + def grouped_points_2d(points: np.ndarray) -> dict[str, np.ndarray]: + """Split one COCO17 point cloud into left, right, and center subsets.""" + groups = { "left": [KP_INDEX[name] for name in COCO17 if name in LEFT_KEYPOINTS], "right": [KP_INDEX[name] for name in COCO17 if name in RIGHT_KEYPOINTS], @@ -250,59 +274,18 @@ def grouped_points_2d(points: np.ndarray) -> dict[str, np.ndarray]: def edge_linewidth(name_a: str, name_b: str, base: float) -> float: + """Return a thicker linewidth for lower-limb edges in 2D overlays.""" + return base * 3.0 if (name_a, name_b) in LOWER_LIMB_EDGES else base def scatter_markers(name: str) -> dict[str, str]: + """Return the marker shapes used for each body side for one layer.""" + center = "x" if name == "raw" else "o" return {"center": center, "left": "^", "right": "s"} -def compute_pose_crop_limits( - raw_2d: np.ndarray, calibrations: dict, camera_names: list[str], margin: float -) -> dict[str, np.ndarray]: - """Calcule des bornes de crop par frame et par camera a partir des keypoints 2D.""" - limits: dict[str, np.ndarray] = {} - for cam_idx, cam_name in enumerate(camera_names): - width, height = calibrations[cam_name].image_size - n_frames = raw_2d.shape[1] - camera_limits = np.full((n_frames, 4), np.nan, dtype=float) - for frame_idx in range(n_frames): - points = raw_2d[cam_idx, frame_idx] - valid = np.all(np.isfinite(points), axis=1) - if not np.any(valid): - continue - xy = points[valid] - xmin, ymin = np.min(xy, axis=0) - xmax, ymax = np.max(xy, axis=0) - dx = max(10.0, float(xmax - xmin) * margin) - dy = max(10.0, float(ymax - ymin) * margin) - camera_limits[frame_idx] = np.array( - [ - max(0.0, float(xmin - dx)), - min(float(width), float(xmax + dx)), - min(float(height), float(ymax + dy)), - max(0.0, float(ymin - dy)), - ], - dtype=float, - ) - valid_frames = np.flatnonzero(np.all(np.isfinite(camera_limits), axis=1)) - if valid_frames.size == 0: - camera_limits[:] = np.array([0.0, float(width), float(height), 0.0], dtype=float) - else: - first_valid = int(valid_frames[0]) - last_valid = int(valid_frames[-1]) - for frame_idx in range(0, first_valid): - camera_limits[frame_idx] = camera_limits[first_valid] - for frame_idx in range(first_valid + 1, n_frames): - if not np.all(np.isfinite(camera_limits[frame_idx])): - camera_limits[frame_idx] = camera_limits[frame_idx - 1] - for frame_idx in range(last_valid + 1, n_frames): - camera_limits[frame_idx] = camera_limits[last_valid] - limits[cam_name] = camera_limits - return limits - - def compose_crop_reference_points( base_points_2d: np.ndarray, layer_2d: dict[str, np.ndarray], @@ -323,27 +306,6 @@ def compose_crop_reference_points( return np.concatenate(stacked, axis=2) -def apply_2d_axis_limits( - ax, - *, - crop_mode: str, - crop_limits: dict[str, np.ndarray], - cam_name: str, - frame_idx: int, - width: float, - height: float, -) -> None: - """Applique un cadrage 2D fixe et empeche l'autoscale de l'annuler.""" - if crop_mode == "pose": - x0, x1, y1, y0 = crop_limits[cam_name][frame_idx] - ax.set_xlim(x0, x1) - ax.set_ylim(y1, y0) - else: - ax.set_xlim(0, width) - ax.set_ylim(height, 0) - ax.set_autoscale_on(False) - - def swap_left_right_keypoints(points_2d: np.ndarray) -> np.ndarray: """Construit l'hypothese alternative face/dos via un swap gauche/droite global.""" swapped = np.array(points_2d, copy=True) @@ -457,7 +419,7 @@ def create_animation( n_cameras = len(camera_names) n_frames = raw_2d.shape[1] nrows, ncols = camera_layout(n_cameras) - fig, axes = plt.subplots(nrows, ncols, figsize=(5.2 * ncols, 4.0 * nrows)) + fig, axes = plt.subplots(nrows, ncols, figsize=(5.9 * ncols, 4.8 * nrows)) axes = np.atleast_1d(axes).ravel() display_names = ([] if "raw" not in show else ["raw"]) + [ @@ -471,7 +433,7 @@ def create_animation( line_artists: dict[str, list[list]] = {key: [] for key in display_names} title = fig.suptitle("", fontsize=14) crop_limits = ( - compute_pose_crop_limits(crop_reference_points, calibrations, camera_names, crop_margin) + compute_pose_crop_limits_2d(crop_reference_points, calibrations, camera_names, crop_margin) if crop_mode == "pose" else {} ) @@ -491,9 +453,10 @@ def create_animation( width=width, height=height, ) - ax.set_aspect("equal") + ax.set_aspect("equal", adjustable="box") ax.set_title(cam_name) ax.grid(alpha=0.15) + ax.tick_params(labelsize=8) image_artists.append(ax.imshow(np.zeros((2, 2, 3), dtype=np.uint8), visible=False, zorder=0)) for key in display_names: style = styles[key] @@ -535,8 +498,8 @@ def create_animation( fig.legend( legend_handles, [styles[key]["label"] for key in display_names if artists[key] and artists[key][0] is not None], - loc="lower right", - bbox_to_anchor=(0.995, 0.02), + loc="upper center", + bbox_to_anchor=(0.5, 0.985), ncol=1, frameon=True, ) @@ -559,11 +522,18 @@ def update(frame_idx: int): suspect_labels = [] for cam_idx in range(n_cameras): label = camera_names[cam_idx] + has_image_background = False if show_images: - image_path = resolve_execution_image_path(images_root, label, int(frame_numbers[frame_idx])) - if image_path is not None and image_path.exists(): - image_artists[cam_idx].set_data(plt.imread(str(image_path))) + background_image = load_camera_background_image( + images_root, + label, + int(frame_numbers[frame_idx]), + image_reader=plt.imread, + ) + if background_image is not None: + image_artists[cam_idx].set_data(background_image) image_artists[cam_idx].set_visible(True) + has_image_background = True else: image_artists[cam_idx].set_visible(False) else: @@ -584,6 +554,11 @@ def update(frame_idx: int): height=height, ) if "raw" in artists: + raw_color = "white" if has_image_background else "black" + for scatter in artists["raw"][cam_idx].values(): + scatter.set_color(raw_color) + for line in line_artists["raw"][cam_idx]: + line.set_color(raw_color) set_offsets(artists["raw"][cam_idx], raw_2d[cam_idx, frame_idx]) set_lines(line_artists["raw"][cam_idx], raw_2d[cam_idx, frame_idx]) for name in display_names: @@ -677,10 +652,10 @@ def render_frame( """Rend une frame PNG de l'animation 2D.""" n_cameras = len(camera_names) nrows, ncols = camera_layout(n_cameras) - fig, axes = plt.subplots(nrows, ncols, figsize=(5.2 * ncols, 4.0 * nrows)) + fig, axes = plt.subplots(nrows, ncols, figsize=(5.9 * ncols, 4.8 * nrows)) axes = np.atleast_1d(axes).ravel() crop_limits = ( - compute_pose_crop_limits( + compute_pose_crop_limits_2d( compose_crop_reference_points(raw_2d, layer_2d, show), calibrations, camera_names, crop_margin ) if crop_mode == "pose" @@ -689,18 +664,23 @@ def render_frame( display_names = ([] if "raw" not in show else ["raw"]) + [ name for name in show if name != "raw" and name in layer_2d ] - styles = {name: layer_style(name, marker_size) for name in display_names} - for ax_idx, ax in enumerate(axes): if ax_idx >= n_cameras: ax.axis("off") continue cam_name = camera_names[ax_idx] width, height = calibrations[cam_name].image_size + has_image_background = False if show_images: - image_path = resolve_execution_image_path(images_root, cam_name, int(frame_numbers[frame_idx])) - if image_path is not None and image_path.exists(): - ax.imshow(plt.imread(str(image_path))) + background_image = load_camera_background_image( + images_root, + cam_name, + int(frame_numbers[frame_idx]), + image_reader=plt.imread, + ) + if background_image is not None: + draw_2d_background_image(ax, background_image, width, height) + has_image_background = True apply_2d_axis_limits( ax, crop_mode=crop_mode, @@ -710,38 +690,43 @@ def render_frame( width=width, height=height, ) - ax.set_aspect("equal") + ax.set_aspect("equal", adjustable="box") if confusion_mask is not None and confusion_mask[ax_idx, frame_idx]: ax.set_title(f"{cam_name} | face/dos ?", color="#c44e52") else: ax.set_title(cam_name) - ax.grid(alpha=0.15) - - if "raw" in styles: - draw_points_and_lines(ax, raw_2d[ax_idx, frame_idx], styles["raw"]) + hide_2d_axes(ax) + ax.tick_params(labelsize=8, length=0) + + if "raw" in display_names: + draw_points_and_lines( + ax, + raw_2d[ax_idx, frame_idx], + adapt_raw_style_for_background(layer_style("raw", marker_size), has_image_background), + ) for name in display_names: if name == "raw": continue - draw_points_and_lines(ax, layer_2d[name][ax_idx, frame_idx], styles[name]) + draw_points_and_lines(ax, layer_2d[name][ax_idx, frame_idx], layer_style(name, marker_size)) handles = [ plt.Line2D( [], [], - color=styles[key]["color"], + color=adapt_raw_style_for_background(layer_style(key, marker_size), False)["color"], marker=scatter_markers(key)["center"], - linestyle=styles[key]["linestyle"], - linewidth=styles[key]["linewidth"], - alpha=styles[key]["alpha"], - label=styles[key]["label"], + linestyle=layer_style(key, marker_size)["linestyle"], + linewidth=layer_style(key, marker_size)["linewidth"], + alpha=layer_style(key, marker_size)["alpha"], + label=layer_style(key, marker_size)["label"], ) for key in display_names ] fig.legend( handles, - [styles[key]["label"] for key in display_names], - loc="lower right", - bbox_to_anchor=(0.995, 0.02), + [layer_style(key, marker_size)["label"] for key in display_names], + loc="upper center", + bbox_to_anchor=(0.5, 0.985), ncol=1, frameon=True, ) @@ -751,7 +736,7 @@ def render_frame( ] warning = "" if not suspect_labels else f" | swap suspect: {', '.join(suspect_labels)}" fig.suptitle(f"Frame {int(frame_numbers[frame_idx])} | {phase}{warning}", fontsize=14) - fig.tight_layout() + fig.subplots_adjust(left=0.035, right=0.995, bottom=0.035, top=0.92, wspace=0.08, hspace=0.18) fig.savefig(output_path, dpi=140, bbox_inches="tight") plt.close(fig) return output_path @@ -840,6 +825,8 @@ def create_animation_parallel( def main() -> None: + """Render and export the multi-view 2D comparison animation.""" + args = parse_args() if args.dataset_dir is not None: sources = dataset_source_paths( diff --git a/animation/animate_triangulated_stick_figure.py b/animation/animate_triangulated_stick_figure.py index 714d5e8..9ca85ac 100644 --- a/animation/animate_triangulated_stick_figure.py +++ b/animation/animate_triangulated_stick_figure.py @@ -11,14 +11,14 @@ import argparse import os -from pathlib import Path import sys +from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) diff --git a/annotation/annotation_store.py b/annotation/annotation_store.py new file mode 100644 index 0000000..2fddd96 --- /dev/null +++ b/annotation/annotation_store.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Sparse multi-view 2D annotation helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np + +ANNOTATION_SCHEMA_VERSION = 1 + + +def default_annotation_path(keypoints_path: Path) -> Path: + """Return the default sparse-annotation JSON path for one keypoints file.""" + + keypoints_path = Path(keypoints_path) + dataset_stem = keypoints_path.stem + if dataset_stem.endswith("_keypoints"): + dataset_stem = dataset_stem[: -len("_keypoints")] + return keypoints_path.parent.parent / "annotations" / f"{dataset_stem}_annotations.json" + + +def empty_annotation_payload(keypoints_path: Path | None = None) -> dict[str, object]: + """Create one empty sparse annotation payload.""" + + payload: dict[str, object] = { + "schema_version": ANNOTATION_SCHEMA_VERSION, + "annotations": {}, + } + if keypoints_path is not None: + payload["keypoints_source"] = str(Path(keypoints_path)) + return payload + + +def load_annotation_payload(path: Path | str | None, *, keypoints_path: Path | None = None) -> dict[str, object]: + """Load one sparse annotation payload, or return an empty one when missing.""" + + if path is None: + return empty_annotation_payload(keypoints_path) + annotation_path = Path(path) + if not annotation_path.exists(): + return empty_annotation_payload(keypoints_path) + payload = json.loads(annotation_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"Invalid annotation payload in {annotation_path}") + payload.setdefault("schema_version", ANNOTATION_SCHEMA_VERSION) + payload.setdefault("annotations", {}) + if keypoints_path is not None: + payload.setdefault("keypoints_source", str(Path(keypoints_path))) + return payload + + +def save_annotation_payload(path: Path | str, payload: dict[str, object]) -> Path: + """Persist one sparse annotation payload.""" + + annotation_path = Path(path) + annotation_path.parent.mkdir(parents=True, exist_ok=True) + serializable = dict(payload) + serializable["schema_version"] = ANNOTATION_SCHEMA_VERSION + serializable.setdefault("annotations", {}) + annotation_path.write_text(json.dumps(serializable, indent=2, sort_keys=True), encoding="utf-8") + return annotation_path + + +def _annotation_leaf( + payload: dict[str, object], + *, + camera_name: str, + frame_number: int, + create: bool, +) -> dict[str, object] | None: + annotations = payload.setdefault("annotations", {}) + if not isinstance(annotations, dict): + raise ValueError("Annotation payload must contain a dict under 'annotations'.") + camera_annotations = annotations.get(str(camera_name)) + if camera_annotations is None: + if not create: + return None + camera_annotations = {} + annotations[str(camera_name)] = camera_annotations + if not isinstance(camera_annotations, dict): + raise ValueError(f"Annotation payload for camera {camera_name} must be a dict.") + frame_key = str(int(frame_number)) + frame_annotations = camera_annotations.get(frame_key) + if frame_annotations is None: + if not create: + return None + frame_annotations = {} + camera_annotations[frame_key] = frame_annotations + if not isinstance(frame_annotations, dict): + raise ValueError(f"Annotation payload for frame {frame_key} must be a dict.") + return frame_annotations + + +def get_annotation_point( + payload: dict[str, object], + *, + camera_name: str, + frame_number: int, + keypoint_name: str, +) -> tuple[np.ndarray, float] | tuple[None, None]: + """Return one sparse annotation point as ``(xy, score)``.""" + + frame_annotations = _annotation_leaf(payload, camera_name=camera_name, frame_number=frame_number, create=False) + if not frame_annotations: + return None, None + value = frame_annotations.get(str(keypoint_name)) + if value is None: + return None, None + xy = np.asarray(value.get("xy", [np.nan, np.nan]), dtype=float) + if xy.shape != (2,) or not np.all(np.isfinite(xy)): + return None, None + score = float(value.get("score", 1.0)) + return xy, score + + +def set_annotation_point( + payload: dict[str, object], + *, + camera_name: str, + frame_number: int, + keypoint_name: str, + xy: np.ndarray | tuple[float, float] | list[float], + score: float = 1.0, +) -> None: + """Write or replace one sparse 2D annotation.""" + + point = np.asarray(xy, dtype=float).reshape(2) + frame_annotations = _annotation_leaf(payload, camera_name=camera_name, frame_number=frame_number, create=True) + frame_annotations[str(keypoint_name)] = {"xy": [float(point[0]), float(point[1])], "score": float(score)} + + +def clear_annotation_point( + payload: dict[str, object], + *, + camera_name: str, + frame_number: int, + keypoint_name: str, +) -> None: + """Remove one sparse 2D annotation if it exists.""" + + annotations = payload.get("annotations") + if not isinstance(annotations, dict): + return + camera_annotations = annotations.get(str(camera_name)) + if not isinstance(camera_annotations, dict): + return + frame_key = str(int(frame_number)) + frame_annotations = camera_annotations.get(frame_key) + if not isinstance(frame_annotations, dict): + return + frame_annotations.pop(str(keypoint_name), None) + if not frame_annotations: + camera_annotations.pop(frame_key, None) + if not camera_annotations: + annotations.pop(str(camera_name), None) + + +def apply_annotations_to_pose_arrays( + *, + keypoints: np.ndarray, + scores: np.ndarray, + camera_names: list[str], + frames: np.ndarray, + keypoint_names: list[str], + payload: dict[str, object] | None, +) -> tuple[np.ndarray, np.ndarray]: + """Overlay sparse annotations onto dense 2D arrays.""" + + if payload is None: + return np.asarray(keypoints, dtype=float), np.asarray(scores, dtype=float) + annotated_keypoints = np.asarray(keypoints, dtype=float).copy() + annotated_scores = np.asarray(scores, dtype=float).copy() + frame_to_index = {int(frame): idx for idx, frame in enumerate(np.asarray(frames, dtype=int))} + keypoint_to_index = {str(name): idx for idx, name in enumerate(keypoint_names)} + annotations = payload.get("annotations", {}) + if not isinstance(annotations, dict): + return annotated_keypoints, annotated_scores + for cam_idx, camera_name in enumerate(camera_names): + camera_annotations = annotations.get(str(camera_name)) + if not isinstance(camera_annotations, dict): + continue + for frame_key, marker_annotations in camera_annotations.items(): + if not isinstance(marker_annotations, dict): + continue + frame_idx = frame_to_index.get(int(frame_key)) + if frame_idx is None: + continue + for keypoint_name, value in marker_annotations.items(): + kp_idx = keypoint_to_index.get(str(keypoint_name)) + if kp_idx is None or not isinstance(value, dict): + continue + xy = np.asarray(value.get("xy", [np.nan, np.nan]), dtype=float) + if xy.shape != (2,) or not np.all(np.isfinite(xy)): + continue + annotated_keypoints[cam_idx, frame_idx, kp_idx] = xy + annotated_scores[cam_idx, frame_idx, kp_idx] = float(value.get("score", 1.0)) + return annotated_keypoints, annotated_scores diff --git a/annotation/frame_navigation.py b/annotation/frame_navigation.py new file mode 100644 index 0000000..32af6b6 --- /dev/null +++ b/annotation/frame_navigation.py @@ -0,0 +1,104 @@ +"""Helpers for filtered frame navigation inside the Annotation tab.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +from judging.execution import available_execution_image_frames + + +def resolve_annotation_frame_filter_mode( + selected_label: str | None, + options: dict[str, str], +) -> str: + """Resolve one UI label/value to a stable frame filter mode.""" + + selected_label = str(selected_label or "").strip() + for mode, label in options.items(): + if selected_label == label or selected_label == mode: + return str(mode) + return "all" + + +def fallback_annotation_filtered_indices( + n_frames: int, + filtered_indices: list[int] | tuple[int, ...] | np.ndarray | None, +) -> list[int]: + """Return a safe filtered subset, falling back to all frames when empty.""" + + fallback = list(range(max(0, int(n_frames)))) + if filtered_indices is None: + return fallback + filtered = [int(idx) for idx in filtered_indices] + return filtered if filtered else fallback + + +def frame_has_available_image( + frame_number: int, + camera_names: list[str], + available_by_camera: dict[str, set[int]] | None, +) -> bool: + """Return whether one frame is available in at least one selected camera.""" + + if not camera_names or not available_by_camera: + return False + frame_number = int(frame_number) + return any(frame_number in available_by_camera.get(str(camera_name), set()) for camera_name in camera_names) + + +def navigable_annotation_frame_local_indices( + frames: np.ndarray, + filtered_indices: list[int] | tuple[int, ...] | np.ndarray | None, + camera_names: list[str], + images_root: Path | None, +) -> list[int]: + """Return the filtered subset further restricted to frames with images when possible.""" + + frames = np.asarray(frames, dtype=int) + filtered = fallback_annotation_filtered_indices(len(frames), filtered_indices) + if images_root is None or not camera_names: + return filtered + available_by_camera = available_execution_image_frames(images_root, camera_names) + with_images = [ + int(local_idx) + for local_idx in filtered + if 0 <= int(local_idx) < len(frames) + and frame_has_available_image(int(frames[int(local_idx)]), camera_names, available_by_camera) + ] + return with_images or filtered + + +def step_frame_index_within_subset( + current_index: int, + delta: int, + candidate_indices: list[int] | tuple[int, ...] | np.ndarray | None, +) -> int | None: + """Move cyclically within one sparse subset of local frame indices.""" + + if not candidate_indices: + return None + candidates = sorted(int(idx) for idx in candidate_indices) + current_index = int(current_index) + if current_index in candidates: + current_pos = candidates.index(current_index) + next_pos = (current_pos + (-1 if int(delta) < 0 else 1)) % len(candidates) + return int(candidates[next_pos]) + if int(delta) < 0: + previous = [idx for idx in candidates if idx < current_index] + return int(previous[-1] if previous else candidates[-1]) + following = [idx for idx in candidates if idx > current_index] + return int(following[0] if following else candidates[0]) + + +def clamp_index_to_subset( + current_index: int, candidate_indices: list[int] | tuple[int, ...] | np.ndarray | None +) -> int | None: + """Return the current index if valid, else the first candidate.""" + + if not candidate_indices: + return None + candidates = [int(idx) for idx in candidate_indices] + current_index = int(current_index) + return current_index if current_index in candidates else int(candidates[0]) diff --git a/annotation/kinematic_assist.py b/annotation/kinematic_assist.py new file mode 100644 index 0000000..e18410b --- /dev/null +++ b/annotation/kinematic_assist.py @@ -0,0 +1,735 @@ +"""Kinematic-assist helpers shared by the Annotation tab.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from vitpose_ekf_pipeline import ( + COCO17, + KP_INDEX, + MultiViewKinematicEKF, + PoseData, + ReconstructionResult, + apply_measurement_update_batch, + canonicalize_model_q_rotation_branches, + compute_biorbd_kalman_initial_state, + stack_measurement_blocks, +) + + +@dataclass(frozen=True) +class AnnotationKinematicStateInfo: + """Describe the exact or nearest saved kinematic state for one frame.""" + + state: np.ndarray | None + source_frame: int | None + is_exact: bool + + +def _canonicalize_annotation_q(model, q_values: np.ndarray) -> np.ndarray: + """Canonicalize q when the model exposes full segment metadata, else keep q as-is.""" + + q_values = np.asarray(q_values, dtype=float).reshape(-1) + if not hasattr(model, "nbSegment"): + return np.array(q_values, copy=True) + try: + return canonicalize_model_q_rotation_branches(model, q_values) + except Exception: + return np.array(q_values, copy=True) + + +def resolve_annotation_kinematic_state_info( + frame_states: dict[tuple[str, int], np.ndarray] | None, + *, + model_label: str, + frame_number: int, + model, +) -> AnnotationKinematicStateInfo: + """Return the normalized exact or nearest saved state for one model/frame.""" + + model_label = str(model_label).strip() + if not model_label: + return AnnotationKinematicStateInfo(state=None, source_frame=None, is_exact=False) + frame_states = frame_states or {} + exact = frame_states.get((model_label, int(frame_number))) + if exact is not None: + normalized = normalize_annotation_kinematic_state(model, exact) + return AnnotationKinematicStateInfo( + state=normalized, + source_frame=int(frame_number) if normalized is not None else None, + is_exact=normalized is not None, + ) + candidates = [ + ( + abs(int(saved_frame) - int(frame_number)), + int(saved_frame), + normalize_annotation_kinematic_state(model, saved_state), + ) + for (saved_model, saved_frame), saved_state in frame_states.items() + if str(saved_model) == model_label + ] + candidates = [item for item in candidates if item[2] is not None] + if not candidates: + return AnnotationKinematicStateInfo(state=None, source_frame=None, is_exact=False) + candidates.sort(key=lambda item: item[0]) + _distance, source_frame, source_state = candidates[0] + return AnnotationKinematicStateInfo( + state=np.asarray(source_state, dtype=float), + source_frame=int(source_frame), + is_exact=False, + ) + + +def store_annotation_kinematic_state( + frame_states: dict[tuple[str, int], np.ndarray] | None, + *, + model_label: str, + frame_number: int, + model, + state: np.ndarray, +) -> np.ndarray: + """Normalize one state, store it in-place, and return the stored value.""" + + normalized_state = normalize_annotation_kinematic_state(model, state) + if normalized_state is None: + raise ValueError("Invalid kinematic state.") + if frame_states is None: + raise ValueError("frame_states must be initialized before storing.") + frame_states[(str(model_label).strip(), int(frame_number))] = np.asarray(normalized_state, dtype=float) + return np.asarray(normalized_state, dtype=float) + + +def annotation_relevant_q_prefixes(keypoint_name: str) -> tuple[str, ...]: + """Return the segment prefixes most relevant to one annotated keypoint.""" + + name = str(keypoint_name) + prefixes = ["TRUNK", "LOWER_TRUNK", "UPPER_BACK"] + if name.startswith("left_"): + prefixes.extend( + ["LEFT_UPPER_ARM", "LEFT_LOWER_ARM"] + if "shoulder" in name or "elbow" in name or "wrist" in name + else ["LEFT_THIGH", "LEFT_SHANK"] + ) + elif name.startswith("right_"): + prefixes.extend( + ["RIGHT_UPPER_ARM", "RIGHT_LOWER_ARM"] + if "shoulder" in name or "elbow" in name or "wrist" in name + else ["RIGHT_THIGH", "RIGHT_SHANK"] + ) + else: + prefixes.append("HEAD") + return tuple(prefixes) + + +def annotation_relevant_q_mask( + q_names: list[str] | np.ndarray, + keypoint_name: str, +) -> np.ndarray: + """Return a boolean mask selecting the DoFs relevant to one annotated keypoint.""" + + prefixes = annotation_relevant_q_prefixes(keypoint_name) + return np.asarray( + [any(str(q_name).startswith(f"{prefix}:") for prefix in prefixes) for q_name in q_names], + dtype=bool, + ) + + +def constrain_annotation_state_to_mask( + state: np.ndarray, + reference_state: np.ndarray, + active_q_mask: np.ndarray | None, +) -> np.ndarray: + """Freeze inactive generalized coordinates and their derivatives to the reference state.""" + + state = np.asarray(state, dtype=float).reshape(-1) + reference_state = np.asarray(reference_state, dtype=float).reshape(-1) + if active_q_mask is None: + return np.array(state, copy=True) + active_q_mask = np.asarray(active_q_mask, dtype=bool).reshape(-1) + if active_q_mask.size == 0: + return np.array(reference_state, copy=True) + nq = active_q_mask.size + constrained = np.array(state, copy=True) + inactive = ~active_q_mask + constrained[:nq][inactive] = reference_state[:nq][inactive] + if constrained.size >= 2 * nq: + constrained[nq : 2 * nq][inactive] = reference_state[nq : 2 * nq][inactive] + if constrained.size >= 3 * nq: + constrained[2 * nq : 3 * nq][inactive] = reference_state[2 * nq : 3 * nq][inactive] + return constrained + + +def annotation_blend_q_by_relevance( + q_names: list[str] | np.ndarray, + previous_q: np.ndarray | None, + estimated_q: np.ndarray, + keypoint_name: str, +) -> np.ndarray: + """Keep unrelated DoFs from the previous state and update only the relevant subtree.""" + + estimated_q = np.asarray(estimated_q, dtype=float).reshape(-1) + if previous_q is None: + return estimated_q + previous_q = np.asarray(previous_q, dtype=float).reshape(-1) + if previous_q.shape != estimated_q.shape: + return estimated_q + prefixes = annotation_relevant_q_prefixes(keypoint_name) + blended = np.array(previous_q, copy=True) + for idx, q_name in enumerate(q_names): + q_name = str(q_name) + if any(q_name.startswith(f"{prefix}:") for prefix in prefixes): + blended[idx] = estimated_q[idx] + return blended + + +def annotation_reconstruction_from_points( + points_3d: np.ndarray, frame_number: int, n_cameras: int +) -> ReconstructionResult: + """Wrap one annotation frame into a minimal ReconstructionResult.""" + + points_3d = np.asarray(points_3d, dtype=float).reshape(1, len(COCO17), 3) + return ReconstructionResult( + frames=np.asarray([int(frame_number)], dtype=int), + points_3d=points_3d, + mean_confidence=np.full((1, len(COCO17)), np.nan, dtype=float), + reprojection_error=np.full((1, len(COCO17)), np.nan, dtype=float), + reprojection_error_per_view=np.full((1, len(COCO17), int(n_cameras)), np.nan, dtype=float), + multiview_coherence=np.full((1, len(COCO17), int(n_cameras)), np.nan, dtype=float), + epipolar_coherence=np.full((1, len(COCO17), int(n_cameras)), np.nan, dtype=float), + triangulation_coherence=np.full((1, len(COCO17), int(n_cameras)), np.nan, dtype=float), + excluded_views=np.ones((1, len(COCO17), int(n_cameras)), dtype=bool), + coherence_method="epipolar_fast_framewise", + ) + + +def annotation_state_from_q( + model, + q_values: np.ndarray, +) -> np.ndarray: + """Expand one ``q`` vector into the stacked ``[q, qdot, qddot]`` state.""" + + nq = int(model.nbQ()) + q_values = _canonicalize_annotation_q(model, np.asarray(q_values, dtype=float).reshape(nq)) + return np.concatenate((q_values, np.zeros(nq, dtype=float), np.zeros(nq, dtype=float))) + + +def normalize_annotation_kinematic_state(model, state: np.ndarray | None) -> np.ndarray | None: + """Normalize one saved annotation-assist state to the canonical EKF layout.""" + + if state is None: + return None + nq = int(model.nbQ()) + array = np.asarray(state, dtype=float).reshape(-1) + if array.size == nq: + return annotation_state_from_q(model, array) + if array.size != 3 * nq: + return None + normalized = np.array(array, copy=True) + normalized[:nq] = _canonicalize_annotation_q(model, normalized[:nq]) + return normalized + + +def propagate_annotation_kinematic_state( + model, + state: np.ndarray, + *, + dt: float, + frame_delta: int, +) -> np.ndarray: + """Propagate one annotation kinematic state with constant acceleration.""" + + nq = int(model.nbQ()) + state = normalize_annotation_kinematic_state(model, state) + if state is None: + raise ValueError("Invalid kinematic state.") + total_dt = float(dt) * float(frame_delta) + propagated = np.array(state, copy=True) + q = propagated[:nq] + qdot = propagated[nq : 2 * nq] + qddot = propagated[2 * nq :] + propagated[:nq] = q + total_dt * qdot + 0.5 * total_dt * total_dt * qddot + propagated[nq : 2 * nq] = qdot + total_dt * qddot + propagated[:nq] = _canonicalize_annotation_q(model, propagated[:nq]) + return propagated + + +def refine_annotation_q_with_local_ekf( + *, + model, + calibrations: dict[str, object], + pose_data: PoseData, + frame_number: int, + seed_state: np.ndarray, + fps: float, + passes: int, + measurement_noise_scale: float = 1.0, + process_noise_scale: float = 1.0, + epipolar_threshold_px: float, + q_names: list[str] | np.ndarray | None = None, + keypoint_name: str | None = None, +) -> tuple[np.ndarray, dict[str, object]]: + """Refine one frame from sparse 2D annotations using local EKF2D corrections.""" + + nq = int(model.nbQ()) + seed_state = np.asarray(seed_state, dtype=float).reshape(-1) + if seed_state.size == nq: + seed_state = np.concatenate((seed_state, np.zeros(nq, dtype=float), np.zeros(nq, dtype=float))) + elif seed_state.size != 3 * nq: + raise ValueError("seed_state must contain either q or [q, qdot, qddot].") + seed_state = np.array(seed_state, copy=True) + seed_state[:nq] = _canonicalize_annotation_q(model, seed_state[:nq]) + active_q_mask = ( + annotation_relevant_q_mask(q_names or [], keypoint_name) + if keypoint_name is not None and q_names is not None + else None + ) + reconstruction = annotation_reconstruction_from_points( + np.full((len(COCO17), 3), np.nan, dtype=float), + frame_number=frame_number, + n_cameras=len(pose_data.camera_names), + ) + ekf = MultiViewKinematicEKF( + model=model, + calibrations={str(name): calibrations[str(name)] for name in pose_data.camera_names}, + pose_data=pose_data, + reconstruction=reconstruction, + dt=1.0 / float(fps), + measurement_noise_scale=float(measurement_noise_scale), + process_noise_scale=float(process_noise_scale), + min_frame_coherence_for_update=0.0, + skip_low_coherence_updates=False, + coherence_confidence_floor=0.0, + epipolar_threshold_px=float(epipolar_threshold_px), + enable_dof_locking=False, + root_flight_dynamics=False, + ) + state = np.array(seed_state, copy=True) + covariance = np.eye(ekf.nx) * 1e-2 + diagnostics: dict[str, object] = { + "method": "annotation_local_ekf", + "requested_passes": int(max(1, passes)), + "completed_passes": 0, + "update_statuses": [], + "converged": False, + "used_fallback": False, + } + for _pass_idx in range(max(1, int(passes))): + state_iter = np.array(state, copy=True) + state_iter[:nq] = _canonicalize_annotation_q(model, state_iter[:nq]) + predicted_state, predicted_covariance = ekf.predict(state_iter, covariance, 0) + corrected_state, corrected_covariance, update_status = ekf.update(predicted_state, predicted_covariance, 0) + diagnostics["update_statuses"].append(str(update_status)) + diagnostics["completed_passes"] = int(diagnostics["completed_passes"]) + 1 + if update_status != "corrected" or not np.all(np.isfinite(corrected_state)): + diagnostics["used_fallback"] = True + diagnostics["reason"] = f"local_{update_status}" + return np.array(seed_state, copy=True), diagnostics + corrected_state = np.asarray(corrected_state, dtype=float).reshape(3 * nq) + corrected_state = constrain_annotation_state_to_mask(corrected_state, seed_state, active_q_mask) + corrected_state[:nq] = _canonicalize_annotation_q(model, corrected_state[:nq]) + q_delta_norm = float(np.linalg.norm(corrected_state[:nq] - state_iter[:nq])) + diagnostics.setdefault("q_delta_norms", []).append(q_delta_norm) + state = corrected_state + covariance = corrected_covariance + if q_delta_norm <= 1e-6: + diagnostics["converged"] = True + break + return np.array(state, copy=True), diagnostics + + +def refine_annotation_q_with_direct_measurements( + *, + model, + calibrations: dict[str, object], + pose_data: PoseData, + seed_state: np.ndarray, + passes: int, + measurement_std_px: float = 2.0, + q_names: list[str] | np.ndarray | None = None, + keypoint_name: str | None = None, +) -> tuple[np.ndarray, dict[str, object]]: + """Apply a direct image-space correction from sparse annotated 2D points. + + This is a lightweight fallback/polish pass for annotation clicks when the + regular local EKF path is too conservative or rejects the update. + """ + + nq = int(model.nbQ()) + nx = 3 * nq + seed_state = np.asarray(seed_state, dtype=float).reshape(-1) + if seed_state.size == nq: + seed_state = np.concatenate((seed_state, np.zeros(nq, dtype=float), np.zeros(nq, dtype=float))) + elif seed_state.size != nx: + raise ValueError("seed_state must contain either q or [q, qdot, qddot].") + marker_pairs = [ + (marker_idx, KP_INDEX[marker_name.to_string()]) + for marker_idx, marker_name in enumerate(model.markerNames()) + if marker_name.to_string() in KP_INDEX + ] + marker_pair_keypoint_indices = np.asarray([kp_idx for _, kp_idx in marker_pairs], dtype=int) + if marker_pair_keypoint_indices.size == 0: + return np.array(seed_state, copy=True), { + "method": "annotation_direct_measurements", + "requested_passes": int(max(1, passes)), + "completed_passes": 0, + "used_fallback": True, + "reason": "no_marker_pairs", + } + + state = np.array(seed_state, copy=True) + state[:nq] = _canonicalize_annotation_q(model, state[:nq]) + active_q_mask = ( + annotation_relevant_q_mask(q_names or [], keypoint_name) + if keypoint_name is not None and q_names is not None + else None + ) + covariance = np.eye(nx) * 1e-2 + identity_x = np.eye(nx) + diagnostics: dict[str, object] = { + "method": "annotation_direct_measurements", + "requested_passes": int(max(1, passes)), + "completed_passes": 0, + "used_fallback": False, + "converged": False, + } + + for _pass_idx in range(max(1, int(passes))): + q = state[:nq] + marker_positions_all = model.markers(q) + marker_jacobians_all = model.markersJacobian(q) + marker_points = [marker_positions_all[marker_idx].to_array() for marker_idx, _ in marker_pairs] + marker_jacobians = [marker_jacobians_all[marker_idx].to_array() for marker_idx, _ in marker_pairs] + marker_points_array = np.asarray(marker_points, dtype=float) + marker_jacobians_array = np.asarray(marker_jacobians, dtype=float) + finite_marker_points = np.all(np.isfinite(marker_points_array), axis=1) + measurement_blocks: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = [] + + for cam_idx, camera_name in enumerate(pose_data.camera_names): + calibration = calibrations.get(str(camera_name)) + if calibration is None: + continue + frame_keypoints = np.asarray(pose_data.keypoints[cam_idx, 0], dtype=float) + frame_scores = np.asarray(pose_data.scores[cam_idx, 0], dtype=float) + valid_keypoints = ( + np.all(np.isfinite(frame_keypoints), axis=1) & np.isfinite(frame_scores) & (frame_scores > 0) + ) + if not np.any(valid_keypoints): + continue + valid_pairs = finite_marker_points & valid_keypoints[marker_pair_keypoint_indices] + if not np.any(valid_pairs): + continue + pair_indices = np.flatnonzero(valid_pairs) + keypoint_indices = marker_pair_keypoint_indices[pair_indices] + projected_uv, projected_jac = calibration.project_points_and_jacobians(marker_points_array[pair_indices]) + H_q_blocks = np.einsum("mab,mbq->maq", projected_jac, marker_jacobians_array[pair_indices], optimize=True) + finite_pairs = np.all(np.isfinite(projected_uv), axis=1) & np.all( + np.isfinite(H_q_blocks.reshape(H_q_blocks.shape[0], -1)), axis=1 + ) + if not np.any(finite_pairs): + continue + keypoint_indices = keypoint_indices[finite_pairs] + projected_uv = projected_uv[finite_pairs] + H_q_blocks = H_q_blocks[finite_pairs] + measured_points = frame_keypoints[keypoint_indices] + measured_scores = frame_scores[keypoint_indices] + selected_mask = ( + np.all(np.isfinite(measured_points), axis=1) & np.isfinite(measured_scores) & (measured_scores > 0) + ) + if not np.any(selected_mask): + continue + selected_scores = np.maximum(measured_scores[selected_mask], 1e-3) + H_q_selected = np.asarray(H_q_blocks[selected_mask], dtype=float) + if active_q_mask is not None: + H_q_selected[..., ~active_q_mask] = 0.0 + variances = np.repeat((float(measurement_std_px) / selected_scores) ** 2, 2).astype(float, copy=False) + measurement_blocks.append( + ( + measured_points[selected_mask].reshape(-1), + projected_uv[selected_mask].reshape(-1), + H_q_selected.reshape(-1, nq), + variances, + ) + ) + + stacked = stack_measurement_blocks(measurement_blocks, nq) + if stacked is None: + diagnostics["used_fallback"] = True + diagnostics["reason"] = "no_measurement" + return np.array(seed_state, copy=True), diagnostics + update_result = apply_measurement_update_batch( + predicted_state=state, + predicted_covariance=covariance, + z=stacked[0], + h=stacked[1], + H_q=stacked[2], + R_diag_array=stacked[3], + nq=nq, + identity_x=identity_x, + ) + if update_result is None: + diagnostics["used_fallback"] = True + diagnostics["reason"] = "singular_gain" + return np.array(seed_state, copy=True), diagnostics + updated_state, covariance = update_result + updated_state = np.asarray(updated_state, dtype=float).reshape(nx) + updated_state = constrain_annotation_state_to_mask(updated_state, seed_state, active_q_mask) + updated_state[:nq] = _canonicalize_annotation_q(model, updated_state[:nq]) + q_delta_norm = float(np.linalg.norm(updated_state[:nq] - state[:nq])) + diagnostics.setdefault("q_delta_norms", []).append(q_delta_norm) + diagnostics["completed_passes"] = int(diagnostics["completed_passes"]) + 1 + state = updated_state + if q_delta_norm <= 1e-7: + diagnostics["converged"] = True + break + + return np.array(state, copy=True), diagnostics + + +def refine_annotation_q_with_marker_lm( + *, + model, + points_3d: np.ndarray, + frame_number: int, + n_cameras: int, + seed_state: np.ndarray | None = None, + passes: int = 30, + initial_damping: float = 1e-2, + damping_up: float = 10.0, + damping_down: float = 0.3, + tolerance: float = 1e-6, + q_names: list[str] | np.ndarray | None = None, + keypoint_name: str | None = None, +) -> tuple[np.ndarray, dict[str, object]]: + """Refine q against triangulated 3D markers with a custom LM solver.""" + + nq = int(model.nbQ()) + points_3d = np.asarray(points_3d, dtype=float).reshape(len(COCO17), 3) + reconstruction = annotation_reconstruction_from_points(points_3d, frame_number=frame_number, n_cameras=n_cameras) + if seed_state is None: + seed_state, _diagnostics = compute_biorbd_kalman_initial_state( + model, reconstruction, method="root_pose_zero_rest" + ) + state = normalize_annotation_kinematic_state(model, seed_state) + if state is None: + raise ValueError("Invalid seed_state for marker LM.") + + marker_pairs = [ + (marker_idx, KP_INDEX[marker_name.to_string()]) + for marker_idx, marker_name in enumerate(model.markerNames()) + if marker_name.to_string() in KP_INDEX + ] + if not marker_pairs: + return np.array(state, copy=True), { + "method": "annotation_marker_lm", + "requested_passes": int(max(1, passes)), + "completed_passes": 0, + "used_fallback": True, + "reason": "no_marker_pairs", + } + + active_q_mask = ( + annotation_relevant_q_mask(q_names or [], keypoint_name) + if keypoint_name is not None and q_names is not None + else None + ) + pair_indices = np.asarray([kp_idx for _, kp_idx in marker_pairs], dtype=int) + valid_pairs = np.all(np.isfinite(points_3d[pair_indices]), axis=1) + if not np.any(valid_pairs): + return np.array(state, copy=True), { + "method": "annotation_marker_lm", + "requested_passes": int(max(1, passes)), + "completed_passes": 0, + "used_fallback": True, + "reason": "no_valid_targets", + } + marker_pairs = [marker_pairs[idx] for idx in np.flatnonzero(valid_pairs)] + target_points = np.asarray([points_3d[kp_idx] for _, kp_idx in marker_pairs], dtype=float) + + diagnostics: dict[str, object] = { + "method": "annotation_marker_lm", + "requested_passes": int(max(1, passes)), + "completed_passes": 0, + "used_fallback": False, + "converged": False, + "cost_history": [], + "accepted_steps": [], + } + + damping = float(initial_damping) + state = np.array(state, copy=True) + q = np.asarray(state[:nq], dtype=float) + + def _cost_and_system(q_values: np.ndarray): + q_values = _canonicalize_annotation_q(model, q_values) + marker_positions_all = model.markers(q_values) + marker_jacobians_all = model.markersJacobian(q_values) + predicted = np.asarray([marker_positions_all[idx].to_array() for idx, _ in marker_pairs], dtype=float) + jacobians = np.asarray([marker_jacobians_all[idx].to_array() for idx, _ in marker_pairs], dtype=float) + residual = (predicted - target_points).reshape(-1) + H = jacobians.reshape(-1, nq) + finite_rows = np.isfinite(residual) & np.all(np.isfinite(H), axis=1) + residual = residual[finite_rows] + H = H[finite_rows] + if active_q_mask is not None and H.size: + H[:, ~np.asarray(active_q_mask, dtype=bool)] = 0.0 + cost = 0.5 * float(np.dot(residual, residual)) + return cost, residual, H + + cost, residual, H = _cost_and_system(q) + diagnostics["cost_history"].append(cost) + if not np.isfinite(cost): + diagnostics["used_fallback"] = True + diagnostics["reason"] = "non_finite_initial_cost" + return np.array(state, copy=True), diagnostics + + for _ in range(max(1, int(passes))): + diagnostics["completed_passes"] = int(diagnostics["completed_passes"]) + 1 + if H.size == 0: + diagnostics["used_fallback"] = True + diagnostics["reason"] = "empty_system" + return np.array(state, copy=True), diagnostics + jt_j = H.T @ H + gradient = H.T @ residual + diagonal = np.diag(jt_j).copy() + diagonal[~np.isfinite(diagonal) | (diagonal <= 1e-8)] = 1.0 + accepted = False + for _trial in range(8): + normal_matrix = jt_j + damping * np.diag(diagonal) + try: + delta_q = -np.linalg.solve(normal_matrix, gradient) + except np.linalg.LinAlgError: + damping *= float(damping_up) + continue + if active_q_mask is not None: + delta_q = np.asarray(delta_q, dtype=float) + delta_q[~np.asarray(active_q_mask, dtype=bool)] = 0.0 + trial_q = _canonicalize_annotation_q(model, q + delta_q) + trial_cost, trial_residual, trial_H = _cost_and_system(trial_q) + if np.isfinite(trial_cost) and trial_cost < cost: + q = trial_q + cost = float(trial_cost) + residual = np.asarray(trial_residual, dtype=float) + H = np.asarray(trial_H, dtype=float) + damping = max(1e-9, damping * float(damping_down)) + diagnostics["accepted_steps"].append(True) + diagnostics["cost_history"].append(cost) + accepted = True + if float(np.linalg.norm(delta_q)) <= float(tolerance): + diagnostics["converged"] = True + break + damping *= float(damping_up) + if not accepted: + diagnostics["accepted_steps"].append(False) + diagnostics["used_fallback"] = True + diagnostics["reason"] = "no_descent_step" + break + if diagnostics["converged"]: + break + + state[:nq] = _canonicalize_annotation_q(model, q) + state[nq : 3 * nq] = 0.0 + return np.array(state, copy=True), diagnostics + + +def refine_annotation_window_states( + *, + model, + calibrations: dict[str, object], + pose_data_by_frame: dict[int, PoseData], + center_frame_number: int, + seed_state: np.ndarray, + fps: float, + passes: int, + epipolar_threshold_px: float, + q_names: list[str] | np.ndarray | None = None, +) -> tuple[dict[int, np.ndarray], dict[str, object]]: + """Refine a short temporal window of sparse annotation states around one frame. + + The window is initialized from the center state, propagated to neighboring + frames with the constant-acceleration model, then corrected frame-by-frame + where annotated 2D measurements exist. + """ + + frame_numbers = sorted(int(frame_number) for frame_number in pose_data_by_frame) + if not frame_numbers: + return {}, { + "method": "annotation_window_local_ekf", + "requested_passes": int(max(1, passes)), + "completed_frames": 0, + "used_fallback": True, + "reason": "no_frames", + } + + center_frame_number = int(center_frame_number) + if center_frame_number not in frame_numbers: + raise ValueError("center_frame_number must be present in pose_data_by_frame.") + + center_state = normalize_annotation_kinematic_state(model, seed_state) + if center_state is None: + raise ValueError("Invalid seed_state for local annotation window.") + + refined_states: dict[int, np.ndarray] = {center_frame_number: np.asarray(center_state, dtype=float)} + diagnostics: dict[str, object] = { + "method": "annotation_window_local_ekf", + "requested_passes": int(max(1, passes)), + "completed_frames": 0, + "used_fallback": False, + "frame_statuses": {}, + } + center_idx = frame_numbers.index(center_frame_number) + + def _refine_one_frame(frame_number: int, seed: np.ndarray) -> np.ndarray: + pose_data = pose_data_by_frame.get(int(frame_number)) + if pose_data is None or float(np.sum(np.asarray(pose_data.scores) > 0.0)) <= 0.0: + diagnostics["frame_statuses"][int(frame_number)] = "propagated" + return np.asarray(seed, dtype=float) + refined_state, frame_diag = refine_annotation_q_with_local_ekf( + model=model, + calibrations=calibrations, + pose_data=pose_data, + frame_number=int(frame_number), + seed_state=np.asarray(seed, dtype=float), + fps=float(fps), + passes=max(1, int(passes)), + measurement_noise_scale=1.0, + process_noise_scale=1.0, + epipolar_threshold_px=float(epipolar_threshold_px), + q_names=q_names, + keypoint_name=None, + ) + diagnostics["completed_frames"] = int(diagnostics["completed_frames"]) + 1 + diagnostics["frame_statuses"][int(frame_number)] = str( + "fallback" if frame_diag.get("used_fallback") else "corrected" + ) + if bool(frame_diag.get("used_fallback")): + return np.asarray(seed, dtype=float) + return np.asarray(refined_state, dtype=float) + + state = refined_states[center_frame_number] + for frame_number in frame_numbers[center_idx + 1 :]: + previous_frame = frame_numbers[frame_numbers.index(frame_number) - 1] + state = propagate_annotation_kinematic_state( + model, + state, + dt=1.0 / float(fps), + frame_delta=int(frame_number) - int(previous_frame), + ) + state = _refine_one_frame(frame_number, state) + refined_states[int(frame_number)] = np.asarray(state, dtype=float) + + state = refined_states[center_frame_number] + for reverse_idx in range(center_idx - 1, -1, -1): + frame_number = frame_numbers[reverse_idx] + next_frame = frame_numbers[reverse_idx + 1] + state = propagate_annotation_kinematic_state( + model, + state, + dt=1.0 / float(fps), + frame_delta=int(frame_number) - int(next_frame), + ) + state = _refine_one_frame(frame_number, state) + refined_states[int(frame_number)] = np.asarray(state, dtype=float) + + return refined_states, diagnostics diff --git a/annotation/preview_render.py b/annotation/preview_render.py new file mode 100644 index 0000000..f43d7f5 --- /dev/null +++ b/annotation/preview_render.py @@ -0,0 +1,217 @@ +"""Rendering helpers for the Matplotlib-based Annotation preview.""" + +from __future__ import annotations + +from collections.abc import Callable + +import matplotlib.pyplot as plt +import numpy as np + + +def annotation_frame_label_text( + *, + frame_idx: int, + frame_number: int, + mode: str, + filtered_indices: list[int], + mode_labels: dict[str, str], +) -> str: + """Build the compact annotation frame label for the current filter mode.""" + + if str(mode) == "all": + return f"frame {int(frame_idx)} | raw {int(frame_number)}" + filtered_position = filtered_indices.index(int(frame_idx)) + 1 if int(frame_idx) in filtered_indices else 0 + return ( + f"frame {int(frame_idx)} | raw {int(frame_number)} | " + f"{mode_labels[str(mode)]} {filtered_position}/{len(filtered_indices)}" + ) + + +def render_annotation_camera_view( + ax, + *, + ax_idx: int, + camera_name: str, + frame_idx: int, + frame_number: int, + width: float, + height: float, + crop_mode: str, + crop_limits: dict[str, np.ndarray], + background_image: np.ndarray | None, + current_marker: str, + current_color, + keypoint_names: list[str] | tuple[str, ...], + kp_index: dict[str, int], + annotation_xy_getter: Callable[[str, int, str], np.ndarray | None], + pending_reprojection_points: dict[tuple[str, int, str], np.ndarray], + marker_color_getter: Callable[[str], object], + marker_shape_getter: Callable[[str], str], + draw_background_fn: Callable[..., None], + apply_axis_limits_fn: Callable[..., None], + hide_axes_fn: Callable[[object], None], + draw_skeleton_fn: Callable[..., None], + draw_upper_back_fn: Callable[..., None], + kinematic_projected_points: np.ndarray | None, + kinematic_segmented_back_projected: dict[str, np.ndarray] | None, + reference_projected_points: np.ndarray | None, + reference_projected_label: str | None, + reference_projected_color, + motion_prior_enabled: bool, + motion_prior_diameter: float, + motion_prior_center_fn: Callable[[np.ndarray | None, np.ndarray | None], np.ndarray | None], +) -> list[dict[str, object]]: + """Render one camera subplot for the annotation preview and return hover entries.""" + + hover_entries: list[dict[str, object]] = [] + if background_image is not None: + draw_background_fn(ax, background_image, width=width, height=height) + apply_axis_limits_fn( + ax, + crop_mode=crop_mode, + crop_limits=crop_limits, + cam_name=camera_name, + frame_idx=frame_idx, + width=width, + height=height, + ) + ax.set_aspect("equal", adjustable="box") + ax.set_title(camera_name) + ax.grid(False) + hide_axes_fn(ax) + + for keypoint_name in keypoint_names: + annotated_xy = annotation_xy_getter(camera_name, frame_number, keypoint_name) + if annotated_xy is None: + continue + marker_color = marker_color_getter(keypoint_name) + display_color = "black" if keypoint_name == current_marker else marker_color + ax.scatter( + [annotated_xy[0]], + [annotated_xy[1]], + s=(72 if keypoint_name == current_marker else 34), + c=[display_color], + marker=marker_shape_getter(keypoint_name), + linewidths=(1.7 if keypoint_name == current_marker else 1.0), + zorder=5, + ) + hover_entries.append( + { + "xy": np.asarray(annotated_xy, dtype=float), + "keypoint_name": str(keypoint_name), + "source": "annotated", + } + ) + + if kinematic_projected_points is not None and ax_idx < kinematic_projected_points.shape[0]: + projected_points = np.asarray(kinematic_projected_points[ax_idx, 0], dtype=float) + draw_skeleton_fn( + ax, + projected_points, + "#00b8d9", + "Model reproj", + marker_size=3.2, + marker_fill=False, + marker_edge_width=0.8, + line_alpha=0.32, + line_style="--", + line_width_scale=0.62, + ) + for keypoint_name in keypoint_names: + projected_xy = np.asarray(projected_points[kp_index[keypoint_name]], dtype=float) + if not np.all(np.isfinite(projected_xy)): + continue + hover_entries.append( + { + "xy": projected_xy, + "keypoint_name": str(keypoint_name), + "source": "model reproj", + } + ) + segmented = kinematic_segmented_back_projected or {} + draw_upper_back_fn( + ax, + hip_triangle_2d=( + segmented.get("hip_triangle", np.empty((0, 0, 0)))[ax_idx, 0] if "hip_triangle" in segmented else None + ), + shoulder_triangle_2d=( + segmented.get("shoulder_triangle", np.empty((0, 0, 0)))[ax_idx, 0] + if "shoulder_triangle" in segmented + else None + ), + mid_back_2d=( + segmented.get("mid_back", np.empty((0, 0, 0)))[ax_idx, 0, 0] if "mid_back" in segmented else None + ), + color="#00b8d9", + line_width=1.0, + line_alpha=0.32, + line_style="--", + marker_size=18.0, + marker_line_width=0.9, + marker_alpha=0.38, + ) + + if reference_projected_points is not None: + draw_skeleton_fn( + ax, + np.asarray(reference_projected_points, dtype=float), + reference_projected_color, + reference_projected_label, + marker_size=2.8, + marker_fill=False, + marker_edge_width=0.7, + line_alpha=0.26, + line_style="--", + line_width_scale=0.5, + ) + for keypoint_name in keypoint_names: + projected_xy = np.asarray(reference_projected_points[kp_index[keypoint_name]], dtype=float) + if not np.all(np.isfinite(projected_xy)): + continue + hover_entries.append( + { + "xy": projected_xy, + "keypoint_name": str(keypoint_name), + "source": "reconstruction reproj", + } + ) + + for (pending_camera, pending_frame, keypoint_name), pending_xy in pending_reprojection_points.items(): + if pending_camera != str(camera_name) or int(pending_frame) != int(frame_number): + continue + ax.scatter( + [pending_xy[0]], + [pending_xy[1]], + s=72, + facecolors="none", + edgecolors=[marker_color_getter(keypoint_name)], + marker="o", + linewidths=1.7, + alpha=0.95, + zorder=6, + ) + hover_entries.append( + { + "xy": np.asarray(pending_xy, dtype=float), + "keypoint_name": str(keypoint_name), + "source": "pending reproj", + } + ) + + if motion_prior_enabled: + previous_xy = annotation_xy_getter(camera_name, int(frame_number - 1), current_marker) + two_back_xy = annotation_xy_getter(camera_name, int(frame_number - 2), current_marker) + prior_center = motion_prior_center_fn(previous_xy, two_back_xy) + if prior_center is not None: + circle = plt.Circle( + tuple(prior_center), + radius=0.5 * float(motion_prior_diameter), + edgecolor=current_color, + facecolor="none", + linestyle="--", + linewidth=1.3, + alpha=0.8, + ) + ax.add_patch(circle) + + return hover_entries diff --git a/batch_run.py b/batch_run.py new file mode 100644 index 0000000..762d0dd --- /dev/null +++ b/batch_run.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +"""Batch execution helper for reconstruction profiles plus Excel synthesis export.""" + +from __future__ import annotations + +import argparse +import glob +import json +import os +import shlex +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any + +if TYPE_CHECKING: + from openpyxl import Workbook + +from annotation.annotation_store import default_annotation_path +from reconstruction.reconstruction_profiles import ReconstructionProfile, build_pipeline_command, validate_profile +from reconstruction.reconstruction_registry import infer_dataset_name, normalize_output_root + +ROOT = Path(__file__).resolve().parent +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" +LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) +os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) + +DEFAULT_CONFIG = Path("reconstruction_profiles.json") +DEFAULT_OUTPUT_ROOT = Path("output") +DEFAULT_CALIB = Path("inputs/calibration/Calib.toml") +DEFAULT_KEYPOINTS_GLOB = "inputs/keypoints/*.json" +DEFAULT_EXCEL_OUTPUT = Path("output/batch_summary.xlsx") + + +def _require_openpyxl(): + try: + from openpyxl import Workbook + except ModuleNotFoundError as exc: + raise RuntimeError( + "Excel export requires `openpyxl`. Install it in the environment or skip the batch workbook export." + ) from exc + return Workbook + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run reconstruction profiles over many keypoint files.") + parser.add_argument( + "--keypoints-glob", + action="append", + default=[DEFAULT_KEYPOINTS_GLOB], + help="Glob pattern for source keypoints JSON files. Can be repeated.", + ) + parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG, help="Profiles JSON file.") + parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT) + parser.add_argument("--calib", type=Path, default=DEFAULT_CALIB) + parser.add_argument("--fps", type=float, default=120.0) + parser.add_argument("--triangulation-workers", type=int, default=6) + parser.add_argument("--profile", action="append", default=None, help="Optional subset of profiles to run.") + parser.add_argument("--continue-on-error", action="store_true") + parser.add_argument("--excel-output", type=Path, default=DEFAULT_EXCEL_OUTPUT) + parser.add_argument("--batch-name", type=str, default="") + parser.add_argument("--export-only", action="store_true", help="Skip execution and export from existing manifests.") + return parser.parse_args() + + +def discover_keypoints_files(patterns: list[str], *, root: Path = ROOT) -> list[Path]: + """Resolve glob patterns or explicit paths into one sorted unique list of keypoints JSON files.""" + + discovered: list[Path] = [] + seen: set[Path] = set() + for raw_pattern in patterns: + pattern = str(raw_pattern).strip() + if not pattern: + continue + has_wildcards = any(char in pattern for char in "*?[]") + if has_wildcards: + if Path(pattern).is_absolute(): + matches = [Path(match) for match in glob.glob(pattern)] + else: + matches = [Path(match) for match in glob.glob(str(root / pattern))] + else: + candidate = Path(pattern) + if not candidate.is_absolute(): + candidate = root / candidate + matches = [candidate] + for match in sorted(matches): + resolved = Path(match) + if not resolved.exists() or resolved.suffix.lower() != ".json": + continue + if resolved in seen: + continue + seen.add(resolved) + discovered.append(resolved) + return discovered + + +def infer_pose2sim_trc_for_keypoints(keypoints_path: Path) -> Path | None: + dataset_name = infer_dataset_name(keypoints_path=keypoints_path) + candidate = keypoints_path.parent.parent / "trc" / f"{dataset_name}.trc" + return candidate if candidate.exists() else None + + +def infer_annotations_for_keypoints(keypoints_path: Path) -> Path | None: + candidate = default_annotation_path(keypoints_path) + return candidate if candidate.exists() else None + + +def build_dataset_run_command( + *, + keypoints_path: Path, + config_path: Path, + output_root: Path, + calib_path: Path, + fps: float, + triangulation_workers: int, + selected_profiles: list[str] | None, + continue_on_error: bool, + python_executable: str | None = None, +) -> list[str]: + dataset_name = infer_dataset_name(keypoints_path=keypoints_path) + pose2sim_trc = infer_pose2sim_trc_for_keypoints(keypoints_path) + annotations_path = infer_annotations_for_keypoints(keypoints_path) + cmd = [ + python_executable or sys.executable, + "run_reconstruction_profiles.py", + "--config", + str(config_path), + "--output-root", + str(normalize_output_root(output_root)), + "--dataset-name", + dataset_name, + "--calib", + str(calib_path), + "--keypoints", + str(keypoints_path), + "--fps", + str(float(fps)), + "--triangulation-workers", + str(int(triangulation_workers)), + ] + if pose2sim_trc is not None: + cmd.extend(["--trc-file", str(pose2sim_trc)]) + if annotations_path is not None: + cmd.extend(["--annotations-path", str(annotations_path)]) + if continue_on_error: + cmd.append("--continue-on-error") + for profile_name in selected_profiles or []: + cmd.extend(["--profile", str(profile_name)]) + return cmd + + +def _load_json_if_exists(path: Path | None) -> dict[str, Any] | None: + if path is None or not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + +def _flatten_scalars( + data: dict[str, Any] | None, + *, + prefix: str, + exclude: set[str] | None = None, +) -> dict[str, Any]: + exclude = exclude or set() + flat: dict[str, Any] = {} + if not isinstance(data, dict): + return flat + for key, value in data.items(): + if key in exclude: + continue + if isinstance(value, dict): + for child_key, child_value in _flatten_scalars(value, prefix=f"{prefix}_{key}", exclude=set()).items(): + flat[child_key] = child_value + elif isinstance(value, (str, int, float, bool)) or value is None: + flat[f"{prefix}_{key}"] = value + else: + flat[f"{prefix}_{key}"] = json.dumps(value, ensure_ascii=True, sort_keys=True) + return flat + + +def load_recognition_rows(reconstruction_dir: Path) -> list[dict[str, Any]]: + """Load optional recognition outputs if future JSON exports are present.""" + + rows: list[dict[str, Any]] = [] + for filename, kind in ( + ("dd_analysis.json", "dd"), + ("execution_analysis.json", "execution"), + ("recognition_summary.json", "recognition"), + ): + path = reconstruction_dir / filename + payload = _load_json_if_exists(path) + if payload is None: + continue + row = {"kind": kind, "source_file": str(path)} + row.update(_flatten_scalars(payload, prefix=kind)) + rows.append(row) + return rows + + +def collect_manifest_rows( + batch_manifest: dict[str, Any], +) -> tuple[ + list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]] +]: + """Flatten one batch manifest into workbook sheets.""" + + run_rows: list[dict[str, Any]] = [] + stage_rows: list[dict[str, Any]] = [] + camera_rows: list[dict[str, Any]] = [] + keypoint_rows: list[dict[str, Any]] = [] + recognition_rows: list[dict[str, Any]] = [] + + for dataset_run in batch_manifest.get("datasets", []): + manifest = dataset_run.get("manifest") or {} + dataset_name = dataset_run.get("dataset_name") or manifest.get("dataset_name") + keypoints = dataset_run.get("keypoints") + annotations_path = dataset_run.get("annotations_path") or manifest.get("annotations_path") + pose2sim_trc = dataset_run.get("pose2sim_trc") or manifest.get("pose2sim_trc") + for run in manifest.get("runs", []): + output_dir = Path(run.get("output_dir", "")) + summary = run.get("bundle_summary") or {} + profile_payload = _load_json_if_exists(output_dir / "profile.json") or {} + profile = validate_profile(ReconstructionProfile(**profile_payload)) if profile_payload else None + repro_command = None + if profile is not None: + repro_command = shlex.join( + build_pipeline_command( + profile, + output_root=Path(batch_manifest.get("output_root", DEFAULT_OUTPUT_ROOT)), + calib=Path(batch_manifest.get("calib", DEFAULT_CALIB)), + keypoints=Path(keypoints), + pose2sim_trc=(None if pose2sim_trc in (None, "") else Path(str(pose2sim_trc))), + dataset_name=dataset_name, + python_executable=batch_manifest.get("python_executable", sys.executable), + ) + + (["--annotations-path", str(annotations_path)] if annotations_path not in (None, "") else []) + + [ + "--fps", + str(batch_manifest.get("fps", 120.0)), + "--triangulation-workers", + str(batch_manifest.get("triangulation_workers", 6)), + ] + ) + + run_row = { + "dataset_name": dataset_name, + "keypoints": keypoints, + "annotations_path": annotations_path, + "pose2sim_trc": pose2sim_trc, + "profile_name": run.get("name"), + "family": run.get("family"), + "returncode": run.get("returncode"), + "output_dir": str(output_dir), + "bundle_summary_path": run.get("bundle_summary_path"), + "latest_family_version": run.get("latest_family_version"), + "manifest_stdout": dataset_run.get("stdout", ""), + "manifest_stderr": dataset_run.get("stderr", ""), + "repro_command": repro_command, + } + run_row.update( + _flatten_scalars( + profile_payload, + prefix="profile", + ) + ) + run_row.update( + _flatten_scalars( + summary, + prefix="summary", + exclude={ + "reprojection_px", + "stage_timings_s", + "pipeline_timing", + "cache_paths", + "algorithm_versions", + "left_right_flip_diagnostics", + "ekf2d_initial_state_diagnostics", + }, + ) + ) + reprojection = summary.get("reprojection_px") or {} + run_row["reprojection_mean_px"] = reprojection.get("mean") + run_row["reprojection_std_px"] = reprojection.get("std") + run_rows.append(run_row) + + for stage_name, stage_duration in (summary.get("stage_timings_s") or {}).items(): + stage_rows.append( + { + "dataset_name": dataset_name, + "profile_name": run.get("name"), + "family": run.get("family"), + "output_dir": str(output_dir), + "stage_name": stage_name, + "duration_s": stage_duration, + } + ) + + for camera_name, camera_summary in (reprojection.get("per_camera") or {}).items(): + camera_rows.append( + { + "dataset_name": dataset_name, + "profile_name": run.get("name"), + "family": run.get("family"), + "camera_name": camera_name, + "mean_px": camera_summary.get("mean_px"), + "std_px": camera_summary.get("std_px"), + } + ) + + for keypoint_name, keypoint_summary in (reprojection.get("per_keypoint") or {}).items(): + keypoint_rows.append( + { + "dataset_name": dataset_name, + "profile_name": run.get("name"), + "family": run.get("family"), + "keypoint_name": keypoint_name, + "mean_px": keypoint_summary.get("mean_px"), + "std_px": keypoint_summary.get("std_px"), + "n_samples": keypoint_summary.get("n_samples"), + } + ) + + for recognition_row in load_recognition_rows(output_dir): + recognition_rows.append( + { + "dataset_name": dataset_name, + "profile_name": run.get("name"), + "family": run.get("family"), + "output_dir": str(output_dir), + **recognition_row, + } + ) + + failures = [ + { + "dataset_name": dataset_run.get("dataset_name"), + "keypoints": dataset_run.get("keypoints"), + "returncode": dataset_run.get("returncode"), + "command": dataset_run.get("command"), + "stdout": dataset_run.get("stdout", ""), + "stderr": dataset_run.get("stderr", ""), + } + for dataset_run in batch_manifest.get("datasets", []) + if int(dataset_run.get("returncode", 0)) != 0 + ] + return run_rows, stage_rows, camera_rows, keypoint_rows, failures + recognition_rows + + +def write_rows_sheet(workbook: Any, title: str, rows: list[dict[str, Any]]) -> None: + worksheet = workbook.create_sheet(title=title) + if not rows: + worksheet.append(["empty"]) + return + columns: list[str] = [] + seen: set[str] = set() + for row in rows: + for key in row.keys(): + if key not in seen: + seen.add(key) + columns.append(key) + worksheet.append(columns) + for row in rows: + worksheet.append([row.get(column) for column in columns]) + worksheet.freeze_panes = "A2" + worksheet.auto_filter.ref = worksheet.dimensions + + +def write_batch_workbook(batch_manifest: dict[str, Any], workbook_path: Path) -> Path: + Workbook = _require_openpyxl() + workbook = Workbook() + default_sheet = workbook.active + workbook.remove(default_sheet) + + run_rows, stage_rows, camera_rows, keypoint_rows, recognition_or_failures = collect_manifest_rows(batch_manifest) + write_rows_sheet(workbook, "Runs", run_rows) + write_rows_sheet(workbook, "StageTimings", stage_rows) + write_rows_sheet(workbook, "ReprojectionPerCamera", camera_rows) + write_rows_sheet(workbook, "ReprojectionPerKeypoint", keypoint_rows) + write_rows_sheet(workbook, "Recognition", [row for row in recognition_or_failures if "kind" in row]) + write_rows_sheet(workbook, "Failures", [row for row in recognition_or_failures if "kind" not in row]) + + workbook_path.parent.mkdir(parents=True, exist_ok=True) + workbook.save(workbook_path) + return workbook_path + + +def run_batch( + *, + keypoints_files: list[Path], + config_path: Path, + output_root: Path, + calib_path: Path, + fps: float, + triangulation_workers: int, + selected_profiles: list[str] | None, + continue_on_error: bool, + export_only: bool = False, + python_executable: str | None = None, +) -> dict[str, Any]: + output_root = normalize_output_root(output_root) + batch_manifest: dict[str, Any] = { + "created_at": datetime.now().isoformat(timespec="seconds"), + "config": str(config_path), + "output_root": str(output_root), + "calib": str(calib_path), + "fps": float(fps), + "triangulation_workers": int(triangulation_workers), + "python_executable": python_executable or sys.executable, + "datasets": [], + } + for keypoints_path in keypoints_files: + dataset_name = infer_dataset_name(keypoints_path=keypoints_path) + pose2sim_trc = infer_pose2sim_trc_for_keypoints(keypoints_path) + annotations_path = infer_annotations_for_keypoints(keypoints_path) + command = build_dataset_run_command( + keypoints_path=keypoints_path, + config_path=config_path, + output_root=output_root, + calib_path=calib_path, + fps=fps, + triangulation_workers=triangulation_workers, + selected_profiles=selected_profiles, + continue_on_error=continue_on_error, + python_executable=python_executable, + ) + completed = None + if not export_only: + completed = subprocess.run( + command, + cwd=ROOT, + capture_output=True, + text=True, + ) + manifest_path = output_root / dataset_name / "manifest.json" + manifest_payload = _load_json_if_exists(manifest_path) or { + "dataset_name": dataset_name, + "keypoints": str(keypoints_path), + "annotations_path": None if annotations_path is None else str(annotations_path), + "pose2sim_trc": None if pose2sim_trc is None else str(pose2sim_trc), + "runs": [], + } + dataset_row = { + "dataset_name": dataset_name, + "keypoints": str(keypoints_path), + "annotations_path": None if annotations_path is None else str(annotations_path), + "pose2sim_trc": None if pose2sim_trc is None else str(pose2sim_trc), + "manifest_path": str(manifest_path), + "command": shlex.join(command), + "returncode": 0 if completed is None else int(completed.returncode), + "stdout": "" if completed is None else completed.stdout, + "stderr": "" if completed is None else completed.stderr, + "manifest": manifest_payload, + } + batch_manifest["datasets"].append(dataset_row) + if completed is not None and completed.returncode != 0 and not continue_on_error: + break + return batch_manifest + + +def main() -> None: + args = parse_args() + keypoints_files = discover_keypoints_files(args.keypoints_glob) + if not keypoints_files: + raise SystemExit("No keypoints JSON files matched the requested patterns.") + batch_manifest = run_batch( + keypoints_files=keypoints_files, + config_path=args.config, + output_root=args.output_root, + calib_path=args.calib, + fps=args.fps, + triangulation_workers=args.triangulation_workers, + selected_profiles=args.profile, + continue_on_error=args.continue_on_error, + export_only=args.export_only, + python_executable=sys.executable, + ) + batch_name = args.batch_name.strip() or datetime.now().strftime("%Y%m%d_%H%M%S") + batch_dir = normalize_output_root(args.output_root) / "_batch" / batch_name + batch_dir.mkdir(parents=True, exist_ok=True) + manifest_path = batch_dir / "batch_manifest.json" + manifest_path.write_text(json.dumps(batch_manifest, indent=2), encoding="utf-8") + workbook_path = args.excel_output + if not workbook_path.is_absolute(): + workbook_path = ROOT / workbook_path + write_batch_workbook(batch_manifest, workbook_path) + print(f"Batch manifest written to {manifest_path}", flush=True) + print(f"Batch workbook written to {workbook_path}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/calibration_qc.py b/calibration_qc.py new file mode 100644 index 0000000..b28b0cd --- /dev/null +++ b/calibration_qc.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +"""Calibration quality analysis helpers for 2D and 3D diagnostics.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from reconstruction.reconstruction_bundle import summarize_reprojection_errors, summarize_view_usage +from vitpose_ekf_pipeline import COCO17, CameraCalibration, PoseData, sampson_error_pixels_vectorized + + +def _nanmean_without_warning(values: np.ndarray, axis: int) -> np.ndarray: + """Return a NaN-aware mean without emitting warnings on empty slices.""" + + values = np.asarray(values, dtype=float) + finite_counts = np.sum(np.isfinite(values), axis=axis) + sums = np.nansum(values, axis=axis) + means = np.full(sums.shape, np.nan, dtype=float) + valid = finite_counts > 0 + means[valid] = sums[valid] / finite_counts[valid] + return means + + +@dataclass +class Calibration2DQC: + trim_fraction: float + trim_threshold_px: float | None + kept_ratio: float + pairwise_median_px: np.ndarray + pairwise_mean_px: np.ndarray + pairwise_sample_count: np.ndarray + per_camera_median_px: np.ndarray + per_camera_mean_px: np.ndarray + per_frame_mean_px: np.ndarray + per_keypoint_mean_px: np.ndarray + + +@dataclass +class Calibration3DQC: + reprojection_summary: dict[str, object] + view_usage_summary: dict[str, object] + point_positions: np.ndarray + point_errors_px: np.ndarray + per_frame_mean_px: np.ndarray + spatial_bin_mean_px: np.ndarray + spatial_bin_count: np.ndarray + spatial_xz_mean_px: np.ndarray + spatial_xz_count: np.ndarray + spatial_uniformity_cv: float | None + spatial_uniformity_range_px: float | None + spatial_axis_means_px: dict[str, np.ndarray] + + +@dataclass +class CalibrationQC: + two_d: Calibration2DQC + three_d: Calibration3DQC | None + + +def compute_calibration_qc( + pose_data: PoseData, + calibrations: dict[str, CameraCalibration], + *, + reconstruction_payload: dict[str, np.ndarray] | None = None, + trim_fraction: float = 0.15, + spatial_bins: int = 3, +) -> CalibrationQC: + """Compute a compact 2D/3D calibration-quality summary.""" + + two_d = compute_2d_calibration_qc(pose_data, calibrations, trim_fraction=trim_fraction) + three_d = None + if reconstruction_payload: + three_d = compute_3d_calibration_qc( + reconstruction_payload, + camera_names=list(pose_data.camera_names), + spatial_bins=spatial_bins, + ) + return CalibrationQC(two_d=two_d, three_d=three_d) + + +def compute_2d_calibration_qc( + pose_data: PoseData, + calibrations: dict[str, CameraCalibration], + *, + trim_fraction: float = 0.15, +) -> Calibration2DQC: + """Summarize pairwise epipolar consistency after trimming the worst 2D samples.""" + + camera_names = list(pose_data.camera_names) + n_cams = len(camera_names) + n_frames = int(pose_data.frames.shape[0]) + n_keypoints = len(COCO17) + + pairwise_median = np.full((n_cams, n_cams), np.nan, dtype=float) + pairwise_mean = np.full((n_cams, n_cams), np.nan, dtype=float) + pairwise_count = np.zeros((n_cams, n_cams), dtype=int) + + valid_mask = (pose_data.scores > 0) & np.all(np.isfinite(pose_data.keypoints), axis=-1) + sample_errors: list[np.ndarray] = [] + sample_camera_i: list[np.ndarray] = [] + sample_camera_j: list[np.ndarray] = [] + sample_frames: list[np.ndarray] = [] + sample_keypoints: list[np.ndarray] = [] + + for cam_i in range(n_cams): + calib_i = calibrations[camera_names[cam_i]] + for cam_j in range(cam_i + 1, n_cams): + calib_j = calibrations[camera_names[cam_j]] + mask_ij = valid_mask[cam_i] & valid_mask[cam_j] + if not np.any(mask_ij): + continue + points_i = np.asarray(pose_data.keypoints[cam_i][mask_ij], dtype=float) + points_j = np.asarray(pose_data.keypoints[cam_j][mask_ij], dtype=float) + if points_i.size == 0: + continue + errors = sampson_error_pixels_vectorized( + points_i, + points_j[np.newaxis, :, :], + np.stack((fundamental_matrix(calib_i, calib_j),), axis=0), + )[0] + finite = np.isfinite(errors) + if not np.any(finite): + continue + frame_idx, kp_idx = np.where(mask_ij) + sample_errors.append(errors[finite]) + sample_camera_i.append(np.full(int(np.count_nonzero(finite)), cam_i, dtype=int)) + sample_camera_j.append(np.full(int(np.count_nonzero(finite)), cam_j, dtype=int)) + sample_frames.append(frame_idx[finite].astype(int, copy=False)) + sample_keypoints.append(kp_idx[finite].astype(int, copy=False)) + + if sample_errors: + all_errors = np.concatenate(sample_errors) + all_camera_i = np.concatenate(sample_camera_i) + all_camera_j = np.concatenate(sample_camera_j) + all_frames = np.concatenate(sample_frames) + all_keypoints = np.concatenate(sample_keypoints) + kept_mask = _trim_upper_mask(all_errors, trim_fraction) + trimmed_values = all_errors[np.isfinite(all_errors) & ~kept_mask] + trim_threshold = float(np.min(trimmed_values)) if trimmed_values.size else None + kept_ratio = float(np.mean(kept_mask)) if kept_mask.size else 0.0 + + for cam_i in range(n_cams): + for cam_j in range(cam_i + 1, n_cams): + pair_mask = kept_mask & (all_camera_i == cam_i) & (all_camera_j == cam_j) + if not np.any(pair_mask): + continue + values = all_errors[pair_mask] + pairwise_median[cam_i, cam_j] = pairwise_median[cam_j, cam_i] = float(np.median(values)) + pairwise_mean[cam_i, cam_j] = pairwise_mean[cam_j, cam_i] = float(np.mean(values)) + pairwise_count[cam_i, cam_j] = pairwise_count[cam_j, cam_i] = int(values.size) + + per_camera_median = np.full(n_cams, np.nan, dtype=float) + per_camera_mean = np.full(n_cams, np.nan, dtype=float) + for cam_idx in range(n_cams): + cam_mask = kept_mask & ((all_camera_i == cam_idx) | (all_camera_j == cam_idx)) + if np.any(cam_mask): + per_camera_median[cam_idx] = float(np.median(all_errors[cam_mask])) + per_camera_mean[cam_idx] = float(np.mean(all_errors[cam_mask])) + + per_frame_sum = np.zeros(n_frames, dtype=float) + per_frame_count = np.zeros(n_frames, dtype=int) + np.add.at(per_frame_sum, all_frames[kept_mask], all_errors[kept_mask]) + np.add.at(per_frame_count, all_frames[kept_mask], 1) + per_frame_mean = np.full(n_frames, np.nan, dtype=float) + valid_frames = per_frame_count > 0 + per_frame_mean[valid_frames] = per_frame_sum[valid_frames] / per_frame_count[valid_frames] + + per_key_sum = np.zeros(n_keypoints, dtype=float) + per_key_count = np.zeros(n_keypoints, dtype=int) + np.add.at(per_key_sum, all_keypoints[kept_mask], all_errors[kept_mask]) + np.add.at(per_key_count, all_keypoints[kept_mask], 1) + per_key_mean = np.full(n_keypoints, np.nan, dtype=float) + valid_keys = per_key_count > 0 + per_key_mean[valid_keys] = per_key_sum[valid_keys] / per_key_count[valid_keys] + else: + trim_threshold = None + kept_ratio = 0.0 + per_camera_median = np.full(n_cams, np.nan, dtype=float) + per_camera_mean = np.full(n_cams, np.nan, dtype=float) + per_frame_mean = np.full(n_frames, np.nan, dtype=float) + per_key_mean = np.full(n_keypoints, np.nan, dtype=float) + + np.fill_diagonal(pairwise_median, 0.0) + np.fill_diagonal(pairwise_mean, 0.0) + np.fill_diagonal(pairwise_count, 0) + return Calibration2DQC( + trim_fraction=float(np.clip(trim_fraction, 0.0, 1.0)), + trim_threshold_px=trim_threshold, + kept_ratio=kept_ratio, + pairwise_median_px=pairwise_median, + pairwise_mean_px=pairwise_mean, + pairwise_sample_count=pairwise_count, + per_camera_median_px=per_camera_median, + per_camera_mean_px=per_camera_mean, + per_frame_mean_px=per_frame_mean, + per_keypoint_mean_px=per_key_mean, + ) + + +def frame_camera_epipolar_errors( + pose_data: PoseData, + calibrations: dict[str, CameraCalibration], + *, + frame_idx: int, + camera_idx: int, +) -> np.ndarray: + """Return one epipolar-error value per keypoint for one camera/frame.""" + + camera_names = list(pose_data.camera_names) + n_cams = len(camera_names) + result = np.full(len(COCO17), np.nan, dtype=float) + if camera_idx < 0 or camera_idx >= n_cams or frame_idx < 0 or frame_idx >= pose_data.frames.shape[0]: + return result + candidate_points = np.asarray(pose_data.keypoints[camera_idx, frame_idx], dtype=float) + candidate_scores = np.asarray(pose_data.scores[camera_idx, frame_idx], dtype=float) + other_indices = [idx for idx in range(n_cams) if idx != camera_idx] + if not other_indices: + return result + other_points = np.asarray(pose_data.keypoints[other_indices, frame_idx], dtype=float) + other_scores = np.asarray(pose_data.scores[other_indices, frame_idx], dtype=float) + valid_candidate = np.all(np.isfinite(candidate_points), axis=1) & (candidate_scores > 0) + if not np.any(valid_candidate): + return result + valid_other = np.all(np.isfinite(other_points), axis=2) & (other_scores > 0) + fundamental_blocks = np.stack( + [ + fundamental_matrix(calibrations[camera_names[camera_idx]], calibrations[camera_names[other_idx]]) + for other_idx in other_indices + ], + axis=0, + ) + errors = sampson_error_pixels_vectorized(candidate_points, other_points, fundamental_blocks) + valid = valid_other & valid_candidate[np.newaxis, :] & np.isfinite(errors) + if not np.any(valid): + return result + counts = np.count_nonzero(valid, axis=0) + sums = np.nansum(np.where(valid, errors, np.nan), axis=0) + finite = counts > 0 + result[finite] = sums[finite] / counts[finite] + return result + + +def compute_3d_calibration_qc( + reconstruction_payload: dict[str, np.ndarray], + *, + camera_names: list[str], + spatial_bins: int = 3, +) -> Calibration3DQC | None: + """Summarize reprojection quality and its spatial uniformity in 3D.""" + + points_3d = reconstruction_payload.get("points_3d") + reprojection_error_per_view = reconstruction_payload.get("reprojection_error_per_view") + excluded_views = reconstruction_payload.get("excluded_views") + if points_3d is None or reprojection_error_per_view is None: + return None + + points_3d = np.asarray(points_3d, dtype=float) + reprojection_error_per_view = np.asarray(reprojection_error_per_view, dtype=float) + excluded_views = None if excluded_views is None else np.asarray(excluded_views, dtype=bool) + if points_3d.ndim != 3 or reprojection_error_per_view.ndim != 3: + return None + + reprojection_summary = summarize_reprojection_errors(reprojection_error_per_view, camera_names) + view_usage_summary = summarize_view_usage(excluded_views, camera_names) + + point_error = _nanmean_without_warning(reprojection_error_per_view, axis=2) + valid = np.all(np.isfinite(points_3d), axis=2) & np.isfinite(point_error) + if not np.any(valid): + empty_bins = np.full((1, 1, 1), np.nan, dtype=float) + return Calibration3DQC( + reprojection_summary=reprojection_summary, + view_usage_summary=view_usage_summary, + point_positions=np.empty((0, 3), dtype=float), + point_errors_px=np.empty((0,), dtype=float), + per_frame_mean_px=np.full(points_3d.shape[0], np.nan, dtype=float), + spatial_bin_mean_px=empty_bins, + spatial_bin_count=np.zeros((1, 1, 1), dtype=int), + spatial_xz_mean_px=np.full((1, 1), np.nan, dtype=float), + spatial_xz_count=np.zeros((1, 1), dtype=int), + spatial_uniformity_cv=None, + spatial_uniformity_range_px=None, + spatial_axis_means_px={"x": np.full(1, np.nan), "y": np.full(1, np.nan), "z": np.full(1, np.nan)}, + ) + + positions = points_3d[valid] + errors = point_error[valid] + per_frame_mean = _nanmean_without_warning(point_error, axis=1) + x_idx, n_x = _quantile_bin_indices(positions[:, 0], spatial_bins) + y_idx, n_y = _quantile_bin_indices(positions[:, 1], spatial_bins) + z_idx, n_z = _quantile_bin_indices(positions[:, 2], spatial_bins) + bin_sum = np.zeros((n_x, n_y, n_z), dtype=float) + bin_count = np.zeros((n_x, n_y, n_z), dtype=int) + np.add.at(bin_sum, (x_idx, y_idx, z_idx), errors) + np.add.at(bin_count, (x_idx, y_idx, z_idx), 1) + bin_mean = np.full_like(bin_sum, np.nan, dtype=float) + occupied = bin_count > 0 + bin_mean[occupied] = bin_sum[occupied] / bin_count[occupied] + xz_sum = np.sum(bin_sum, axis=1) + xz_count = np.sum(bin_count, axis=1) + xz_mean = np.full_like(xz_sum, np.nan, dtype=float) + xz_occupied = xz_count > 0 + xz_mean[xz_occupied] = xz_sum[xz_occupied] / xz_count[xz_occupied] + occupied_means = bin_mean[occupied] + if occupied_means.size >= 2: + overall_mean = float(np.mean(occupied_means)) + uniformity_cv = float(np.std(occupied_means) / overall_mean) if overall_mean > 1e-12 else None + uniformity_range = float(np.max(occupied_means) - np.min(occupied_means)) + elif occupied_means.size == 1: + uniformity_cv = 0.0 + uniformity_range = 0.0 + else: + uniformity_cv = None + uniformity_range = None + + spatial_axis_means = { + "x": _axis_bin_means(x_idx, n_x, errors), + "y": _axis_bin_means(y_idx, n_y, errors), + "z": _axis_bin_means(z_idx, n_z, errors), + } + return Calibration3DQC( + reprojection_summary=reprojection_summary, + view_usage_summary=view_usage_summary, + point_positions=positions, + point_errors_px=errors, + per_frame_mean_px=per_frame_mean, + spatial_bin_mean_px=bin_mean, + spatial_bin_count=bin_count, + spatial_xz_mean_px=xz_mean, + spatial_xz_count=xz_count, + spatial_uniformity_cv=uniformity_cv, + spatial_uniformity_range_px=uniformity_range, + spatial_axis_means_px=spatial_axis_means, + ) + + +def _axis_bin_means(indices: np.ndarray, n_bins: int, errors: np.ndarray) -> np.ndarray: + sums = np.zeros(n_bins, dtype=float) + counts = np.zeros(n_bins, dtype=int) + np.add.at(sums, indices, errors) + np.add.at(counts, indices, 1) + means = np.full(n_bins, np.nan, dtype=float) + valid = counts > 0 + means[valid] = sums[valid] / counts[valid] + return means + + +def _quantile_bin_indices(values: np.ndarray, requested_bins: int) -> tuple[np.ndarray, int]: + values = np.asarray(values, dtype=float) + if values.size == 0: + return np.zeros(0, dtype=int), 1 + n_bins = max(1, int(requested_bins)) + if n_bins == 1: + return np.zeros(values.shape[0], dtype=int), 1 + quantiles = np.linspace(0.0, 1.0, n_bins + 1) + edges = np.quantile(values, quantiles) + edges = np.unique(edges) + if edges.size <= 1: + return np.zeros(values.shape[0], dtype=int), 1 + bins = np.searchsorted(edges[1:-1], values, side="right") + return np.asarray(bins, dtype=int), int(edges.size - 1) + + +def _trim_upper_mask(values: np.ndarray, trim_fraction: float) -> np.ndarray: + values = np.asarray(values, dtype=float) + keep = np.isfinite(values) + finite_idx = np.flatnonzero(keep) + if finite_idx.size == 0: + return keep + fraction = float(np.clip(trim_fraction, 0.0, 1.0)) + n_remove = int(np.floor(finite_idx.size * fraction)) + if n_remove <= 0: + return keep + order = finite_idx[np.argsort(values[finite_idx])] + keep[order[-n_remove:]] = False + return keep + + +def fundamental_matrix(calib_a: CameraCalibration, calib_b: CameraCalibration) -> np.ndarray: + """Small local wrapper to avoid importing the whole GUI stack.""" + + from vitpose_ekf_pipeline import fundamental_matrix as _fundamental_matrix + + return _fundamental_matrix(calib_a, calib_b) diff --git a/camera_tools/camera_selection.py b/camera_tools/camera_selection.py index a31aa09..51f6e44 100644 --- a/camera_tools/camera_selection.py +++ b/camera_tools/camera_selection.py @@ -34,7 +34,9 @@ def format_camera_names(camera_names: list[str] | tuple[str, ...]) -> str: return ", ".join(str(name) for name in camera_names) -def select_camera_names(available_camera_names: list[str], requested_camera_names: list[str] | tuple[str, ...] | None) -> list[str]: +def select_camera_names( + available_camera_names: list[str], requested_camera_names: list[str] | tuple[str, ...] | None +) -> list[str]: """Validate and resolve a requested camera subset against the available names.""" requested = parse_camera_names(list(requested_camera_names) if requested_camera_names is not None else None) diff --git a/examples/convert_image.py b/examples/convert_image.py new file mode 100644 index 0000000..b43f56f --- /dev/null +++ b/examples/convert_image.py @@ -0,0 +1,50 @@ +from pathlib import Path + +from PIL import Image + +input_dir = Path("/Users/mickaelbegon/Documents/Playground/inputs/images/1_partie_0429") + +QUALITY = 85 # ajuste entre 75–90 selon ton besoin +DELETE_SOURCE = True # True pour supprimer l'image source après conversion réussie + +valid_ext = {".jpg", ".jpeg", ".png"} + +for img_path in input_dir.rglob("*"): + if not img_path.is_file() or img_path.suffix.lower() not in valid_ext: + continue + + try: + with Image.open(img_path) as img: + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + elif img.mode != "RGB": + img = img.convert("RGB") + + # Le JPEG est sauvegardé dans le même dossier que l'image source + out_path = img_path.with_suffix(".jpg") + + # Si le fichier de sortie est le même que la source (.jpg déjà existant), + # on passe par un fichier temporaire pour éviter les conflits. + if out_path.resolve() == img_path.resolve(): + temp_out_path = img_path.with_name(img_path.stem + "_temp_compressed.jpg") + img.save(temp_out_path, "JPEG", quality=QUALITY, optimize=True, progressive=True) + size_before = img_path.stat().st_size / 1e6 + temp_out_path.replace(out_path) + size_after = out_path.stat().st_size / 1e6 + + print(f"✔ {img_path.name}: {size_before:.2f} MB → {size_after:.2f} MB") + + else: + img.save(out_path, "JPEG", quality=QUALITY, optimize=True, progressive=True) + + size_before = img_path.stat().st_size / 1e6 + size_after = out_path.stat().st_size / 1e6 + + if DELETE_SOURCE: + img_path.unlink() + print(f"✔ {img_path.name}: {size_before:.2f} MB → {size_after:.2f} MB (source supprimée)") + else: + print(f"✔ {img_path.name}: {size_before:.2f} MB → {size_after:.2f} MB") + + except Exception as e: + print(f"✖ {img_path.name}: {e}") diff --git a/examples/plot_somersault_twist.py b/examples/plot_somersault_twist.py new file mode 100644 index 0000000..5fdb0b7 --- /dev/null +++ b/examples/plot_somersault_twist.py @@ -0,0 +1,117 @@ +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + +fs = 120 +base_path = Path("/Users/mickaelbegon/Documents/Playground/output/1_partie_0429/reconstructions/") + +# Récupérer tous les dossiers triés alphabétiquement +all_dirs = sorted([d for d in base_path.iterdir() if d.is_dir()]) +print(all_dirs) + +# Sélectionner les dossiers par index (1-based → -1) +labels = ["EKF_2D[1]", "EKF_2D[0]", "EKF_3D[1]", "EKF_3D[0]", "Triang.[1]", "Triang.[0]", "Triang. [once]"] + +selected_indices = [1, 2, 5, 6, 7, 8, 9] +folders = [all_dirs[i - 1] for i in selected_indices] + +colors = plt.cm.tab10.colors +linestyles = ["-", "--", "-.", ":", "-", "--", "-"] +markers = ["o", "s", "^", "d", "x", "+", "."] + +fig, axes = plt.subplots(2, 1, figsize=(10, 6), sharex=False) + +for i, folder_path in enumerate(folders): + print(folder_path) + print(i) + files = list(folder_path.glob("*.npz")) + data = None + + for f in files: + try: + d = np.load(f) + if "q" in d: + q_tmp = d["q"] + if q_tmp is not None and q_tmp.size > 0 and q_tmp.ndim == 2: + data = d + break + except Exception: + continue + + if data is None: + continue + + q = data["q"] + + n_frames = q.shape[0] + + markevery_base = max(n_frames // 20, 1) + offset = i * 10 % markevery_base + + frames = np.arange(n_frames) / fs # temps en frames (120 Hz) + + # Conversion en rotations + q4 = np.unwrap(q[:, 3]) / (2 * np.pi) + q6 = np.unwrap(q[:, 5]) / (2 * np.pi) + + indices_nan = np.argwhere(np.isnan(q6)) + print(indices_nan) + + for qi in [q4, q6]: + nan_mask = np.isnan(qi) + + if nan_mask.all(): + continue + + if nan_mask.any(): + indices = np.arange(len(qi)) + valid_mask = ~nan_mask + qi[nan_mask] = np.interp(indices[nan_mask], indices[valid_mask], qi[valid_mask]) + + style = dict( + color=colors[i % len(colors)], + linestyle=linestyles[i], + marker=markers[i], + markevery=(offset, markevery_base), + linewidth=2, + alpha=0.9, + ) + + # label = f"{selected_indices[i]}" # numéro demandé + + t_start = 5.5 + t_end = 8.0 + + i_start = int(t_start * fs) # 660 + i_end = int(t_end * fs) # 960 + + print(q6[-1]) + + axes[0].plot(frames[i_start:i_end], q4[i_start:i_end], label=labels[i], **style) + axes[1].plot(frames[i_start:i_end], q6[i_start:i_end], label=labels[i], **style) + +# Labels +axes[0].set_ylabel("Somersault [rotation]") +axes[1].set_ylabel("Twist [rotation]") +axes[1].set_xlabel("Time (s)") + +# axes[0].legend(title="Folder index", ncol=3) +axes[0].grid(True) + +axes[1].grid(True) +axes[0].legend(loc="upper right") + +plt.rcParams.update( + { + "font.size": 30, + "axes.titlesize": 30, + "axes.labelsize": 30, + "xtick.labelsize": 30, + "ytick.labelsize": 30, + "legend.fontsize": 30, + "legend.title_fontsize": 30, + } +) +plt.tight_layout() +plt.show() diff --git a/export_reconstruction_bundle.py b/export_reconstruction_bundle.py index 5415463..c49d080 100644 --- a/export_reconstruction_bundle.py +++ b/export_reconstruction_bundle.py @@ -8,6 +8,8 @@ import time from pathlib import Path +import numpy as np + ROOT = Path(__file__).resolve().parent LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) @@ -23,6 +25,7 @@ build_triangulation_bundle, ) from vitpose_ekf_pipeline import ( + DEFAULT_ANKLE_BED_PSEUDO_STD_M, DEFAULT_BIORBD_KALMAN_ERROR_FACTOR, DEFAULT_BIORBD_KALMAN_INIT_METHOD, DEFAULT_BIORBD_KALMAN_NOISE_FACTOR, @@ -45,13 +48,24 @@ DEFAULT_SUBJECT_MASS_KG, DEFAULT_TRIANGULATION_METHOD, DEFAULT_TRIANGULATION_WORKERS, + DEFAULT_UPPER_BACK_PSEUDO_STD_RAD, + DEFAULT_UPPER_BACK_SAGITTAL_GAIN, SUPPORTED_COHERENCE_METHODS, SUPPORTED_MODEL_VARIANTS, + SUPPORTED_ROOT_UNWRAP_MODES, SUPPORTED_TRIANGULATION_METHODS, load_calibrations, + normalize_root_unwrap_mode, ) +def parse_optional_reprojection_threshold(value: str) -> float | None: + raw = str(value).strip().lower() + if raw in {"none", "off", ""}: + return None + return float(raw) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Genere un bundle standardise pour une reconstruction.") parser.add_argument("--name", required=True, help="Nom stable de la reconstruction.") @@ -59,16 +73,22 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--calib", type=Path, default=DEFAULT_CALIB) parser.add_argument("--keypoints", type=Path, default=DEFAULT_KEYPOINTS) + parser.add_argument("--annotations-path", type=Path, default=None) parser.add_argument("--trc-file", "--pose2sim-trc", dest="pose2sim_trc", type=Path, default=None) parser.add_argument("--biomod", type=Path, default=None) parser.add_argument("--model-variant", choices=SUPPORTED_MODEL_VARIANTS, default=DEFAULT_MODEL_VARIANT) + parser.add_argument( + "--no-symmetrize-limbs", + action="store_true", + help="Conserve des longueurs gauche/droite distinctes au lieu de symétriser les membres.", + ) parser.add_argument("--fps", type=float, default=DEFAULT_CAMERA_FPS) parser.add_argument( "--camera-names", type=str, default="", help="Liste de cameras a utiliser, separees par des virgules." ) parser.add_argument("--max-frames", type=int, default=None) parser.add_argument("--frame-stride", type=int, choices=(1, 2, 3, 4), default=1) - parser.add_argument("--pose-data-mode", choices=("raw", "filtered", "cleaned"), default="cleaned") + parser.add_argument("--pose-data-mode", choices=("raw", "filtered", "cleaned", "annotated"), default="cleaned") parser.add_argument("--pose-filter-window", type=int, default=9) parser.add_argument("--pose-outlier-threshold-ratio", type=float, default=0.10) parser.add_argument("--pose-amplitude-lower-percentile", type=float, default=5.0) @@ -78,7 +98,11 @@ def parse_args() -> argparse.Namespace: "--triangulation-method", choices=SUPPORTED_TRIANGULATION_METHODS, default=DEFAULT_TRIANGULATION_METHOD ) parser.add_argument("--triangulation-workers", type=int, default=DEFAULT_TRIANGULATION_WORKERS) - parser.add_argument("--reprojection-threshold-px", type=float, default=DEFAULT_REPROJECTION_THRESHOLD_PX) + parser.add_argument( + "--reprojection-threshold-px", + type=parse_optional_reprojection_threshold, + default=DEFAULT_REPROJECTION_THRESHOLD_PX, + ) parser.add_argument("--epipolar-threshold-px", type=float, default=DEFAULT_EPIPOLAR_THRESHOLD_PX) parser.add_argument("--min-cameras-for-triangulation", type=int, default=DEFAULT_MIN_CAMERAS_FOR_TRIANGULATION) parser.add_argument("--coherence-method", choices=SUPPORTED_COHERENCE_METHODS, default=DEFAULT_COHERENCE_METHOD) @@ -96,7 +120,7 @@ def parse_args() -> argparse.Namespace: ), default=DEFAULT_BIORBD_KALMAN_INIT_METHOD, ) - parser.add_argument("--predictor", choices=("acc", "dyn"), default="acc") + parser.add_argument("--predictor", choices=("acc", "dyn", "history3", "dyn_history3"), default="acc") parser.add_argument("--ekf2d-3d-source", choices=SUPPORTED_EKF2D_3D_SOURCE_MODES, default="full_triangulation") parser.add_argument( "--ekf2d-initial-state-method", @@ -138,16 +162,24 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--measurement-noise-scale", type=float, default=DEFAULT_MEASUREMENT_NOISE_SCALE) parser.add_argument("--process-noise-scale", type=float, default=1.0) parser.add_argument("--coherence-confidence-floor", type=float, default=DEFAULT_COHERENCE_CONFIDENCE_FLOOR) + parser.add_argument("--upper-back-sagittal-gain", type=float, default=DEFAULT_UPPER_BACK_SAGITTAL_GAIN) + parser.add_argument( + "--upper-back-pseudo-std-deg", type=float, default=np.rad2deg(DEFAULT_UPPER_BACK_PSEUDO_STD_RAD) + ) + parser.add_argument("--ankle-bed-pseudo-obs", action="store_true") + parser.add_argument("--ankle-bed-pseudo-std-m", type=float, default=DEFAULT_ANKLE_BED_PSEUDO_STD_M) parser.add_argument("--min-frame-coherence-for-update", type=float, default=DEFAULT_MIN_FRAME_COHERENCE_FOR_UPDATE) parser.add_argument("--skip-low-coherence-updates", action="store_true") parser.add_argument("--flight-height-threshold-m", type=float, default=DEFAULT_FLIGHT_HEIGHT_THRESHOLD_M) parser.add_argument("--flight-min-consecutive-frames", type=int, default=DEFAULT_FLIGHT_MIN_CONSECUTIVE_FRAMES) + parser.add_argument("--root-unwrap-mode", choices=SUPPORTED_ROOT_UNWRAP_MODES, default="off") parser.add_argument("--no-root-unwrap", action="store_true") return parser.parse_args() def main() -> None: args = parse_args() + root_unwrap_mode = normalize_root_unwrap_mode(("off" if args.no_root_unwrap else args.root_unwrap_mode)) args.output_dir.mkdir(parents=True, exist_ok=True) calibrations = load_calibrations(args.calib) selected_camera_names = parse_camera_names(args.camera_names) @@ -172,6 +204,7 @@ def main() -> None: pose_outlier_threshold_ratio=args.pose_outlier_threshold_ratio, pose_amplitude_lower_percentile=args.pose_amplitude_lower_percentile, pose_amplitude_upper_percentile=args.pose_amplitude_upper_percentile, + annotations_path=args.annotations_path, ) pose_data_compute_time_s = time.perf_counter() - pose_data_start @@ -185,7 +218,8 @@ def main() -> None: pose_data_compute_time_s=pose_data_compute_time_s, fps=args.fps, initial_rotation_correction=args.initial_rotation_correction, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, ) elif args.family == "triangulation": build_triangulation_bundle( @@ -196,7 +230,8 @@ def main() -> None: calibrations=calibrations, fps=args.fps, initial_rotation_correction=args.initial_rotation_correction, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, triangulation_method=args.triangulation_method, reprojection_threshold_px=args.reprojection_threshold_px, min_cameras_for_triangulation=args.min_cameras_for_triangulation, @@ -229,7 +264,8 @@ def main() -> None: calibrations=calibrations, fps=args.fps, initial_rotation_correction=args.initial_rotation_correction, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, triangulation_method=args.triangulation_method, reprojection_threshold_px=args.reprojection_threshold_px, min_cameras_for_triangulation=args.min_cameras_for_triangulation, @@ -258,6 +294,7 @@ def main() -> None: biorbd_kalman_init_method=args.biorbd_kalman_init_method, biomod_path=args.biomod, model_variant=args.model_variant, + symmetrize_limbs=not args.no_symmetrize_limbs, ) else: build_ekf_2d_bundle( @@ -268,7 +305,8 @@ def main() -> None: calibrations=calibrations, fps=args.fps, initial_rotation_correction=args.initial_rotation_correction, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, triangulation_method=args.triangulation_method, reprojection_threshold_px=args.reprojection_threshold_px, min_cameras_for_triangulation=args.min_cameras_for_triangulation, @@ -304,8 +342,13 @@ def main() -> None: skip_low_coherence_updates=args.skip_low_coherence_updates, flight_height_threshold_m=args.flight_height_threshold_m, flight_min_consecutive_frames=args.flight_min_consecutive_frames, + upper_back_sagittal_gain=args.upper_back_sagittal_gain, + upper_back_pseudo_std_deg=args.upper_back_pseudo_std_deg, + ankle_bed_pseudo_obs=args.ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=args.ankle_bed_pseudo_std_m, biomod_path=args.biomod, model_variant=args.model_variant, + symmetrize_limbs=not args.no_symmetrize_limbs, ) print(f"Bundle ecrit dans {args.output_dir}", flush=True) diff --git a/inputs/annotations/1_partie_0429_annotations.json b/inputs/annotations/1_partie_0429_annotations.json new file mode 100644 index 0000000..aeefeaa --- /dev/null +++ b/inputs/annotations/1_partie_0429_annotations.json @@ -0,0 +1,4092 @@ +{ + "annotations": { + "M11139": { + "0": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1080.3307368827966, + 644.8254165093247 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1412.6194891355574, + 651.7276649736746 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1315.6568366656145, + 639.471817436181 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1258.2169559105143, + 657.5827288315237 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1169.2280448781562, + 649.7149495268021 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1377.2978710292257, + 640.4976217301794 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1261.5815432900688, + 636.771377525147 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1348.9160956844446, + 705.5532412306794 + ] + } + }, + "126": { + "left_ankle": { + "score": 1.0, + "xy": [ + 985.8606554328014, + 796.5461531657875 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 927.3270236662995, + 509.61658568293564 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1019.7183443957779, + 529.1277962717695 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 910.6851087522941, + 502.73027606334716 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 993.8946833223213, + 620.3713987313165 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 981.8436414880414, + 698.9901002216178 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 972.6618953285902, + 510.764303952867 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1061.6100612482742, + 543.4742746459121 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 903.22493999774, + 506.7472900081071 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 962.3324308992076, + 796.5461531657875 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 900.9295034578772, + 530.8493736766666 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 919.8668549117455, + 596.8431741977226 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 899.2079260529802, + 515.3551770325927 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 953.1506847397562, + 629.5531448907677 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 952.0029664698249, + 712.7627194607948 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 908.3896722124314, + 558.9684712899862 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 941.6735020404421, + 632.4224405655962 + ] + } + }, + "183": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1288.668560194713, + 591.0109077151754 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1427.2007065682456, + 687.0388728150103 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1359.5088623175423, + 609.9016549479298 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1295.752590406996, + 609.1145404798984 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1399.6517001871453, + 650.044492817533 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1364.2315491257307, + 725.6074817485506 + ] + } + }, + "195": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1249.7496443633495, + 645.4811173974169 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1424.9884226500742, + 601.9390991673142 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1343.257913021439, + 606.9357242101129 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1412.8537618318487, + 590.1613401378602 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1315.419573497275, + 672.2487515552668 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1315.7764752860462, + 602.6529027448569 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1399.2914938585382, + 599.7976884346863 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1313.6350645534183, + 595.8717687582016 + ] + } + }, + "3": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1062.0695119124232, + 638.8015375046666 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1383.1297546983837, + 653.7800072492449 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1282.8391311912073, + 636.8478240597217 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1377.2686143635487, + 642.057726579575 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1230.7401059926742, + 645.965153469465 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1145.4279522300762, + 644.6626778395016 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1347.9629126893738, + 639.4527753196484 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1230.0888681776926, + 632.28915935485 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 1060.9705480996415, + 657.0361963241533 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1278.9317043013175, + 703.9253190028331 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 1224.8789656578392, + 697.4129408530165 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 1142.1717631551678, + 675.2708551436399 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1338.8455832796305, + 709.1352215226865 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1232.2460934398193, + 707.8327458927231 + ] + } + }, + "315": { + "left_ankle": { + "score": 1.0, + "xy": [ + 313.69727350687754, + 547.2893949805834 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 540.5738041552464, + 619.0366739035719 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 534.7564572155447, + 585.1021500886449 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 412.10739257016576, + 562.3175412414796 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 361.20560684777524, + 556.015415390136 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 562.8736340907699, + 597.2216228796902 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 526.9999946292756, + 583.6478133537195 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 288.4887701015032, + 506.0831874910292 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 530.3934470107683, + 559.8936466832706 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 480.94599802330333, + 527.4134596032691 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 533.3021204806192, + 577.3456875023759 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 412.10739257016576, + 530.3221330731199 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 342.78400820538633, + 524.5047861334182 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 515.850079661514, + 550.1980684504342 + ] + } + }, + "522": { + "left_ankle": { + "score": 1.0, + "xy": [ + 635.5335992209866, + 439.3091672115166 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 786.684623564277, + 560.0656920944714 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 732.0566718315117, + 460.2567276503965 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 682.3575578490711, + 454.9171534208781 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 796.1315625857326, + 519.8135171334865 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 783.3987317307271, + 603.1930224098124 + ] + }, + "mid_back": { + "score": 1.0, + "xy": [ + 785.0416776475021, + 512.4202605079994 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 792.434934272989, + 517.7598347375178 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 628.5510790746932, + 397.41404633375674 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 807.2214475239632, + 488.18680823556974 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 737.8069825402238, + 477.50765977653293 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 792.0241977937953, + 506.2592133200936 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 742.3250838113548, + 434.7910659403856 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 680.7146119322962, + 418.7723432518304 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 785.0416776475021, + 460.66746412959026 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 700.8406994127887, + 491.8834365483132 + ] + } + }, + "6": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1043.7798842000418, + 636.5374083641526 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1257.7156442366645, + 630.2451801277814 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1206.1193726984202, + 643.458859424161 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1121.1742915074083, + 642.2004137768868 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1316.8625896585545, + 631.5036257750556 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1199.827144462049, + 628.35751165687 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 1040.6337700818563, + 657.3017615441778 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1255.8279757657533, + 701.9765820224137 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 1206.1193726984202, + 691.2797940205826 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 1116.1405089183113, + 671.1446636641945 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1316.2333668349172, + 700.7181363751395 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1207.3778183456945, + 703.864250493325 + ] + } + } + }, + "M11140": { + "0": { + "right_ankle": { + "score": 1.0, + "xy": [ + 879.4238812390382, + 335.97063176690165 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 1131.455902795813, + 338.91063438947043 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1049.705268358346, + 331.3320588644825 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 1018.7072699706863, + 327.9056736610706 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 947.452857098818, + 337.42874873823973 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1096.1092994393623, + 338.595380681124 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1009.3938738650301, + 327.00812866103 + ] + } + }, + "126": { + "left_ankle": { + "score": 1.0, + "xy": [ + 812.9263109112231, + 246.32925444524008 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 829.3160630394482, + 427.4160279595303 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 820.9213119493817, + 371.0512706405128 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 813.3260609631311, + 302.2942617123497 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 790.9400580562873, + 445.8045303472949 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 865.6933177630693, + 410.6265257793974 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 787.342307589116, + 245.92950439333217 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 739.3723013601649, + 433.8120287900571 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 756.5615535922057, + 367.8532702252494 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 786.942557537208, + 365.0550198618939 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 786.942557537208, + 302.69401176425754 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 747.3673023983234, + 407.0287753122261 + ] + } + }, + "183": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1043.2270684690443, + 446.0419384054221 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1153.8489627258004, + 334.65183654966074 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1109.292921983496, + 417.6182572422278 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1114.6703751765326, + 330.8107985546345 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1148.4715095327635, + 341.56570494070803 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 1143.8622639387322, + 319.2876845695557 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1081.6374484193068, + 366.9165557078813 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 1040.9224456720285, + 387.65816088102304 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 1097.001600399412, + 426.8367484302908 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1076.25999522627, + 407.63155845515956 + ] + } + }, + "195": { + "right_ankle": { + "score": 1.0, + "xy": [ + 1021.3988388846489, + 389.80150380960913 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1120.4308485122265, + 383.8765117806088 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 1076.9809069662238, + 354.81583659074977 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 1069.3630600717947, + 419.7086064321825 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1138.7701095543705, + 409.5514772396104 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1080.6487591746527, + 398.83006309189545 + ] + } + }, + "3": { + "nose": { + "score": 1.0, + "xy": [ + 1102.6812101868093, + 361.70860995821204 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 863.66566955914, + 338.0223852113259 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 1115.0626458499542, + 344.4822646877494 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1028.1350875374294, + 331.04183238912515 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 1110.7560595323387, + 360.6319633788081 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 997.7081686949275, + 327.7942427069887 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 929.8794341924809, + 334.7924454731142 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1083.036040385245, + 332.62551372127365 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 995.4056733396931, + 329.4581510569766 + ] + } + }, + "315": { + "left_elbow": { + "score": 1.0, + "xy": [ + 372.18792451388725, + 428.9471279246578 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 280.150143630516, + 449.7422750698547 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 416.0887907093029, + 420.47503093957766 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 400.684978009157, + 393.9034540318261 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 416.4738860268065, + 397.3693118893589 + ] + } + }, + "522": { + "left_ankle": { + "score": 1.0, + "xy": [ + 450.2574736808681, + 605.3853817329931 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 640.3051852752523, + 492.9467953912957 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 608.5043729765903, + 473.2605782540288 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 635.7622120897291, + 481.5893624274879 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 566.4818710105014, + 578.8847048174416 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 507.80180069749434, + 588.3492322872814 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 617.5903193476365, + 506.5757149478651 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 606.990048581416, + 435.4024683746694 + ] + }, + "mid_back": { + "score": 1.0, + "xy": [ + 628.1905901138573, + 477.80355143955194 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 627.0548468174765, + 475.910645945584 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 468.050785324167, + 592.8922054728046 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 600.5541699019249, + 479.6964569335199 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 507.80180069749434, + 585.6991645957263 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 563.8318033189462, + 458.49591540107866 + ] + } + }, + "6": { + "nose": { + "score": 1.0, + "xy": [ + 1080.9006729735086, + 364.3096207019721 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 846.5144401288799, + 340.53931878612536 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 1094.167818228865, + 346.6200936948303 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1011.8009581018609, + 332.24735300152764 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 1091.9566273529722, + 363.756822982999 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 972.5523200547651, + 328.93056668768855 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 907.3221892159297, + 336.66973475331304 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1061.5527528094472, + 332.8001507205008 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 976.4219040875773, + 331.1417575635813 + ] + } + } + }, + "M11141": { + "0": { + "left_ankle": { + "score": 1.0, + "xy": [ + 869.7619542811327, + 726.90935959632 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1192.391045164973, + 697.383169923899 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1108.483222801581, + 723.3226254554087 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1182.4390312576315, + 676.7155420105588 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1050.1217319867078, + 726.3670846055277 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 961.1542927022067, + 724.5476882624258 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1164.5128367885166, + 711.6558616182008 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1057.5922791114567, + 729.6055203084912 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1166.685397414093, + 672.6025630825842 + ] + } + }, + "126": { + "left_ankle": { + "score": 1.0, + "xy": [ + 779.1316050918563, + 860.8995891000952 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 676.6097331726952, + 570.323312117787 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 792.0200689902651, + 601.9586325956997 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 656.105358788863, + 573.2525084583345 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 773.2732124107614, + 709.1672186597367 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 773.2732124107614, + 796.4572696080511 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 737.5370170560824, + 583.2117760161959 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 841.2305675114625, + 623.0488462476414 + ] + }, + "mid_back": { + "score": 1.0, + "xy": [ + 652.590323180206, + 589.0701686972909 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 654.3478409845345, + 585.5551330886339 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 748.6679631501627, + 863.2429461725332 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 693.5990719478705, + 704.4805045148607 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 642.0452163542352, + 587.8984901610718 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 732.8503029112064, + 719.7123254857077 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 737.5370170560824, + 794.113912535613 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 669.5796619553813, + 649.997452580678 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 723.4768746214545, + 753.6910030360582 + ] + } + }, + "183": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1079.847192705889, + 573.7700010732759 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1205.9033556845477, + 725.7789034887172 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1158.6322945675506, + 641.4325003191735 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1089.1160282190258, + 644.2131509731146 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1148.4365755031004, + 598.7958569587449 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1188.2925682095881, + 687.7766778848569 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1155.8516439136097, + 599.7227405100585 + ] + } + }, + "195": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1037.624986659855, + 652.2334070350756 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1216.5444607635234, + 611.0894773361308 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1142.5608807544115, + 654.1207432597978 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1200.6908364758565, + 591.083713354075 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1095.000007891411, + 707.7210920419094 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1102.5493527903, + 619.0162894799641 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1194.6513605567454, + 633.3600447878532 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1108.5888287094112, + 624.3008309091864 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1188.6118846376341, + 589.1963771293528 + ] + } + }, + "3": { + "left_ankle": { + "score": 1.0, + "xy": [ + 855.0116037083428, + 727.8359011847655 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1171.2784265350858, + 699.026170360467 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1079.0872878973307, + 726.5554687036855 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1161.0349666864463, + 676.6186019415682 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1024.0286912108936, + 724.6348199820657 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 943.361444902858, + 725.2750362226056 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1137.3469657864675, + 717.5924413361261 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1042.5949621865527, + 732.3174148685453 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1150.151290597267, + 674.0577369794083 + ] + } + }, + "315": { + "right_ear": { + "score": 1.0, + "xy": [ + 152.46540463389303, + 659.2126371595231 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 115.00837366342043, + 695.9052389265166 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 162.40298427912046, + 680.616654856936 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 104.68857941645348, + 646.599555302119 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 164.31405728781803, + 692.0830929091214 + ] + } + }, + "522": { + "left_elbow": { + "score": 1.0, + "xy": [ + 475.436182262977, + 578.1404980120519 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 486.97516567954733, + 628.3690140606518 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 486.97516567954733, + 556.4200586396844 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 191.71294296142634, + 360.9361042883767 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 510.05313251268774, + 532.6633280761574 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 416.3837377193529, + 545.5598389535006 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 493.0840392530257, + 548.2748938750466 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 385.839369851961, + 396.2318182684739 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 288.0973926763072, + 382.6565436607442 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 474.07865480220414, + 500.0826690176061 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 368.19151286191243, + 580.8555529335979 + ] + } + }, + "6": { + "left_ankle": { + "score": 1.0, + "xy": [ + 820.5467404895185, + 726.1602308528559 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1146.2960965157502, + 695.5695552869407 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1052.8705197874147, + 725.3334558375609 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1138.0283463628, + 677.3805049504506 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 994.1694937014693, + 726.9870058681508 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 910.6652171566739, + 725.3334558375609 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1118.18574599572, + 720.3728057457909 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1004.0907938850094, + 731.1208809446259 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1128.1070461792601, + 675.7269549198606 + ] + } + } + }, + "M11458": { + "0": { + "left_ankle": { + "score": 1.0, + "xy": [ + 497.0052972449817, + 615.6427295727798 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 423.1459010948139, + 619.1897693321486 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 217.9982516058435, + 603.0437858822997 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 499.65457295655165, + 626.5462649075969 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 223.8510171314956, + 629.8646872498158 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 305.93870978602854, + 648.6743536938764 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 213.84284451967687, + 606.5709592656451 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 343.72512802291885, + 646.8813970767644 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 426.8806259629955, + 629.2057766072522 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 248.23254708487798, + 646.7340082592468 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 353.75399131411973, + 654.1562870917687 + ] + } + }, + "126": { + "left_ankle": { + "score": 1.0, + "xy": [ + 571.7715018242791, + 728.001152714939 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 643.9427051990383, + 490.0188934855116 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 564.919805301359, + 511.9443223588562 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 569.9443827515004, + 591.8807817929248 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 572.6850613606684, + 664.051985167684 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 602.8325260615172, + 493.67313163106905 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 526.550304773006, + 528.3883940138646 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 591.8698116248449, + 728.9147122513284 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 633.4367705305607, + 584.57230550181 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 605.1164249024906, + 597.362139011261 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 600.0918474523492, + 660.8545267903213 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 652.6215207947372, + 542.5485668278997 + ] + } + }, + "183": { + "left_ankle": { + "score": 1.0, + "xy": [ + 317.18772083123156, + 520.8469477620316 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 249.48257501812384, + 544.7428815784226 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 228.772765710585, + 610.0584340098912 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 204.87683189419405, + 631.564774444643 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 327.542625485001, + 525.6261345253098 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 211.2490809118983, + 656.2572393882471 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 289.3091313787754, + 625.1925254269388 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 204.080300766981, + 645.1058036072646 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 329.13568773942706, + 585.3659690662872 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 247.09298163648475, + 653.0711148793949 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 294.8848492692666, + 575.0110644125177 + ] + } + }, + "3": { + "left_ankle": { + "score": 1.0, + "xy": [ + 524.1951499814278, + 613.51518654457 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 452.1368245298264, + 615.7323657892348 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 264.23088354449686, + 600.2121110765822 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 523.6408551702616, + 619.0581346562317 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 247.0477443983458, + 617.3952502227332 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 351.2551688975846, + 646.7728752145399 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 255.36216656583827, + 606.8636488105761 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 380.07849907822515, + 632.9155049353858 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 462.11413113081744, + 626.8182620125581 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 285.8483811799773, + 645.1099907810415 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 387.83862643455143, + 647.3271700257061 + ] + } + }, + "315": { + "left_elbow": { + "score": 1.0, + "xy": [ + 992.9927562108521, + 563.3649775582672 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1078.3099216080118, + 526.5234743185847 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1008.8281391822945, + 537.5112910742795 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 957.120766214319, + 557.2247270183202 + ] + } + }, + "6": { + "nose": { + "score": 1.0, + "xy": [ + 287.4105625956186, + 597.8875088393617 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 538.95067342584, + 617.4968185546209 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 271.8583514421372, + 622.2301002100282 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 372.60963239295165, + 641.1632268316578 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 274.5630838166557, + 607.3540721501764 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 410.4758856362108, + 633.0490297081024 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 482.15129356095133, + 624.2586494909172 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 311.7531539662852, + 639.134677550769 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 398.98077304450715, + 643.8679592061764 + ] + } + } + }, + "M11459": { + "0": { + "left_ankle": { + "score": 1.0, + "xy": [ + 754.675320479655, + 319.01780607636374 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 554.552735696345, + 299.5978541857911 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 473.9923100825863, + 358.3292906694476 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 603.3485792225429, + 313.4328515077141 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 679.868407830658, + 317.628925665132 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 500.97602997513025, + 310.9764223049102 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 600.1226566831393, + 294.81486462071746 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 487.2205025264908, + 361.6009845885343 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 756.8536364354126, + 332.10053416010396 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 575.1156061984327, + 361.18730059920654 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 491.150589522998, + 377.84792126380194 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 607.0532465923202, + 343.1598070605179 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 684.2319385819424, + 341.71220837370026 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 523.7574940877637, + 368.23296208598583 + ] + } + }, + "183": { + "left_ankle": { + "score": 1.0, + "xy": [ + 570.3092538140031, + 428.93199646221217 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 468.43927813573407, + 342.9084614450073 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 506.9234911697468, + 362.5278641682294 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 460.89335401141784, + 364.79164140552433 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 568.8000689891398, + 369.319195880114 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 485.04031120922974, + 339.13549938284916 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 503.9051215200203, + 412.3309633887165 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 469.19387054816565, + 368.56460346768245 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 462.40253883628105, + 372.33756552984056 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 503.1505291075887, + 413.08555580114813 + ] + } + }, + "195": { + "left_ankle": { + "score": 1.0, + "xy": [ + 615.9406487653774, + 388.1136606865082 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 455.4908828468051, + 390.45843805901336 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 517.4599991201577, + 349.25734994213576 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 465.2049605328982, + 405.1970386861891 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 562.3457373938293, + 344.90276339319746 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 555.981341668458, + 393.473151823663 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 471.90432445434175, + 364.66588696145584 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 557.3212144527466, + 374.0449964514768 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 471.23438806219735, + 411.56143441156047 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 467.2147697093313, + 418.5957665290762 + ] + } + }, + "3": { + "left_ankle": { + "score": 1.0, + "xy": [ + 773.8920878241494, + 317.29345350790084 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 499.1911750840351, + 339.8561568130437 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 590.0060558872351, + 305.44803427270085 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 506.5240536582065, + 360.16258978767223 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 623.8501108449493, + 320.1137914210437 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 699.9992344998066, + 320.1137914210437 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 533.5992976243779, + 311.08871009898655 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 630.0548542538636, + 295.29481778538656 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 516.1132025628922, + 365.803265613958 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 777.8405609025494, + 335.9076837346437 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 508.2162564060922, + 369.751738692358 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 631.1829894191208, + 354.52191396138653 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 707.8961806566065, + 344.9327650567009 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 544.8806492769494, + 372.0080090228723 + ] + } + }, + "315": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1499.6756805873024, + 368.1240529500402 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1257.4543803192669, + 393.80656087866765 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1391.4062844175985, + 374.16699599207016 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1444.7856146221966, + 373.1598388183985 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1287.6690955294168, + 382.7278319682793 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1242.8506013010278, + 375.1741531657418 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 1508.7400951503475, + 322.8019801348152 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 1277.0939452058644, + 346.47017371609934 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1298.2442458529695, + 289.5657934036502 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 1395.4349131122851, + 338.412916326726 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 1458.8858150536, + 328.3413445900094 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1308.8193961765219, + 329.34850176368104 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1266.5187948823118, + 311.2196726375911 + ] + } + }, + "6": { + "left_ankle": { + "score": 1.0, + "xy": [ + 802.335414612832, + 316.88137895549335 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 524.6854323783808, + 338.86200254905407 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 605.6666771967624, + 304.7341922327361 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 528.7344946192999, + 356.79356390169573 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 651.3632367728492, + 319.1951288074471 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 725.9816694983579, + 318.03825388147027 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 551.293555675849, + 315.146066566528 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 650.2063618468723, + 295.4791928249211 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 535.6757441751612, + 362.5779385315801 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 801.1785396868551, + 333.6560653821581 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 530.4698070082652, + 367.20543823548763 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 654.8338615507798, + 351.58762673479976 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 728.2954193503117, + 340.018877475031 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 565.1760547875716, + 371.83293793939515 + ] + } + } + }, + "M11461": { + "0": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1392.6871586396464, + 684.9205386081402 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1345.3345545086008, + 376.74872373179966 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1372.264611435352, + 436.63747940514173 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1328.7652396759324, + 398.9778297765467 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1387.2599174648242, + 499.84544245929123 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1384.2242982511136, + 591.5195826703596 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1359.251735166044, + 389.59383750591417 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1377.8737403053192, + 480.20309552983684 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1326.6610484021198, + 417.19342637363707 + ] + } + }, + "183": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1219.846703802921, + 518.4143959136456 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1383.6187694080372, + 371.76882082932565 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1278.7190149681587, + 418.866669761516 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1285.1414489134575, + 484.16141487205266 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1248.747656556765, + 444.55640554271076 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1340.8025431060462, + 380.3320660897239 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1241.2548169539166, + 439.20437725496186 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 1231.6211660359684, + 541.9633203797408 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1407.1676938741325, + 448.8380281729099 + ] + } + }, + "195": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1310.8516847264998, + 550.7040927876094 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1293.4602659696804, + 423.0436785088295 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1357.1054580159127, + 469.2974517982425 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1262.7477605055103, + 474.4778744066567 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1272.3685453497083, + 379.7501467099389 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1263.857851064456, + 470.7775725435037 + ] + } + }, + "3": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1385.5038946606785, + 710.4561141192088 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1345.3286984967226, + 396.3591259282825 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1366.0250116720938, + 451.1434843336766 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1325.8498155081381, + 415.2292938234738 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1376.9818833531726, + 513.8411389531832 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1381.2428890069255, + 606.36583314896 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1355.6768550844083, + 406.0985674225748 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1372.7208776994198, + 495.5796861513852 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1326.4585306015315, + 431.0558862516988 + ] + } + }, + "6": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1385.5841701301651, + 735.1537757623446 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1345.5502219309865, + 418.6949471402641 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1364.6140067877382, + 475.25084221529454 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1326.4864370742346, + 433.9459750256656 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1379.8650346731397, + 541.3386297187009 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1379.8650346731397, + 631.5738780406596 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1353.8111953689122, + 426.9559205781899 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1372.239520730439, + 524.1812233476242 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1324.5800585885593, + 452.3743003871923 + ] + } + } + }, + "M11462": { + "0": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1171.8664453421072, + 557.0513940503954 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1531.7460470129608, + 493.8632906496629 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1438.6399801303187, + 494.44573293749215 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1377.5034178483884, + 527.3729088339298 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1276.1717106381407, + 540.4521113163795 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1501.0575773009236, + 489.7761377932385 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1381.5400578301583, + 499.7402469214232 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 1167.4618116663828, + 584.6107186740531 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1413.5854192533413, + 588.968197360618 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 1366.4633986918864, + 577.1935012803518 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 1269.8530710761597, + 577.9763522538185 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1356.8589077112993, + 606.1664693548767 + ] + } + }, + "183": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1367.091293773179, + 481.6343098378568 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1562.8027604737108, + 536.6781598473814 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1463.7238304565665, + 468.17914650219524 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1392.7784237776236, + 485.3038998384918 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1463.7238304565665, + 490.1966865060051 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1522.437270466726, + 495.0894731735184 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 1363.4217037725439, + 502.42865317478834 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 1556.686777139319, + 577.0436498543661 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1434.3671104514867, + 586.8292231893927 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 1378.1000637750838, + 550.133323183043 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1502.8661237966728, + 592.9452065237843 + ] + } + }, + "195": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1349.7617252655398, + 537.447209494842 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1448.9728487740347, + 468.17347764154215 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1425.649532019406, + 540.2320831371857 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1407.895962549465, + 476.8762077738662 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1507.8205184687472, + 451.09137949878254 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1404.0667612912423, + 468.17347764154215 + ] + } + }, + "3": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1156.0279556219723, + 555.6618416769707 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1409.4043347774798, + 500.21382518587626 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1351.8506974069767, + 531.0962647505364 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1259.2033787129963, + 542.3262427740492 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1475.3804556656175, + 489.685720788833 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1352.5525710334464, + 499.5119515594067 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 1153.9223347425636, + 583.0349131092831 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1405.1930930186625, + 587.2461548681005 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 1349.745076527568, + 584.4386603622222 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 1255.6940105806484, + 579.5255449769354 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1467.6598457744526, + 580.9292922298746 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1343.4282138893423, + 608.302363662187 + ] + } + }, + "315": { + "left_elbow": { + "score": 1.0, + "xy": [ + 416.9556525581594, + 696.0263482871857 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 415.07649402190464, + 642.7835230933017 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 220.89677860891626, + 649.6737710595692 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 381.87802654807115, + 654.6848604895818 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 450.7805062107444, + 658.4431775620911 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 406.30708751938255, + 637.7724336632891 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 403.1751566256247, + 603.3211938319525 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 356.19619321925654, + 559.4741613193423 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 412.57094930689834, + 623.9919377307544 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 212.1273721063942, + 603.9475800107041 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 351.18510378924395, + 580.1449052181442 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 405.05431516187946, + 587.6615393631631 + ] + } + }, + "6": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1125.3924627490478, + 555.5002393138973 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1482.415779403776, + 504.7013305313608 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1377.9560514847574, + 498.97750982346935 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 1321.4333219943294, + 531.8894788938451 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1220.5509820177429, + 540.4752099556822 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1445.2109448024817, + 491.82273393860504 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1316.4249788749244, + 508.27871847379294 + ] + }, + "mid_back": { + "score": 1.0, + "xy": [ + 1377.9560514847574, + 556.9311944908702 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 1131.8317610454258, + 584.8348204418409 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1377.240573896271, + 588.4122083842731 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 1326.4416651137344, + 582.6883876763816 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 1223.4128923716887, + 581.2574324994088 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1445.2109448024817, + 579.1109997339495 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1316.4249788749244, + 606.2991480964338 + ] + } + } + }, + "M11463": { + "0": { + "left_ankle": { + "score": 1.0, + "xy": [ + 757.9987869649196, + 460.3144932078461 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 952.8409710355462, + 502.65078109202193 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1049.6841363292465, + 489.72770549796275 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 910.1922051295279, + 476.62953768873876 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 835.6793918116882, + 469.81909177673805 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 1006.3189673840963, + 503.4829908025896 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 905.6685132760877, + 503.27257952492477 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1038.0963736147175, + 489.8846628690637 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 761.1187758663879, + 436.98267751555625 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 957.9999395278375, + 418.95086471923565 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 1043.2954893938083, + 471.48887750207575 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 919.2179604902078, + 428.4246350729478 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 840.6014279896718, + 433.99600787127963 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1009.4274446162445, + 422.8143900007684 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 914.9822191728457, + 407.0770830168775 + ] + } + }, + "1291": { + "left_shoulder": { + "score": 1.0, + "xy": [ + 918.7400519400102, + 687.6502557583963 + ] + } + }, + "183": { + "left_ankle": { + "score": 1.0, + "xy": [ + 1008.3021542166565, + 484.5068868378409 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 1029.4768924934308, + 505.68162511461526 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1075.0346627252786, + 434.45750545637435 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 976.8608761693249, + 492.20679166575883 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 1067.9764166330206, + 479.3736169525623 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 1057.7098768624633, + 501.1900139649964 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 1001.8855668600582, + 467.18210097502555 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1007.6604954809967, + 401.0912512020633 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 1076.3179801965982, + 448.5739976408905 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 968.5193126057472, + 446.00736269825126 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 1069.90139284, + 478.73195821690246 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1037.1767973213487, + 408.7911560299812 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1020.4936701941932, + 420.982672007518 + ] + } + }, + "195": { + "left_eye": { + "score": 1.0, + "xy": [ + 1099.3255953405408, + 513.2600085040583 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1093.931320397199, + 506.51716482488104 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 941.5430532477931, + 419.19733917953556 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 1108.42843430743, + 483.25435413171954 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 1057.5199645296418, + 401.3288034297159 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 1100.6741640763762, + 500.7857476975804 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 994.4743761293345, + 403.35165653346905 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 1010.6572009593599, + 451.5629888395864 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 1092.2456094774047, + 441.1115811368617 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 1024.1428883177146, + 421.55733446724764 + ] + } + }, + "3": { + "left_ankle": { + "score": 1.0, + "xy": [ + 740.0223022655966, + 457.5155240680274 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1029.0946169847891, + 486.7335859858813 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 934.602161420666, + 499.1668038232659 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1032.8245823360046, + 481.1386379590582 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 890.4642380979506, + 474.30036814849666 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 817.7299137492505, + 466.21877655419667 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 988.0649981214199, + 501.0317864988736 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 890.4642380979506, + 499.78846471513515 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1024.1213298498353, + 473.67870725662743 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 738.7789804818581, + 433.2707492851274 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 1030.9595996603969, + 451.29891514933513 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 948.2787010417891, + 417.7292269883966 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 1035.9328867953507, + 469.3270810135428 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 899.789151475989, + 437.00071463634276 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 824.568183559812, + 434.51407106886586 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 998.6332332831969, + 426.43247947456587 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 899.1674905841198, + 410.2692962859658 + ] + } + }, + "315": { + "left_ear": { + "score": 1.0, + "xy": [ + 76.88890665604526, + 381.4683496635007 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 88.87678296448021, + 318.86499560834045 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 45.25423306434192, + 360.48956612373956 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 126.17239814627786, + 364.48552489321787 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 80.88486542552357, + 421.76093392240705 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 49.58318839794343, + 478.70334638747306 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 36.596322397138906, + 449.3996487446321 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 86.87880357974106, + 447.0686727957697 + ] + } + }, + "6": { + "left_ankle": { + "score": 1.0, + "xy": [ + 715.1427080327087, + 457.7135875534432 + ] + }, + "left_ear": { + "score": 1.0, + "xy": [ + 1004.4675338676643, + 488.80331664535726 + ] + }, + "left_elbow": { + "score": 1.0, + "xy": [ + 915.0052521950136, + 497.68609638590414 + ] + }, + "left_eye": { + "score": 1.0, + "xy": [ + 1012.7158293410293, + 479.2860526376285 + ] + }, + "left_hip": { + "score": 1.0, + "xy": [ + 873.7637748281887, + 473.57569423299117 + ] + }, + "left_knee": { + "score": 1.0, + "xy": [ + 795.0877256976306, + 468.4998200955358 + ] + }, + "left_shoulder": { + "score": 1.0, + "xy": [ + 965.7639935695672, + 500.2240334546318 + ] + }, + "left_wrist": { + "score": 1.0, + "xy": [ + 868.0534164235514, + 497.05161211872223 + ] + }, + "nose": { + "score": 1.0, + "xy": [ + 1001.9295967989367, + 472.30672569862736 + ] + }, + "right_ankle": { + "score": 1.0, + "xy": [ + 718.3151293686183, + 430.4307640646206 + ] + }, + "right_ear": { + "score": 1.0, + "xy": [ + 1007.005470936392, + 448.19632354571434 + ] + }, + "right_elbow": { + "score": 1.0, + "xy": [ + 924.5225162027424, + 417.10659445380026 + ] + }, + "right_eye": { + "score": 1.0, + "xy": [ + 1012.7158293410293, + 466.59636729399006 + ] + }, + "right_hip": { + "score": 1.0, + "xy": [ + 882.0120703015536, + 430.4307640646206 + ] + }, + "right_knee": { + "score": 1.0, + "xy": [ + 800.163599835086, + 432.33421686616634 + ] + }, + "right_shoulder": { + "score": 1.0, + "xy": [ + 975.281257577296, + 424.08592139280137 + ] + }, + "right_wrist": { + "score": 1.0, + "xy": [ + 878.2051646984622, + 407.5893304460715 + ] + } + } + } + }, + "keypoints_source": "/Users/mickaelbegon/Documents/Playground/inputs/keypoints/1_partie_0429_keypoints.json", + "schema_version": 1 +} \ No newline at end of file diff --git a/judging/__init__.py b/judging/__init__.py index e69de29..00a7772 100644 --- a/judging/__init__.py +++ b/judging/__init__.py @@ -0,0 +1 @@ +"""Judging and scoring helpers for DD, execution, and trampoline analyses.""" diff --git a/judging/body_geometry.py b/judging/body_geometry.py new file mode 100644 index 0000000..4c6d37b --- /dev/null +++ b/judging/body_geometry.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Shared geometric body-angle helpers used by DD and execution analysis.""" + +from __future__ import annotations + +import numpy as np + +from vitpose_ekf_pipeline import KP_INDEX + + +def vector_angle(vectors_a: np.ndarray, vectors_b: np.ndarray) -> np.ndarray: + """Return the frame-wise angle between two vector series.""" + + vectors_a = np.asarray(vectors_a, dtype=float) + vectors_b = np.asarray(vectors_b, dtype=float) + if vectors_a.shape != vectors_b.shape: + raise ValueError("Both vector series must share the same shape.") + norms_a = np.linalg.norm(vectors_a, axis=-1) + norms_b = np.linalg.norm(vectors_b, axis=-1) + denom = norms_a * norms_b + cosine = np.full(vectors_a.shape[:-1], np.nan, dtype=float) + valid = ( + np.all(np.isfinite(vectors_a), axis=-1) + & np.all(np.isfinite(vectors_b), axis=-1) + & np.isfinite(denom) + & (denom > 1e-12) + ) + if np.any(valid): + cosine[valid] = np.sum(vectors_a[valid] * vectors_b[valid], axis=-1) / denom[valid] + return np.arccos(np.clip(cosine, -1.0, 1.0)) + + +def midpoint(points_a: np.ndarray, points_b: np.ndarray) -> np.ndarray: + """Return the midpoint between two point trajectories.""" + + return 0.5 * (np.asarray(points_a, dtype=float) + np.asarray(points_b, dtype=float)) + + +def joint_angle_series( + points_proximal: np.ndarray, + points_joint: np.ndarray, + points_distal: np.ndarray, +) -> np.ndarray: + """Return the internal joint angle defined by three point series.""" + + return vector_angle( + np.asarray(points_proximal, dtype=float) - np.asarray(points_joint, dtype=float), + np.asarray(points_distal, dtype=float) - np.asarray(points_joint, dtype=float), + ) + + +def knee_angle_series(points_3d: np.ndarray) -> np.ndarray: + """Estimate the average internal knee angle from left and right leg markers.""" + + points_3d = np.asarray(points_3d, dtype=float) + left = joint_angle_series( + points_3d[:, KP_INDEX["left_hip"], :], + points_3d[:, KP_INDEX["left_knee"], :], + points_3d[:, KP_INDEX["left_ankle"], :], + ) + right = joint_angle_series( + points_3d[:, KP_INDEX["right_hip"], :], + points_3d[:, KP_INDEX["right_knee"], :], + points_3d[:, KP_INDEX["right_ankle"], :], + ) + return np.nanmean(np.column_stack((left, right)), axis=1) + + +def hip_angle_series(points_3d: np.ndarray) -> np.ndarray: + """Estimate the average internal hip angle from trunk and thigh directions.""" + + points_3d = np.asarray(points_3d, dtype=float) + shoulder_center = midpoint(points_3d[:, KP_INDEX["left_shoulder"], :], points_3d[:, KP_INDEX["right_shoulder"], :]) + left = joint_angle_series( + shoulder_center, + points_3d[:, KP_INDEX["left_hip"], :], + points_3d[:, KP_INDEX["left_knee"], :], + ) + right = joint_angle_series( + shoulder_center, + points_3d[:, KP_INDEX["right_hip"], :], + points_3d[:, KP_INDEX["right_knee"], :], + ) + return np.nanmean(np.column_stack((left, right)), axis=1) + + +def arm_raise_series(points_3d: np.ndarray) -> np.ndarray: + """Estimate how far the upper arms move away from the trunk.""" + + points_3d = np.asarray(points_3d, dtype=float) + hip_center = midpoint(points_3d[:, KP_INDEX["left_hip"], :], points_3d[:, KP_INDEX["right_hip"], :]) + left = vector_angle( + points_3d[:, KP_INDEX["left_elbow"], :] - points_3d[:, KP_INDEX["left_shoulder"], :], + hip_center - points_3d[:, KP_INDEX["left_shoulder"], :], + ) + right = vector_angle( + points_3d[:, KP_INDEX["right_elbow"], :] - points_3d[:, KP_INDEX["right_shoulder"], :], + hip_center - points_3d[:, KP_INDEX["right_shoulder"], :], + ) + return np.nanmean(np.column_stack((left, right)), axis=1) diff --git a/judging/dd_analysis.py b/judging/dd_analysis.py index 6353af7..991902c 100644 --- a/judging/dd_analysis.py +++ b/judging/dd_analysis.py @@ -9,6 +9,7 @@ import numpy as np from scipy.spatial.transform import Rotation as R +from judging.body_geometry import hip_angle_series, knee_angle_series from kinematics.root_kinematics import ( ROOT_ROTATION_SLICE, TRUNK_ROOT_ROTATION_SEQUENCE, @@ -18,6 +19,11 @@ DEFAULT_HIP_DOFS = ("LEFT_THIGH:RotY", "RIGHT_THIGH:RotY") DEFAULT_KNEE_DOFS = ("LEFT_SHANK:RotY", "RIGHT_SHANK:RotY") +BODY_SHAPE_DISPLAY = { + "grouped": "groupé", + "piked": "carpé", + "straight": "tendu", +} def dd_debug(message: str) -> None: @@ -400,6 +406,25 @@ def detect_body_shape( return "straight" +def detect_body_shape_from_curves( + hip_curve_rad: np.ndarray, + knee_curve_rad: np.ndarray, + *, + hip_threshold_deg: float = 70.0, + knee_tuck_threshold_deg: float = 70.0, + knee_pike_threshold_deg: float = 20.0, +) -> str: + """Infer one coarse body shape directly from geometric flexion curves.""" + + hip_value = float(np.rad2deg(np.nanmax(hip_curve_rad))) if np.any(np.isfinite(hip_curve_rad)) else float("nan") + knee_value = float(np.rad2deg(np.nanmax(knee_curve_rad))) if np.any(np.isfinite(knee_curve_rad)) else float("nan") + if hip_value >= hip_threshold_deg and knee_value >= knee_tuck_threshold_deg: + return "grouped" + if hip_value >= hip_threshold_deg and knee_value <= knee_pike_threshold_deg: + return "piked" + return "straight" + + def normalize_flexion_curve_rad(curve_rad: np.ndarray) -> np.ndarray: """Fold wrapped joint angles back to a physiological flexion range. @@ -416,6 +441,15 @@ def normalize_flexion_curve_rad(curve_rad: np.ndarray) -> np.ndarray: return normalized +def flexion_curve_from_internal_angle(angle_curve_rad: np.ndarray) -> np.ndarray: + """Convert one internal joint-angle curve into a flexion magnitude.""" + + angle_curve = np.asarray(angle_curve_rad, dtype=float) + flexion = np.abs(np.pi - angle_curve) + flexion[~np.isfinite(angle_curve)] = np.nan + return flexion + + def flexion_curves_from_segment( q_segment: np.ndarray, hip_indices: list[int], @@ -450,7 +484,23 @@ def body_shape_phase_masks( def body_shape_suffix(body_shape: str) -> str: """Map a body-shape label to the suffix used in DD codes.""" - return {"grouped": "o", "piked": "<", "straight": "/"}.get(body_shape, "?") + normalized = str(body_shape).strip().lower() + return { + "grouped": "o", + "groupé": "o", + "piked": "<", + "carpé": "<", + "straight": "/", + "tendu": "/", + }.get(normalized, "?") + + +def body_shape_display_label(body_shape: str | None) -> str | None: + """Return the French display label used in the GUI summaries.""" + + if body_shape is None: + return None + return BODY_SHAPE_DISPLAY.get(str(body_shape), str(body_shape)) def quarter_salto_count_token(total_saltos: float) -> str: @@ -493,6 +543,7 @@ def analyze_single_jump( segment: JumpSegment, full_q: np.ndarray | None = None, q_names: list[str] | None = None, + points_3d: np.ndarray | None = None, angle_mode: str = "euler", ) -> DDJumpAnalysis: """Analyze one jump segment and derive its DD-oriented summary metrics.""" @@ -529,7 +580,15 @@ def analyze_single_jump( knee_flex_curve = np.full_like(som, np.nan) grouped_mask = np.zeros_like(som, dtype=bool) piked_mask = np.zeros_like(som, dtype=bool) - if full_q is not None and q_names is not None: + if points_3d is not None: + points_segment = np.asarray(points_3d[segment.start : segment.end + 1], dtype=float) + if points_segment.ndim == 3 and points_segment.shape[0] == som.shape[0]: + hip_flex_curve = flexion_curve_from_internal_angle(hip_angle_series(points_segment)) + knee_flex_curve = flexion_curve_from_internal_angle(knee_angle_series(points_segment)) + if np.any(np.isfinite(hip_flex_curve)) and np.any(np.isfinite(knee_flex_curve)): + grouped_mask, piked_mask = body_shape_phase_masks(hip_flex_curve, knee_flex_curve) + body_shape = detect_body_shape_from_curves(hip_flex_curve, knee_flex_curve) + if body_shape is None and full_q is not None and q_names is not None: body_indices = default_body_shape_indices(q_names) if body_indices is not None: hip_indices, knee_indices = body_indices @@ -581,6 +640,7 @@ def analyze_dd_session( contact_window_s: float = 0.35, full_q: np.ndarray | None = None, q_names: list[str] | None = None, + points_3d: np.ndarray | None = None, angle_mode: str = "euler", analysis_start_frame: int = 0, require_complete_jumps: bool = True, @@ -598,10 +658,15 @@ def analyze_dd_session( analysis_start_frame = max(0, int(analysis_start_frame)) window_frames = max(1, int(round(float(smoothing_window_s) * float(fps)))) smoothed_height = smooth_signal(height, window_frames=window_frames) + threshold_reference = ( + smoothed_height[analysis_start_frame:] if analysis_start_frame < smoothed_height.shape[0] else smoothed_height + ) + if threshold_reference.size == 0: + threshold_reference = smoothed_height effective_threshold = ( float(height_threshold) if height_threshold is not None - else relative_height_threshold(smoothed_height, ratio=height_threshold_range_ratio) + else relative_height_threshold(threshold_reference, ratio=height_threshold_range_ratio) ) airborne_mask = smoothed_height > effective_threshold airborne_mask[:analysis_start_frame] = False @@ -634,7 +699,14 @@ def analyze_dd_session( ) q_name_list = list(q_names) if q_names is not None else None jumps = [ - analyze_single_jump(root_q, segment, full_q=full_q, q_names=q_name_list, angle_mode=angle_mode) + analyze_single_jump( + root_q, + segment, + full_q=full_q, + q_names=q_name_list, + points_3d=points_3d, + angle_mode=angle_mode, + ) for segment in segments ] dd_debug(f"analyze_dd_session done jumps={len(jumps)}") diff --git a/judging/dd_presenter.py b/judging/dd_presenter.py index b8ff080..f27663f 100644 --- a/judging/dd_presenter.py +++ b/judging/dd_presenter.py @@ -7,7 +7,7 @@ import numpy as np -from judging.dd_analysis import DDJumpAnalysis, DDSessionAnalysis +from judging.dd_analysis import DDJumpAnalysis, DDSessionAnalysis, body_shape_display_label @dataclass @@ -191,11 +191,12 @@ def jump_list_label(index: int, jump: DDJumpAnalysis) -> str: def jump_list_label_with_reference(index: int, jump: DDJumpAnalysis, expected_code: str | None = None) -> str: - """Build one jump-list label and append the expected DD code when available.""" + """Build one jump-list label and append detected/expected DD codes when available.""" label = jump_list_label(index, jump) if expected_code: - return f"{label} | ref {expected_code}" + detected_code = str(jump.code or "-") + return f"{label} | det {detected_code} | exp {expected_code}" return label @@ -237,7 +238,7 @@ def format_dd_summary( f"{jump.classification}" ) code_text = jump.code if jump.code is not None else "-" - body_shape = jump.body_shape if jump.body_shape is not None else "-" + body_shape = body_shape_display_label(jump.body_shape) if jump.body_shape is not None else "-" lines.append(f" body shape={body_shape} | code={code_text}") expected_code = (expected_codes_by_jump or {}).get(idx) if expected_code: diff --git a/judging/execution.py b/judging/execution.py index bdfdc49..fc4006f 100644 --- a/judging/execution.py +++ b/judging/execution.py @@ -14,12 +14,15 @@ from __future__ import annotations +import os +import re from dataclasses import dataclass from pathlib import Path import numpy as np from scipy.signal import butter, filtfilt +from judging.body_geometry import arm_raise_series, hip_angle_series, knee_angle_series from judging.dd_analysis import DDSessionAnalysis, JumpSegment from vitpose_ekf_pipeline import KP_INDEX @@ -34,6 +37,8 @@ EXECUTION_KNEE_DOF_NAMES = ("LEFT_SHANK:RotY", "RIGHT_SHANK:RotY") ROOT_TILT_DOF_NAME = "TRUNK:RotX" ROOT_TRANSLATION_VELOCITY_NAMES = ("TRUNK:TransX", "TRUNK:TransY", "TRUNK:TransZ") +SUPPORTED_OVERLAY_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff") +_EXECUTION_IMAGE_INDEX_CACHE: dict[str, tuple[float, dict[tuple[str, int], Path], dict[str, set[int]]]] = {} @dataclass @@ -230,98 +235,6 @@ def _optional_dof_indices(q_names: list[str] | np.ndarray, names: tuple[str, ... return [name_to_index[name] for name in names if name in name_to_index] -def _vector_angle(vectors_a: np.ndarray, vectors_b: np.ndarray) -> np.ndarray: - """Return the frame-wise angle between two vector series.""" - - vectors_a = np.asarray(vectors_a, dtype=float) - vectors_b = np.asarray(vectors_b, dtype=float) - if vectors_a.shape != vectors_b.shape: - raise ValueError("Both vector series must share the same shape.") - norms_a = np.linalg.norm(vectors_a, axis=-1) - norms_b = np.linalg.norm(vectors_b, axis=-1) - denom = norms_a * norms_b - cosine = np.full(vectors_a.shape[:-1], np.nan, dtype=float) - valid = ( - np.all(np.isfinite(vectors_a), axis=-1) - & np.all(np.isfinite(vectors_b), axis=-1) - & np.isfinite(denom) - & (denom > 1e-12) - ) - if np.any(valid): - cosine[valid] = np.sum(vectors_a[valid] * vectors_b[valid], axis=-1) / denom[valid] - return np.arccos(np.clip(cosine, -1.0, 1.0)) - - -def _midpoint(points_a: np.ndarray, points_b: np.ndarray) -> np.ndarray: - """Return the midpoint between two point trajectories.""" - - return 0.5 * (np.asarray(points_a, dtype=float) + np.asarray(points_b, dtype=float)) - - -def _joint_angle_series( - points_proximal: np.ndarray, - points_joint: np.ndarray, - points_distal: np.ndarray, -) -> np.ndarray: - """Return the internal joint angle defined by three point series.""" - - return _vector_angle( - np.asarray(points_proximal, dtype=float) - np.asarray(points_joint, dtype=float), - np.asarray(points_distal, dtype=float) - np.asarray(points_joint, dtype=float), - ) - - -def knee_angle_series(points_3d: np.ndarray) -> np.ndarray: - """Estimate the average internal knee angle from left and right leg markers.""" - - points_3d = np.asarray(points_3d, dtype=float) - left = _joint_angle_series( - points_3d[:, KP_INDEX["left_hip"], :], - points_3d[:, KP_INDEX["left_knee"], :], - points_3d[:, KP_INDEX["left_ankle"], :], - ) - right = _joint_angle_series( - points_3d[:, KP_INDEX["right_hip"], :], - points_3d[:, KP_INDEX["right_knee"], :], - points_3d[:, KP_INDEX["right_ankle"], :], - ) - return np.nanmean(np.column_stack((left, right)), axis=1) - - -def hip_angle_series(points_3d: np.ndarray) -> np.ndarray: - """Estimate the average internal hip angle from trunk and thigh directions.""" - - points_3d = np.asarray(points_3d, dtype=float) - shoulder_center = _midpoint(points_3d[:, KP_INDEX["left_shoulder"], :], points_3d[:, KP_INDEX["right_shoulder"], :]) - left = _joint_angle_series( - shoulder_center, - points_3d[:, KP_INDEX["left_hip"], :], - points_3d[:, KP_INDEX["left_knee"], :], - ) - right = _joint_angle_series( - shoulder_center, - points_3d[:, KP_INDEX["right_hip"], :], - points_3d[:, KP_INDEX["right_knee"], :], - ) - return np.nanmean(np.column_stack((left, right)), axis=1) - - -def arm_raise_series(points_3d: np.ndarray) -> np.ndarray: - """Estimate how far the upper arms move away from the trunk.""" - - points_3d = np.asarray(points_3d, dtype=float) - hip_center = _midpoint(points_3d[:, KP_INDEX["left_hip"], :], points_3d[:, KP_INDEX["right_hip"], :]) - left = _vector_angle( - points_3d[:, KP_INDEX["left_elbow"], :] - points_3d[:, KP_INDEX["left_shoulder"], :], - hip_center - points_3d[:, KP_INDEX["left_shoulder"], :], - ) - right = _vector_angle( - points_3d[:, KP_INDEX["right_elbow"], :] - points_3d[:, KP_INDEX["right_shoulder"], :], - hip_center - points_3d[:, KP_INDEX["right_shoulder"], :], - ) - return np.nanmean(np.column_stack((left, right)), axis=1) - - def root_tilt_series(q_series: np.ndarray, q_names: list[str] | np.ndarray) -> np.ndarray: """Extract the root tilt DoF from the generalized coordinates.""" @@ -421,6 +334,103 @@ def _normalize_overlay_token(value: str) -> str: return "".join(ch for ch in str(value).lower() if ch.isalnum()) +def _extract_overlay_frame_number(path: Path) -> int | None: + """Extract one frame number from a pragmatic camera-image filename.""" + + stem = str(path.stem) + patterns = ( + r"(?:^|[_-])frame[_-]?(\d+)(?:$|[_-])", + r"(?:^|[_-])(\d{4,})(?:$|[_-])", + ) + for pattern in patterns: + match = re.search(pattern, stem, flags=re.IGNORECASE) + if match: + try: + return int(match.group(1)) + except ValueError: + return None + return None + + +def _overlay_camera_tokens(path: Path, *, directory_token: str | None = None) -> set[str]: + tokens: set[str] = set() + if directory_token: + tokens.add(directory_token) + for match in re.findall(r"M\d{4,}", path.stem, flags=re.IGNORECASE): + tokens.add(_normalize_overlay_token(match)) + return tokens + + +def _execution_image_index( + images_root: Path | str | None, +) -> tuple[dict[tuple[str, int], Path], dict[str, set[int]]]: + """Index execution images once per root to avoid repeated directory scans.""" + + if images_root is None: + return {}, {} + root = Path(images_root) + if not root.exists() or not root.is_dir(): + return {}, {} + + cache_key = str(root.resolve()) + try: + root_mtime = float(root.stat().st_mtime_ns) + except OSError: + root_mtime = -1.0 + cached = _EXECUTION_IMAGE_INDEX_CACHE.get(cache_key) + if cached is not None and cached[0] == root_mtime: + return cached[1], cached[2] + + paths_by_camera_frame: dict[tuple[str, int], Path] = {} + frames_by_camera: dict[str, set[int]] = {} + + candidate_directories: list[tuple[Path, str | None]] = [(root, None)] + try: + with os.scandir(root) as entries: + for entry in entries: + if entry.is_dir(): + candidate_directories.append((Path(entry.path), _normalize_overlay_token(entry.name))) + except OSError: + pass + + for directory, directory_token in candidate_directories: + try: + with os.scandir(directory) as entries: + for entry in entries: + if not entry.is_file(): + continue + path = Path(entry.path) + if path.suffix.lower() not in SUPPORTED_OVERLAY_IMAGE_EXTENSIONS: + continue + frame_number = _extract_overlay_frame_number(path) + if frame_number is None: + continue + for token in _overlay_camera_tokens(path, directory_token=directory_token): + frames_by_camera.setdefault(token, set()).add(frame_number) + paths_by_camera_frame.setdefault((token, frame_number), path) + except OSError: + continue + + _EXECUTION_IMAGE_INDEX_CACHE[cache_key] = (root_mtime, paths_by_camera_frame, frames_by_camera) + return paths_by_camera_frame, frames_by_camera + + +def available_execution_image_frames( + images_root: Path | str | None, + camera_names: list[str] | tuple[str, ...] | None = None, +) -> dict[str, set[int]]: + """Return available frame ids per camera without rescanning the disk repeatedly.""" + + _paths_by_camera_frame, frames_by_camera = _execution_image_index(images_root) + if camera_names is None: + return {camera: set(frames) for camera, frames in frames_by_camera.items()} + available: dict[str, set[int]] = {} + for camera_name in camera_names: + token = _normalize_overlay_token(str(camera_name)) + available[str(camera_name)] = set(frames_by_camera.get(token, set())) + return available + + def infer_execution_images_root(keypoints_path: Path | str | None) -> Path | None: """Infer the most likely image root for a keypoint file. @@ -457,7 +467,12 @@ def resolve_execution_image_path(images_root: Path | str | None, camera_name: st frame_number = int(frame_number) camera_name = str(camera_name) - extensions = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff") + normalized_camera = _normalize_overlay_token(camera_name) + indexed_paths, _frames_by_camera = _execution_image_index(images_root) + indexed = indexed_paths.get((normalized_camera, frame_number)) + if indexed is not None and indexed.exists(): + return indexed + frame_tokens = ( f"{frame_number}", f"{frame_number:04d}", @@ -468,7 +483,7 @@ def resolve_execution_image_path(images_root: Path | str | None, camera_name: st f"frame_{frame_number:05d}", f"frame_{frame_number:06d}", ) - camera_tokens = {camera_name, camera_name.lower(), _normalize_overlay_token(camera_name)} + camera_tokens = {camera_name, camera_name.lower(), normalized_camera} candidate_dirs = [images_root] for child in images_root.iterdir(): @@ -477,13 +492,23 @@ def resolve_execution_image_path(images_root: Path | str | None, camera_name: st for directory in candidate_dirs: for frame_token in frame_tokens: - for extension in extensions: + for extension in SUPPORTED_OVERLAY_IMAGE_EXTENSIONS: direct_path = directory / f"{frame_token}{extension}" if direct_path.exists(): return direct_path camera_prefixed = directory / f"{camera_name}_{frame_token}{extension}" if camera_prefixed.exists(): return camera_prefixed + for candidate in directory.iterdir(): + if not candidate.is_file() or candidate.suffix.lower() not in SUPPORTED_OVERLAY_IMAGE_EXTENSIONS: + continue + stem_normalized = _normalize_overlay_token(candidate.stem) + if normalized_camera not in stem_normalized: + continue + candidate_frame = _extract_overlay_frame_number(candidate) + if candidate_frame != frame_number: + continue + return candidate return None diff --git a/judging/jump_cache.py b/judging/jump_cache.py new file mode 100644 index 0000000..dd6cb4a --- /dev/null +++ b/judging/jump_cache.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Shared DD jump-segmentation cache helpers.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from judging.dd_analysis import DDSessionAnalysis, analyze_dd_session +from vitpose_ekf_pipeline import KP_INDEX + + +def jump_segmentation_height_series(points_3d: np.ndarray | None, root_q: np.ndarray) -> np.ndarray: + """Return the vertical series used to segment jumps.""" + + if points_3d is not None: + points_3d = np.asarray(points_3d, dtype=float) + if points_3d.ndim == 3 and points_3d.shape[2] >= 3: + ankles = np.asarray(points_3d[:, [KP_INDEX["left_ankle"], KP_INDEX["right_ankle"]], 2], dtype=float) + if np.any(np.isfinite(ankles)): + return np.nanmin(ankles, axis=1) + all_markers_z = np.asarray(points_3d[:, :, 2], dtype=float) + if np.any(np.isfinite(all_markers_z)): + return np.nanmin(all_markers_z, axis=1) + return np.asarray(root_q[:, 2], dtype=float) + + +def jump_analysis_cache_key( + *, + reconstruction_name: str, + root_q: np.ndarray, + points_3d: np.ndarray | None, + full_q: np.ndarray | None, + q_names: list[str] | None, + fps: float, + height_threshold: float | None, + height_threshold_range_ratio: float, + smoothing_window_s: float, + min_airtime_s: float, + min_gap_s: float, + min_peak_prominence_m: float, + contact_window_s: float, + angle_mode: str, + analysis_start_frame: int, + require_complete_jumps: bool, +) -> tuple[object, ...]: + """Build a stable in-memory cache key for one DD analysis request.""" + + return ( + str(reconstruction_name), + id(root_q), + id(points_3d), + id(full_q), + tuple(str(name) for name in (q_names or [])), + float(fps), + None if height_threshold is None else float(height_threshold), + float(height_threshold_range_ratio), + float(smoothing_window_s), + float(min_airtime_s), + float(min_gap_s), + float(min_peak_prominence_m), + float(contact_window_s), + str(angle_mode), + int(analysis_start_frame), + bool(require_complete_jumps), + ) + + +def get_cached_jump_analysis( + cache: dict[tuple[object, ...], DDSessionAnalysis], + *, + reconstruction_name: str, + root_q: np.ndarray, + points_3d: np.ndarray | None, + fps: float, + height_threshold: float | None, + height_threshold_range_ratio: float, + smoothing_window_s: float, + min_airtime_s: float, + min_gap_s: float, + min_peak_prominence_m: float, + contact_window_s: float, + full_q: np.ndarray | None = None, + q_names: list[str] | None = None, + angle_mode: str = "euler", + analysis_start_frame: int = 0, + require_complete_jumps: bool = True, +) -> DDSessionAnalysis: + """Reuse one DD jump analysis when the inputs and parameters match.""" + + key = jump_analysis_cache_key( + reconstruction_name=reconstruction_name, + root_q=root_q, + points_3d=points_3d, + full_q=full_q, + q_names=q_names, + fps=fps, + height_threshold=height_threshold, + height_threshold_range_ratio=height_threshold_range_ratio, + smoothing_window_s=smoothing_window_s, + min_airtime_s=min_airtime_s, + min_gap_s=min_gap_s, + min_peak_prominence_m=min_peak_prominence_m, + contact_window_s=contact_window_s, + angle_mode=angle_mode, + analysis_start_frame=analysis_start_frame, + require_complete_jumps=require_complete_jumps, + ) + cached = cache.get(key) + if cached is not None: + return cached + analysis = analyze_dd_session( + np.asarray(root_q, dtype=float), + float(fps), + height_values=jump_segmentation_height_series(points_3d, np.asarray(root_q, dtype=float)), + height_threshold=height_threshold, + height_threshold_range_ratio=height_threshold_range_ratio, + smoothing_window_s=smoothing_window_s, + min_airtime_s=min_airtime_s, + min_gap_s=min_gap_s, + min_peak_prominence_m=min_peak_prominence_m, + contact_window_s=contact_window_s, + full_q=full_q, + q_names=q_names, + points_3d=points_3d, + angle_mode=angle_mode, + analysis_start_frame=analysis_start_frame, + require_complete_jumps=require_complete_jumps, + ) + cache[key] = analysis + return analysis diff --git a/judging/trampoline_displacement.py b/judging/trampoline_displacement.py index 369cc66..2e8efdf 100644 --- a/judging/trampoline_displacement.py +++ b/judging/trampoline_displacement.py @@ -43,6 +43,26 @@ def _reference_marker_xy() -> dict[str, np.ndarray]: } +def _reference_marker_xyz() -> dict[str, np.ndarray]: + """Return the full reference trampoline markers extracted from the TRC example.""" + + return { + "Center": np.array([0.043268113558143854, -0.05300411860982727, 1.2440734991150493], dtype=float), + "Cross_1": np.array([-0.27952443398574717, -0.04696764728727904, 1.2391880887638858], dtype=float), + "Cross_2": np.array([0.37652836700158177, -0.04795804673711549, 1.2402354305239016], dtype=float), + "Cross_3": np.array([0.05005495872135084, -0.3823917151684797, 1.2399942447962533], dtype=float), + "Cross_4": np.array([0.04356532271660445, 0.27947414593708636, 1.245396056095114], dtype=float), + "SmallSquare_1": np.array([-0.45678352093271957, -0.558066501261472, 1.2396003056682545], dtype=float), + "SmallSquare_2": np.array([-0.46609020457361006, 0.46948830182957657, 1.2484197798034131], dtype=float), + "SmallSquare_3": np.array([0.564131366062569, -0.5636934229359234, 1.2398627229190031], dtype=float), + "SmallSquare_4": np.array([0.55429137445568, 0.4711246832757012, 1.2490164829519783], dtype=float), + "BigRect_1": np.array([-0.9598132124496965, -0.5786406873658397, 1.2396327440493142], dtype=float), + "BigRect_2": np.array([-0.9982785248343781, 0.47024547295091973, 1.2461274907323676], dtype=float), + "BigRect_3": np.array([1.0873758536665161, -0.5780009194576564, 1.2474209618855885], dtype=float), + "BigRect_4": np.array([1.08636698254981, 0.46828409287201767, 1.2550467553780242], dtype=float), + } + + def trampoline_geometry_from_reference() -> TrampolineGeometry: """Build a centered geometry model from the reference TRC marker set.""" @@ -82,6 +102,9 @@ def rectangle_half_extents(corners: dict[str, np.ndarray]) -> tuple[float, float TRAMPOLINE_GEOMETRY = trampoline_geometry_from_reference() X_INNER, Y_INNER = rectangle_half_extents(TRAMPOLINE_GEOMETRY.small_square) X_MAX, Y_MAX = rectangle_half_extents(TRAMPOLINE_GEOMETRY.big_rectangle) +TRAMPOLINE_BED_HEIGHT_M = float( + np.mean(np.asarray([point[2] for point in _reference_marker_xyz().values()], dtype=float)) +) @dataclass diff --git a/kinematics/__init__.py b/kinematics/__init__.py index e69de29..ce6b35d 100644 --- a/kinematics/__init__.py +++ b/kinematics/__init__.py @@ -0,0 +1 @@ +"""Root-kinematics and 3D-analysis helpers shared across the project.""" diff --git a/kinematics/root_kinematics.py b/kinematics/root_kinematics.py index 45b9052..29d1c87 100644 --- a/kinematics/root_kinematics.py +++ b/kinematics/root_kinematics.py @@ -12,6 +12,7 @@ TRUNK_ROTATION_NAMES = ["TRUNK:RotY", "TRUNK:RotX", "TRUNK:RotZ"] TRUNK_ROOT_ROTATION_SEQUENCE = "yxz" TRUNK_ROOT_ROTATION_SCIPY_SEQUENCE = TRUNK_ROOT_ROTATION_SEQUENCE.upper() +SUPPORTED_ROOT_UNWRAP_MODES = ("off", "single", "double") ROOT_Q_NAMES = np.asarray(TRUNK_TRANSLATION_NAMES + TRUNK_ROTATION_NAMES, dtype=object) # Shared slices avoid hard-coded root DoF indexing across the codebase. ROOT_TRANSLATION_SLICE = slice(0, len(TRUNK_TRANSLATION_NAMES)) @@ -71,12 +72,43 @@ def reextract_euler_with_gaps(rotations: np.ndarray, sequence: str = TRUNK_ROOT_ return reextracted +def normalize_root_unwrap_mode(mode: str | None, *, legacy_unwrap: bool | None = None) -> str: + """Normalize one root-angle stabilization mode with backward compatibility.""" + + candidate = "" if mode is None else str(mode).strip().lower() + if not candidate: + if legacy_unwrap is None: + candidate = "single" + else: + candidate = "single" if bool(legacy_unwrap) else "off" + if candidate not in SUPPORTED_ROOT_UNWRAP_MODES: + raise ValueError(f"Unsupported root unwrap mode: {mode}") + return candidate + + +def stabilize_root_rotations( + rotations: np.ndarray, mode: str | None, *, legacy_unwrap: bool | None = None +) -> np.ndarray: + """Apply root Euler reextraction / unwrap stabilization according to one explicit mode.""" + + normalized_mode = normalize_root_unwrap_mode(mode, legacy_unwrap=legacy_unwrap) + stabilized = np.array(rotations, copy=True) + if normalized_mode == "off": + return reextract_euler_with_gaps(stabilized, TRUNK_ROOT_ROTATION_SEQUENCE) + passes = 1 if normalized_mode == "single" else 2 + for _ in range(passes): + stabilized = reextract_euler_with_gaps(stabilized, TRUNK_ROOT_ROTATION_SEQUENCE) + stabilized = unwrap_with_gaps(stabilized) + return stabilized + + def build_root_rotation_matrices( points_3d: np.ndarray, left_hip_idx: int = 11, right_hip_idx: int = 12, left_shoulder_idx: int = 5, right_shoulder_idx: int = 6, + translation_origin: str = "pelvis", ) -> tuple[np.ndarray, np.ndarray]: """Build trunk translations and body-to-world rotation matrices from markers.""" @@ -108,7 +140,7 @@ def build_root_rotation_matrices( y_axis = normalize(np.cross(z_axis, x_axis)) if not (np.all(np.isfinite(x_axis)) and np.all(np.isfinite(y_axis)) and np.all(np.isfinite(z_axis))): continue - translations[frame_idx] = hip_center + translations[frame_idx] = shoulder_center if str(translation_origin) == "upper_trunk" else hip_center rotation_matrices[frame_idx] = np.column_stack((x_axis, y_axis, z_axis)) return translations, rotation_matrices @@ -158,10 +190,14 @@ def root_z_correction_angle_from_points( return root_z_correction_angle_from_rotation_matrices(rotation_matrices) -def compute_trunk_dofs_from_points(points_3d: np.ndarray, unwrap_rotations: bool = True) -> np.ndarray: +def compute_trunk_dofs_from_points( + points_3d: np.ndarray, + unwrap_rotations: bool = True, + translation_origin: str = "pelvis", +) -> np.ndarray: """Convert 3D trunk markers into the ordered root DoFs [T, R].""" - translations, rotation_matrices = build_root_rotation_matrices(points_3d) + translations, rotation_matrices = build_root_rotation_matrices(points_3d, translation_origin=translation_origin) rotations_xyz = np.full((points_3d.shape[0], 3), np.nan, dtype=float) for frame_idx in range(points_3d.shape[0]): matrix = rotation_matrices[frame_idx] @@ -180,6 +216,7 @@ def extract_root_from_q( q_trajectory: np.ndarray, unwrap_rotations: bool = True, renormalize_rotations: bool = True, + unwrap_mode: str | None = None, ) -> np.ndarray: """Extract ordered root DoFs from a full generalized-coordinate trajectory.""" @@ -188,12 +225,14 @@ def extract_root_from_q( for out_idx, dof_name in enumerate(ROOT_Q_NAMES): if str(dof_name) in name_to_index: root_q[:, out_idx] = q_trajectory[:, name_to_index[str(dof_name)]] - if renormalize_rotations: - root_q[:, ROOT_ROTATION_SLICE] = reextract_euler_with_gaps( - root_q[:, ROOT_ROTATION_SLICE], TRUNK_ROOT_ROTATION_SEQUENCE - ) - if unwrap_rotations: - root_q[:, ROOT_ROTATION_SLICE] = unwrap_with_gaps(root_q[:, ROOT_ROTATION_SLICE]) + rotations = np.array(root_q[:, ROOT_ROTATION_SLICE], copy=True) + if renormalize_rotations or unwrap_rotations: + if unwrap_rotations: + mode = normalize_root_unwrap_mode(unwrap_mode, legacy_unwrap=True) + rotations = stabilize_root_rotations(rotations, mode) + else: + rotations = reextract_euler_with_gaps(rotations, TRUNK_ROOT_ROTATION_SEQUENCE) + root_q[:, ROOT_ROTATION_SLICE] = rotations return root_q diff --git a/kinematics/root_series.py b/kinematics/root_series.py index 3710150..c805b26 100644 --- a/kinematics/root_series.py +++ b/kinematics/root_series.py @@ -24,6 +24,86 @@ rotation_unit_scale, ) from reconstruction.reconstruction_bundle import extract_root_from_points +from vitpose_ekf_pipeline import KP_INDEX + + +def _interpolate_short_nan_runs(values: np.ndarray, max_gap_frames: int) -> np.ndarray: + data = np.asarray(values, dtype=float) + if data.ndim == 1: + data = data[:, np.newaxis] + squeeze = True + else: + squeeze = False + if data.shape[0] == 0 or max_gap_frames <= 0: + return np.asarray(values, dtype=float) + + result = np.array(data, copy=True) + for col_idx in range(result.shape[1]): + column = result[:, col_idx] + finite_idx = np.flatnonzero(np.isfinite(column)) + if finite_idx.size < 2: + continue + for left_idx, right_idx in zip(finite_idx[:-1], finite_idx[1:]): + gap_start = int(left_idx) + 1 + gap_end = int(right_idx) + gap_len = gap_end - gap_start + if gap_len <= 0 or gap_len > int(max_gap_frames): + continue + if np.any(np.isfinite(column[gap_start:gap_end])): + continue + result[gap_start:gap_end, col_idx] = np.interp( + np.arange(gap_start, gap_end, dtype=float), + np.array([left_idx, right_idx], dtype=float), + np.array([column[left_idx], column[right_idx]], dtype=float), + ) + return result[:, 0] if squeeze else result + + +def _fill_short_edge_nan_runs(values: np.ndarray, max_gap_frames: int) -> np.ndarray: + data = np.asarray(values, dtype=float) + if data.ndim == 1: + data = data[:, np.newaxis] + squeeze = True + else: + squeeze = False + if data.shape[0] == 0 or max_gap_frames <= 0: + return np.asarray(values, dtype=float) + + result = np.array(data, copy=True) + for col_idx in range(result.shape[1]): + column = result[:, col_idx] + finite_idx = np.flatnonzero(np.isfinite(column)) + if finite_idx.size == 0: + continue + first_finite = int(finite_idx[0]) + if 0 < first_finite <= int(max_gap_frames) and not np.any(np.isfinite(column[:first_finite])): + result[:first_finite, col_idx] = column[first_finite] + last_finite = int(finite_idx[-1]) + trailing_len = int(column.shape[0] - last_finite - 1) + if 0 < trailing_len <= int(max_gap_frames) and not np.any(np.isfinite(column[last_finite + 1 :])): + result[last_finite + 1 :, col_idx] = column[last_finite] + return result[:, 0] if squeeze else result + + +def interpolate_trunk_marker_gaps_for_root(points_3d: np.ndarray, max_gap_frames: int) -> np.ndarray: + """Fill short gaps on trunk markers before root extraction / unwrap.""" + + points_3d = np.asarray(points_3d, dtype=float) + if points_3d.ndim != 3 or points_3d.shape[0] == 0 or max_gap_frames <= 0: + return np.array(points_3d, copy=True) + + trunk_points = np.array(points_3d, copy=True) + trunk_marker_indices = [ + KP_INDEX["left_hip"], + KP_INDEX["right_hip"], + KP_INDEX["left_shoulder"], + KP_INDEX["right_shoulder"], + ] + trunk_marker_values = trunk_points[:, trunk_marker_indices, :].reshape(points_3d.shape[0], -1) + trunk_marker_values = _interpolate_short_nan_runs(trunk_marker_values, max_gap_frames) + trunk_marker_values = _fill_short_edge_nan_runs(trunk_marker_values, max_gap_frames) + trunk_points[:, trunk_marker_indices, :] = trunk_marker_values.reshape(points_3d.shape[0], 4, 3) + return trunk_points def quantity_unit_label(quantity: str, family_is_translation: bool, rotation_unit: str) -> str: @@ -63,13 +143,22 @@ def root_series_from_points( dt: float, initial_rotation_correction: bool, unwrap_rotations: bool, + unwrap_mode: str | None = None, + translation_origin: str = "pelvis", + interpolate_gap_frames: int | None = None, ) -> np.ndarray: """Build a root q or qdot series directly from geometric 3D points.""" + points_3d = np.asarray(points_3d, dtype=float) + if interpolate_gap_frames is not None and int(interpolate_gap_frames) > 0: + points_3d = interpolate_trunk_marker_gaps_for_root(points_3d, int(interpolate_gap_frames)) + root_q, _ = extract_root_from_points( - np.asarray(points_3d, dtype=float), + points_3d, bool(initial_rotation_correction), bool(unwrap_rotations), + unwrap_mode=unwrap_mode, + translation_origin=str(translation_origin), ) if quantity == "q": return root_q @@ -86,6 +175,9 @@ def root_series_from_model_markers( dt: float, initial_rotation_correction: bool, unwrap_rotations: bool, + unwrap_mode: str | None = None, + translation_origin: str = "pelvis", + interpolate_gap_frames: int | None = None, ) -> tuple[np.ndarray, np.ndarray]: """Build one root series from model markers reconstructed from ``q``. @@ -96,12 +188,17 @@ def root_series_from_model_markers( if marker_points is None: marker_points = marker_builder(Path(biomod_path), np.asarray(q_series, dtype=float)) marker_points = np.asarray(marker_points, dtype=float) + if interpolate_gap_frames is not None and int(interpolate_gap_frames) > 0: + marker_points = interpolate_trunk_marker_gaps_for_root(marker_points, int(interpolate_gap_frames)) series = root_series_from_points( marker_points, quantity=quantity, dt=dt, initial_rotation_correction=initial_rotation_correction, unwrap_rotations=unwrap_rotations, + unwrap_mode=unwrap_mode, + translation_origin=translation_origin, + interpolate_gap_frames=None, ) return series, marker_points @@ -116,6 +213,7 @@ def root_series_from_q( fd_qdot: bool = False, unwrap_rotations: bool = True, renormalize_rotations: bool = True, + unwrap_mode: str | None = None, ) -> np.ndarray: """Build a root q or qdot series from generalized coordinates.""" @@ -124,6 +222,7 @@ def root_series_from_q( np.asarray(q, dtype=float), unwrap_rotations=bool(unwrap_rotations), renormalize_rotations=bool(renormalize_rotations), + unwrap_mode=unwrap_mode, ) if quantity == "q": return root_q diff --git a/pipeline_gui.py b/pipeline_gui.py index fa2d0b1..02d13bc 100644 --- a/pipeline_gui.py +++ b/pipeline_gui.py @@ -21,8 +21,10 @@ import subprocess import sys import threading +import time import tkinter as tk -from dataclasses import dataclass, field +from contextlib import contextmanager +from dataclasses import dataclass, field, replace from datetime import datetime from pathlib import Path from tkinter import filedialog, messagebox, ttk @@ -40,6 +42,7 @@ DEFAULT_GUI_TRC_PATH = "inputs/trc/1_partie_0429.trc" DEFAULT_GUI_PROFILES_CONFIG = "reconstruction_profiles.json" +import matplotlib.patheffects as path_effects import matplotlib.pyplot as plt import numpy as np from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk @@ -47,9 +50,50 @@ from mpl_toolkits.mplot3d.art3d import Poly3DCollection from scipy.spatial.transform import Rotation +from annotation.annotation_store import ( + apply_annotations_to_pose_arrays, + clear_annotation_point, + default_annotation_path, + empty_annotation_payload, + get_annotation_point, + load_annotation_payload, + save_annotation_payload, + set_annotation_point, +) +from annotation.frame_navigation import ( + clamp_index_to_subset, + fallback_annotation_filtered_indices, + navigable_annotation_frame_local_indices, + resolve_annotation_frame_filter_mode, + step_frame_index_within_subset, +) +from annotation.kinematic_assist import ( + annotation_blend_q_by_relevance, + annotation_reconstruction_from_points, + annotation_state_from_q, + propagate_annotation_kinematic_state, + refine_annotation_q_with_direct_measurements, + refine_annotation_q_with_local_ekf, + refine_annotation_q_with_marker_lm, + refine_annotation_window_states, + resolve_annotation_kinematic_state_info, + store_annotation_kinematic_state, +) +from annotation.preview_render import ( + annotation_frame_label_text, + render_annotation_camera_view, +) +from batch_run import ( + DEFAULT_EXCEL_OUTPUT, + DEFAULT_KEYPOINTS_GLOB, +) +from batch_run import discover_keypoints_files as batch_discover_keypoints_files +from batch_run import infer_annotations_for_keypoints as batch_infer_annotations_for_keypoints +from batch_run import infer_pose2sim_trc_for_keypoints as batch_infer_pose2sim_trc_for_keypoints +from calibration_qc import compute_calibration_qc, frame_camera_epipolar_errors from camera_tools.camera_metrics import compute_camera_metric_rows, suggest_best_camera_names from camera_tools.camera_selection import format_camera_names, parse_camera_names -from judging.dd_analysis import DDSessionAnalysis, analyze_dd_session, contiguous_true_regions +from judging.dd_analysis import DDSessionAnalysis, contiguous_true_regions from judging.dd_presenter import ( build_jump_plot_data, compare_dd_code_characters, @@ -67,11 +111,14 @@ ExecutionOverlayFrame, ExecutionSessionAnalysis, analyze_execution_session, + available_execution_image_frames, build_execution_overlay_frame, execution_focus_frame, infer_execution_images_root, resolve_execution_image_path, ) +from judging.jump_cache import get_cached_jump_analysis +from judging.jump_cache import jump_segmentation_height_series as shared_jump_segmentation_height_series from judging.trampoline_displacement import ( BED_X_MAX, BED_Y_MAX, @@ -93,6 +140,7 @@ valid_segment_length_samples, ) from kinematics.root_kinematics import ( + ROOT_Q_NAMES, TRUNK_ROOT_ROTATION_SEQUENCE, TRUNK_ROTATION_NAMES, TRUNK_TRANSLATION_NAMES, @@ -100,10 +148,12 @@ compute_trunk_dofs_from_points, extract_root_from_q, normalize, + normalize_root_unwrap_mode, rotation_unit_label, rotation_unit_scale, ) from kinematics.root_series import ( + interpolate_trunk_marker_gaps_for_root, quantity_unit_label, root_axis_display_labels, root_ordered_names, @@ -117,7 +167,13 @@ ) from observability.observability_analysis import compute_observability_rank_series, summarize_rank_series from preview.dataset_preview_loader import load_dataset_preview_resources -from preview.dataset_preview_state import build_dataset_preview_state +from preview.dataset_preview_state import DatasetPreviewState, build_dataset_preview_state +from preview.frame_2d_render import ( + PointValueOverlay2D, + SkeletonLayer2D, + draw_point_value_overlay, + render_camera_frame_2d, +) from preview.preview_bundle import ( align_to_reference, load_dataset_preview_bundle, @@ -126,6 +182,17 @@ ) from preview.preview_navigation import clamp_frame_index, frame_from_slider_click, step_frame_index from preview.shared_reconstruction_panel import SharedReconstructionPanel, show_placeholder_figure +from preview.two_d_view import ( + adjust_image_levels, + apply_2d_axis_limits, + camera_layout, + compute_pose_crop_limits_2d, + crop_limits_from_points, + draw_2d_background_image, + hide_2d_axes, + load_camera_background_image, + square_crop_bounds, +) from reconstruction.reconstruction_bundle import ( extract_root_from_points, load_or_compute_left_right_flip_cache, @@ -164,6 +231,7 @@ validate_profile, ) from reconstruction.reconstruction_registry import ( + DEFAULT_MODEL_SYMMETRIZE_LIMBS, dataset_figures_dir, dataset_models_dir, dataset_reconstructions_dir, @@ -197,8 +265,11 @@ DEFAULT_MIN_CAMERAS_FOR_TRIANGULATION, DEFAULT_MODEL_VARIANT, DEFAULT_REPROJECTION_THRESHOLD_PX, + PoseData, ReconstructionResult, apply_left_right_flip_to_points, + canonicalize_model_q_rotation_branches, + fundamental_matrix, initial_state_from_triangulation, load_calibrations, load_pose_data, @@ -207,6 +278,7 @@ reconstruction_cache_metadata, swap_left_right_keypoints, triangulate_pose2sim_like, + weighted_triangulation, ) SKELETON_EDGES = [ @@ -255,6 +327,26 @@ "right_ear", } BODY_ONLY_KEYPOINTS = tuple(name for name in COCO17 if name not in FACE_KEYPOINTS) +ANNOTATION_KEYPOINT_ORDER = ( + "left_shoulder", + "left_elbow", + "left_wrist", + "left_hip", + "left_knee", + "left_ankle", + "right_shoulder", + "right_elbow", + "right_wrist", + "right_hip", + "right_knee", + "right_ankle", + "nose", + "left_eye", + "right_eye", + "left_ear", + "right_ear", +) +SUPPORTED_MODEL_PREVIEW_VIEWERS = ("matplotlib", "pyorerun") LOWER_LIMB_EDGES = { ("left_hip", "left_knee"), ("left_knee", "left_ankle"), @@ -268,8 +360,12 @@ "triangulation_fast", "ekf_2d_acc", "ekf_2d_flip_acc", + "ekf_2d_history3", + "ekf_2d_flip_history3", "ekf_2d_dyn", "ekf_2d_flip_dyn", + "ekf_2d_dyn_history3", + "ekf_2d_flip_dyn_history3", "ekf_3d_flip", "ekf_3d", ] @@ -280,8 +376,12 @@ "triangulation_fast": "Triangulation fast", "ekf_2d_acc": "EKF 2D ACC", "ekf_2d_flip_acc": "EKF 2D FLIP ACC", + "ekf_2d_history3": "EKF 2D HISTORY3", + "ekf_2d_flip_history3": "EKF 2D FLIP HISTORY3", "ekf_2d_dyn": "EKF 2D DYN", "ekf_2d_flip_dyn": "EKF 2D FLIP DYN", + "ekf_2d_dyn_history3": "EKF 2D DYN+HISTORY3", + "ekf_2d_flip_dyn_history3": "EKF 2D FLIP DYN+HISTORY3", "ekf_3d_flip": "EKF 3D FLIP", "ekf_3d": "EKF 3D", } @@ -292,8 +392,12 @@ "triangulation_fast": "#f2a104", "ekf_2d_acc": "#c44e52", "ekf_2d_flip_acc": "#937860", + "ekf_2d_history3": "#b55d60", + "ekf_2d_flip_history3": "#9a6c5a", "ekf_2d_dyn": "#8172b3", "ekf_2d_flip_dyn": "#da8bc3", + "ekf_2d_dyn_history3": "#6b63a8", + "ekf_2d_flip_dyn_history3": "#c67ab4", "ekf_3d_flip": "#4c9f70", "ekf_3d": "#55a868", } @@ -451,30 +555,17 @@ def read_q_variant( ) -> tuple[np.ndarray | None, np.ndarray | None]: ekf = np.load(ekf_states_path, allow_pickle=True) if ekf_states_path.exists() else None comparison = np.load(kalman_comparison_path, allow_pickle=True) if kalman_comparison_path.exists() else None - if variant == "ekf_2d_acc" and ekf is not None: - q = ( - np.asarray(ekf["q_ekf_2d_acc"], dtype=float) - if "q_ekf_2d_acc" in ekf - else (np.asarray(ekf["q"], dtype=float) if "q" in ekf else None) - ) - qdot = ( - np.asarray(ekf["qdot_ekf_2d_acc"], dtype=float) - if "qdot_ekf_2d_acc" in ekf - else (np.asarray(ekf["qdot"], dtype=float) if "qdot" in ekf else None) - ) - return q, qdot - if variant == "ekf_2d_flip_acc" and ekf is not None and "q_ekf_2d_flip_acc" in ekf: - q = np.asarray(ekf["q_ekf_2d_flip_acc"], dtype=float) - qdot = np.asarray(ekf["qdot_ekf_2d_flip_acc"], dtype=float) if "qdot_ekf_2d_flip_acc" in ekf else None - return q, qdot - if variant == "ekf_2d_dyn" and ekf is not None and "q_ekf_2d_dyn" in ekf: - q = np.asarray(ekf["q_ekf_2d_dyn"], dtype=float) - qdot = np.asarray(ekf["qdot_ekf_2d_dyn"], dtype=float) if "qdot_ekf_2d_dyn" in ekf else None - return q, qdot - if variant == "ekf_2d_flip_dyn" and ekf is not None and "q_ekf_2d_flip_dyn" in ekf: - q = np.asarray(ekf["q_ekf_2d_flip_dyn"], dtype=float) - qdot = np.asarray(ekf["qdot_ekf_2d_flip_dyn"], dtype=float) if "qdot_ekf_2d_flip_dyn" in ekf else None - return q, qdot + if variant.startswith("ekf_2d") and ekf is not None: + q_key = f"q_{variant}" + qdot_key = f"qdot_{variant}" + if q_key in ekf: + q = np.asarray(ekf[q_key], dtype=float) + qdot = np.asarray(ekf[qdot_key], dtype=float) if qdot_key in ekf else None + return q, qdot + if variant == "ekf_2d_acc": + q = np.asarray(ekf["q"], dtype=float) if "q" in ekf else None + qdot = np.asarray(ekf["qdot"], dtype=float) if "qdot" in ekf else None + return q, qdot if variant == "ekf_3d" and comparison is not None: q = ( np.asarray(comparison["q_ekf_3d"], dtype=float) @@ -607,8 +698,12 @@ def discover_reconstruction_catalog(output_dir: Path, pose2sim_trc: Path | None for variant, comparison_path in [ ("ekf_2d_acc", kalman_path), ("ekf_2d_flip_acc", kalman_flip_acc_path), + ("ekf_2d_history3", kalman_path), + ("ekf_2d_flip_history3", kalman_flip_acc_path), ("ekf_2d_dyn", kalman_path), + ("ekf_2d_dyn_history3", kalman_path), ("ekf_2d_flip_dyn", output_dir / "kalman_comparison_flip_dyn.npz"), + ("ekf_2d_flip_dyn_history3", output_dir / "kalman_comparison_flip_dyn.npz"), ("ekf_3d", kalman_path), ]: q, _ = read_q_variant(ekf_states_path, comparison_path, variant) @@ -666,8 +761,12 @@ def available_dual_show_options(output_dir: Path, pose2sim_trc: Path | None = No "ekf_3d": "ekf_3d", "ekf_2d_acc": "ekf_2d_acc", "ekf_2d_flip_acc": "ekf_2d_flip_acc", + "ekf_2d_history3": "ekf_2d_history3", + "ekf_2d_flip_history3": "ekf_2d_flip_history3", "ekf_2d_dyn": "ekf_2d_dyn", "ekf_2d_flip_dyn": "ekf_2d_flip_dyn", + "ekf_2d_dyn_history3": "ekf_2d_dyn_history3", + "ekf_2d_flip_dyn_history3": "ekf_2d_flip_dyn_history3", } options = [] for row in catalog: @@ -703,6 +802,68 @@ def resample_points(points: np.ndarray, source_time: np.ndarray, target_time: np return out +def interpolate_short_nan_runs(values: np.ndarray, max_gap_frames: int) -> np.ndarray: + """Linearly fill only short interior NaN runs along time for each column.""" + + data = np.asarray(values, dtype=float) + if data.ndim == 1: + data = data[:, np.newaxis] + squeeze = True + else: + squeeze = False + if data.shape[0] == 0 or max_gap_frames <= 0: + return np.asarray(values, dtype=float) + + result = np.array(data, copy=True) + for col_idx in range(result.shape[1]): + column = result[:, col_idx] + finite_idx = np.flatnonzero(np.isfinite(column)) + if finite_idx.size < 2: + continue + for left_idx, right_idx in zip(finite_idx[:-1], finite_idx[1:]): + gap_start = int(left_idx) + 1 + gap_end = int(right_idx) + gap_len = gap_end - gap_start + if gap_len <= 0 or gap_len > int(max_gap_frames): + continue + if np.any(np.isfinite(column[gap_start:gap_end])): + continue + result[gap_start:gap_end, col_idx] = np.interp( + np.arange(gap_start, gap_end, dtype=float), + np.array([left_idx, right_idx], dtype=float), + np.array([column[left_idx], column[right_idx]], dtype=float), + ) + return result[:, 0] if squeeze else result + + +def fill_short_edge_nan_runs(values: np.ndarray, max_gap_frames: int) -> np.ndarray: + """Fill short leading/trailing NaN runs with the nearest finite value.""" + + data = np.asarray(values, dtype=float) + if data.ndim == 1: + data = data[:, np.newaxis] + squeeze = True + else: + squeeze = False + if data.shape[0] == 0 or max_gap_frames <= 0: + return np.asarray(values, dtype=float) + + result = np.array(data, copy=True) + for col_idx in range(result.shape[1]): + column = result[:, col_idx] + finite_idx = np.flatnonzero(np.isfinite(column)) + if finite_idx.size == 0: + continue + first_finite = int(finite_idx[0]) + if 0 < first_finite <= int(max_gap_frames) and not np.any(np.isfinite(column[:first_finite])): + result[:first_finite, col_idx] = column[first_finite] + last_finite = int(finite_idx[-1]) + trailing_len = int(column.shape[0] - last_finite - 1) + if 0 < trailing_len <= int(max_gap_frames) and not np.any(np.isfinite(column[last_finite + 1 :])): + result[last_finite + 1 :, col_idx] = column[last_finite] + return result[:, 0] if squeeze else result + + def biorbd_markers_from_q(biomod_path: Path, q_series: np.ndarray) -> np.ndarray: import biorbd @@ -760,7 +921,7 @@ def single_frame_reconstruction(reconstruction: ReconstructionResult, frame_idx: ) -def pair_dof_names(q_names: np.ndarray) -> list[tuple[str, str, str]]: +def pair_dof_names(q_names: np.ndarray) -> list[tuple[str, str, str | None]]: names = [str(name) for name in q_names] pairs = [] for name in names: @@ -770,10 +931,75 @@ def pair_dof_names(q_names: np.ndarray) -> list[tuple[str, str, str]]: if right_name in names: pair_label = name.replace("LEFT_", "", 1) pairs.append((pair_label, name, right_name)) + for name in names: + if name.startswith(("UPPER_BACK:", "LOWER_TRUNK:")): + pairs.append((name, name, None)) pairs.sort(key=lambda item: item[0]) return pairs +def resolve_biomod_path(biomod_path: str | Path | None) -> Path | None: + if biomod_path is None: + return None + path = Path(str(biomod_path)) + if not path.is_absolute(): + path = ROOT / path + return path + + +def infer_model_variant_from_biomod(biomod_path: str | Path | None) -> str: + path = resolve_biomod_path(biomod_path) + if path is None or not path.exists(): + return DEFAULT_MODEL_VARIANT + try: + lines = path.read_text(encoding="utf-8").splitlines() + except Exception: + return DEFAULT_MODEL_VARIANT + + current_segment_name = None + rotations_value = "" + for raw_line in lines: + line = raw_line.strip() + if line.startswith("segment\t"): + current_segment_name = line.split("\t", 1)[1].strip() + continue + if current_segment_name not in {"UPPER_BACK", "LOWER_TRUNK"}: + continue + if line == "endsegment": + if rotations_value: + break + current_segment_name = None + continue + if line.startswith("rotations\t"): + rotations_value = line.split("\t", 1)[1].strip().lower().replace(" ", "") + if current_segment_name == "LOWER_TRUNK": + return ( + "upper_root_back_flexion_1d" + if rotations_value in {"x", "rx", "y", "ry"} + else "upper_root_back_3dof" + ) + if current_segment_name == "UPPER_BACK": + return "back_flexion_1d" if rotations_value in {"x", "rx", "y", "ry"} else "back_3dof" + + if not rotations_value: + text = "\n".join(lines) + if "LOWER_TRUNK" in text: + return "upper_root_back_flexion_1d" + if "UPPER_BACK" in text: + return "back_flexion_1d" + return DEFAULT_MODEL_VARIANT + return DEFAULT_MODEL_VARIANT + + +def biomod_supports_upper_back_options(biomod_path: str | Path | None) -> bool: + return infer_model_variant_from_biomod(biomod_path) in { + "back_flexion_1d", + "back_3dof", + "upper_root_back_flexion_1d", + "upper_root_back_3dof", + } + + def set_equal_3d_limits(ax, points_dict: dict[str, np.ndarray], frame_idx: int | None) -> None: """Apply equal 3D limits either from one frame or from the full sequence.""" pts = [] @@ -870,6 +1096,206 @@ def draw_skeleton_3d(ax, frame_points: np.ndarray, color: str, label: str, marke label_drawn = True +def draw_upper_back_preview( + ax, + frame_points: np.ndarray, + segment_frames: list[tuple[str, np.ndarray, np.ndarray]] | None = None, + *, + color: str = "#1d4e89", +) -> None: + """Draw back triangles around the mid-back origin for models with a segmented trunk.""" + + geometry = segmented_back_frame_geometry(frame_points, segment_frames) + if geometry is None: + return + mid_back, hip_triangle, shoulder_triangle = geometry + ax.plot( + hip_triangle[:, 0], + hip_triangle[:, 1], + hip_triangle[:, 2], + color=color, + linewidth=2.4, + alpha=0.9, + ) + ax.plot( + shoulder_triangle[:, 0], + shoulder_triangle[:, 1], + shoulder_triangle[:, 2], + color=color, + linewidth=2.4, + alpha=0.9, + ) + ax.scatter( + [mid_back[0]], + [mid_back[1]], + [mid_back[2]], + s=44, + c=color, + marker="D", + depthshade=False, + ) + + +def segmented_back_frame_geometry( + frame_points: np.ndarray, + segment_frames: list[tuple[str, np.ndarray, np.ndarray]] | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None: + """Return mid-back and both segmented-back triangles for one 3D frame.""" + + if frame_points is None: + return None + left_hip = np.asarray(frame_points[KP_INDEX["left_hip"]], dtype=float) + right_hip = np.asarray(frame_points[KP_INDEX["right_hip"]], dtype=float) + left_shoulder = np.asarray(frame_points[KP_INDEX["left_shoulder"]], dtype=float) + right_shoulder = np.asarray(frame_points[KP_INDEX["right_shoulder"]], dtype=float) + if not ( + np.all(np.isfinite(left_hip)) + and np.all(np.isfinite(right_hip)) + and np.all(np.isfinite(left_shoulder)) + and np.all(np.isfinite(right_shoulder)) + ): + return None + segment_map = ( + { + str(name): (np.asarray(origin, dtype=float), np.asarray(rotation, dtype=float)) + for name, origin, rotation in segment_frames + } + if segment_frames is not None + else {} + ) + upper_back_frame = segment_map.get("UPPER_BACK") or segment_map.get("LOWER_TRUNK") + hip_center = 0.5 * (left_hip + right_hip) + upper_center = 0.5 * (left_shoulder + right_shoulder) + mid_back = upper_back_frame[0] if upper_back_frame is not None else 0.5 * (hip_center + upper_center) + hip_triangle = np.vstack([right_hip, left_hip, mid_back, right_hip]) + shoulder_triangle = np.vstack([right_shoulder, left_shoulder, mid_back, right_shoulder]) + return mid_back, hip_triangle, shoulder_triangle + + +def draw_upper_back_overlay_2d( + ax, + *, + hip_triangle_2d: np.ndarray | None, + shoulder_triangle_2d: np.ndarray | None, + mid_back_2d: np.ndarray | None, + color: str, + line_width: float = 1.9, + line_alpha: float = 0.9, + line_style: str = "-", + marker_size: float = 34.0, + marker_line_width: float = 1.5, + marker_alpha: float = 0.95, +) -> None: + """Draw the segmented-back overlay in a 2D camera view.""" + + if hip_triangle_2d is not None: + hip_triangle_2d = np.asarray(hip_triangle_2d, dtype=float) + if np.all(np.isfinite(hip_triangle_2d)): + ax.plot( + hip_triangle_2d[:, 0], + hip_triangle_2d[:, 1], + color=color, + linewidth=line_width, + alpha=line_alpha, + linestyle=line_style, + ) + if shoulder_triangle_2d is not None: + shoulder_triangle_2d = np.asarray(shoulder_triangle_2d, dtype=float) + if np.all(np.isfinite(shoulder_triangle_2d)): + ax.plot( + shoulder_triangle_2d[:, 0], + shoulder_triangle_2d[:, 1], + color=color, + linewidth=line_width, + alpha=line_alpha, + linestyle=line_style, + ) + if mid_back_2d is not None: + mid_back_2d = np.asarray(mid_back_2d, dtype=float) + if np.all(np.isfinite(mid_back_2d)): + ax.scatter( + [mid_back_2d[0]], + [mid_back_2d[1]], + s=marker_size, + facecolors="none", + edgecolors=color, + linewidths=marker_line_width, + marker="D", + alpha=marker_alpha, + ) + + +def segmented_back_overlay_from_q( + biomod_path: Path | str | None, + q_series: np.ndarray, +) -> dict[str, np.ndarray] | None: + """Compute 3D overlay trajectories for segmented-back models.""" + + biomod_path = resolve_biomod_path(biomod_path) + if biomod_path is None or not biomod_path.exists(): + return None + if infer_model_variant_from_biomod(biomod_path) not in { + "back_flexion_1d", + "back_3dof", + "upper_root_back_flexion_1d", + "upper_root_back_3dof", + }: + return None + + import biorbd + + q_series = np.asarray(q_series, dtype=float) + if q_series.ndim != 2 or q_series.size == 0: + return None + model = biorbd.Model(str(biomod_path)) + marker_names = [name.to_string() for name in model.markerNames()] + required_marker_names = {"left_hip", "right_hip", "left_shoulder", "right_shoulder"} + if not required_marker_names.issubset(set(marker_names)): + return None + + n_frames = q_series.shape[0] + mid_back = np.full((n_frames, 1, 3), np.nan, dtype=float) + hip_triangle = np.full((n_frames, 4, 3), np.nan, dtype=float) + shoulder_triangle = np.full((n_frames, 4, 3), np.nan, dtype=float) + for frame_idx, q_values in enumerate(q_series): + marker_points = np.full((len(COCO17), 3), np.nan, dtype=float) + for marker_name, marker in zip(marker_names, model.markers(q_values)): + kp_idx = KP_INDEX.get(marker_name) + if kp_idx is not None: + marker_points[kp_idx, :] = marker.to_array() + geometry = segmented_back_frame_geometry(marker_points, biorbd_segment_frames_from_q(model, q_values)) + if geometry is None: + continue + mid_back_point, hip_triangle_points, shoulder_triangle_points = geometry + mid_back[frame_idx, 0, :] = mid_back_point + hip_triangle[frame_idx, :, :] = hip_triangle_points + shoulder_triangle[frame_idx, :, :] = shoulder_triangle_points + return {"mid_back": mid_back, "hip_triangle": hip_triangle, "shoulder_triangle": shoulder_triangle} + + +def has_segmented_back_visualization( + *, + segment_frames: list[tuple[str, np.ndarray, np.ndarray]] | None = None, + q_names: list[str] | np.ndarray | None = None, + summary: dict[str, object] | None = None, +) -> bool: + """Return whether one model/reconstruction includes a dedicated back segment to visualize.""" + + if segment_frames is not None: + segment_names = {str(name) for name, _origin, _rotation in segment_frames} + if {"UPPER_BACK", "LOWER_TRUNK"} & segment_names: + return True + if q_names is not None: + q_name_set = {str(name) for name in q_names} + if any(name.startswith(("UPPER_BACK:", "LOWER_TRUNK:")) for name in q_name_set): + return True + if summary: + model_variant = str(summary.get("model_variant", "") or "") + if model_variant in {"back_flexion_1d", "back_3dof", "upper_root_back_flexion_1d", "upper_root_back_3dof"}: + return True + return False + + def compute_root_frame_from_points(frame_points: np.ndarray) -> tuple[np.ndarray | None, np.ndarray | None]: left_hip = frame_points[KP_INDEX["left_hip"]] right_hip = frame_points[KP_INDEX["right_hip"]] @@ -929,14 +1355,17 @@ def draw_skeleton_2d( ax, frame_points: np.ndarray, color: str, - label: str, + label: str | None = None, marker_size: float = 12.0, marker_fill: bool = True, marker_edge_width: float = 1.4, line_alpha: float = 0.85, line_style: str = "-", line_width_scale: float = 1.0, + legend_label: str | None = None, ) -> None: + if label is None: + label = legend_label or "" grouped = keypoint_groups(frame_points) facecolors = color if marker_fill else "none" edgecolors = color @@ -1167,76 +1596,173 @@ def compute_airborne_mask_from_points( def jump_segmentation_height_series(points_3d: np.ndarray | None, root_q: np.ndarray) -> np.ndarray: - """Return the vertical series used to segment jumps. + """Backward-compatible wrapper around the shared jump-segmentation helper.""" - Prefer foot-to-bed clearance when 3D markers are available. Fall back to the - lowest visible marker, then to the root vertical translation. - """ + return shared_jump_segmentation_height_series(points_3d, root_q) + + +def shared_jump_analysis( + state: "SharedAppState", + *, + reconstruction_name: str, + root_q: np.ndarray, + points_3d: np.ndarray | None, + fps: float, + height_threshold: float | None, + height_threshold_range_ratio: float, + smoothing_window_s: float, + min_airtime_s: float, + min_gap_s: float, + min_peak_prominence_m: float, + contact_window_s: float, + full_q: np.ndarray | None = None, + q_names: list[str] | None = None, + angle_mode: str = "euler", + analysis_start_frame: int = 0, + require_complete_jumps: bool = True, +) -> DDSessionAnalysis: + """Reuse one DD jump analysis through the shared GUI state cache.""" + + cache = getattr(state, "jump_analysis_cache", None) + if cache is None: + cache = {} + state.jump_analysis_cache = cache + return get_cached_jump_analysis( + cache, + reconstruction_name=reconstruction_name, + root_q=np.asarray(root_q, dtype=float), + points_3d=None if points_3d is None else np.asarray(points_3d, dtype=float), + fps=float(fps), + height_threshold=height_threshold, + height_threshold_range_ratio=height_threshold_range_ratio, + smoothing_window_s=smoothing_window_s, + min_airtime_s=min_airtime_s, + min_gap_s=min_gap_s, + min_peak_prominence_m=min_peak_prominence_m, + contact_window_s=contact_window_s, + full_q=None if full_q is None else np.asarray(full_q, dtype=float), + q_names=list(q_names) if q_names is not None else None, + angle_mode=angle_mode, + analysis_start_frame=analysis_start_frame, + require_complete_jumps=require_complete_jumps, + ) - if points_3d is not None: - points_3d = np.asarray(points_3d, dtype=float) - if points_3d.ndim == 3 and points_3d.shape[2] >= 3: - ankles = np.asarray(points_3d[:, [KP_INDEX["left_ankle"], KP_INDEX["right_ankle"]], 2], dtype=float) - if np.any(np.isfinite(ankles)): - return np.nanmin(ankles, axis=1) - all_markers_z = np.asarray(points_3d[:, :, 2], dtype=float) - if np.any(np.isfinite(all_markers_z)): - return np.nanmin(all_markers_z, axis=1) - return np.asarray(root_q[:, 2], dtype=float) +def shared_jump_analysis_for_reconstruction( + state: "SharedAppState", + reconstruction_name: str | None, + *, + analysis_start_frame: int = ANALYSIS_START_FRAME, + require_complete_jumps: bool = True, +) -> DDSessionAnalysis | None: + """Resolve one reconstruction through the shared preview cache and reuse jump analysis.""" -def camera_layout(n_cameras: int) -> tuple[int, int]: - ncols = 4 - nrows = int(math.ceil(n_cameras / ncols)) - return nrows, ncols + name = str(reconstruction_name or "").strip() + if not name: + return None + try: + _output_dir, bundle, _preview_state = load_shared_reconstruction_preview_state( + state, + preferred_names=[name], + fallback_count=1, + include_3d=True, + include_q=True, + include_q_root=True, + ) + root_q, full_q, q_names = preview_root_series_for_reconstruction( + bundle=bundle, + name=name, + initial_rotation_correction=bool(state.initial_rotation_correction_var.get()), + ) + if root_q is None: + return None + recon_3d = bundle.get("recon_3d", {}) if isinstance(bundle, dict) else {} + points_3d = np.asarray(recon_3d[name], dtype=float) if name in recon_3d else None + return shared_jump_analysis( + state, + reconstruction_name=name, + root_q=np.asarray(root_q, dtype=float), + points_3d=points_3d, + fps=float(state.fps_var.get()), + height_threshold=TRAMPOLINE_BED_HEIGHT_M, + height_threshold_range_ratio=0.20, + smoothing_window_s=0.15, + min_airtime_s=0.25, + min_gap_s=0.08, + min_peak_prominence_m=0.35, + contact_window_s=0.35, + full_q=None if full_q is None else np.asarray(full_q, dtype=float), + q_names=q_names, + angle_mode="euler", + analysis_start_frame=analysis_start_frame, + require_complete_jumps=require_complete_jumps, + ) + except Exception: + return None -def compute_pose_crop_limits_2d( - raw_2d: np.ndarray, - calibrations: dict, - camera_names: list[str], - margin: float, -) -> dict[str, np.ndarray]: - """Calcule un cadrage par frame et par camera a partir de la sequence 2D.""" - limits: dict[str, np.ndarray] = {} - for cam_idx, cam_name in enumerate(camera_names): - width, height = calibrations[cam_name].image_size - n_frames = raw_2d.shape[1] - camera_limits = np.full((n_frames, 4), np.nan, dtype=float) - for frame_idx in range(n_frames): - points = raw_2d[cam_idx, frame_idx] - valid = np.all(np.isfinite(points), axis=1) - if not np.any(valid): - continue - xy = points[valid] - xmin, ymin = np.min(xy, axis=0) - xmax, ymax = np.max(xy, axis=0) - dx = max(10.0, float(xmax - xmin) * margin) - dy = max(10.0, float(ymax - ymin) * margin) - camera_limits[frame_idx] = np.array( - [ - max(0.0, float(xmin - dx)), - min(float(width), float(xmax + dx)), - min(float(height), float(ymax + dy)), - max(0.0, float(ymin - dy)), - ], - dtype=float, +def contact_segments_from_airborne_regions( + airborne_regions: list[tuple[int, int]], + *, + n_frames: int, + analysis_start_frame: int = 0, +) -> list[tuple[int, int]]: + """Return the contact intervals complementary to airborne regions.""" + + n_frames = max(0, int(n_frames)) + if n_frames <= 0: + return [] + start_frame = min(max(int(analysis_start_frame), 0), n_frames - 1) + contacts: list[tuple[int, int]] = [] + cursor = start_frame + for start, end in airborne_regions: + region_start = min(max(int(start), start_frame), n_frames - 1) + region_end = min(max(int(end), start_frame), n_frames - 1) + if cursor < region_start: + contacts.append((cursor, region_start - 1)) + cursor = max(cursor, region_end + 1) + if cursor < n_frames: + contacts.append((cursor, n_frames - 1)) + return contacts + + +def draw_jump_phase_spans( + ax, + *, + time_s: np.ndarray, + analysis: DDSessionAnalysis | None, + contact_color: str = "#4c72b0", + airborne_color: str = "#dd8452", +) -> None: + """Overlay contact and airborne intervals on one time-series axis.""" + + if analysis is None: + return + time_s = np.asarray(time_s, dtype=float) + if time_s.ndim != 1 or time_s.size == 0: + return + contacts = contact_segments_from_airborne_regions( + analysis.airborne_regions, + n_frames=int(time_s.shape[0]), + analysis_start_frame=int(getattr(analysis, "analysis_start_frame", 0)), + ) + for idx, (start, end) in enumerate(contacts): + ax.axvspan( + float(time_s[start]), + float(time_s[end]), + color=contact_color, + alpha=0.05, + label="contact" if idx == 0 else None, + ) + for idx, (start, end) in enumerate(analysis.airborne_regions): + if 0 <= start < time_s.shape[0] and 0 <= end < time_s.shape[0]: + ax.axvspan( + float(time_s[start]), + float(time_s[end]), + color=airborne_color, + alpha=0.10, + label="airborne" if idx == 0 else None, ) - valid_frames = np.flatnonzero(np.all(np.isfinite(camera_limits), axis=1)) - if valid_frames.size == 0: - camera_limits[:] = np.array([0.0, float(width), float(height), 0.0], dtype=float) - else: - first_valid = int(valid_frames[0]) - last_valid = int(valid_frames[-1]) - for frame_idx in range(0, first_valid): - camera_limits[frame_idx] = camera_limits[first_valid] - for frame_idx in range(first_valid + 1, n_frames): - if not np.all(np.isfinite(camera_limits[frame_idx])): - camera_limits[frame_idx] = camera_limits[frame_idx - 1] - for frame_idx in range(last_valid + 1, n_frames): - camera_limits[frame_idx] = camera_limits[last_valid] - limits[cam_name] = camera_limits - return limits def preview_pose_frame_indices(pose_frames: np.ndarray, target_frames: np.ndarray) -> np.ndarray: @@ -1274,25 +1800,299 @@ def compose_multiview_crop_points( return np.concatenate(stacked, axis=2) -def apply_2d_axis_limits( - ax, +ANNOTATION_MARKER_COLORS = plt.colormaps["tab20"].resampled(len(COCO17)) +ANNOTATION_DELETE_RADIUS_PX = 18.0 +ANNOTATION_HOVER_RADIUS_PX = 10.0 +ANNOTATION_DRAG_START_RADIUS_PX = 10.0 +ANNOTATION_DRAG_ACTIVATION_PX = 3.0 +ANNOTATION_SNAP_RADIUS_PX = 24.0 +ANNOTATION_KINEMATIC_BOOTSTRAP_PASSES = 10 +ANNOTATION_KINEMATIC_INITIAL_BOOTSTRAP_MULTIPLIER = 3 +ANNOTATION_KINEMATIC_CLICK_PASSES = 3 +ANNOTATION_KINEMATIC_CLICK_DIRECT_PASSES = 1 +ANNOTATION_KINEMATIC_WINDOW_RADIUS = 1 +ANNOTATION_KINEMATIC_WINDOW_PASSES = 2 +ANNOTATION_FRAME_FILTER_OPTIONS = { + "all": "All frames", + "flipped": "Flipped L/R", + "worst_reproj": "Worst reproj 5%", +} + + +def annotation_marker_color(keypoint_name: str) -> tuple[float, float, float, float]: + """Return one stable color for a keypoint name.""" + + kp_idx = KP_INDEX.get(str(keypoint_name), 0) + return ANNOTATION_MARKER_COLORS(int(kp_idx)) + + +def annotation_marker_shape(keypoint_name: str) -> str: + """Return one side-aware marker shape for annotations.""" + + if str(keypoint_name) in LEFT_KEYPOINTS: + return "+" + if str(keypoint_name) in RIGHT_KEYPOINTS: + return "x" + return "+" + + +def annotation_keypoint_names_for_biomod(biomod_path: str | Path | None) -> tuple[str, ...]: + """Return the annotation keypoint order, adding segmented-back markers when available.""" + + names = list(ANNOTATION_KEYPOINT_ORDER) + if biomod_supports_upper_back_options(biomod_path) and "mid_back" not in names: + names.insert(12, "mid_back") + return tuple(names) + + +def annotation_motion_prior_center( + point_t_minus_1: np.ndarray | None, + point_t_minus_2: np.ndarray | None, +) -> np.ndarray | None: + """Predict one simple constant-velocity 2D center from the two previous frames.""" + + if point_t_minus_1 is None or point_t_minus_2 is None: + return None + pt1 = np.asarray(point_t_minus_1, dtype=float).reshape(2) + pt2 = np.asarray(point_t_minus_2, dtype=float).reshape(2) + if not (np.all(np.isfinite(pt1)) and np.all(np.isfinite(pt2))): + return None + return pt1 + (pt1 - pt2) + + +def annotation_epipolar_guides( + calibrations: dict[str, object], + source_camera_name: str, + target_camera_name: str, + source_point_2d: np.ndarray, +) -> np.ndarray | None: + """Return one image-space epipolar line ``ax + by + c = 0`` for a target camera.""" + + point = np.asarray(source_point_2d, dtype=float).reshape(2) + if not np.all(np.isfinite(point)): + return None + if source_camera_name == target_camera_name: + return None + source_calibration = calibrations.get(str(source_camera_name)) + target_calibration = calibrations.get(str(target_camera_name)) + if source_calibration is None or target_calibration is None: + return None + fundamental = fundamental_matrix(source_calibration, target_calibration) + point_h = np.array([point[0], point[1], 1.0], dtype=float) + line = fundamental @ point_h + if not np.all(np.isfinite(line)) or np.linalg.norm(line[:2]) < 1e-12: + return None + return line + + +def annotation_project_point_to_line(line: np.ndarray, point_xy: np.ndarray) -> np.ndarray | None: + """Project one 2D point orthogonally onto an epipolar line.""" + + line = np.asarray(line, dtype=float).reshape(3) + point_xy = np.asarray(point_xy, dtype=float).reshape(2) + if not (np.all(np.isfinite(line)) and np.all(np.isfinite(point_xy))): + return None + a, b, c = [float(value) for value in line] + denom = a * a + b * b + if denom <= 1e-12: + return None + distance = (a * point_xy[0] + b * point_xy[1] + c) / denom + return np.array([point_xy[0] - a * distance, point_xy[1] - b * distance], dtype=float) + + +def annotation_intersect_epipolar_lines(lines: list[np.ndarray]) -> np.ndarray | None: + """Return the least-squares intersection of multiple epipolar lines.""" + + if len(lines) < 2: + return None + rows = [] + offsets = [] + for line in lines: + array = np.asarray(line, dtype=float).reshape(3) + if not np.all(np.isfinite(array)): + continue + normal = array[:2] + norm = float(np.linalg.norm(normal)) + if norm <= 1e-12: + continue + rows.append(normal / norm) + offsets.append(-array[2] / norm) + if len(rows) < 2: + return None + matrix = np.asarray(rows, dtype=float) + rhs = np.asarray(offsets, dtype=float) + try: + solution, *_rest = np.linalg.lstsq(matrix, rhs, rcond=None) + except np.linalg.LinAlgError: + return None + solution = np.asarray(solution, dtype=float).reshape(2) + return solution if np.all(np.isfinite(solution)) else None + + +def annotation_triangulated_reprojection( + calibrations: dict[str, object], *, - crop_mode: str, - crop_limits: dict[str, np.ndarray], - cam_name: str, - frame_idx: int, - width: float, - height: float, -) -> None: - """Applique un cadrage 2D fixe et desactive l'autoscale matplotlib.""" - if crop_mode == "pose": - x0, x1, y1, y0 = crop_limits[cam_name][frame_idx] - ax.set_xlim(x0, x1) - ax.set_ylim(y1, y0) - else: - ax.set_xlim(0, width) - ax.set_ylim(height, 0) - ax.set_autoscale_on(False) + target_camera_name: str, + source_camera_names: list[str], + source_points_2d: list[np.ndarray], +) -> np.ndarray | None: + """Triangulate one 3D point from already placed views and reproject it to a target camera.""" + + valid_projections: list[np.ndarray] = [] + valid_points: list[np.ndarray] = [] + valid_scores: list[float] = [] + for camera_name, point_2d in zip(source_camera_names, source_points_2d): + point = np.asarray(point_2d, dtype=float).reshape(2) + calibration = calibrations.get(str(camera_name)) + if calibration is None or not np.all(np.isfinite(point)): + continue + projection_matrix = getattr(calibration, "projection_matrix", None) + if projection_matrix is None: + projection_matrix = getattr(calibration, "P", None) + if projection_matrix is None: + continue + valid_projections.append(np.asarray(projection_matrix, dtype=float)) + valid_points.append(point) + valid_scores.append(1.0) + if len(valid_points) < 2: + return None + point_3d = weighted_triangulation(valid_projections, np.asarray(valid_points), np.asarray(valid_scores)) + if not np.all(np.isfinite(point_3d)): + return None + target_calibration = calibrations.get(str(target_camera_name)) + if target_calibration is None: + return None + projected = np.asarray(target_calibration.project_point(point_3d), dtype=float) + return projected if np.all(np.isfinite(projected)) else None + + +def triangulate_annotation_frame_points( + calibrations: dict[str, object], + *, + camera_names: list[str], + frame_number: int, + annotation_payload: dict[str, object], +) -> np.ndarray: + """Triangulate all currently annotated keypoints for one frame.""" + + points_3d = np.full((len(COCO17), 3), np.nan, dtype=float) + for keypoint_name in COCO17: + valid_projections: list[np.ndarray] = [] + valid_points: list[np.ndarray] = [] + valid_scores: list[float] = [] + for camera_name in camera_names: + point_xy, _score = get_annotation_point( + annotation_payload, + camera_name=camera_name, + frame_number=frame_number, + keypoint_name=keypoint_name, + ) + if point_xy is None: + continue + calibration = calibrations.get(str(camera_name)) + if calibration is None: + continue + projection_matrix = getattr(calibration, "projection_matrix", None) + if projection_matrix is None: + projection_matrix = getattr(calibration, "P", None) + if projection_matrix is None: + continue + point = np.asarray(point_xy, dtype=float).reshape(2) + if not np.all(np.isfinite(point)): + continue + valid_projections.append(np.asarray(projection_matrix, dtype=float)) + valid_points.append(point) + valid_scores.append(1.0) + if len(valid_points) < 2: + continue + point_3d = weighted_triangulation(valid_projections, np.asarray(valid_points), np.asarray(valid_scores)) + if np.all(np.isfinite(point_3d)): + points_3d[KP_INDEX[keypoint_name], :] = point_3d + return points_3d + + +def annotation_pose_data_for_frame( + base_pose_data: PoseData, + *, + camera_names: list[str], + frame_number: int, + annotation_payload: dict[str, object], +) -> PoseData: + """Build one-frame PoseData from sparse annotations only.""" + + base_camera_names = [str(name) for name in base_pose_data.camera_names] + selected_camera_names = [str(name) for name in camera_names if str(name) in base_camera_names] + if not selected_camera_names: + raise ValueError("No selected cameras are available in the current pose data.") + frames = np.asarray([int(frame_number)], dtype=int) + keypoints = np.full((len(selected_camera_names), 1, len(COCO17), 2), np.nan, dtype=float) + scores = np.zeros((len(selected_camera_names), 1, len(COCO17)), dtype=float) + for cam_idx, camera_name in enumerate(selected_camera_names): + for keypoint_name in COCO17: + point_xy, _score = get_annotation_point( + annotation_payload, + camera_name=camera_name, + frame_number=frame_number, + keypoint_name=keypoint_name, + ) + if point_xy is None: + continue + point = np.asarray(point_xy, dtype=float).reshape(2) + if not np.all(np.isfinite(point)): + continue + kp_idx = KP_INDEX[str(keypoint_name)] + keypoints[cam_idx, 0, kp_idx] = point + scores[cam_idx, 0, kp_idx] = 1.0 + return PoseData( + camera_names=selected_camera_names, + frames=frames, + keypoints=keypoints, + scores=scores, + frame_stride=int(getattr(base_pose_data, "frame_stride", 1) or 1), + raw_keypoints=np.array(keypoints, copy=True), + annotated_keypoints=np.array(keypoints, copy=True), + annotated_scores=np.array(scores, copy=True), + ) + + +def annotation_adjust_image( + image: np.ndarray, + *, + brightness: float = 1.0, + contrast: float = 1.0, +) -> np.ndarray: + """Backward-compatible wrapper for annotation image level adjustment.""" + + return adjust_image_levels(image, brightness=brightness, contrast=contrast) + + +def find_annotation_frame_with_images( + *, + frames: np.ndarray, + current_index: int, + direction: int, + camera_names: list[str], + images_root: Path | None, +) -> int: + """Move to the next frame that actually has one image for the selected cameras.""" + + frame_array = np.asarray(frames, dtype=int) + if frame_array.size == 0: + return 0 + step = -1 if int(direction) < 0 else 1 + current_index = max(0, min(int(current_index), frame_array.size - 1)) + fallback_index = max(0, min(frame_array.size - 1, current_index + step)) + if images_root is None or not camera_names: + return fallback_index + candidate_index = fallback_index + while 0 <= candidate_index < frame_array.size: + frame_number = int(frame_array[candidate_index]) + for camera_name in camera_names: + image_path = resolve_execution_image_path(images_root, camera_name, frame_number) + if image_path is not None and image_path.exists(): + return candidate_index + candidate_index += step + return fallback_index def load_preview_bundle( @@ -1350,7 +2150,7 @@ def load_preview_bundle( if master_frames is None: master_points = bundle["recon_3d"]["triangulation_fast"] master_frames = np.asarray(data["frames"], dtype=int) - if master_frames is None and pose2sim_trc.exists(): + if master_frames is None and pose2sim_trc is not None and pose2sim_trc.exists(): pose2sim_points, pose2sim_time, pose2sim_rate = parse_trc_points(pose2sim_trc) master_frames = np.arange(pose2sim_points.shape[0], dtype=int) master_points = pose2sim_points @@ -1421,8 +2221,10 @@ def get_cached_preview_bundle( cached = state.preview_bundle_cache.get(cache_key) if cached is not None: gui_debug(f"preview bundle cache hit dataset={output_dir}") + report_startup_status(state, f"Using cached preview bundle: {output_dir.name}") return cached gui_debug(f"preview bundle cache miss dataset={output_dir}") + report_startup_status(state, f"Loading preview bundle: {output_dir.name}") bundle = load_preview_bundle(output_dir, biomod_path, pose2sim_trc, align_root=align_root) state.preview_bundle_cache[cache_key] = bundle return bundle @@ -1487,11 +2289,13 @@ def __init__( label_padx: tuple[int, int] = (0, 6), filetypes: tuple[tuple[str, str], ...] | None = None, browse_initialdir: str | None = None, + on_browse_selected=None, ): super().__init__(master) self.directory = directory self.filetypes = filetypes self.browse_initialdir = browse_initialdir + self.on_browse_selected = on_browse_selected self.label_widget = ttk.Label(self, text=label, width=label_width) self.label_widget.pack(side=tk.LEFT, padx=label_padx) self.var = tk.StringVar(value=default) @@ -1571,6 +2375,8 @@ def _browse(self) -> None: self.var.set(str(rel)) except Exception: self.var.set(path) + if callable(self.on_browse_selected): + self.on_browse_selected(self.get()) def get(self) -> str: return self.var.get().strip() @@ -1651,20 +2457,127 @@ def hide(self, _event=None) -> None: self.tipwindow = None -def attach_tooltip(widget: tk.Widget, text: str) -> ToolTip: - tooltip = ToolTip(widget, text) - setattr(widget, "_tooltip_ref", tooltip) - return tooltip +def extend_listbox_selection(widget: tk.Listbox, direction: int) -> str: + """Extend one listbox selection by one row with Shift+Up/Down semantics.""" + size = int(widget.size()) + if size <= 0: + return "break" + selected = [int(index) for index in widget.curselection()] + if selected: + target = min(selected) - 1 if direction < 0 else max(selected) + 1 + else: + try: + active = int(widget.index(tk.ACTIVE)) + except Exception: + active = 0 + target = active + (1 if direction > 0 else -1) + target = max(0, min(size - 1, int(target))) + widget.selection_set(target) + try: + widget.activate(target) + except Exception: + pass + try: + widget.see(target) + except Exception: + pass + return "break" -@dataclass -class SharedAppState: - calib_var: tk.StringVar - keypoints_var: tk.StringVar - pose2sim_trc_var: tk.StringVar - fps_var: tk.StringVar - workers_var: tk.StringVar - pose_data_mode_var: tk.StringVar + +def select_all_listbox(widget: tk.Listbox) -> str: + """Select all rows in one Tk listbox.""" + + if int(widget.size()) > 0: + widget.selection_set(0, tk.END) + try: + widget.activate(0) + except Exception: + pass + return "break" + + +def bind_extended_listbox_shortcuts(widget: tk.Listbox) -> None: + """Add additive keyboard selection shortcuts to one multi-select listbox.""" + + widget.bind("", lambda _event: extend_listbox_selection(widget, -1), add="+") + widget.bind("", lambda _event: extend_listbox_selection(widget, 1), add="+") + for sequence in ("", "", "", ""): + widget.bind(sequence, lambda _event: select_all_listbox(widget), add="+") + + +def extend_treeview_selection(tree: ttk.Treeview, direction: int) -> str: + """Extend one Treeview selection by one row with Shift+Up/Down semantics.""" + + rows = list(tree.get_children("")) + if not rows: + return "break" + selected = list(tree.selection()) + if selected: + anchor = rows.index(selected[0]) if direction < 0 else rows.index(selected[-1]) + target_index = anchor + (-1 if direction < 0 else 1) + else: + focus_item = tree.focus() + focus_index = rows.index(focus_item) if focus_item in rows else 0 + target_index = focus_index + (-1 if direction < 0 else 1) + target_index = max(0, min(len(rows) - 1, int(target_index))) + target_item = rows[target_index] + updated = [] + for item in selected: + if item in rows: + updated.append(item) + if target_item not in updated: + updated.append(target_item) + tree.selection_set(tuple(updated)) + tree.focus(target_item) + try: + tree.see(target_item) + except Exception: + pass + return "break" + + +def select_all_treeview(tree: ttk.Treeview) -> str: + """Select all rows in one Treeview.""" + + rows = list(tree.get_children("")) + if rows: + tree.selection_set(tuple(rows)) + tree.focus(rows[0]) + return "break" + + +def bind_extended_treeview_shortcuts(tree: ttk.Treeview) -> None: + """Add additive keyboard selection shortcuts to one multi-select Treeview.""" + + tree.bind("", lambda _event: extend_treeview_selection(tree, -1), add="+") + tree.bind("", lambda _event: extend_treeview_selection(tree, 1), add="+") + for sequence in ("", "", "", ""): + tree.bind(sequence, lambda _event: select_all_treeview(tree), add="+") + + +def attach_tooltip(widget: tk.Widget, text: str) -> ToolTip: + tooltip = ToolTip(widget, text) + setattr(widget, "_tooltip_ref", tooltip) + return tooltip + + +@dataclass +class SharedAppState: + """Mutable application state shared across all GUI tabs. + + Attributes: + profiles_dirty: Whether the in-memory reconstruction profiles differ + from the last saved or loaded profiles JSON on disk. + """ + + calib_var: tk.StringVar + keypoints_var: tk.StringVar + annotation_path_var: tk.StringVar + pose2sim_trc_var: tk.StringVar + fps_var: tk.StringVar + workers_var: tk.StringVar + pose_data_mode_var: tk.StringVar pose_filter_window_var: tk.StringVar pose_outlier_ratio_var: tk.StringVar pose_p_low_var: tk.StringVar @@ -1678,6 +2591,7 @@ class SharedAppState: flip_temporal_weight_var: tk.StringVar flip_temporal_tau_px_var: tk.StringVar calibration_correction_var: tk.StringVar + shared_images_root_var: tk.StringVar initial_rotation_correction_var: tk.BooleanVar selected_camera_names_var: tk.StringVar output_root_var: tk.StringVar @@ -1688,26 +2602,50 @@ class SharedAppState: calibration_cache: dict[str, dict[str, object]] = field(default_factory=dict) pose_data_cache: dict[tuple[object, ...], object] = field(default_factory=dict) preview_bundle_cache: dict[tuple[object, ...], dict[str, object]] = field(default_factory=dict) + jump_analysis_cache: dict[tuple[object, ...], DDSessionAnalysis] = field(default_factory=dict) shared_reconstruction_selection: list[str] = field(default_factory=list) shared_reconstruction_selection_listeners: list[callable] = field(default_factory=list) shared_reconstruction_panel: object | None = None active_reconstruction_consumer: object | None = None clean_trial_outputs_callback: callable | None = None - - def set_profiles(self, profiles: list[ReconstructionProfile]) -> None: + clean_trial_caches_callback: callable | None = None + startup_status_callback: callable | None = None + profiles_dirty: bool = False + + def set_profiles(self, profiles: list[ReconstructionProfile], *, mark_dirty: bool = True) -> None: + """Replace the in-memory reconstruction profiles and notify listeners. + + Args: + profiles: New list of reconstruction profiles to expose in the GUI. + mark_dirty: When ``True``, mark the profiles configuration as having + unsaved changes. Callers should pass ``False`` after loading + profiles from disk or restoring startup defaults. + """ self.profiles = list(profiles) + if mark_dirty: + self.profiles_dirty = True for callback in list(self.profile_listeners): try: callback() except Exception: pass + def clear_profiles_dirty(self) -> None: + """Mark the current in-memory profiles as saved.""" + + self.profiles_dirty = False + def register_profile_listener(self, callback) -> None: + """Register a callback invoked whenever the profile list changes.""" + if callback not in self.profile_listeners: self.profile_listeners.append(callback) def notify_reconstructions_updated(self) -> None: + """Invalidate reconstruction-derived caches and notify dependent tabs.""" + self.preview_bundle_cache.clear() + self.jump_analysis_cache.clear() for callback in list(self.reconstruction_listeners): try: callback() @@ -1715,10 +2653,19 @@ def notify_reconstructions_updated(self) -> None: pass def register_reconstruction_listener(self, callback) -> None: + """Register a callback triggered after reconstruction outputs change.""" + if callback not in self.reconstruction_listeners: self.reconstruction_listeners.append(callback) def set_shared_reconstruction_selection(self, names: list[str]) -> None: + """Store the shared reconstruction selection and notify listeners. + + Args: + names: Ordered reconstruction names currently selected in the + shared selector. + """ + self.shared_reconstruction_selection = list(names) for callback in list(self.shared_reconstruction_selection_listeners): try: @@ -1727,10 +2674,130 @@ def set_shared_reconstruction_selection(self, names: list[str]) -> None: pass def register_shared_reconstruction_selection_listener(self, callback) -> None: + """Register a callback for shared reconstruction-selection changes.""" + if callback not in self.shared_reconstruction_selection_listeners: self.shared_reconstruction_selection_listeners.append(callback) +def report_startup_status(state: SharedAppState | None, message: str) -> None: + """Send one startup-status message to the temporary splash, if active.""" + + if state is None: + return + callback = getattr(state, "startup_status_callback", None) + if callback is None: + return + try: + callback(str(message)) + except Exception: + pass + + +class BusyStatusWindow(tk.Toplevel): + """Small transient popup shown during long synchronous computations.""" + + def __init__(self, parent, title: str, message: str): + super().__init__(parent) + self.title(str(title)) + self.resizable(False, False) + self.transient(parent) + self.attributes("-topmost", True) + body = ttk.Frame(self, padding=14) + body.pack(fill=tk.BOTH, expand=True) + self.message_var = tk.StringVar(value=str(message)) + ttk.Label(body, textvariable=self.message_var, justify=tk.LEFT, wraplength=320).pack(fill=tk.X) + self.progress = ttk.Progressbar(body, mode="indeterminate", length=320) + self.progress.pack(fill=tk.X, pady=(10, 0)) + self.progress.start(12) + self.update_idletasks() + try: + parent_root_x = int(parent.winfo_rootx()) + parent_root_y = int(parent.winfo_rooty()) + parent_width = int(parent.winfo_width()) + parent_height = int(parent.winfo_height()) + width = int(self.winfo_reqwidth()) + height = int(self.winfo_reqheight()) + x_pos = parent_root_x + max((parent_width - width) // 2, 0) + y_pos = parent_root_y + max((parent_height - height) // 2, 0) + self.geometry(f"+{x_pos}+{y_pos}") + except Exception: + pass + + def set_status(self, message: str) -> None: + """Update the message displayed in the long-running-task popup.""" + + self.message_var.set(str(message)) + self.update_idletasks() + + def close(self) -> None: + try: + self.progress.stop() + except Exception: + pass + try: + self.destroy() + except Exception: + pass + + +@contextmanager +def gui_busy_popup(parent, *, title: str, message: str): + """Show one small popup while a long synchronous task is running.""" + + delay_s = 0.5 + + class _NullBusyPopup: + def set_status(self, _message: str) -> None: + return + + def close(self) -> None: + return + + def update(self) -> None: + return + + class _DelayedBusyPopup: + def __init__(self) -> None: + self._started_at = time.monotonic() + self._last_message = str(message) + self._popup = None + + def _ensure_popup(self) -> None: + if self._popup is not None: + return + if (time.monotonic() - self._started_at) < delay_s: + return + try: + self._popup = BusyStatusWindow(parent, title=title, message=self._last_message) + except Exception: + self._popup = _NullBusyPopup() + + def set_status(self, current_message: str) -> None: + self._last_message = str(current_message) + self._ensure_popup() + self._popup.set_status(self._last_message) if self._popup is not None else None + + def close(self) -> None: + if self._popup is not None: + self._popup.close() + + def update(self) -> None: + self._ensure_popup() + if self._popup is not None: + self._popup.update() + + popup = _DelayedBusyPopup() + try: + yield popup + finally: + popup.close() + try: + parent.update_idletasks() + except Exception: + pass + + def current_dataset_name(state: SharedAppState) -> str: keypoints_path = ROOT / state.keypoints_var.get() trc_path = ROOT / state.pose2sim_trc_var.get() if state.pose2sim_trc_var.get().strip() else None @@ -1741,6 +2808,33 @@ def current_dataset_dir(state: SharedAppState) -> Path: return normalize_output_root(ROOT / state.output_root_var.get()) / current_dataset_name(state) +def shared_images_root_path(state: SharedAppState) -> Path | None: + """Return the shared images root selected in the first 2D-analysis tab.""" + + raw_value = str(getattr(state, "shared_images_root_var", "").get() if hasattr(state, "shared_images_root_var") else "") + raw_value = raw_value.strip() + if not raw_value: + return None + return Path(raw_value) + + +def sync_shared_images_root_from_keypoints( + state: SharedAppState, + keypoints_path: Path, + *, + set_if_empty_only: bool = True, +) -> Path | None: + """Infer and optionally publish the shared images root for one keypoints file.""" + + inferred_images_root = infer_execution_images_root(keypoints_path) + if inferred_images_root is None: + return None + current_value = str(state.shared_images_root_var.get()).strip() + if (not set_if_empty_only) or (not current_value): + state.shared_images_root_var.set(display_path(inferred_images_root)) + return inferred_images_root + + def current_selected_camera_names(state: SharedAppState) -> list[str]: return parse_camera_names(state.selected_camera_names_var.get()) @@ -1762,6 +2856,42 @@ def current_calibration_correction_mode(state: SharedAppState) -> str: ) +def schedule_after_idle_once(widget, attr_name: str, callback) -> None: + """Coalesce repeated refresh requests into one idle callback.""" + + scheduled_id = getattr(widget, attr_name, None) + if scheduled_id is not None: + return + if not hasattr(widget, "after_idle"): + callback() + return + + def _runner(): + setattr(widget, attr_name, None) + callback() + + try: + scheduled_id = widget.after_idle(_runner) + except Exception: + callback() + return + setattr(widget, attr_name, scheduled_id) + + +def cancel_scheduled_after_idle(widget, attr_name: str) -> None: + """Cancel one scheduled idle callback when the widget supports it.""" + + scheduled_id = getattr(widget, attr_name, None) + if scheduled_id is None: + return + setattr(widget, attr_name, None) + if hasattr(widget, "after_cancel"): + try: + widget.after_cancel(scheduled_id) + except Exception: + pass + + def normalize_pose_correction_mode(raw: str) -> str: value = str(raw).strip() return ( @@ -1912,7 +3042,7 @@ def synchronize_profiles_initial_rotation_correction(state: SharedAppState) -> N updated = True profiles.append(profile) if updated: - state.set_profiles(profiles) + state.set_profiles(profiles, mark_dirty=True) def ensure_dataset_layout(state: SharedAppState) -> None: @@ -2015,6 +3145,54 @@ def coherence_method_from_display_name(label: str) -> str: return normalized or "epipolar" +ROOT_UNWRAP_MODE_DISPLAY_NAMES = { + "double": "Double unwrap", + "single": "Single unwrap", + "off": "Off", +} + +REPROJECTION_THRESHOLD_DISPLAY_VALUES = ("none", "5", "10", "15", "20", "25") + + +def reprojection_threshold_display_value(threshold_px: float | None) -> str: + if threshold_px is None: + return "none" + value = float(threshold_px) + return str(int(value)) if value.is_integer() else f"{value:g}" + + +def reprojection_threshold_from_display_value(label: str) -> float | None: + normalized = str(label).strip().lower() + if normalized in {"", "none", "off"}: + return None + return float(normalized) + + +def root_unwrap_mode_display_name(mode: str) -> str: + """Return a user-facing label for one root-angle stabilization mode.""" + + normalized = normalize_root_unwrap_mode(mode) + return ROOT_UNWRAP_MODE_DISPLAY_NAMES.get(normalized, normalized) + + +def root_unwrap_mode_from_display_name(label: str) -> str: + """Map one UI label back to the canonical root-angle stabilization mode.""" + + normalized = str(label).strip() + for mode, display_name in ROOT_UNWRAP_MODE_DISPLAY_NAMES.items(): + if normalized == display_name: + return mode + return normalize_root_unwrap_mode(normalized or "off") + + +def profile_root_unwrap_mode(profile) -> str: + """Resolve one profile root-angle stabilization mode with legacy fallback.""" + + if bool(getattr(profile, "no_root_unwrap", False)): + return "off" + return normalize_root_unwrap_mode(getattr(profile, "root_unwrap_mode", None), legacy_unwrap=True) + + def write_runtime_profiles_config(state: SharedAppState) -> Path: """Persist the in-memory profiles to a cache file used only for command execution.""" @@ -2039,7 +3217,9 @@ def pose_data_cache_key( outlier_threshold_ratio: float, lower_percentile: float, upper_percentile: float, + annotations_path: Path | None = None, ) -> tuple[object, ...]: + resolved_annotations = None if annotations_path is None else Path(annotations_path) return ( str(keypoints_path.resolve()), str(calib_path.resolve()), @@ -2051,6 +3231,12 @@ def pose_data_cache_key( float(outlier_threshold_ratio), float(lower_percentile), float(upper_percentile), + None if resolved_annotations is None else str(resolved_annotations.resolve()), + ( + None + if resolved_annotations is None or not resolved_annotations.exists() + else resolved_annotations.stat().st_mtime_ns + ), ) @@ -2058,8 +3244,11 @@ def get_cached_calibrations(state: SharedAppState, calib_path: Path) -> dict[str key = calibration_cache_key(calib_path) cached = state.calibration_cache.get(key) if cached is None: + report_startup_status(state, f"Loading calibrations: {calib_path.name}") cached = load_calibrations(calib_path) state.calibration_cache[key] = cached + else: + report_startup_status(state, f"Using cached calibrations: {calib_path.name}") return cached @@ -2076,7 +3265,16 @@ def get_cached_pose_data( outlier_threshold_ratio: float = 0.10, lower_percentile: float = 5.0, upper_percentile: float = 95.0, + annotations_path: Path | None = None, ): + resolved_annotations_path = None if annotations_path is None else Path(annotations_path) + if resolved_annotations_path is None and str(data_mode) == "annotated": + state_annotation_path = getattr(state, "annotation_path_var", None) + state_annotation_value = "" if state_annotation_path is None else str(state_annotation_path.get()).strip() + if state_annotation_value: + resolved_annotations_path = ROOT / state_annotation_value + else: + resolved_annotations_path = default_annotation_path(keypoints_path) cache_key = pose_data_cache_key( keypoints_path=keypoints_path, calib_path=calib_path, @@ -2088,12 +3286,15 @@ def get_cached_pose_data( outlier_threshold_ratio=outlier_threshold_ratio, lower_percentile=lower_percentile, upper_percentile=upper_percentile, + annotations_path=resolved_annotations_path, ) cached = state.pose_data_cache.get(cache_key) if cached is not None: + report_startup_status(state, f"Using cached 2D poses: {keypoints_path.name}") calibrations = get_cached_calibrations(state, calib_path) return calibrations, cached calibrations = get_cached_calibrations(state, calib_path) + report_startup_status(state, f"Loading 2D pose data: {keypoints_path.name}") pose_data = load_pose_data( keypoints_path, calibrations, @@ -2105,11 +3306,73 @@ def get_cached_pose_data( outlier_threshold_ratio=outlier_threshold_ratio, lower_percentile=lower_percentile, upper_percentile=upper_percentile, + annotations_path=resolved_annotations_path, ) state.pose_data_cache[cache_key] = pose_data return calibrations, pose_data +def existing_annotation_path_for_keypoints(state: SharedAppState, keypoints_path: Path) -> Path | None: + """Return the annotation file to use for a keypoints file when it exists.""" + + state_annotation_path = getattr(state, "annotation_path_var", None) + state_annotation_value = "" if state_annotation_path is None else str(state_annotation_path.get()).strip() + candidates: list[Path] = [] + if state_annotation_value: + candidates.append(ROOT / state_annotation_value) + candidates.append(default_annotation_path(keypoints_path)) + for candidate in candidates: + try: + if Path(candidate).exists(): + return Path(candidate) + except Exception: + continue + return None + + +def annotation_only_pose_data( + pose_data: PoseData, + *, + keypoints_path: Path, + annotations_path: Path | None, +) -> PoseData: + """Return one sparse pose-data view containing only manual annotations.""" + + payload = load_annotation_payload(annotations_path, keypoints_path=keypoints_path) + sparse_keypoints, sparse_scores = apply_annotations_to_pose_arrays( + keypoints=np.full_like(np.asarray(pose_data.keypoints, dtype=float), np.nan), + scores=np.zeros_like(np.asarray(pose_data.scores, dtype=float)), + camera_names=list(pose_data.camera_names), + frames=np.asarray(pose_data.frames, dtype=int), + keypoint_names=COCO17, + payload=payload, + ) + return replace(pose_data, keypoints=sparse_keypoints, scores=sparse_scores) + + +def annotation_payload_signature(payload: dict[str, object]) -> str: + """Return a deterministic serialized signature for one annotation payload. + + Args: + payload: Sparse manual-annotation payload organized by camera, frame, + and marker. + + Returns: + Stable JSON text suitable for unsaved-change detection. + """ + + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def available_model_pose_modes(state: SharedAppState, keypoints_path: Path) -> list[str]: + """Return the 2D source modes available in the Models tab.""" + + modes = ["raw", "cleaned"] + if existing_annotation_path_for_keypoints(state, keypoints_path) is not None: + modes.insert(1, "annotated") + return modes + + def get_pose_data_with_correction( state: SharedAppState, *, @@ -2662,7 +3925,7 @@ def __init__(self, master): mode_label.pack(side=tk.LEFT) self.pose_data_mode = tk.StringVar(value="cleaned") pose_mode_box = ttk.Combobox( - row0, textvariable=self.pose_data_mode, values=["raw", "cleaned"], width=12, state="readonly" + row0, textvariable=self.pose_data_mode, values=["raw", "annotated", "cleaned"], width=12, state="readonly" ) pose_mode_box.pack(side=tk.LEFT, padx=(0, 6)) self.subject_mass = LabeledEntry(form, "Subject mass", "55") @@ -2816,7 +4079,6 @@ def __init__(self, master): checks.pack(fill=tk.X, padx=8, pady=6) self.compare_var = tk.BooleanVar(value=True) self.flip_acc_var = tk.BooleanVar(value=True) - self.no_unwrap_var = tk.BooleanVar(value=False) self.model_only_var = tk.BooleanVar(value=False) self.triang_only_var = tk.BooleanVar(value=False) self.reuse_triang_var = tk.BooleanVar(value=False) @@ -2826,7 +4088,6 @@ def __init__(self, master): check_tooltips = { "compare-biorbd-kalman": "Calcule aussi la comparaison directe avec l'EKF 3D biorbd.", "run-ekf-2d-flip-acc": "Lance la variante EKF 2D avec correction gauche/droite et predicteur acceleration.", - "no-root-unwrap": "Desactive l'unwrap temporel des angles de racine.", "model-only": "Construit seulement le modele sans lancer les filtres.", "triangulate-only": "S'arrete apres la triangulation 3D.", "reuse-triangulation": "Reutilise une triangulation en cache si les options correspondent.", @@ -2837,7 +4098,6 @@ def __init__(self, master): for text, var in [ ("compare-biorbd-kalman", self.compare_var), ("run-ekf-2d-flip-acc", self.flip_acc_var), - ("no-root-unwrap", self.no_unwrap_var), ("model-only", self.model_only_var), ("triangulate-only", self.triang_only_var), ("reuse-triangulation", self.reuse_triang_var), @@ -2956,8 +4216,7 @@ def build_command(self) -> list[str]: cmd.append("--compare-biorbd-kalman") if self.flip_acc_var.get(): cmd.append("--run-ekf-2d-flip-acc") - if self.no_unwrap_var.get(): - cmd.append("--no-root-unwrap") + cmd.extend(["--root-unwrap-mode", "off"]) if self.model_only_var.get(): cmd.append("--model-only") if self.triang_only_var.get(): @@ -3108,6 +4367,7 @@ def __init__(self, master, state: SharedAppState): ) self.state.keypoints_var.trace_add("write", lambda *_args: self.sync_dataset_defaults()) self.state.output_root_var.trace_add("write", lambda *_args: self.sync_dataset_defaults()) + self.state.shared_images_root_var.trace_add("write", lambda *_args: self.refresh_preview()) self.state.register_reconstruction_listener(self.refresh_available_reconstructions) self.refresh_available_reconstructions() @@ -3137,6 +4397,10 @@ def configure_shared_reconstruction_panel(self, panel: SharedReconstructionPanel def selected_reconstruction_names(self) -> list[str]: return list(self.state.shared_reconstruction_selection) + def _selected_reconstruction(self) -> str | None: + selected = self.selected_reconstruction_names() + return str(selected[-1]) if selected else None + def _publish_reconstruction_rows(self, rows: list[dict[str, object]], defaults: list[str]) -> None: panel = self.state.shared_reconstruction_panel if panel is not None and self.state.active_reconstruction_consumer is self: @@ -3366,13 +4630,18 @@ def refresh_preview(self) -> None: ) for name in show_names: frame_points = available[name][frame_idx] + recon_color = reconstruction_display_color(self.state, name) draw_skeleton_3d( ax, frame_points, - reconstruction_display_color(self.state, name), + recon_color, reconstruction_legend_label(self.state, name), marker_size=float(self.marker_size.get()), ) + summary = self.bundle.get("recon_summary", {}).get(name, {}) + q_names = self.bundle.get("q_names", []) + if has_segmented_back_visualization(q_names=q_names, summary=summary): + draw_upper_back_preview(ax, frame_points, color=recon_color) if self.show_trunk_frames_var.get(): origin, rotation = compute_root_frame_from_points(frame_points) if origin is not None and rotation is not None: @@ -3419,6 +4688,8 @@ def __init__(self, master, state: SharedAppState): self.calibrations = None self.preview_bundle = None self.projected_layers: dict[str, np.ndarray] = {} + self.segmented_back_projected_layers: dict[str, dict[str, np.ndarray]] = {} + self.reconstruction_payloads: dict[str, dict[str, np.ndarray]] = {} self.preview_frame_numbers = np.array([], dtype=int) self.preview_raw_points = None self.preview_pose_points = None @@ -3482,6 +4753,7 @@ def __init__(self, master, state: SharedAppState): height=5, ) self.multiview_cameras_list.pack(fill=tk.BOTH, expand=True) + bind_extended_listbox_shortcuts(self.multiview_cameras_list) self.multiview_cameras_list.bind( "<>", lambda _event: self.on_multiview_camera_selection_changed() ) @@ -3494,8 +4766,16 @@ def __init__(self, master, state: SharedAppState): ttk.Checkbutton( row_images_1, text="Show images", variable=self.show_images_var, command=self.refresh_preview ).pack(side=tk.LEFT) - self.images_root_entry = LabeledEntry(images_box, "Images root", "", browse=True, directory=True) - self.images_root_entry.pack(fill=tk.X, padx=8, pady=(0, 6)) + ttk.Label(row_images_1, text="QA").pack(side=tk.LEFT, padx=(12, 4)) + self.qa_overlay_var = tk.StringVar(value="none") + self.qa_overlay_box = ttk.Combobox( + row_images_1, + textvariable=self.qa_overlay_var, + values=["none", "2D epipolar", "3D reproj", "3D excluded"], + width=14, + state="readonly", + ) + self.qa_overlay_box.pack(side=tk.LEFT) preview_box = ttk.LabelFrame(right, text="Preview 2D multivues") preview_box.pack(fill=tk.BOTH, expand=True, pady=(0, 8)) @@ -3536,13 +4816,21 @@ def __init__(self, master, state: SharedAppState): self.generate_button, "Genere le GIF 2D multivues, puis devient STOP tant que l'export est en cours." ) attach_tooltip(self.multiview_cameras_list, "Choisissez les caméras visibles dans le preview et le GIF.") - self.images_root_entry.set_tooltip("Dossier d'images utilisé comme fond des vues 2D, par caméra si disponible.") + attach_tooltip( + images_box, + "Le fond image utilise le chemin partagé 'Images root' défini dans l'onglet 2D analysis.", + ) + attach_tooltip( + self.qa_overlay_box, + "Overlay QA rapide dans le preview 2D: erreur épipolaire 2D ou diagnostics 3D de la reconstruction sélectionnée.", + ) attach_tooltip(self.frame_scale, "Slider de navigation temporelle du preview 2D.") attach_tooltip(self.frame_label, "Index de frame actuellement affiche dans le preview 2D.") self.extra.set_tooltip("Options CLI supplémentaires pour animation/animate_multiview_2d_comparison.py.") self.state.keypoints_var.trace_add("write", lambda *_args: self.sync_dataset_defaults()) self.state.output_root_var.trace_add("write", lambda *_args: self.sync_dataset_defaults()) self.state.register_reconstruction_listener(self.refresh_available_reconstructions) + self.qa_overlay_var.trace_add("write", lambda *_args: self.refresh_preview()) self.refresh_available_reconstructions() def default_gif_name(self) -> str: @@ -3612,6 +4900,12 @@ def configure_shared_reconstruction_panel(self, panel: SharedReconstructionPanel def selected_reconstruction_names(self) -> list[str]: return list(self.state.shared_reconstruction_selection) + def _selected_reconstruction(self) -> str | None: + """Return the last selected reconstruction from the shared selector.""" + + selected = self.selected_reconstruction_names() + return str(selected[-1]) if selected else None + def _publish_reconstruction_rows(self, rows: list[dict[str, object]], defaults: list[str]) -> None: panel = self.state.shared_reconstruction_panel if panel is not None and self.state.active_reconstruction_consumer is self: @@ -3681,9 +4975,12 @@ def step_frame(self, delta: int) -> str: def sync_dataset_defaults(self) -> None: self.output_gif.var.set(self.default_gif_name()) - inferred_images_root = infer_execution_images_root(ROOT / self.state.keypoints_var.get()) + inferred_images_root = sync_shared_images_root_from_keypoints( + self.state, + ROOT / self.state.keypoints_var.get(), + set_if_empty_only=True, + ) self.images_root = inferred_images_root - self.images_root_entry.var.set("" if inferred_images_root is None else display_path(inferred_images_root)) self.refresh_available_reconstructions() def refresh_available_reconstructions(self) -> None: @@ -3740,7 +5037,7 @@ def build_command(self) -> list[str]: selected_cameras = self.selected_multiview_camera_names() if selected_cameras: cmd.extend(["--camera-names", ",".join(selected_cameras)]) - images_root = self.images_root_entry.get().strip() + images_root = str(self.state.shared_images_root_var.get()).strip() if self.show_images_var.get(): cmd.append("--show-images") if images_root: @@ -3786,12 +5083,13 @@ def load_preview(self) -> None: ) self.preview_bundle = preview_load.bundle self.refresh_multiview_camera_choices() - inferred_images_root = infer_execution_images_root(ROOT / self.state.keypoints_var.get()) - if not self.images_root_entry.get().strip() or inferred_images_root is not None: + inferred_images_root = sync_shared_images_root_from_keypoints( + self.state, + ROOT / self.state.keypoints_var.get(), + set_if_empty_only=True, + ) + if inferred_images_root is not None: self.images_root = inferred_images_root - self.images_root_entry.var.set( - "" if inferred_images_root is None else display_path(inferred_images_root) - ) self._publish_reconstruction_rows(preview_load.preview_state.rows, preview_load.preview_state.defaults) bundle_frames = ( np.asarray(self.preview_bundle["frames"], dtype=int) @@ -3811,8 +5109,22 @@ def load_preview(self) -> None: ) camera_names = list(self.pose_data.camera_names) self.projected_layers = {} + self.segmented_back_projected_layers = {} + self.reconstruction_payloads = {} for name, points_3d in self.preview_bundle["recon_3d"].items(): self.projected_layers[name] = project_points_all_cameras(points_3d, self.calibrations, camera_names) + recon_dir = reconstruction_dir_by_name(output_dir, name) + if recon_dir is not None: + self.reconstruction_payloads[name] = load_bundle_payload(recon_dir) + q_series = self.preview_bundle.get("recon_q", {}).get(name) + biomod_path = resolve_reconstruction_biomod(output_dir, name) + if q_series is not None and biomod_path is not None and biomod_path.exists(): + overlay_3d = segmented_back_overlay_from_q(biomod_path, np.asarray(q_series, dtype=float)) + if overlay_3d: + self.segmented_back_projected_layers[name] = { + overlay_name: project_points_all_cameras(points, self.calibrations, camera_names) + for overlay_name, points in overlay_3d.items() + } self.crop_limits_cache = {} self.crop_limits_key = None n_frames = min( @@ -3868,11 +5180,13 @@ def refresh_preview(self) -> None: crop_points = compose_multiview_crop_points(pose_points, projected_layers, selected) crop_mode = "pose" if self.crop_var.get() else "full" crop_margin = 0.1 - crop_limits = self._ensure_crop_limits(crop_points, camera_names, crop_margin) if crop_mode == "pose" else {} - frame_number = int(self.preview_frame_numbers[frame_idx]) if self.preview_frame_numbers.size else int(frame_idx) - images_root = ( - Path(self.images_root_entry.get().strip()) if self.images_root_entry.get().strip() else self.images_root + crop_limits = ( + self._ensure_crop_limits(crop_points, camera_names, crop_margin, tuple(selected)) + if crop_mode == "pose" + else {} ) + frame_number = int(self.preview_frame_numbers[frame_idx]) if self.preview_frame_numbers.size else int(frame_idx) + images_root = shared_images_root_path(self.state) or self.images_root for ax_idx, ax in enumerate(axes): if ax_idx >= len(camera_names): @@ -3880,25 +5194,74 @@ def refresh_preview(self) -> None: continue cam_name = camera_names[ax_idx] width, height = self.calibrations[cam_name].image_size - if self.show_images_var.get(): - image_path = resolve_execution_image_path(images_root, cam_name, frame_number) - if image_path is not None and image_path.exists(): - ax.imshow(plt.imread(str(image_path))) - apply_2d_axis_limits( + background_image = ( + load_camera_background_image( + images_root, + cam_name, + frame_number, + image_reader=plt.imread, + ) + if self.show_images_var.get() + else None + ) + layers: list[SkeletonLayer2D] = [] + if "raw" in selected: + layers.append( + SkeletonLayer2D( + points=raw_points[ax_idx, frame_idx], + color=("white" if background_image is not None else "#444444"), + label="Raw", + marker_size=float(self.marker_size.get()), + ) + ) + for raw_name in selected: + mapped = "ekf_2d_acc" if raw_name == "ekf_2d" else raw_name + mapped = "triangulation_adaptive" if raw_name == "triangulation" else mapped + if mapped == "biorbd_kalman": + mapped = "ekf_3d" + if mapped == "raw" or mapped not in projected_layers: + continue + points_2d = projected_layers[mapped][ax_idx, frame_idx] + layers.append( + SkeletonLayer2D( + points=points_2d, + color=reconstruction_display_color(self.state, mapped), + label=reconstruction_legend_label(self.state, mapped), + marker_size=float(self.marker_size.get()), + ) + ) + render_camera_frame_2d( ax, + width=width, + height=height, + title=cam_name.replace("Camera", ""), + layers=layers, + draw_skeleton_fn=draw_skeleton_2d, + background_image=background_image, + draw_background_fn=draw_2d_background_image, crop_mode=crop_mode, crop_limits=crop_limits, cam_name=cam_name, frame_idx=frame_idx, - width=width, - height=height, + apply_axis_limits_fn=apply_2d_axis_limits, + hide_axes=True, + hide_axes_fn=hide_2d_axes, ) - ax.set_title(cam_name.replace("Camera", "")) - ax.grid(alpha=0.15) - if "raw" in selected: - draw_skeleton_2d( - ax, raw_points[ax_idx, frame_idx], "#444444", "Raw", marker_size=float(self.marker_size.get()) - ) + overlay_label, overlay_values, overlay_mask, overlay_cmap = self._qa_overlay_data(cam_name, frame_idx) + overlay_scatter = draw_point_value_overlay( + ax, + PointValueOverlay2D( + label=overlay_label, + points=raw_points[ax_idx, frame_idx], + values=overlay_values, + mask=overlay_mask, + cmap=overlay_cmap, + size=16.0, + excluded_size=max(18.0, float(self.marker_size.get()) * 0.75), + ), + ) + if overlay_scatter is not None: + self.preview_figure.colorbar(overlay_scatter, ax=ax, fraction=0.042, pad=0.02, label=overlay_label) for raw_name in selected: mapped = "ekf_2d_acc" if raw_name == "ekf_2d" else raw_name mapped = "triangulation_adaptive" if raw_name == "triangulation" else mapped @@ -3907,16 +5270,34 @@ def refresh_preview(self) -> None: if mapped == "raw" or mapped not in projected_layers: continue points_2d = projected_layers[mapped][ax_idx, frame_idx] - draw_skeleton_2d( + segmented_back_layers = self.segmented_back_projected_layers.get(mapped, {}) + draw_upper_back_overlay_2d( ax, - points_2d, - reconstruction_display_color(self.state, mapped), - reconstruction_legend_label(self.state, mapped), - marker_size=float(self.marker_size.get()), + hip_triangle_2d=( + segmented_back_layers.get("hip_triangle", np.empty((0, 0, 0)))[ax_idx, frame_idx] + if "hip_triangle" in segmented_back_layers + else None + ), + shoulder_triangle_2d=( + segmented_back_layers.get("shoulder_triangle", np.empty((0, 0, 0)))[ax_idx, frame_idx] + if "shoulder_triangle" in segmented_back_layers + else None + ), + mid_back_2d=( + segmented_back_layers.get("mid_back", np.empty((0, 0, 0)))[ax_idx, frame_idx, 0] + if "mid_back" in segmented_back_layers + else None + ), + color=reconstruction_display_color(self.state, mapped), ) - if ax_idx >= (nrows - 1) * ncols: - ax.set_xlabel("x (px)") - ax.set_ylabel("y (px)") + self._draw_excluded_reprojection_points( + ax=ax, + reconstruction_name=mapped, + camera_name=cam_name, + frame_number=frame_number, + points_2d=points_2d, + ) + ax.tick_params(labelsize=8, length=0) handles, labels = axes[0].get_legend_handles_labels() if axes.size else ([], []) if handles: @@ -3924,20 +5305,28 @@ def refresh_preview(self) -> None: for handle, label in zip(handles, labels): uniq[label] = handle self.preview_figure.legend( - list(uniq.values()), list(uniq.keys()), loc="upper center", ncol=min(5, len(uniq)), fontsize=8 + list(uniq.values()), + list(uniq.keys()), + loc="upper center", + bbox_to_anchor=(0.5, 0.985), + ncol=min(5, len(uniq)), + fontsize=8, ) - self.preview_figure.tight_layout() + self.preview_figure.subplots_adjust(left=0.035, right=0.995, bottom=0.035, top=0.93, wspace=0.08, hspace=0.18) self.preview_canvas.draw_idle() def _ensure_crop_limits( - self, crop_points: np.ndarray, camera_names: list[str], crop_margin: float + self, crop_points: np.ndarray, camera_names: list[str], crop_margin: float, selected_layers: tuple[str, ...] ) -> dict[str, np.ndarray]: cache_key = ( id(self.pose_data), id(self.calibrations), tuple(camera_names), + tuple(selected_layers), tuple(crop_points.shape), float(crop_margin), + float(np.nanmin(crop_points)) if np.any(np.isfinite(crop_points)) else np.nan, + float(np.nanmax(crop_points)) if np.any(np.isfinite(crop_points)) else np.nan, ) if self.crop_limits_key != cache_key: gui_debug( @@ -3950,19 +5339,2079 @@ def _ensure_crop_limits( self.crop_limits_key = cache_key return self.crop_limits_cache + def _qa_overlay_data( + self, camera_name: str, frame_local_idx: int + ) -> tuple[str, np.ndarray | None, np.ndarray | None, str | None]: + if self.pose_data is None or self.calibrations is None: + return "none", None, None, None + mode = str(self.qa_overlay_var.get()).strip().lower() + if mode == "2d epipolar": + cam_idx = list(self.pose_data.camera_names).index(camera_name) + values = frame_camera_epipolar_errors( + self.pose_data, self.calibrations, frame_idx=frame_local_idx, camera_idx=cam_idx + ) + return "2D epipolar", values, None, "turbo" + reference_name = (self._selected_reconstruction() or "").strip() + if not reference_name: + return "none", None, None, None + payload = self.reconstruction_payloads.get(reference_name, {}) + if mode == "3d reproj": + errors = payload.get("reprojection_error_per_view") + if errors is None: + return "3D reproj", None, None, None + errors = np.asarray(errors, dtype=float) + cam_idx = list(self.pose_data.camera_names).index(camera_name) + if errors.ndim == 3 and frame_local_idx < errors.shape[0] and cam_idx < errors.shape[2]: + return "3D reproj", np.asarray(errors[frame_local_idx, :, cam_idx], dtype=float), None, "turbo" + if mode == "3d excluded": + excluded = payload.get("excluded_views") + if excluded is None: + return "3D excluded", None, None, None + excluded = np.asarray(excluded, dtype=bool) + cam_idx = list(self.pose_data.camera_names).index(camera_name) + if excluded.ndim == 3 and frame_local_idx < excluded.shape[0] and cam_idx < excluded.shape[2]: + return "3D excluded", None, np.asarray(excluded[frame_local_idx, :, cam_idx], dtype=bool), None + return "none", None, None, None + + def _draw_excluded_reprojection_points( + self, + *, + ax, + reconstruction_name: str, + camera_name: str, + frame_number: int, + points_2d: np.ndarray, + ) -> None: + payload = self.reconstruction_payloads.get(str(reconstruction_name), {}) + excluded_views = payload.get("excluded_views") + payload_frames = payload.get("frames") + payload_camera_names = payload.get("camera_names") + if excluded_views is None or payload_frames is None or payload_camera_names is None: + return + excluded_views = np.asarray(excluded_views, dtype=bool) + payload_frames = np.asarray(payload_frames, dtype=int) + payload_camera_names = [str(name) for name in np.asarray(payload_camera_names, dtype=object)] + if excluded_views.ndim != 3 or frame_number not in set(int(frame) for frame in payload_frames): + return + try: + frame_idx = int(np.where(payload_frames == int(frame_number))[0][0]) + cam_idx = payload_camera_names.index(str(camera_name)) + except Exception: + return + if frame_idx >= excluded_views.shape[0] or cam_idx >= excluded_views.shape[2]: + return + excluded_mask = np.asarray(excluded_views[frame_idx, :, cam_idx], dtype=bool) + if excluded_mask.shape[0] != points_2d.shape[0] or not np.any(excluded_mask): + return + excluded_points = np.asarray(points_2d[excluded_mask], dtype=float) + valid = np.all(np.isfinite(excluded_points), axis=1) + if not np.any(valid): + return + ax.scatter( + excluded_points[valid, 0], + excluded_points[valid, 1], + s=max(18.0, float(self.marker_size.get()) * 0.8), + facecolors="none", + edgecolors="#111111", + linewidths=1.6, + marker="x", + alpha=0.9, + ) -class FiguresTab(CommandTab): - def __init__(self, master): - super().__init__(master, "Figures") - form = ttk.LabelFrame(self.main, text="analysis/plot_kinematic_comparison.py") - form.pack(fill=tk.X, pady=(0, 8), before=self.output) - - self.input_dir = LabeledEntry(form, "Input dir", "output/vitpose_full", browse=True, directory=True) - self.input_dir.pack(fill=tk.X, padx=8, pady=4) - self.output_dir = LabeledEntry(form, "Output dir", "output/vitpose_full/figures", browse=True, directory=True) - self.output_dir.pack(fill=tk.X, padx=8, pady=4) - row = ttk.Frame(form) +class AnnotationTab(ttk.Frame): + def __init__(self, master, state: SharedAppState): + super().__init__(master) + self.state = state + self.unsaved_changes_label = "annotations" + self.uses_shared_reconstruction_panel = True + self.shared_reconstruction_selectmode = "browse" + self.calibrations = None + self.pose_data = None + self.annotation_payload = empty_annotation_payload() + self.annotation_path: Path | None = None + self.images_root: Path | None = None + self.crop_limits_cache: dict[str, np.ndarray] = {} + self.crop_limits_key: tuple[object, ...] | None = None + self._navigable_frame_cache: dict[tuple[object, ...], list[int]] = {} + self._axis_to_camera: dict[object, str] = {} + self._current_frame_idx = 0 + self._pan_state: dict[str, object] | None = None + self._drag_annotation_state: dict[str, object] | None = None + self._dragging_frame_scale = False + self._cursor_artists: dict[object, tuple[object, ...]] = {} + self._annotation_hover_entries: dict[object, list[dict[str, object]]] = {} + self._annotation_view_limits: dict[str, tuple[tuple[float, float], tuple[float, float]]] = {} + self._reset_annotation_view_limits_on_next_refresh = False + self._pending_reprojection_points: dict[tuple[str, int, str], np.ndarray] = {} + self._syncing_annotation_defaults = False + self._scheduled_annotation_refresh_id = None + self._scheduled_annotation_load_id = None + self._scheduled_annotation_recon_id = None + self.kinematic_model_choices: dict[str, Path] = {} + self.kinematic_frame_states: dict[tuple[str, int], np.ndarray] = {} + self.kinematic_q_current: np.ndarray | None = None + self.kinematic_state_current: np.ndarray | None = None + self.kinematic_q_names: list[str] = [] + self.kinematic_projected_points: np.ndarray | None = None + self.kinematic_segmented_back_projected: dict[str, np.ndarray] = {} + self.annotation_jump_analysis: DDSessionAnalysis | None = None + self._last_saved_annotation_signature = annotation_payload_signature(self.annotation_payload) + + body = ttk.Panedwindow(self, orient=tk.HORIZONTAL) + body.pack(fill=tk.BOTH, expand=True) + left = ttk.Frame(body) + right = ttk.Frame(body) + body.add(left, weight=1) + body.add(right, weight=9) + + controls = ttk.LabelFrame(left, text="Annotation controls") + controls.pack(fill=tk.BOTH, expand=True, padx=(0, 8), pady=(0, 8)) + + row0 = ttk.Frame(controls) + row0.pack(fill=tk.X, padx=8, pady=4) + ttk.Button(row0, text="Load data", command=self.load_resources).pack(side=tk.LEFT) + ttk.Button(row0, text="Save annotations", command=self.save_annotations).pack(side=tk.LEFT, padx=(8, 0)) + ttk.Button(row0, text="Refresh frame", command=self.refresh_preview).pack(side=tk.LEFT, padx=(8, 0)) + self.reproject_button_var = tk.StringVar(value="Reproject") + self.reproject_button = ttk.Button( + row0, textvariable=self.reproject_button_var, command=self.on_reproject_button + ) + self.reproject_button.pack(side=tk.LEFT, padx=(8, 0)) + + row1 = ttk.Frame(controls) + row1.pack(fill=tk.X, padx=8, pady=4) + self.show_images_var = tk.BooleanVar(value=True) + ttk.Checkbutton(row1, text="Show images", variable=self.show_images_var, command=self.refresh_preview).pack( + side=tk.LEFT, padx=(0, 8) + ) + self.crop_var = tk.BooleanVar(value=True) + ttk.Checkbutton(row1, text="Crop +20%", variable=self.crop_var, command=self.refresh_preview).pack( + side=tk.LEFT, padx=(0, 8) + ) + + row2 = ttk.Frame(controls) + row2.pack(fill=tk.X, padx=8, pady=4) + self.advance_marker_var = tk.BooleanVar(value=True) + ttk.Checkbutton( + row2, + text="Advance marker on click", + variable=self.advance_marker_var, + ).pack(side=tk.LEFT, padx=(0, 8)) + self.show_epipolar_var = tk.BooleanVar(value=True) + ttk.Checkbutton( + row2, text="Epipolar guide", variable=self.show_epipolar_var, command=self.refresh_preview + ).pack(side=tk.LEFT, padx=(0, 8)) + self.show_triangulated_hint_var = tk.BooleanVar(value=True) + ttk.Checkbutton( + row2, + text="Triangulated reproj", + variable=self.show_triangulated_hint_var, + command=self.refresh_preview, + ).pack(side=tk.LEFT) + self.show_reference_reprojection_var = tk.BooleanVar(value=False) + ttk.Checkbutton( + row2, + text="Selected recon reproj", + variable=self.show_reference_reprojection_var, + command=self.refresh_preview, + ).pack(side=tk.LEFT, padx=(8, 0)) + + row3 = ttk.Frame(controls) + row3.pack(fill=tk.X, padx=8, pady=4) + self.snap_reprojection_var = tk.BooleanVar(value=False) + ttk.Checkbutton( + row3, + text="Snap reproj", + variable=self.snap_reprojection_var, + ).pack(side=tk.LEFT, padx=(0, 8)) + self.snap_epipolar_var = tk.BooleanVar(value=False) + ttk.Checkbutton( + row3, + text="Snap epipolar", + variable=self.snap_epipolar_var, + ).pack(side=tk.LEFT, padx=(0, 8)) + ttk.Label(row3, text=f"radius {int(ANNOTATION_SNAP_RADIUS_PX)} px", foreground="#4f5b66").pack(side=tk.LEFT) + + row4 = ttk.Frame(controls) + row4.pack(fill=tk.X, padx=8, pady=4) + ttk.Label(row4, text="Frame set", width=10).pack(side=tk.LEFT, padx=(0, 6)) + self.frame_filter_var = tk.StringVar(value=ANNOTATION_FRAME_FILTER_OPTIONS["all"]) + self.frame_filter_box = ttk.Combobox( + row4, + textvariable=self.frame_filter_var, + values=list(ANNOTATION_FRAME_FILTER_OPTIONS.values()), + width=18, + state="readonly", + ) + self.frame_filter_box.pack(side=tk.LEFT, padx=(0, 10)) + + row5 = ttk.Frame(controls) + row5.pack(fill=tk.X, padx=8, pady=4) + self.show_motion_prior_var = tk.BooleanVar(value=True) + ttk.Checkbutton( + row5, + text="Velocity prior zone", + variable=self.show_motion_prior_var, + command=self.refresh_preview, + ).pack(side=tk.LEFT, padx=(0, 8)) + self.motion_prior_diameter = LabeledEntry(row5, "Diameter", "15", label_width=7, entry_width=4) + self.motion_prior_diameter.pack(side=tk.LEFT) + + row6 = ttk.Frame(controls) + row6.pack(fill=tk.X, padx=8, pady=4) + self.image_brightness_var = tk.DoubleVar(value=1.0) + ttk.Label(row6, text="Brightness", width=10).pack(side=tk.LEFT) + ttk.Scale( + row6, + from_=0.2, + to=2.0, + orient=tk.HORIZONTAL, + variable=self.image_brightness_var, + command=lambda _value: self.refresh_preview(), + ).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8)) + self.image_contrast_var = tk.DoubleVar(value=1.0) + ttk.Label(row6, text="Contrast", width=8).pack(side=tk.LEFT) + ttk.Scale( + row6, + from_=0.2, + to=2.0, + orient=tk.HORIZONTAL, + variable=self.image_contrast_var, + command=lambda _value: self.refresh_preview(), + ).pack(side=tk.LEFT, fill=tk.X, expand=True) + + self.images_root_entry = LabeledEntry(controls, "Images root", "", browse=True, directory=True) + self.images_root_entry.pack(fill=tk.X, padx=8, pady=4) + self.images_root_entry.var.trace_add("write", lambda *_args: self.request_refresh_preview()) + self.annotations_path_entry = LabeledEntry(controls, "Annotations", "", browse=True, directory=False) + self.annotations_path_entry.pack(fill=tk.X, padx=8, pady=4) + self.annotations_path_entry.var = self.state.annotation_path_var + self.annotations_path_entry.entry_widget.configure(textvariable=self.state.annotation_path_var) + self.annotations_path_entry.on_browse_selected = lambda _value: self.request_load_resources() + self.state.annotation_path_var.trace_add("write", lambda *_args: self._on_annotation_path_changed()) + + kinematic_row = ttk.Frame(controls) + kinematic_row.pack(fill=tk.X, padx=8, pady=4) + self.kinematic_assist_var = tk.BooleanVar(value=False) + ttk.Checkbutton( + kinematic_row, text="Kinematic assist", variable=self.kinematic_assist_var, command=self.refresh_preview + ).pack(side=tk.LEFT, padx=(0, 8)) + ttk.Label(kinematic_row, text="Model", width=6).pack(side=tk.LEFT) + self.kinematic_model_var = tk.StringVar(value="") + self.kinematic_model_box = ttk.Combobox( + kinematic_row, textvariable=self.kinematic_model_var, values=[], width=28, state="readonly" + ) + self.kinematic_model_box.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8)) + self.kinematic_model_var.trace_add( + "write", + lambda *_args: ( + self.refresh_annotation_keypoint_choices(), + self._clear_kinematic_assist_preview(), + self.request_refresh_preview(), + ), + ) + ttk.Button(kinematic_row, text="Estimate q", command=self.estimate_kinematic_assist_state).pack(side=tk.LEFT) + self.kinematic_status_var = tk.StringVar(value="") + ttk.Label(controls, textvariable=self.kinematic_status_var, anchor="w", foreground="#4f5b66").pack( + fill=tk.X, padx=8, pady=(0, 4) + ) + self.jump_context_var = tk.StringVar(value="") + ttk.Label(controls, textvariable=self.jump_context_var, anchor="w", foreground="#4f5b66").pack( + fill=tk.X, padx=8, pady=(0, 4) + ) + + lists_row = ttk.Frame(controls) + lists_row.pack(fill=tk.BOTH, expand=True, padx=8, pady=(4, 8)) + + cameras_box = ttk.LabelFrame(lists_row, text="Cameras") + cameras_box.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 4)) + self.annotation_cameras_summary = tk.StringVar(value="Cameras (n=0/0)") + ttk.Label(cameras_box, textvariable=self.annotation_cameras_summary, anchor="w").pack( + fill=tk.X, padx=6, pady=(6, 2) + ) + cameras_body = ttk.Frame(cameras_box, height=145, width=145) + cameras_body.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6)) + cameras_body.pack_propagate(False) + self.annotation_cameras_list = tk.Listbox( + cameras_body, selectmode=tk.EXTENDED, exportselection=False, height=8, width=12 + ) + self.annotation_cameras_list.pack(fill=tk.BOTH, expand=True) + bind_extended_listbox_shortcuts(self.annotation_cameras_list) + self.annotation_cameras_list.bind("<>", lambda _event: self.on_camera_selection_changed()) + + keypoints_box = ttk.LabelFrame(lists_row, text="Markers") + keypoints_box.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(4, 0)) + self.current_marker_var = tk.StringVar(value="") + ttk.Label(keypoints_box, textvariable=self.current_marker_var, anchor="w").pack(fill=tk.X, padx=6, pady=(6, 2)) + keypoints_body = ttk.Frame(keypoints_box, height=145, width=155) + keypoints_body.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6)) + keypoints_body.pack_propagate(False) + self.annotation_keypoints_list = tk.Listbox(keypoints_body, exportselection=False, height=8, width=14) + self.annotation_keypoints_list.pack(fill=tk.BOTH, expand=True) + self.annotation_keypoints_list.bind("<>", lambda _event: self.on_keypoint_selection_changed()) + self.annotation_keypoints_list.bind("", lambda event: self._step_annotation_keypoint(-1, event)) + self.annotation_keypoints_list.bind("", lambda event: self._step_annotation_keypoint(1, event)) + self.refresh_annotation_keypoint_choices() + + preview_box = ttk.LabelFrame(right, text="Annotation preview") + preview_box.pack(fill=tk.BOTH, expand=True) + preview_controls = ttk.Frame(preview_box) + preview_controls.pack(fill=tk.X, padx=8, pady=4) + ttk.Button(preview_controls, text="Prev frame", command=lambda: self.step_frame(-1)).pack(side=tk.LEFT) + ttk.Button(preview_controls, text="Next frame", command=lambda: self.step_frame(1)).pack( + side=tk.LEFT, padx=(8, 0) + ) + self.frame_var = tk.IntVar(value=0) + self.frame_scale = ttk.Scale( + preview_controls, + from_=0, + to=0, + orient=tk.HORIZONTAL, + variable=self.frame_var, + command=self.on_frame_scale_changed, + ) + self.frame_scale.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(12, 8)) + self.frame_label = ttk.Label(preview_controls, text="frame 0") + self.frame_label.pack(side=tk.LEFT) + + self.preview_figure = Figure(figsize=(11, 8)) + self.preview_canvas = FigureCanvasTkAgg(self.preview_figure, master=preview_box) + self.preview_canvas_widget = self.preview_canvas.get_tk_widget() + self.preview_canvas_widget.pack(fill=tk.BOTH, expand=True) + self.preview_canvas_widget.configure(cursor="crosshair") + self.preview_canvas.mpl_connect("button_press_event", self.on_preview_click) + self.preview_canvas.mpl_connect("scroll_event", self.on_preview_scroll) + self.preview_canvas.mpl_connect("motion_notify_event", self.on_preview_motion) + self.preview_canvas.mpl_connect("button_release_event", self.on_preview_release) + self._bind_frame_navigation(self.preview_canvas_widget) + self._bind_annotation_keypoint_navigation(self) + self._bind_annotation_camera_navigation(self) + self._bind_cancel_pending_reprojection(controls) + for clickable_parent in (self, body, left, right, preview_box, preview_controls): + clickable_parent.bind("", self._cancel_pending_reprojection_from_tk_event, add="+") + self._bind_frame_navigation(self.frame_scale) + + attach_tooltip( + self.annotation_keypoints_list, + "Liste des marqueurs annotés. Si l’avance automatique est activée, un clic place le point puis passe au suivant.", + ) + attach_tooltip( + self.annotation_cameras_list, + "Caméras visibles dans l’annotation. Les vues sélectionnées sont affichées simultanément.", + ) + self.annotations_path_entry.set_tooltip( + "Fichier JSON sparse d’annotations 2D. Par défaut: inputs/annotations/_annotations.json" + ) + self.images_root_entry.set_tooltip("Dossier d’images utilisé comme fond des vues 2D pendant l’annotation.") + self.motion_prior_diameter.set_tooltip( + "Diamètre du cercle de confiance basé sur la vitesse estimée entre t-2 et t-1." + ) + attach_tooltip( + self.frame_filter_box, + "Choisit un sous-ensemble de frames: toutes, celles marquées comme flip L/R, ou les 5% pires erreurs de reprojection pour la reconstruction sélectionnée.", + ) + attach_tooltip( + row3, + "Snaps the placed point to the nearest reprojection or epipolar guide when the corresponding option is enabled.", + ) + attach_tooltip( + self.preview_canvas.get_tk_widget(), + "Souris: clic gauche place un point, clic droit efface, molette zoome, clic molette + glisser déplace.", + ) + + self.state.keypoints_var.trace_add("write", lambda *_args: self.sync_dataset_defaults()) + self.state.output_root_var.trace_add("write", lambda *_args: self.sync_dataset_defaults()) + self.frame_filter_var.trace_add("write", lambda *_args: self.on_frame_filter_changed()) + self.state.register_reconstruction_listener(self.request_refresh_available_reconstructions) + self.sync_dataset_defaults() + self.request_refresh_available_reconstructions() + + def configure_shared_reconstruction_panel(self, panel: SharedReconstructionPanel) -> None: + panel.configure_for_consumer( + title="Reconstructions | Annotation", + refresh_callback=self.refresh_available_reconstructions, + selection_callback=self._on_reconstruction_selection_changed, + selectmode=self.shared_reconstruction_selectmode, + ) + self.refresh_available_reconstructions() + + def selected_reconstruction_names(self) -> list[str]: + return list(self.state.shared_reconstruction_selection) + + def _publish_reconstruction_rows(self, rows: list[dict[str, object]], defaults: list[str]) -> None: + panel = self.state.shared_reconstruction_panel + if panel is not None and self.state.active_reconstruction_consumer is self: + panel.set_rows(rows, defaults) + + def _on_reconstruction_selection_changed(self) -> None: + self.on_frame_filter_changed() + + def request_refresh_preview(self) -> None: + schedule_after_idle_once(self, "_scheduled_annotation_refresh_id", self.refresh_preview) + + def request_load_resources(self) -> None: + schedule_after_idle_once(self, "_scheduled_annotation_load_id", self.load_resources) + + def request_refresh_available_reconstructions(self) -> None: + schedule_after_idle_once(self, "_scheduled_annotation_recon_id", self.refresh_available_reconstructions) + + def _on_annotation_path_changed(self) -> None: + if bool(getattr(self, "_syncing_annotation_defaults", False)): + return + self.request_load_resources() + + def refresh_available_reconstructions(self) -> None: + cancel_scheduled_after_idle(self, "_scheduled_annotation_recon_id") + try: + _output_dir, _bundle, preview_state = load_shared_reconstruction_preview_state( + self.state, + preferred_names=["triangulation", "ekf_2d", "ekf_3d"], + fallback_count=3, + include_3d=True, + include_q=False, + include_q_root=False, + ) + self._publish_reconstruction_rows(preview_state.rows, preview_state.defaults[:1]) + except Exception as exc: + gui_debug(f"Annotation refresh_available_reconstructions error: {exc}") + self._publish_reconstruction_rows([], []) + + def sync_dataset_defaults(self) -> None: + keypoints_path = ROOT / self.state.keypoints_var.get() + self._syncing_annotation_defaults = True + try: + self.annotation_path = default_annotation_path(keypoints_path) + annotation_display = display_path(self.annotation_path) + if str(self.state.annotation_path_var.get()) != annotation_display: + self.state.annotation_path_var.set(annotation_display) + inferred_images_root = infer_execution_images_root(keypoints_path) + self.images_root = inferred_images_root + images_display = "" if inferred_images_root is None else display_path(inferred_images_root) + if str(self.images_root_entry.var.get()) != images_display: + self.images_root_entry.var.set(images_display) + self.refresh_kinematic_model_choices() + finally: + self._syncing_annotation_defaults = False + self.request_load_resources() + + def refresh_kinematic_model_choices(self) -> None: + dataset_dir = current_dataset_dir(self.state) + biomod_paths: list[Path] = [] + for model_dir in scan_model_dirs(dataset_dir): + biomod_paths.extend(sorted(model_dir.glob("*.bioMod"))) + choices: dict[str, Path] = {} + labels: list[str] = [] + for biomod_path in biomod_paths: + label = display_path(biomod_path) + if label in choices: + continue + choices[label] = biomod_path + labels.append(label) + self.kinematic_model_choices = choices + self.kinematic_model_box.configure(values=labels) + current = str(self.kinematic_model_var.get()).strip() + if current not in choices: + self.kinematic_model_var.set(labels[0] if labels else "") + self.refresh_annotation_keypoint_choices() + self.kinematic_status_var.set("" if labels else "No existing bioMod available for kinematic assist.") + + def _selected_kinematic_biomod_path(self) -> Path | None: + if not hasattr(self, "kinematic_model_var"): + return None + label = str(self.kinematic_model_var.get()).strip() + return self.kinematic_model_choices.get(label) + + def annotation_keypoint_names(self) -> tuple[str, ...]: + biomod_path = self._selected_kinematic_biomod_path() + return annotation_keypoint_names_for_biomod(biomod_path) + + def refresh_annotation_keypoint_choices(self) -> None: + if not hasattr(self, "annotation_keypoints_list"): + return + previous_name = None + selection = self.annotation_keypoints_list.curselection() + if selection: + previous_name = str(self.annotation_keypoints_list.get(selection[0])) + names = list(self.annotation_keypoint_names()) + self.annotation_keypoints_list.delete(0, tk.END) + for keypoint_name in names: + self.annotation_keypoints_list.insert(tk.END, keypoint_name) + if not names: + self.current_marker_var.set("Current marker:") + return + target_name = previous_name if previous_name in names else names[0] + target_index = names.index(target_name) + self.annotation_keypoints_list.selection_set(target_index) + self.annotation_keypoints_list.activate(target_index) + self.annotation_keypoints_list.see(target_index) + self.current_marker_var.set(f"Current marker: {target_name}") + + def _frame_annotation_measurement_count(self, frame_number: int, camera_names: list[str]) -> int: + if self.pose_data is None: + return 0 + annotation_pose_data = annotation_pose_data_for_frame( + self.pose_data, + camera_names=camera_names, + frame_number=int(frame_number), + annotation_payload=self.annotation_payload, + ) + return int(np.sum(np.asarray(annotation_pose_data.scores, dtype=float) > 0.0)) + + def selected_annotation_camera_names(self) -> list[str]: + if self.pose_data is None: + return [] + all_camera_names = [str(name) for name in self.pose_data.camera_names] + indices = [int(index) for index in self.annotation_cameras_list.curselection()] + if not indices: + return list(all_camera_names) + return [all_camera_names[index] for index in indices if 0 <= index < len(all_camera_names)] + + def update_camera_summary(self) -> None: + total_count = self.annotation_cameras_list.size() + selected_count = len(self.annotation_cameras_list.curselection()) if total_count else 0 + if total_count and selected_count == 0: + selected_count = total_count + self.annotation_cameras_summary.set(f"Cameras (n={selected_count}/{total_count})") + + def on_camera_selection_changed(self) -> None: + self._clear_pending_reprojection() + self._clear_kinematic_assist_preview() + self.update_camera_summary() + self.request_refresh_preview() + + def selected_keypoint_name(self) -> str: + selection = self.annotation_keypoints_list.curselection() + index = int(selection[0]) if selection else 0 + return str(self.annotation_keypoints_list.get(index)) + + def on_keypoint_selection_changed(self) -> None: + self._clear_pending_reprojection() + self.current_marker_var.set(f"Current marker: {self.selected_keypoint_name()}") + self.request_refresh_preview() + + def _store_current_annotation_view_limits(self) -> None: + if not hasattr(self, "preview_figure") or self.preview_figure is None: + return + for ax in list(getattr(self.preview_figure, "axes", [])): + camera_name = self._axis_to_camera.get(ax) + if camera_name is None: + continue + self._annotation_view_limits[str(camera_name)] = ( + tuple(float(value) for value in ax.get_xlim()), + tuple(float(value) for value in ax.get_ylim()), + ) + + def _annotation_view_limits_for_camera( + self, camera_name: str + ) -> tuple[tuple[float, float], tuple[float, float]] | tuple[None, None]: + limits = getattr(self, "_annotation_view_limits", {}).get(str(camera_name)) + if limits is None: + return None, None + return limits + + def _step_annotation_keypoint(self, delta: int, event=None) -> str: + if not hasattr(self, "annotation_keypoints_list"): + return "break" + size = int(self.annotation_keypoints_list.size()) + if size <= 0: + return "break" + selection = self.annotation_keypoints_list.curselection() + current_index = int(selection[0]) if selection else 0 + next_index = int((current_index + int(delta)) % size) + if next_index != current_index or not selection: + self.annotation_keypoints_list.selection_clear(0, tk.END) + self.annotation_keypoints_list.selection_set(next_index) + self.annotation_keypoints_list.activate(next_index) + self.annotation_keypoints_list.see(next_index) + self.on_keypoint_selection_changed() + return "break" + + def _bind_annotation_keypoint_navigation(self, widget) -> None: + widget.bind("", lambda event: self._step_annotation_keypoint(-1, event), add="+") + widget.bind("", lambda event: self._step_annotation_keypoint(1, event), add="+") + for child in widget.winfo_children(): + self._bind_annotation_keypoint_navigation(child) + + def _step_annotation_camera(self, delta: int, event=None) -> str: + if not hasattr(self, "annotation_cameras_list"): + return "break" + size = int(self.annotation_cameras_list.size()) + if size <= 0: + return "break" + selection = self.annotation_cameras_list.curselection() + current_index = int(selection[-1]) if selection else 0 + next_index = int((current_index + int(delta)) % size) + self.annotation_cameras_list.selection_clear(0, tk.END) + self.annotation_cameras_list.selection_set(next_index) + self.annotation_cameras_list.activate(next_index) + self.annotation_cameras_list.see(next_index) + self.on_camera_selection_changed() + return "break" + + def _bind_annotation_camera_navigation(self, widget) -> None: + widget.bind("c", lambda event: self._step_annotation_camera(1, event), add="+") + widget.bind("C", lambda event: self._step_annotation_camera(1, event), add="+") + widget.bind("d", lambda event: self._step_annotation_camera(-1, event), add="+") + widget.bind("D", lambda event: self._step_annotation_camera(-1, event), add="+") + for child in widget.winfo_children(): + self._bind_annotation_camera_navigation(child) + + def _cancel_pending_reprojection_from_tk_event(self, event) -> None: + if not self._pending_reprojection_points: + return + widget = getattr(event, "widget", None) + if widget is None: + return + if hasattr(self, "reproject_button") and widget is self.reproject_button: + return + self._clear_pending_reprojection() + self.refresh_preview() + + def _bind_cancel_pending_reprojection(self, widget) -> None: + widget.bind("", self._cancel_pending_reprojection_from_tk_event, add="+") + for child in widget.winfo_children(): + self._bind_cancel_pending_reprojection(child) + + def current_frame_number(self) -> int: + if self.pose_data is None or len(self.pose_data.frames) == 0: + return 0 + frame_idx = max(0, min(len(self.pose_data.frames) - 1, int(round(self.frame_var.get())))) + return int(self.pose_data.frames[frame_idx]) + + def _current_images_root(self) -> Path | None: + images_value = self.images_root_entry.get().strip() + if images_value: + return ROOT / images_value + return self.images_root + + def _set_frame_index(self, frame_idx: int) -> None: + clamped = max(0, int(frame_idx)) + if self.pose_data is not None and len(self.pose_data.frames) > 0: + clamped = min(clamped, len(self.pose_data.frames) - 1) + if clamped != int(self._current_frame_idx): + self.save_annotations() + self._clear_pending_reprojection() + self._clear_kinematic_assist_preview() + self._annotation_view_limits = {} + self._reset_annotation_view_limits_on_next_refresh = True + self._current_frame_idx = clamped + self.frame_var.set(clamped) + + def _bind_frame_navigation(self, widget: tk.Widget) -> None: + if widget is self.frame_scale: + widget.bind("", self._on_frame_scale_click) + widget.bind("", self._on_frame_scale_drag) + widget.bind("", self._on_frame_scale_release) + else: + widget.bind("", lambda _event: widget.focus_set()) + widget.bind("", lambda _event: self.step_frame(-1)) + widget.bind("", lambda _event: self.step_frame(1)) + + def _frame_from_scale_event(self, event) -> int: + return frame_from_slider_click( + x=event.x, + width=self.frame_scale.winfo_width(), + from_value=self.frame_scale.cget("from"), + to_value=self.frame_scale.cget("to"), + ) + + def _on_frame_scale_click(self, event) -> str: + self._dragging_frame_scale = True + self.frame_scale.focus_set() + frame = self._frame_from_scale_event(event) + self._set_frame_index(frame) + self.refresh_preview() + return "break" + + def _on_frame_scale_drag(self, event) -> str: + if not self._dragging_frame_scale: + return "break" + self._set_frame_index(self._frame_from_scale_event(event)) + self.refresh_preview() + return "break" + + def _on_frame_scale_release(self, event) -> str: + if not self._dragging_frame_scale: + return "break" + self._dragging_frame_scale = False + self._set_frame_index(self._frame_from_scale_event(event)) + self.refresh_preview() + return "break" + + def on_frame_scale_changed(self, _value) -> None: + self._set_frame_index(int(round(self.frame_var.get()))) + self.refresh_preview() + + def _clear_pending_reprojection(self) -> None: + self._pending_reprojection_points = {} + if hasattr(self, "reproject_button_var"): + self.reproject_button_var.set("Reproject") + + def _reference_projected_points( + self, camera_name: str, frame_number: int + ) -> tuple[np.ndarray | None, str | None, str]: + selected = self.selected_reconstruction_names() + if not selected: + return None, None, "#6c5ce7" + reference_name = str(selected[-1]).strip() + if not reference_name or reference_name == "raw": + return None, None, "#6c5ce7" + recon_dir = reconstruction_dir_by_name(current_dataset_dir(self.state), reference_name) + if recon_dir is None: + return None, None, reconstruction_display_color(self.state, reference_name) + payload = load_bundle_payload(recon_dir) + points_3d = np.asarray(payload.get("points_3d"), dtype=float) if "points_3d" in payload else None + bundle_frames = np.asarray(payload.get("frames"), dtype=int) if "frames" in payload else None + bundle_camera_names = ( + [str(name) for name in np.asarray(payload.get("camera_names"), dtype=object).tolist()] + if "camera_names" in payload + else list(self.pose_data.camera_names if self.pose_data is not None else []) + ) + if points_3d is None or points_3d.ndim != 3 or bundle_frames is None: + return ( + None, + reconstruction_legend_label(self.state, reference_name), + reconstruction_display_color(self.state, reference_name), + ) + matches = np.flatnonzero(bundle_frames == int(frame_number)) + if matches.size == 0 or camera_name not in bundle_camera_names: + return ( + None, + reconstruction_legend_label(self.state, reference_name), + reconstruction_display_color(self.state, reference_name), + ) + frame_idx = int(matches[0]) + calibration = self.calibrations.get(str(camera_name)) if self.calibrations is not None else None + if calibration is None: + return ( + None, + reconstruction_legend_label(self.state, reference_name), + reconstruction_display_color(self.state, reference_name), + ) + projected = np.full((points_3d.shape[1], 2), np.nan, dtype=float) + for kp_idx, point_3d in enumerate(np.asarray(points_3d[frame_idx], dtype=float)): + if np.all(np.isfinite(point_3d)): + projected[kp_idx] = calibration.project_point(point_3d) + return ( + projected, + reconstruction_legend_label(self.state, reference_name), + reconstruction_display_color(self.state, reference_name), + ) + + def _compute_pending_reprojection_points(self) -> dict[tuple[str, int, str], np.ndarray]: + if self.pose_data is None or self.calibrations is None: + return {} + frame_number = self.current_frame_number() + camera_names = self.selected_annotation_camera_names() + pending: dict[tuple[str, int, str], np.ndarray] = {} + for target_camera_name in camera_names: + for keypoint_name in self.annotation_keypoint_names(): + existing_xy = self._annotation_xy(target_camera_name, frame_number, keypoint_name) + if existing_xy is None: + continue + source_camera_names: list[str] = [] + source_points_2d: list[np.ndarray] = [] + for source_camera_name in camera_names: + if source_camera_name == target_camera_name: + continue + source_xy = self._annotation_xy(source_camera_name, frame_number, keypoint_name) + if source_xy is None: + continue + source_camera_names.append(source_camera_name) + source_points_2d.append(source_xy) + reprojection_xy = annotation_triangulated_reprojection( + self.calibrations, + target_camera_name=target_camera_name, + source_camera_names=source_camera_names, + source_points_2d=source_points_2d, + ) + if reprojection_xy is None or not np.all(np.isfinite(reprojection_xy)): + continue + pending[(str(target_camera_name), int(frame_number), str(keypoint_name))] = np.asarray( + reprojection_xy, dtype=float + ) + return pending + + def _confirm_pending_reprojection(self) -> None: + for (camera_name, frame_number, keypoint_name), xy in self._pending_reprojection_points.items(): + set_annotation_point( + self.annotation_payload, + camera_name=camera_name, + frame_number=int(frame_number), + keypoint_name=keypoint_name, + xy=[float(xy[0]), float(xy[1])], + ) + self.save_annotations() + self._clear_pending_reprojection() + + def on_reproject_button(self) -> None: + if self._pending_reprojection_points: + self._confirm_pending_reprojection() + self.refresh_preview() + return + self._pending_reprojection_points = self._compute_pending_reprojection_points() + self.reproject_button_var.set("Confirm" if self._pending_reprojection_points else "Reproject") + self.refresh_preview() + + def step_frame(self, delta: int) -> None: + if self.pose_data is None or len(self.pose_data.frames) == 0: + return + current_index = int(round(self.frame_var.get())) + current_frame_number = int(self.pose_data.frames[current_index]) + try: + selected_camera_names = self.selected_annotation_camera_names() + except Exception: + selected_camera_names = [] + candidates = self._navigable_annotation_frame_local_indices() + if candidates: + next_frame = step_frame_index_within_subset(current_index, int(delta), candidates) + else: + next_frame = find_annotation_frame_with_images( + frames=self.pose_data.frames, + current_index=current_index, + direction=int(delta), + camera_names=selected_camera_names, + images_root=self._current_images_root(), + ) + if next_frame is None: + return + if ( + self.pose_data is not None + and bool(getattr(self, "kinematic_assist_var", None) and self.kinematic_assist_var.get()) + and next_frame != current_index + ): + biomod_path = self._selected_kinematic_biomod_path() + if biomod_path is not None and biomod_path.exists(): + try: + import biorbd + + model = biorbd.Model(str(biomod_path)) + current_state, _source_frame, _is_exact = self._selected_or_nearest_kinematic_state_info( + current_frame_number, model + ) + if current_state is not None: + next_frame_number = int(self.pose_data.frames[int(next_frame)]) + frame_delta = int(next_frame_number) - int(current_frame_number) + propagated_state = propagate_annotation_kinematic_state( + model, + current_state, + dt=1.0 / float(self.state.fps_var.get()), + frame_delta=frame_delta, + ) + self.kinematic_frame_states[self._kinematic_state_key(next_frame_number)] = propagated_state + except Exception: + pass + self._set_frame_index(next_frame) + if bool(getattr(self, "kinematic_assist_var", None) and self.kinematic_assist_var.get()): + try: + next_frame_number = int(self.pose_data.frames[int(next_frame)]) + if self._frame_annotation_measurement_count(next_frame_number, selected_camera_names) > 0: + self._estimate_kinematic_q() + except Exception: + pass + self.refresh_preview() + + def load_resources(self) -> None: + cancel_scheduled_after_idle(self, "_scheduled_annotation_load_id") + try: + with gui_busy_popup(self, title="Annotation", message="Chargement des données d'annotation...") as popup: + keypoints_path = ROOT / self.state.keypoints_var.get() + calib_path = ROOT / self.state.calib_var.get() + popup.set_status("Chargement des calibrations et des 2D bruts...") + self.calibrations, self.pose_data = get_cached_pose_data( + self.state, + keypoints_path=keypoints_path, + calib_path=calib_path, + data_mode="raw", + smoothing_window=int(self.state.pose_filter_window_var.get()), + outlier_threshold_ratio=float(self.state.pose_outlier_ratio_var.get()), + lower_percentile=float(self.state.pose_p_low_var.get()), + upper_percentile=float(self.state.pose_p_high_var.get()), + ) + annotation_path_value = self.annotations_path_entry.get().strip() + self.annotation_path = ( + (ROOT / annotation_path_value) if annotation_path_value else default_annotation_path(keypoints_path) + ) + popup.set_status("Chargement des annotations et des modèles existants...") + self.annotation_payload = load_annotation_payload(self.annotation_path, keypoints_path=keypoints_path) + self._last_saved_annotation_signature = annotation_payload_signature(self.annotation_payload) + self.refresh_kinematic_model_choices() + self.annotation_cameras_list.delete(0, tk.END) + for camera_name in self.pose_data.camera_names: + self.annotation_cameras_list.insert(tk.END, str(camera_name)) + self.annotation_cameras_list.selection_set(tk.END) + self.update_camera_summary() + self.current_marker_var.set(f"Current marker: {self.selected_keypoint_name()}") + self.frame_scale.configure(to=max(len(self.pose_data.frames) - 1, 0)) + self._set_frame_index(min(int(self.frame_var.get()), max(len(self.pose_data.frames) - 1, 0))) + self.crop_limits_cache = {} + self.crop_limits_key = None + self._navigable_frame_cache = {} + self._clear_kinematic_assist_preview() + self._clear_pending_reprojection() + self.on_frame_filter_changed() + self.refresh_preview() + except Exception as exc: + messagebox.showerror("Annotation", str(exc)) + + def save_annotations(self) -> None: + """Write the current sparse annotation payload to disk.""" + + if self.annotation_path is None: + return + save_annotation_payload(self.annotation_path, self.annotation_payload) + self._last_saved_annotation_signature = annotation_payload_signature(self.annotation_payload) + + def has_unsaved_changes(self) -> bool: + """Return whether the annotation payload differs from the last saved state.""" + + return annotation_payload_signature(self.annotation_payload) != self._last_saved_annotation_signature + + def _clear_kinematic_assist_preview(self) -> None: + self.kinematic_q_current = None + self.kinematic_state_current = None + self.kinematic_q_names = [] + self.kinematic_projected_points = None + self.kinematic_segmented_back_projected = {} + + def _kinematic_state_key(self, frame_number: int) -> tuple[str, int]: + return (str(self.kinematic_model_var.get()).strip(), int(frame_number)) + + def _selected_or_nearest_kinematic_state_info( + self, frame_number: int, model + ) -> tuple[np.ndarray | None, int | None, bool]: + model_label = str(self.kinematic_model_var.get()).strip() + info = resolve_annotation_kinematic_state_info( + getattr(self, "kinematic_frame_states", {}), + model_label=model_label, + frame_number=int(frame_number), + model=model, + ) + return info.state, info.source_frame, info.is_exact + + def _selected_or_nearest_kinematic_state(self, frame_number: int, model) -> np.ndarray | None: + state, _source_frame, _is_exact = self._selected_or_nearest_kinematic_state_info(frame_number, model) + return state + + def _annotation_window_frame_numbers(self, frame_number: int) -> list[int]: + if self.pose_data is None or len(self.pose_data.frames) == 0: + return [int(frame_number)] + frame_values = np.asarray(self.pose_data.frames, dtype=int) + if frame_values.size == 0: + return [int(frame_number)] + current_matches = np.flatnonzero(frame_values == int(frame_number)) + if current_matches.size > 0: + current_index = int(current_matches[0]) + else: + current_index = int(np.argmin(np.abs(frame_values - int(frame_number)))) + start = max(0, current_index - ANNOTATION_KINEMATIC_WINDOW_RADIUS) + end = min(frame_values.size, current_index + ANNOTATION_KINEMATIC_WINDOW_RADIUS + 1) + return [int(value) for value in frame_values[start:end]] + + def _annotation_pose_data_by_frame( + self, + frame_numbers: list[int], + camera_names: list[str], + ) -> dict[int, PoseData]: + if self.pose_data is None: + return {} + pose_data_by_frame: dict[int, PoseData] = {} + for frame_number in frame_numbers: + if self._frame_annotation_measurement_count(int(frame_number), camera_names) <= 0: + continue + pose_data_by_frame[int(frame_number)] = annotation_pose_data_for_frame( + self.pose_data, + camera_names=camera_names, + frame_number=int(frame_number), + annotation_payload=self.annotation_payload, + ) + return pose_data_by_frame + + def _set_kinematic_preview_from_q(self, biomod_path: Path, camera_names: list[str], q_values: np.ndarray) -> None: + q_values = np.asarray(q_values, dtype=float).reshape(-1) + q_series = q_values.reshape(1, -1) + self.kinematic_q_current = q_values + self.kinematic_state_current = None + self.kinematic_projected_points = project_points_all_cameras( + biorbd_markers_from_q(biomod_path, q_series), self.calibrations, camera_names + ) + segmented_overlay = segmented_back_overlay_from_q(biomod_path, q_series) + self.kinematic_segmented_back_projected = ( + { + overlay_name: project_points_all_cameras(points, self.calibrations, camera_names) + for overlay_name, points in segmented_overlay.items() + } + if segmented_overlay + else {} + ) + + def _estimate_kinematic_q( + self, + *, + keypoint_name: str | None = None, + ) -> np.ndarray: + biomod_path = self._selected_kinematic_biomod_path() + if biomod_path is None or not biomod_path.exists(): + raise ValueError("Choose an existing bioMod first.") + camera_names = self.selected_annotation_camera_names() + if not camera_names: + raise ValueError("Select at least one camera.") + frame_number = self.current_frame_number() + import biorbd + + model = biorbd.Model(str(biomod_path)) + self.kinematic_q_names = biorbd_q_names(model) + previous_state, source_frame, is_exact = self._selected_or_nearest_kinematic_state_info(frame_number, model) + previous_q = None if previous_state is None else np.asarray(previous_state[: model.nbQ()], dtype=float) + seed_source = "nearest state" + estimated_state = None + n_triangulated = 0 + if previous_state is not None: + estimated_state = np.array(previous_state, copy=True) + seed_source = "current state" if is_exact else f"nearest frame {source_frame}" + else: + triangulated_points = triangulate_annotation_frame_points( + self.calibrations, + camera_names=camera_names, + frame_number=frame_number, + annotation_payload=self.annotation_payload, + ) + n_triangulated = int(np.sum(np.all(np.isfinite(triangulated_points), axis=1))) + if n_triangulated < 2: + raise ValueError("Not enough annotated support to initialize q. Add points on more views first.") + reconstruction = annotation_reconstruction_from_points( + triangulated_points, frame_number=frame_number, n_cameras=len(camera_names) + ) + triangulation_state = initial_state_from_triangulation(model, reconstruction) + estimated_state = annotation_state_from_q( + model, np.asarray(triangulation_state[: model.nbQ()], dtype=float) + ) + seed_source = f"triangulation ({n_triangulated} markers)" + if keypoint_name is not None: + estimated_state[model.nbQ() : 3 * model.nbQ()] = 0.0 + bootstrap_summary = " | local ekf skipped" + direct_summary = "" + annotation_pose_data = None + try: + annotation_pose_data = annotation_pose_data_for_frame( + self.pose_data, + camera_names=camera_names, + frame_number=frame_number, + annotation_payload=self.annotation_payload, + ) + valid_measurements = int(np.sum(np.asarray(annotation_pose_data.scores) > 0.0)) + min_measurements = 1 if previous_state is not None else 2 + if valid_measurements >= min_measurements: + passes = ( + ANNOTATION_KINEMATIC_CLICK_PASSES + if keypoint_name is not None + else ANNOTATION_KINEMATIC_BOOTSTRAP_PASSES + * (ANNOTATION_KINEMATIC_INITIAL_BOOTSTRAP_MULTIPLIER if previous_state is None else 1) + ) + refined_state, bootstrap_diagnostics = refine_annotation_q_with_local_ekf( + model=model, + calibrations=self.calibrations, + pose_data=annotation_pose_data, + frame_number=frame_number, + seed_state=estimated_state, + fps=float(self.state.fps_var.get()), + passes=passes, + measurement_noise_scale=1.0, + process_noise_scale=1.0, + epipolar_threshold_px=DEFAULT_EPIPOLAR_THRESHOLD_PX, + q_names=self.kinematic_q_names, + keypoint_name=None, + ) + estimated_state = np.asarray(refined_state, dtype=float) + bootstrap_summary = f" | local ekf {int(bootstrap_diagnostics.get('completed_passes', 0))}/" f"{passes}" + if bool(bootstrap_diagnostics.get("used_fallback")): + bootstrap_summary += " fallback" + else: + bootstrap_summary = f" | local ekf skipped ({valid_measurements} meas)" + except Exception: + bootstrap_summary = " | local ekf fallback" + if keypoint_name is not None and annotation_pose_data is not None: + try: + direct_state, direct_diagnostics = refine_annotation_q_with_direct_measurements( + model=model, + calibrations=self.calibrations, + pose_data=annotation_pose_data, + seed_state=estimated_state, + passes=ANNOTATION_KINEMATIC_CLICK_DIRECT_PASSES, + measurement_std_px=2.0, + q_names=self.kinematic_q_names, + keypoint_name=None, + ) + if not bool(direct_diagnostics.get("used_fallback")): + estimated_state = np.asarray(direct_state, dtype=float) + direct_summary = ( + f" | direct fit {int(direct_diagnostics.get('completed_passes', 0))}/" + f"{ANNOTATION_KINEMATIC_CLICK_DIRECT_PASSES}" + ) + else: + direct_summary = " | direct fit fallback" + except Exception: + direct_summary = " | direct fit fallback" + if not hasattr(self, "kinematic_frame_states") or self.kinematic_frame_states is None: + self.kinematic_frame_states = {} + temporal_summary = "" + if keypoint_name is None: + try: + pose_data_by_frame = self._annotation_pose_data_by_frame( + self._annotation_window_frame_numbers(frame_number), + camera_names, + ) + if len(pose_data_by_frame) > 1: + refined_window_states, window_diagnostics = refine_annotation_window_states( + model=model, + calibrations=self.calibrations, + pose_data_by_frame=pose_data_by_frame, + center_frame_number=frame_number, + seed_state=estimated_state, + fps=float(self.state.fps_var.get()), + passes=ANNOTATION_KINEMATIC_WINDOW_PASSES, + epipolar_threshold_px=DEFAULT_EPIPOLAR_THRESHOLD_PX, + q_names=self.kinematic_q_names, + ) + for saved_frame_number, saved_state in refined_window_states.items(): + store_annotation_kinematic_state( + self.kinematic_frame_states, + model_label=str(self.kinematic_model_var.get()).strip(), + frame_number=int(saved_frame_number), + model=model, + state=np.asarray(saved_state, dtype=float), + ) + if frame_number in refined_window_states: + estimated_state = np.asarray(refined_window_states[frame_number], dtype=float) + temporal_summary = ( + f" | local window {int(window_diagnostics.get('completed_frames', 0))}/" + f"{len(pose_data_by_frame)}" + ) + except Exception: + temporal_summary = "" + estimated_state = store_annotation_kinematic_state( + self.kinematic_frame_states, + model_label=str(self.kinematic_model_var.get()).strip(), + frame_number=frame_number, + model=model, + state=estimated_state, + ) + self.kinematic_state_current = np.asarray(estimated_state, dtype=float) + self._set_kinematic_preview_from_q(biomod_path, camera_names, estimated_state[: model.nbQ()]) + self.kinematic_state_current = np.asarray(estimated_state, dtype=float) + if keypoint_name is None: + self.kinematic_status_var.set(f"Estimated q from {seed_source}{bootstrap_summary}{temporal_summary}.") + else: + warm_start_text = "" + if previous_state is not None and source_frame is not None: + warm_start_text = " current frame" if is_exact else f" nearest frame {source_frame}" + warm_start_text = f" from{warm_start_text}" + self.kinematic_status_var.set( + f"Updated model after {keypoint_name} using {seed_source}{warm_start_text}{bootstrap_summary}" + f"{direct_summary}." + ) + return np.asarray(estimated_state[: model.nbQ()], dtype=float) + + def _estimate_kinematic_q_via_triangulation_ik(self) -> np.ndarray: + biomod_path = self._selected_kinematic_biomod_path() + if biomod_path is None or not biomod_path.exists(): + raise ValueError("Choose an existing bioMod first.") + camera_names = self.selected_annotation_camera_names() + if not camera_names: + raise ValueError("Select at least one camera.") + frame_number = self.current_frame_number() + import biorbd + + model = biorbd.Model(str(biomod_path)) + self.kinematic_q_names = biorbd_q_names(model) + annotation_pose_data = annotation_pose_data_for_frame( + self.pose_data, + camera_names=camera_names, + frame_number=frame_number, + annotation_payload=self.annotation_payload, + ) + valid_measurements = int(np.sum(np.asarray(annotation_pose_data.scores) > 0.0)) + if valid_measurements < 4: + raise ValueError( + "Not enough annotated 2D markers to estimate q. Add at least 4 annotated 2D points across views first." + ) + triangulated_points = triangulate_annotation_frame_points( + self.calibrations, + camera_names=camera_names, + frame_number=frame_number, + annotation_payload=self.annotation_payload, + ) + n_triangulated = int(np.sum(np.all(np.isfinite(triangulated_points), axis=1))) + if n_triangulated < 2: + raise ValueError( + "Not enough triangulated markers to estimate q. Add more annotated 2D points on multiple views first." + ) + previous_state, _source_frame, _is_exact = self._selected_or_nearest_kinematic_state_info(frame_number, model) + triangulation_state, lm_diagnostics = refine_annotation_q_with_marker_lm( + model=model, + points_3d=triangulated_points, + frame_number=frame_number, + n_cameras=len(camera_names), + seed_state=previous_state, + passes=40, + initial_damping=1e-2, + damping_up=10.0, + damping_down=0.3, + tolerance=1e-6, + q_names=self.kinematic_q_names, + keypoint_name=None, + ) + estimated_state = store_annotation_kinematic_state( + self.kinematic_frame_states, + model_label=str(self.kinematic_model_var.get()).strip(), + frame_number=frame_number, + model=model, + state=np.asarray(triangulation_state, dtype=float), + ) + self.kinematic_state_current = np.asarray(estimated_state, dtype=float) + self._set_kinematic_preview_from_q(biomod_path, camera_names, estimated_state[: model.nbQ()]) + self.kinematic_state_current = np.asarray(estimated_state, dtype=float) + self.kinematic_status_var.set( + "Estimated q from triangulation LM " + f"({n_triangulated} markers, {valid_measurements} 2D points, " + f"{int(lm_diagnostics.get('completed_passes', 0))} iter)." + ) + return np.asarray(estimated_state[: model.nbQ()], dtype=float) + + def estimate_kinematic_assist_state(self) -> None: + if self.pose_data is None or self.calibrations is None: + return + try: + self._estimate_kinematic_q_via_triangulation_ik() + self.refresh_preview() + except Exception as exc: + self._clear_kinematic_assist_preview() + self.kinematic_status_var.set("") + messagebox.showerror("Annotation", str(exc)) + + def _ensure_crop_limits(self, camera_names: list[str]) -> dict[str, np.ndarray]: + if self.pose_data is None: + return {} + all_camera_names = [str(name) for name in self.pose_data.camera_names] + camera_indices = [all_camera_names.index(str(name)) for name in camera_names if str(name) in all_camera_names] + if not camera_indices: + return {} + raw_points = np.asarray( + self.pose_data.raw_keypoints if self.pose_data.raw_keypoints is not None else self.pose_data.keypoints, + dtype=float, + ) + raw_points = raw_points[camera_indices] + cache_key = ( + id(self.pose_data), + tuple(camera_indices), + tuple(camera_names), + 0.2, + ) + if self.crop_limits_key != cache_key: + self.crop_limits_cache = compute_pose_crop_limits_2d(raw_points, self.calibrations, camera_names, 0.2) + self.crop_limits_key = cache_key + return self.crop_limits_cache + + def _annotation_xy(self, camera_name: str, frame_number: int, keypoint_name: str) -> np.ndarray | None: + xy, _score = get_annotation_point( + self.annotation_payload, + camera_name=camera_name, + frame_number=frame_number, + keypoint_name=keypoint_name, + ) + return None if xy is None else np.asarray(xy, dtype=float) + + def _annotation_support_camera_names(self) -> list[str]: + if self.pose_data is None: + return [] + return [str(name) for name in self.pose_data.camera_names] + + def _reference_projected_keypoint( + self, + camera_name: str, + frame_number: int, + keypoint_name: str, + ) -> np.ndarray | None: + if keypoint_name not in KP_INDEX: + return None + projected_points, _label, _color = self._reference_projected_points(camera_name, frame_number) + if projected_points is None: + return None + xy = np.asarray(projected_points[KP_INDEX[keypoint_name]], dtype=float) + return xy if np.all(np.isfinite(xy)) else None + + def _triangulated_hint_for_keypoint( + self, + camera_name: str, + frame_number: int, + keypoint_name: str, + ) -> np.ndarray | None: + support_camera_names = [] + support_points = [] + for other_camera_name in self._annotation_support_camera_names(): + if other_camera_name == str(camera_name): + continue + other_xy = self._annotation_xy(other_camera_name, frame_number, keypoint_name) + if other_xy is None: + continue + support_camera_names.append(str(other_camera_name)) + support_points.append(np.asarray(other_xy, dtype=float)) + return annotation_triangulated_reprojection( + self.calibrations, + target_camera_name=str(camera_name), + source_camera_names=support_camera_names, + source_points_2d=support_points, + ) + + def _epipolar_lines_for_keypoint( + self, + target_camera_name: str, + frame_number: int, + keypoint_name: str, + ) -> list[np.ndarray]: + lines: list[np.ndarray] = [] + for other_camera_name in self._annotation_support_camera_names(): + if str(other_camera_name) == str(target_camera_name): + continue + other_xy = self._annotation_xy(other_camera_name, frame_number, keypoint_name) + if other_xy is None: + continue + line = annotation_epipolar_guides( + self.calibrations, + str(other_camera_name), + str(target_camera_name), + other_xy, + ) + if line is not None: + lines.append(np.asarray(line, dtype=float)) + return lines + + def _snap_annotation_xy( + self, + *, + camera_name: str, + frame_number: int, + keypoint_name: str, + pointer_xy: np.ndarray, + ) -> np.ndarray: + pointer_xy = np.asarray(pointer_xy, dtype=float).reshape(2) + candidates: list[np.ndarray] = [] + if bool(getattr(self, "snap_reprojection_var", None) and self.snap_reprojection_var.get()): + triangulated = self._triangulated_hint_for_keypoint(camera_name, frame_number, keypoint_name) + if triangulated is not None and np.all(np.isfinite(triangulated)): + candidates.append(np.asarray(triangulated, dtype=float)) + reference_xy = self._reference_projected_keypoint(camera_name, frame_number, keypoint_name) + if reference_xy is not None: + candidates.append(np.asarray(reference_xy, dtype=float)) + if bool(getattr(self, "snap_epipolar_var", None) and self.snap_epipolar_var.get()): + lines = self._epipolar_lines_for_keypoint(camera_name, frame_number, keypoint_name) + intersection = annotation_intersect_epipolar_lines(lines) + if intersection is not None: + candidates.append(np.asarray(intersection, dtype=float)) + for line in lines: + projected = annotation_project_point_to_line(line, pointer_xy) + if projected is not None: + candidates.append(np.asarray(projected, dtype=float)) + if not candidates: + return np.array(pointer_xy, copy=True) + distances = np.array([np.linalg.norm(candidate - pointer_xy) for candidate in candidates], dtype=float) + if not np.any(np.isfinite(distances)): + return np.array(pointer_xy, copy=True) + best_index = int(np.nanargmin(distances)) + if float(distances[best_index]) > ANNOTATION_SNAP_RADIUS_PX: + return np.array(pointer_xy, copy=True) + return np.asarray(candidates[best_index], dtype=float) + + def _advance_to_next_keypoint(self) -> None: + if not self.advance_marker_var.get(): + return + size = int(self.annotation_keypoints_list.size()) + if size <= 0: + return + selection = self.annotation_keypoints_list.curselection() + index = int(selection[0]) if selection else 0 + next_index = int((index + 1) % size) + self.annotation_keypoints_list.selection_clear(0, tk.END) + self.annotation_keypoints_list.selection_set(next_index) + self.annotation_keypoints_list.activate(next_index) + self.annotation_keypoints_list.see(next_index) + self.on_keypoint_selection_changed() + + def _selected_reconstruction(self) -> str | None: + selected = list(getattr(self.state, "shared_reconstruction_selection", [])) + return str(selected[-1]) if selected else None + + def _annotation_jump_context(self) -> str: + selected_name = self._selected_reconstruction() + if not selected_name or self.pose_data is None: + self.annotation_jump_analysis = None + return "" + analysis = shared_jump_analysis_for_reconstruction(self.state, selected_name) + self.annotation_jump_analysis = analysis + if analysis is None: + return "" + frame_number = self.current_frame_number() + for jump_index, jump in enumerate(analysis.jumps, start=1): + if int(jump.segment.start) <= int(frame_number) <= int(jump.segment.end): + return f"Jump context: S{jump_index} | {jump.classification} | frames {jump.segment.start}-{jump.segment.end}" + return "Jump context: between jumps" + + def _frame_filter_mode(self) -> str: + return resolve_annotation_frame_filter_mode(self.frame_filter_var.get(), ANNOTATION_FRAME_FILTER_OPTIONS) + + def _pose_data_mode_for_annotation_filters(self) -> str: + value = str(self.state.pose_data_mode_var.get()).strip().lower() + return value if value in {"raw", "cleaned"} else "cleaned" + + def _annotation_flip_frame_local_indices(self) -> list[int]: + if self.pose_data is None or self.calibrations is None: + return [] + correction_mode = current_calibration_correction_mode(self.state) + correction_to_method = { + "flip_epipolar": "epipolar", + "flip_epipolar_fast": "epipolar_fast", + "flip_epipolar_viterbi": "epipolar_viterbi", + "flip_epipolar_fast_viterbi": "epipolar_fast_viterbi", + "flip_triangulation": "triangulation_exhaustive", + } + method = correction_to_method.get(correction_mode) + if method is None: + return [] + keypoints_path = ROOT / self.state.keypoints_var.get() + calib_path = ROOT / self.state.calib_var.get() + pose_data_mode = self._pose_data_mode_for_annotation_filters() + _calibrations, pose_data_for_flip = get_cached_pose_data( + self.state, + keypoints_path=keypoints_path, + calib_path=calib_path, + data_mode=pose_data_mode, + smoothing_window=int(self.state.pose_filter_window_var.get()), + outlier_threshold_ratio=float(self.state.pose_outlier_ratio_var.get()), + lower_percentile=float(self.state.pose_p_low_var.get()), + upper_percentile=float(self.state.pose_p_high_var.get()), + ) + suspect_mask, _diagnostics, _compute_time_s, _cache_path, _source = load_or_compute_left_right_flip_cache( + output_dir=current_dataset_dir(self.state), + pose_data=pose_data_for_flip, + calibrations=self.calibrations, + method=method, + pose_data_mode=pose_data_mode, + pose_filter_window=int(self.state.pose_filter_window_var.get()), + pose_outlier_threshold_ratio=float(self.state.pose_outlier_ratio_var.get()), + pose_amplitude_lower_percentile=float(self.state.pose_p_low_var.get()), + pose_amplitude_upper_percentile=float(self.state.pose_p_high_var.get()), + improvement_ratio=float(self.state.flip_improvement_ratio_var.get()), + min_gain_px=float(self.state.flip_min_gain_px_var.get()), + min_other_cameras=int(self.state.flip_min_other_cameras_var.get()), + restrict_to_outliers=bool(self.state.flip_restrict_to_outliers_var.get()), + outlier_percentile=float(self.state.flip_outlier_percentile_var.get()), + outlier_floor_px=float(self.state.flip_outlier_floor_px_var.get()), + tau_px=( + DEFAULT_EPIPOLAR_THRESHOLD_PX + if method in {"epipolar", "epipolar_fast", "epipolar_viterbi", "epipolar_fast_viterbi"} + else DEFAULT_REPROJECTION_THRESHOLD_PX + ), + temporal_weight=float(self.state.flip_temporal_weight_var.get()), + temporal_tau_px=float(self.state.flip_temporal_tau_px_var.get()), + ) + selected_camera_names = set(self.selected_annotation_camera_names()) + camera_indices = [ + idx for idx, name in enumerate(pose_data_for_flip.camera_names) if str(name) in selected_camera_names + ] + if not camera_indices: + camera_indices = list(range(len(pose_data_for_flip.camera_names))) + flagged_local_indices = np.flatnonzero(np.any(np.asarray(suspect_mask)[camera_indices], axis=0)) + frame_to_local = {int(frame): idx for idx, frame in enumerate(np.asarray(self.pose_data.frames, dtype=int))} + return sorted( + frame_to_local[int(frame)] + for frame in np.asarray(pose_data_for_flip.frames, dtype=int)[flagged_local_indices] + if int(frame) in frame_to_local + ) + + def _annotation_worst_reprojection_frame_local_indices(self) -> list[int]: + if self.pose_data is None: + return [] + selected_name = self._selected_reconstruction() + if not selected_name: + return [] + recon_dir = reconstruction_dir_by_name(current_dataset_dir(self.state), selected_name) + if recon_dir is None: + return [] + payload = load_bundle_payload(recon_dir) + if "reprojection_error_per_view" not in payload or "frames" not in payload: + return [] + errors = np.asarray(payload["reprojection_error_per_view"], dtype=float) + bundle_frames = np.asarray(payload["frames"], dtype=int) + bundle_camera_names = ( + [str(name) for name in np.asarray(payload["camera_names"], dtype=object).tolist()] + if "camera_names" in payload + else list(self.pose_data.camera_names) + ) + selected_camera_names = set(self.selected_annotation_camera_names()) + camera_indices = [idx for idx, name in enumerate(bundle_camera_names) if name in selected_camera_names] + if not camera_indices: + camera_indices = list(range(errors.shape[2])) + frame_errors = np.nanmean(errors[:, :, camera_indices], axis=(1, 2)) + finite_indices = np.flatnonzero(np.isfinite(frame_errors)) + if finite_indices.size == 0: + return [] + worst_count = max(1, int(math.ceil(0.05 * float(finite_indices.size)))) + ranked_indices = finite_indices[np.argsort(frame_errors[finite_indices])] + worst_bundle_indices = ranked_indices[-worst_count:] + frame_to_local = {int(frame): idx for idx, frame in enumerate(np.asarray(self.pose_data.frames, dtype=int))} + return sorted( + frame_to_local[int(frame)] for frame in bundle_frames[worst_bundle_indices] if int(frame) in frame_to_local + ) + + def _filtered_annotation_frame_local_indices(self) -> list[int]: + if self.pose_data is None: + return [] + mode = self._frame_filter_mode() + if mode == "flipped": + filtered = self._annotation_flip_frame_local_indices() + elif mode == "worst_reproj": + filtered = self._annotation_worst_reprojection_frame_local_indices() + else: + filtered = list(range(len(self.pose_data.frames))) + return fallback_annotation_filtered_indices(len(self.pose_data.frames), filtered) + + def _navigable_annotation_frame_local_indices(self) -> list[int]: + if self.pose_data is None: + return [] + filtered = self._filtered_annotation_frame_local_indices() + camera_names = self.selected_annotation_camera_names() + images_root = self._current_images_root() + if images_root is None or not camera_names: + return filtered + cache_key = ( + tuple(int(value) for value in filtered), + tuple(str(name) for name in camera_names), + str(images_root.resolve()) if images_root.exists() else str(images_root), + ) + cached = self._navigable_frame_cache.get(cache_key) + if cached is not None: + return list(cached) + resolved = navigable_annotation_frame_local_indices( + np.asarray(self.pose_data.frames, dtype=int), + filtered, + camera_names, + images_root, + ) + self._navigable_frame_cache[cache_key] = list(resolved) + return resolved + + def on_frame_filter_changed(self) -> None: + if self.pose_data is None or len(self.pose_data.frames) == 0: + return + self._clear_pending_reprojection() + candidates = self._filtered_annotation_frame_local_indices() + clamped_index = clamp_index_to_subset(int(round(self.frame_var.get())), candidates) + if clamped_index is not None and clamped_index != int(round(self.frame_var.get())): + self._set_frame_index(clamped_index) + self.request_refresh_preview() + + def _ensure_cursor_artists(self, ax) -> tuple[object, ...]: + artists = self._cursor_artists.get(ax) + if artists is not None: + return artists + artists = [] + for _ in range(4): + line = ax.plot([], [], color="#f8f8f8", linewidth=1.2, zorder=30, solid_capstyle="butt")[0] + line.set_path_effects([path_effects.Stroke(linewidth=2.6, foreground="black"), path_effects.Normal()]) + line.set_visible(False) + artists.append(line) + hover_text = ax.text( + 0.02, + 0.98, + "", + transform=ax.transAxes, + ha="left", + va="top", + fontsize=8, + color="#111111", + zorder=31, + bbox={"boxstyle": "round,pad=0.25", "facecolor": "#fff8dc", "edgecolor": "#333333", "alpha": 0.92}, + ) + hover_text.set_visible(False) + artists.append(hover_text) + self._cursor_artists[ax] = tuple(artists) + return self._cursor_artists[ax] + + def _nearest_annotation_hover_entry(self, ax, x: float, y: float) -> dict[str, object] | None: + entries = getattr(self, "_annotation_hover_entries", {}).get(ax, []) + best_entry = None + best_distance = None + for entry in entries: + point = np.asarray(entry.get("xy"), dtype=float).reshape(2) + if not np.all(np.isfinite(point)): + continue + distance = float(np.linalg.norm(point - np.array([x, y], dtype=float))) + if best_distance is None or distance < best_distance: + best_distance = distance + best_entry = entry + if best_entry is None or best_distance is None or best_distance > ANNOTATION_HOVER_RADIUS_PX: + return None + return best_entry + + def _nearest_annotated_drag_entry(self, ax, x: float, y: float) -> dict[str, object] | None: + hover_entry = self._nearest_annotation_hover_entry(ax, x, y) + if hover_entry is None or str(hover_entry.get("source")) != "annotated": + return None + point = np.asarray(hover_entry.get("xy"), dtype=float).reshape(2) + distance = float(np.linalg.norm(point - np.array([x, y], dtype=float))) + if not np.isfinite(distance) or distance > ANNOTATION_DRAG_START_RADIUS_PX: + return None + return hover_entry + + def _annotation_hover_label(self, entry: dict[str, object]) -> str: + keypoint_name = str(entry.get("keypoint_name", "")) + source = str(entry.get("source", "")).strip() + try: + selected_keypoint = self.selected_keypoint_name() + except Exception: + selected_keypoint = "" + current_suffix = " | current" if keypoint_name == selected_keypoint else "" + if source: + return f"{keypoint_name} | {source}{current_suffix}" + return f"{keypoint_name}{current_suffix}" + + def _update_preview_cursor(self, event) -> None: + if not hasattr(self, "preview_canvas_widget"): + return + active_ax = event.inaxes if event is not None else None + has_position = active_ax is not None and event.xdata is not None and event.ydata is not None + self.preview_canvas_widget.configure(cursor=("crosshair" if has_position else "arrow")) + for ax, artists in list(self._cursor_artists.items()): + visible = has_position and ax is active_ax + hover_text = artists[4] if len(artists) >= 5 else None + if not visible: + for artist in artists: + if artist is not None: + artist.set_visible(False) + continue + x0, x1 = ax.get_xlim() + y0, y1 = ax.get_ylim() + x = float(event.xdata) + y = float(event.ydata) + gap_x = max(3.0, 0.015 * abs(float(x1) - float(x0))) + gap_y = max(3.0, 0.015 * abs(float(y1) - float(y0))) + segments = ( + ([x0, x - gap_x], [y, y]), + ([x + gap_x, x1], [y, y]), + ([x, x], [y0, y - gap_y]), + ([x, x], [y + gap_y, y1]), + ) + for artist, (xs, ys) in zip(artists[:4], segments): + if artist is None: + continue + artist.set_data(xs, ys) + artist.set_visible(True) + if hover_text is not None: + hover_entry = self._nearest_annotation_hover_entry(ax, x, y) + if hover_entry is None: + hover_text.set_visible(False) + else: + hover_text.set_text(self._annotation_hover_label(hover_entry)) + hover_text.set_visible(True) + self.preview_canvas.draw_idle() + + def _delete_nearest_annotation(self, camera_name: str, frame_number: int, xy: np.ndarray) -> bool: + nearest_name = None + nearest_distance = None + point = np.asarray(xy, dtype=float).reshape(2) + for keypoint_name in self.annotation_keypoint_names(): + annotated_xy = self._annotation_xy(camera_name, frame_number, keypoint_name) + if annotated_xy is None: + continue + distance = float(np.linalg.norm(annotated_xy - point)) + if nearest_distance is None or distance < nearest_distance: + nearest_distance = distance + nearest_name = str(keypoint_name) + if nearest_name is None or nearest_distance is None or nearest_distance > ANNOTATION_DELETE_RADIUS_PX: + return False + clear_annotation_point( + self.annotation_payload, + camera_name=camera_name, + frame_number=frame_number, + keypoint_name=nearest_name, + ) + return True + + def on_preview_click(self, event) -> None: + if self._pending_reprojection_points and (event.inaxes is None or event.xdata is None or event.ydata is None): + self._clear_pending_reprojection() + self.refresh_preview() + return + if self.pose_data is None or event.inaxes is None or event.xdata is None or event.ydata is None: + return + if int(getattr(event, "button", 1)) == 2: + self._pan_state = { + "axes": event.inaxes, + "xdata": float(event.xdata), + "ydata": float(event.ydata), + "xlim": tuple(event.inaxes.get_xlim()), + "ylim": tuple(event.inaxes.get_ylim()), + } + return + camera_name = self._axis_to_camera.get(event.inaxes) + if camera_name is None: + return + keypoint_name = self.selected_keypoint_name() + frame_number = self.current_frame_number() + event_key = str(getattr(event, "key", "") or "").lower() + delete_request = int(getattr(event, "button", 1)) == 3 or ("control" in event_key or "ctrl" in event_key) + pointer_xy = np.array([float(event.xdata), float(event.ydata)], dtype=float) + if delete_request: + self._delete_nearest_annotation(camera_name, frame_number, pointer_xy) + else: + hover_entry = self._nearest_annotated_drag_entry(event.inaxes, float(event.xdata), float(event.ydata)) + self._drag_annotation_state = { + "camera_name": str(camera_name), + "frame_number": int(frame_number), + "selected_keypoint_name": str(keypoint_name), + "pressed_xy": np.array(pointer_xy, copy=True), + "did_drag": False, + "existing_keypoint_name": ( + str(hover_entry.get("keypoint_name", "")) if hover_entry is not None else None + ), + } + return + self._clear_kinematic_assist_preview() + if bool(getattr(self, "kinematic_assist_var", None) and self.kinematic_assist_var.get()): + try: + self._estimate_kinematic_q(keypoint_name=None if delete_request else keypoint_name) + except Exception as exc: + self._clear_kinematic_assist_preview() + self.kinematic_status_var.set(f"Kinematic assist update skipped: {exc}") + self.save_annotations() + self.refresh_preview() + + def on_preview_scroll(self, event) -> None: + if event.inaxes is None or event.xdata is None or event.ydata is None: + return + scale = 0.9 if str(getattr(event, "button", "")).lower() == "up" else 1.1 + x0, x1 = event.inaxes.get_xlim() + y0, y1 = event.inaxes.get_ylim() + cx = float(event.xdata) + cy = float(event.ydata) + event.inaxes.set_xlim(cx + (x0 - cx) * scale, cx + (x1 - cx) * scale) + event.inaxes.set_ylim(cy + (y0 - cy) * scale, cy + (y1 - cy) * scale) + camera_name = self._axis_to_camera.get(event.inaxes) + if camera_name is not None: + self._annotation_view_limits[str(camera_name)] = ( + tuple(float(value) for value in event.inaxes.get_xlim()), + tuple(float(value) for value in event.inaxes.get_ylim()), + ) + self.preview_canvas.draw_idle() + + def on_preview_motion(self, event) -> None: + if self._drag_annotation_state is not None: + if event.inaxes is None or event.xdata is None or event.ydata is None: + self._update_preview_cursor(event) + return + camera_name = self._axis_to_camera.get(event.inaxes) + if camera_name == self._drag_annotation_state.get("camera_name"): + pointer_xy = np.array([float(event.xdata), float(event.ydata)], dtype=float) + pressed_xy = np.asarray(self._drag_annotation_state.get("pressed_xy"), dtype=float).reshape(2) + if not bool(self._drag_annotation_state.get("did_drag")): + drag_distance = float(np.linalg.norm(pointer_xy - pressed_xy)) + if drag_distance < ANNOTATION_DRAG_ACTIVATION_PX: + self._update_preview_cursor(event) + return + self._drag_annotation_state["did_drag"] = True + dragged_keypoint_name = str( + self._drag_annotation_state.get("existing_keypoint_name") + or self._drag_annotation_state.get("selected_keypoint_name") + or "" + ) + snapped_xy = self._snap_annotation_xy( + camera_name=str(self._drag_annotation_state["camera_name"]), + frame_number=int(self._drag_annotation_state["frame_number"]), + keypoint_name=dragged_keypoint_name, + pointer_xy=pointer_xy, + ) + set_annotation_point( + self.annotation_payload, + camera_name=str(self._drag_annotation_state["camera_name"]), + frame_number=int(self._drag_annotation_state["frame_number"]), + keypoint_name=dragged_keypoint_name, + xy=snapped_xy, + ) + self.refresh_preview() + return + if self._pan_state is None or event.inaxes is None or event.xdata is None or event.ydata is None: + self._update_preview_cursor(event) + return + if event.inaxes is not self._pan_state.get("axes"): + self._update_preview_cursor(event) + return + x_press = float(self._pan_state["xdata"]) + y_press = float(self._pan_state["ydata"]) + xlim0 = tuple(self._pan_state["xlim"]) + ylim0 = tuple(self._pan_state["ylim"]) + dx = float(event.xdata) - x_press + dy = float(event.ydata) - y_press + event.inaxes.set_xlim(xlim0[0] - dx, xlim0[1] - dx) + event.inaxes.set_ylim(ylim0[0] - dy, ylim0[1] - dy) + camera_name = self._axis_to_camera.get(event.inaxes) + if camera_name is not None: + self._annotation_view_limits[str(camera_name)] = ( + tuple(float(value) for value in event.inaxes.get_xlim()), + tuple(float(value) for value in event.inaxes.get_ylim()), + ) + self.preview_canvas.draw_idle() + self._update_preview_cursor(None) + + def on_preview_release(self, event) -> None: + if self._drag_annotation_state is not None: + state = dict(self._drag_annotation_state) + self._drag_annotation_state = None + camera_name = str(state.get("camera_name", "")) + frame_number = int(state.get("frame_number", self.current_frame_number())) + selected_keypoint_name = str(state.get("selected_keypoint_name", self.selected_keypoint_name())) + did_drag = bool(state.get("did_drag")) + if ( + not did_drag + and event is not None + and event.inaxes is not None + and event.xdata is not None + and event.ydata is not None + ): + release_camera_name = self._axis_to_camera.get(event.inaxes) + if release_camera_name == camera_name: + snapped_xy = self._snap_annotation_xy( + camera_name=camera_name, + frame_number=frame_number, + keypoint_name=selected_keypoint_name, + pointer_xy=np.array([float(event.xdata), float(event.ydata)], dtype=float), + ) + set_annotation_point( + self.annotation_payload, + camera_name=camera_name, + frame_number=frame_number, + keypoint_name=selected_keypoint_name, + xy=snapped_xy, + ) + if self.advance_marker_var.get(): + self._advance_to_next_keypoint() + updated_keypoint = str(state.get("existing_keypoint_name")) if did_drag else selected_keypoint_name + self._clear_kinematic_assist_preview() + if bool(getattr(self, "kinematic_assist_var", None) and self.kinematic_assist_var.get()): + try: + self._estimate_kinematic_q(keypoint_name=updated_keypoint) + except Exception as exc: + self._clear_kinematic_assist_preview() + self.kinematic_status_var.set(f"Kinematic assist update skipped: {exc}") + self.save_annotations() + self.refresh_preview() + self._pan_state = None + + def refresh_preview(self) -> None: + cancel_scheduled_after_idle(self, "_scheduled_annotation_refresh_id") + if self.pose_data is None or self.calibrations is None or len(self.pose_data.frames) == 0: + return + if not hasattr(self, "_annotation_view_limits"): + self._annotation_view_limits = {} + if bool(getattr(self, "_reset_annotation_view_limits_on_next_refresh", False)): + self._annotation_view_limits = {} + self._reset_annotation_view_limits_on_next_refresh = False + else: + self._store_current_annotation_view_limits() + all_camera_names = [str(name) for name in self.pose_data.camera_names] + camera_names = self.selected_annotation_camera_names() + if not camera_names: + camera_names = list(all_camera_names) + support_camera_names = list(all_camera_names) + frame_idx = max(0, min(len(self.pose_data.frames) - 1, int(round(self.frame_var.get())))) + self._current_frame_idx = frame_idx + self.frame_var.set(frame_idx) + frame_number = int(self.pose_data.frames[frame_idx]) + mode = self._frame_filter_mode() + filtered_indices = self._filtered_annotation_frame_local_indices() + self.frame_label.configure( + text=annotation_frame_label_text( + frame_idx=frame_idx, + frame_number=frame_number, + mode=mode, + filtered_indices=filtered_indices, + mode_labels=ANNOTATION_FRAME_FILTER_OPTIONS, + ) + ) + jump_context_var = getattr(self, "jump_context_var", None) + if jump_context_var is not None: + jump_context_var.set(self._annotation_jump_context()) + crop_limits = self._ensure_crop_limits(camera_names) if self.crop_var.get() else {} + current_marker = self.selected_keypoint_name() + current_color = annotation_marker_color(current_marker) + if ( + bool(getattr(self, "kinematic_assist_var", None) and self.kinematic_assist_var.get()) + and self.kinematic_projected_points is None + ): + biomod_path = self._selected_kinematic_biomod_path() + if biomod_path is not None and biomod_path.exists(): + try: + import biorbd + + model = biorbd.Model(str(biomod_path)) + cached_state, source_frame, is_exact = self._selected_or_nearest_kinematic_state_info( + frame_number, model + ) + if cached_state is None: + raise ValueError("No cached state") + self._set_kinematic_preview_from_q(biomod_path, camera_names, cached_state[: model.nbQ()]) + self.kinematic_state_current = np.asarray(cached_state, dtype=float) + if is_exact: + self.kinematic_status_var.set(f"Using saved q for frame {frame_number}.") + elif source_frame is not None: + self.kinematic_status_var.set( + f"Using nearest saved q from frame {source_frame} for frame {frame_number}." + ) + except Exception: + self._clear_kinematic_assist_preview() + self.preview_figure.clear() + self._cursor_artists = {} + self._annotation_hover_entries = {} + nrows, ncols = camera_layout(len(camera_names)) + axes = np.atleast_1d(self.preview_figure.subplots(nrows, ncols)).ravel() + self._axis_to_camera = {} + images_root = self._current_images_root() + + for ax_idx, ax in enumerate(axes): + if ax_idx >= len(camera_names): + ax.axis("off") + continue + camera_name = camera_names[ax_idx] + self._axis_to_camera[ax] = camera_name + width, height = self.calibrations[camera_name].image_size + background_image = ( + load_camera_background_image( + images_root, + camera_name, + frame_number, + image_reader=plt.imread, + brightness=float(self.image_brightness_var.get()), + contrast=float(self.image_contrast_var.get()), + ) + if self.show_images_var.get() + else None + ) + x_limits, y_limits = self._annotation_view_limits_for_camera(camera_name) + reference_projected_points = None + reference_projected_label = None + reference_projected_color = "#6c5ce7" + if bool( + getattr(self, "show_reference_reprojection_var", None) and self.show_reference_reprojection_var.get() + ): + ( + reference_projected_points, + reference_projected_label, + reference_projected_color, + ) = self._reference_projected_points(camera_name, frame_number) + self._annotation_hover_entries[ax] = render_annotation_camera_view( + ax, + ax_idx=ax_idx, + camera_name=camera_name, + frame_idx=frame_idx, + frame_number=frame_number, + width=width, + height=height, + crop_mode=("pose" if self.crop_var.get() else "full"), + crop_limits=crop_limits, + background_image=background_image, + current_marker=current_marker, + current_color=current_color, + keypoint_names=COCO17, + kp_index=KP_INDEX, + annotation_xy_getter=self._annotation_xy, + pending_reprojection_points=self._pending_reprojection_points, + marker_color_getter=annotation_marker_color, + marker_shape_getter=annotation_marker_shape, + draw_background_fn=draw_2d_background_image, + apply_axis_limits_fn=apply_2d_axis_limits, + hide_axes_fn=hide_2d_axes, + draw_skeleton_fn=draw_skeleton_2d, + draw_upper_back_fn=draw_upper_back_overlay_2d, + kinematic_projected_points=( + self.kinematic_projected_points + if bool(getattr(self, "kinematic_assist_var", None) and self.kinematic_assist_var.get()) + else None + ), + kinematic_segmented_back_projected=self.kinematic_segmented_back_projected, + reference_projected_points=reference_projected_points, + reference_projected_label=reference_projected_label, + reference_projected_color=reference_projected_color, + motion_prior_enabled=bool(self.show_motion_prior_var.get()), + motion_prior_diameter=float(self.motion_prior_diameter.get()), + motion_prior_center_fn=annotation_motion_prior_center, + ) + if x_limits is not None and y_limits is not None: + ax.set_xlim(*x_limits) + ax.set_ylim(*y_limits) + + other_camera_names: list[str] = [] + other_points: list[np.ndarray] = [] + for other_camera_name in support_camera_names: + if other_camera_name == camera_name: + continue + other_xy = self._annotation_xy(other_camera_name, frame_number, current_marker) + if other_xy is None: + continue + other_camera_names.append(other_camera_name) + other_points.append(other_xy) + if self.show_epipolar_var.get(): + line = annotation_epipolar_guides(self.calibrations, other_camera_name, camera_name, other_xy) + if line is not None: + x0, x1 = ax.get_xlim() + y0, y1 = ax.get_ylim() + xs = np.array([x0, x1], dtype=float) + if abs(line[1]) > 1e-8: + ys = -(line[0] * xs + line[2]) / line[1] + ax.plot(xs, ys, color=current_color, linewidth=1.0, alpha=0.6, linestyle=":") + elif abs(line[0]) > 1e-8: + x_const = -line[2] / line[0] + ax.plot( + [x_const, x_const], + [y0, y1], + color=current_color, + linewidth=1.0, + alpha=0.6, + linestyle=":", + ) + if self.show_triangulated_hint_var.get(): + triangulated_hint = annotation_triangulated_reprojection( + self.calibrations, + target_camera_name=camera_name, + source_camera_names=other_camera_names, + source_points_2d=other_points, + ) + if triangulated_hint is not None: + ax.scatter( + [triangulated_hint[0]], + [triangulated_hint[1]], + s=90, + facecolors="none", + edgecolors=[current_color], + marker="o", + linewidths=1.9, + zorder=6, + ) + self.preview_figure.subplots_adjust(left=0.03, right=0.995, bottom=0.035, top=0.96, wspace=0.05, hspace=0.12) + self.preview_canvas.draw_idle() + + +class FiguresTab(CommandTab): + def __init__(self, master): + super().__init__(master, "Figures") + form = ttk.LabelFrame(self.main, text="analysis/plot_kinematic_comparison.py") + form.pack(fill=tk.X, pady=(0, 8), before=self.output) + + self.input_dir = LabeledEntry(form, "Input dir", "output/vitpose_full", browse=True, directory=True) + self.input_dir.pack(fill=tk.X, padx=8, pady=4) + self.output_dir = LabeledEntry(form, "Output dir", "output/vitpose_full/figures", browse=True, directory=True) + self.output_dir.pack(fill=tk.X, padx=8, pady=4) + + row = ttk.Frame(form) row.pack(fill=tk.X, padx=8, pady=4) self.fps = LabeledEntry(row, "FPS", "120") self.fps.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6)) @@ -4265,6 +7714,10 @@ def __init__(self, master, state: SharedAppState): ttk.Label(row_shared, textvariable=self.selected_cameras_label_var, foreground="#4f5b66").pack( side=tk.LEFT, padx=(8, 0) ) + self.images_root = LabeledEntry(controls, "Images root", "", browse=True, directory=True) + self.images_root.var = state.shared_images_root_var + self.images_root.entry_widget.configure(textvariable=self.images_root.var) + self.images_root.pack(fill=tk.X, padx=8, pady=4) ttk.Label(controls, textvariable=self.dataset_summary_var, foreground="#4f5b66", justify=tk.LEFT).pack( fill=tk.X, padx=8, pady=(0, 4) ) @@ -4410,6 +7863,7 @@ def __init__(self, master, state: SharedAppState): for idx in range(len(COCO17)): self.keypoint_list.selection_set(idx) self.keypoint_list.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) + bind_extended_listbox_shortcuts(self.keypoint_list) self.figure = Figure(figsize=(12, 8)) self.canvas = FigureCanvasTkAgg(self.figure, master=figure_box) @@ -4423,6 +7877,9 @@ def __init__(self, master, state: SharedAppState): self.pose2sim_trc.set_tooltip("TRC file used for direct 3D import. It is auto-detected from the 2D JSON.") self.fps.set_tooltip("FPS partage pour les derives temporelles et les animations.") self.workers.set_tooltip("Nombre de workers partage pour les rendus et les calculs paralleles.") + self.images_root.set_tooltip( + "Dossier d'images partagé utilisé comme fond dans les onglets 2D multiview et Caméras." + ) attach_tooltip( self.root_rotfix_check, "Si coché, la racine est réalignée en lacet autour de Z à partir de l'axe médio-latéral du tronc à t0. L'angle est arrondi au multiple de pi/2 le plus proche, puis partagé par le modèle, les reconstructions et les analyses.", @@ -4508,6 +7965,7 @@ def __init__(self, master, state: SharedAppState): ) self.state.selected_camera_names_var.trace_add("write", lambda *_args: self.update_dataset_summary()) self.state.clean_trial_outputs_callback = self.clean_trial_outputs + self.state.clean_trial_caches_callback = self.clean_trial_caches self.on_keypoints_changed() def selected_triangulation_flip_method(self) -> str: @@ -4518,6 +7976,7 @@ def selected_triangulation_flip_method(self) -> str: def on_keypoints_changed(self) -> None: keypoints_path = ROOT / self.keypoints.get() + sync_shared_images_root_from_keypoints(self.state, keypoints_path, set_if_empty_only=True) trc_path = infer_pose2sim_trc_from_keypoints(keypoints_path) if keypoints_path.exists() else None if trc_path is not None: rel = display_path(trc_path) @@ -4603,6 +8062,41 @@ def clean_trial_outputs(self) -> None: except Exception as exc: messagebox.showerror("Clean trial outputs", str(exc)) + def clean_trial_caches(self) -> None: + dataset_dir = current_dataset_dir(self.state) + trial_name = current_dataset_name(self.state) + cache_root = dataset_dir / "_cache" + preview_cache_paths = list(dataset_dir.glob("models/**/preview_q0_cache.npz")) if dataset_dir.exists() else [] + if not cache_root.exists() and not preview_cache_paths: + messagebox.showinfo("Clean trial caches", f"No caches found for this dataset:\n{display_path(dataset_dir)}") + return + confirmed = messagebox.askyesno( + "Clean trial caches", + f"Delete generated caches for trial '{trial_name}'?\n\n" + f"{display_path(dataset_dir)}\n\n" + "This removes cached intermediate files only. Models, reconstructions, and figures are kept.", + icon=messagebox.WARNING, + ) + if not confirmed: + return + try: + if cache_root.exists(): + shutil.rmtree(cache_root) + for cache_path in preview_cache_paths: + if cache_path.exists(): + cache_path.unlink() + self.state.pose_data_cache.clear() + self.state.calibration_cache.clear() + self.state.notify_reconstructions_updated() + self.update_dataset_summary() + panel = getattr(self.state, "shared_reconstruction_panel", None) + refresh_callback = getattr(panel, "_refresh_callback", None) + if callable(refresh_callback): + self.after_idle(refresh_callback) + messagebox.showinfo("Clean trial caches", f"Deleted caches for:\n{display_path(dataset_dir)}") + except Exception as exc: + messagebox.showerror("Clean trial caches", str(exc)) + def update_dataset_summary(self) -> None: ensure_dataset_layout(self.state) self.dataset_summary_var.set("") @@ -4828,6 +8322,7 @@ def __init__(self, master, state: SharedAppState): self.preview_q_t0: np.ndarray | None = None self.preview_q_current: np.ndarray | None = None self.preview_q_names: list[str] = [] + self.preview_viewer = tk.StringVar(value="matplotlib") self.preview_model = None self.preview_marker_names: list[str] = [] self.preview_segment_frames: list[tuple[str, np.ndarray, np.ndarray]] = [] @@ -4836,9 +8331,13 @@ def __init__(self, master, state: SharedAppState): self._auto_frame_range: tuple[str, str] | None = None self._syncing_frame_defaults = False self.set_run_button_text("Generate model") + self.content_pane = ttk.Panedwindow(self.main, orient=tk.HORIZONTAL) + self.left_panel = ttk.Frame(self.content_pane) + self.right_panel = ttk.Frame(self.content_pane) + self.content_pane.add(self.left_panel, weight=1) + self.content_pane.add(self.right_panel, weight=2) - form = ttk.LabelFrame(self.main, text="Construction du modèle") - form.pack(fill=tk.X, pady=(0, 8)) + form = ttk.LabelFrame(self.left_panel, text="Construction du modèle") row = ttk.Frame(form) row.pack(fill=tk.X, padx=8, pady=4) @@ -4855,11 +8354,25 @@ def __init__(self, master, state: SharedAppState): state="readonly", ) structure_box.pack(side=tk.LEFT, padx=(0, 8)) + + row1b = ttk.Frame(form) + row1b.pack(fill=tk.X, padx=8, pady=4) + self.symmetrize_limbs_var = tk.BooleanVar(value=DEFAULT_MODEL_SYMMETRIZE_LIMBS) + symmetrize_check = ttk.Checkbutton( + row1b, + text="Symmetrize limbs", + variable=self.symmetrize_limbs_var, + ) + symmetrize_check.pack(side=tk.LEFT, padx=(0, 12)) self.triang_method = tk.StringVar(value="exhaustive") - triang_label = ttk.Label(row, text="Triangulation", width=12) + triang_label = ttk.Label(row1b, text="Triangulation", width=12) triang_label.pack(side=tk.LEFT) triang_box = ttk.Combobox( - row, textvariable=self.triang_method, values=["once", "greedy", "exhaustive"], width=12, state="readonly" + row1b, + textvariable=self.triang_method, + values=["once", "greedy", "exhaustive"], + width=12, + state="readonly", ) triang_box.pack(side=tk.LEFT, padx=(0, 8)) @@ -4876,21 +8389,25 @@ def __init__(self, master, state: SharedAppState): row2b = ttk.Frame(form) row2b.pack(fill=tk.X, padx=8, pady=4) default_model_pose_mode = state.pose_data_mode_var.get().strip() - if default_model_pose_mode not in ("raw", "cleaned"): + if default_model_pose_mode not in ("raw", "annotated", "cleaned"): default_model_pose_mode = "cleaned" + self.pose_mode_box = None self.pose_data_mode = tk.StringVar(value=default_model_pose_mode) pose_mode_label = ttk.Label(row2b, text="2D source", width=10) pose_mode_label.pack(side=tk.LEFT) - pose_mode_box = ttk.Combobox( + self.pose_mode_box = ttk.Combobox( row2b, textvariable=self.pose_data_mode, values=["raw", "cleaned"], width=10, state="readonly" ) - pose_mode_box.pack(side=tk.LEFT, padx=(0, 8)) + self.pose_mode_box.pack(side=tk.LEFT, padx=(0, 8)) + + row2c = ttk.Frame(form) + row2c.pack(fill=tk.X, padx=8, pady=4) default_pose_correction_mode = current_calibration_correction_mode(state) self.pose_correction_mode = tk.StringVar(value=default_pose_correction_mode) - pose_correction_label = ttk.Label(row2b, text="L/R corr", width=8) + pose_correction_label = ttk.Label(row2c, text="L/R corr", width=8) pose_correction_label.pack(side=tk.LEFT) pose_correction_box = ttk.Combobox( - row2b, + row2c, textvariable=self.pose_correction_mode, values=[ "none", @@ -4900,12 +8417,12 @@ def __init__(self, master, state: SharedAppState): "flip_epipolar_fast_viterbi", "flip_triangulation", ], - width=24, + width=17, state="readonly", ) pose_correction_box.pack(side=tk.LEFT, padx=(0, 8)) self.model_info_var = tk.StringVar(value="") - ttk.Label(row2b, textvariable=self.model_info_var, foreground="#4f5b66").pack(side=tk.LEFT, padx=(6, 0)) + ttk.Label(row2c, textvariable=self.model_info_var, foreground="#4f5b66").pack(side=tk.LEFT, padx=(6, 0)) row3 = ttk.Frame(form) row3.pack(fill=tk.X, padx=8, pady=4) @@ -4933,11 +8450,15 @@ def __init__(self, master, state: SharedAppState): self.subject_mass.set_tooltip("Masse du sujet utilisée pour les paramètres inertiels du modèle.") attach_tooltip( structure_label, - "Topologie du bioMod: single_trunk garde le modèle actuel; back_3dof ajoute un segment de dos à 3 DoF au milieu du tronc.", + "Topologie du bioMod: single_trunk garde le modèle actuel; back_flexion_1d/back_3dof gardent la racine au bassin; upper_root_back_* place la racine au haut du tronc et met le dos mobile vers le bassin.", ) attach_tooltip( structure_box, - "single_trunk: modèle actuel. back_3dof: segment UPPER_BACK rotatif inséré entre le bassin et les épaules/tête/bras.", + "single_trunk: modèle actuel. back_flexion_1d/back_3dof: UPPER_BACK entre bassin et épaules. upper_root_back_flexion_1d/upper_root_back_3dof: racine au haut du tronc, LOWER_TRUNK mobile vers le bassin.", + ) + attach_tooltip( + symmetrize_check, + "Si coché, les longueurs bras/jambe gauche et droite sont moyennées pour construire un bioMod symétrique. Sinon, le modèle conserve les longueurs latéralisées estimées.", ) attach_tooltip( triang_label, "Méthode de triangulation utilisée pour construire le modèle: once, greedy, ou exhaustive." @@ -4948,11 +8469,11 @@ def __init__(self, master, state: SharedAppState): ) attach_tooltip( pose_mode_label, - "Choix de la version de base des 2D utilisées pour construire le modèle: raw ou cleaned.", + "Choix de la version de base des 2D utilisées pour construire le modèle: raw, cleaned, ou annotated si un fichier d'annotations existe.", ) attach_tooltip( - pose_mode_box, - "Choix de la version de base des 2D utilisées pour construire le modèle. `cleaned` applique le rejet des points aberrants.", + self.pose_mode_box, + "Choix de la version de base des 2D utilisées pour construire le modèle. `annotated` n'est proposé que si un fichier d'annotations existe pour l'essai courant.", ) attach_tooltip( pose_correction_label, "Correction optionnelle des labels gauche/droite avant la triangulation du modèle." @@ -4973,10 +8494,14 @@ def __init__(self, master, state: SharedAppState): self.state.calib_var.trace_add("write", lambda *_args: self.sync_paths_from_state()) self.state.keypoints_var.trace_add("write", lambda *_args: self.sync_paths_from_state()) + self.state.annotation_path_var.trace_add("write", lambda *_args: self.sync_paths_from_state()) self.state.output_root_var.trace_add("write", lambda *_args: self.sync_paths_from_state()) self.state.register_reconstruction_listener(self.refresh_existing_models) self.model_variant.trace_add("write", lambda *_args: self.update_details()) self.model_variant.trace_add("write", lambda *_args: self.refresh_existing_models()) + self.preview_viewer.trace_add("write", lambda *_args: self.update_preview_viewer_controls()) + self.symmetrize_limbs_var.trace_add("write", lambda *_args: self.update_details()) + self.symmetrize_limbs_var.trace_add("write", lambda *_args: self.refresh_existing_models()) self.triang_method.trace_add("write", lambda *_args: self.update_details()) self.pose_data_mode.trace_add("write", lambda *_args: self.update_details()) self.pose_correction_mode.trace_add("write", lambda *_args: self.update_details()) @@ -4997,19 +8522,7 @@ def __init__(self, master, state: SharedAppState): # Layout: controls and model list on the left, preview on the right. self.hide_preview_copy_buttons() - form.pack_forget() - if self.buttons_frame is not None: - self.buttons_frame.pack_forget() - if self.progress_row is not None: - self.progress_row.pack_forget() - form.grid(row=0, column=0, sticky="ew", padx=(0, 10), pady=(0, 8)) - if self.buttons_frame is not None: - self.buttons_frame.grid(row=1, column=0, sticky="ew", padx=(0, 10), pady=(0, 8)) - if self.progress_row is not None: - self.progress_row.grid(row=2, column=0, sticky="ew", padx=(0, 10), pady=(0, 8)) - - existing_box = ttk.LabelFrame(self.main, text="Existing models") - existing_box.grid(row=3, column=0, sticky="nsew", padx=(0, 10), pady=(0, 8)) + existing_box = ttk.LabelFrame(self.left_panel, text="Existing models") existing_controls = ttk.Frame(existing_box) existing_controls.pack(fill=tk.X, padx=8, pady=(8, 0)) ttk.Button(existing_controls, text="Refresh models", command=self.refresh_existing_models).pack(side=tk.LEFT) @@ -5028,8 +8541,7 @@ def __init__(self, master, state: SharedAppState): "Liste des modeles detectes pour le trial courant. La colonne Match indique s'ils correspondent aux options 2D courantes.", ) - preview_box = ttk.LabelFrame(self.main, text="Première frame triangulée / modèle") - preview_box.grid(row=0, column=1, rowspan=4, sticky="nsew", pady=(0, 8)) + preview_box = ttk.LabelFrame(self.right_panel, text="Première frame triangulée / modèle") preview_controls_top = ttk.Frame(preview_box) preview_controls_top.pack(fill=tk.X, padx=8, pady=(8, 2)) self.show_triangulation_var = tk.BooleanVar(value=False) @@ -5046,6 +8558,19 @@ def __init__(self, master, state: SharedAppState): variable=self.show_local_frames_var, command=self.refresh_preview, ).pack(side=tk.LEFT, padx=(0, 12)) + ttk.Label(preview_controls_top, text="Viewer", width=7).pack(side=tk.LEFT) + self.preview_viewer_box = ttk.Combobox( + preview_controls_top, + textvariable=self.preview_viewer, + values=list(SUPPORTED_MODEL_PREVIEW_VIEWERS), + width=11, + state="readonly", + ) + self.preview_viewer_box.pack(side=tk.LEFT, padx=(0, 8)) + self.open_preview_viewer_button = ttk.Button( + preview_controls_top, text="Open pyorerun", command=self.open_preview_in_viewer + ) + self.open_preview_viewer_button.pack(side=tk.LEFT, padx=(0, 12)) ttk.Label(preview_controls_top, text="Pose", width=6).pack(side=tk.LEFT) self.preview_pose_mode = tk.StringVar(value="q=0") self.preview_pose_box = ttk.Combobox( @@ -5084,6 +8609,14 @@ def __init__(self, master, state: SharedAppState): self.preview_pose_box, "Choisit la pose de référence utilisée pour afficher le modèle: neutre q=0 ou q(t0) estimé à partir de la première frame triangulée valide.", ) + attach_tooltip( + self.preview_viewer_box, + "Choisit entre le preview intégré matplotlib et une ouverture externe dans pyorerun.", + ) + attach_tooltip( + self.open_preview_viewer_button, + "Ouvre le modèle courant dans pyorerun avec la pose actuellement affichée. Le preview matplotlib reste disponible dans l'onglet.", + ) attach_tooltip(self.preview_dof_box, "Choisit le DoF du modele a modifier manuellement dans le preview.") attach_tooltip(self.preview_dof_slider, "Fait varier le DoF selectionne entre -2pi et 2pi.") self.preview_figure = Figure(figsize=(8, 6)) @@ -5093,23 +8626,47 @@ def __init__(self, master, state: SharedAppState): self.extra = LabeledEntry(form, "Extra args", "") self.extra.pack(fill=tk.X, padx=8, pady=4) - self.main.grid_columnconfigure(0, weight=1, uniform="modeltab") - self.main.grid_columnconfigure(1, weight=2, uniform="modeltab") - self.main.grid_rowconfigure(3, weight=1) + form.pack(fill=tk.X, pady=(0, 8)) + existing_box.pack(fill=tk.BOTH, expand=True, pady=(0, 8)) + preview_box.pack(fill=tk.BOTH, expand=True, pady=(0, 8)) + self.content_pane.pack(fill=tk.BOTH, expand=True) self.update_details() self.sync_paths_from_state() self.refresh_existing_models() + self.update_preview_viewer_controls() def current_pose_correction_mode(self) -> str: return normalize_pose_correction_mode(self.pose_correction_mode.get()) + def _available_model_pose_modes(self) -> list[str]: + keypoints_value = str(self.state.keypoints_var.get()).strip() + if not keypoints_value: + return ["raw", "cleaned"] + return available_model_pose_modes(self.state, ROOT / keypoints_value) + + def _sync_available_pose_modes(self) -> None: + modes = self._available_model_pose_modes() + if hasattr(self, "pose_mode_box") and self.pose_mode_box is not None: + self.pose_mode_box.configure(values=modes) + current = str(self.pose_data_mode.get()).strip() + if current not in modes: + fallback = "annotated" if "annotated" in modes else ("cleaned" if "cleaned" in modes else modes[0]) + self.pose_data_mode.set(fallback) + def current_pose_source_label(self) -> str: correction_mode = self.current_pose_correction_mode() if correction_mode == "none": return self.pose_data_mode.get() return f"{self.pose_data_mode.get()} + {correction_mode}" + def _model_min_cameras_for_triangulation(self) -> int: + """Allow sparse annotated frames to bootstrap model creation with two views.""" + + if str(self.pose_data_mode.get()).strip() == "annotated": + return 2 + return DEFAULT_MIN_CAMERAS_FOR_TRIANGULATION + def update_details(self) -> None: max_frames = self.max_frames.get() or "all" frame_start = self.frame_start.get() or "-" @@ -5118,6 +8675,7 @@ def update_details(self) -> None: f"Model creation will use: {self.current_pose_source_label()} 2D data, " f"frames {frame_start} -> {frame_end}, nb {max_frames}, " f"structure {self.model_variant.get()}, " + f"{'sym' if self.symmetrize_limbs_var.get() else 'asym'}, " f"triangulation {self.triang_method.get()}, " f"root rot-fix {'on' if self.initial_rot_var.get() else 'off'}, " f"subject mass {self.subject_mass.get()} kg." @@ -5182,6 +8740,7 @@ def _sync_frame_range_defaults(self) -> None: self._syncing_frame_defaults = False def sync_paths_from_state(self) -> None: + self._sync_available_pose_modes() self._sync_frame_range_defaults() try: subject_mass_kg = float(self.subject_mass.get()) @@ -5202,6 +8761,7 @@ def sync_paths_from_state(self) -> None: pose_data_mode=self.pose_data_mode.get(), triangulation_method=self.triang_method.get(), model_variant=self.model_variant.get(), + symmetrize_limbs=self.symmetrize_limbs_var.get(), pose_correction_mode=self.current_pose_correction_mode(), initial_rotation_correction=self.initial_rot_var.get(), max_frames=max_frames, @@ -5219,6 +8779,7 @@ def sync_paths_from_state(self) -> None: pose_data_mode=self.pose_data_mode.get(), triangulation_method=self.triang_method.get(), model_variant=self.model_variant.get(), + symmetrize_limbs=self.symmetrize_limbs_var.get(), pose_correction_mode=self.current_pose_correction_mode(), initial_rotation_correction=self.initial_rot_var.get(), max_frames=max_frames, @@ -5238,6 +8799,58 @@ def on_command_success(self) -> None: self.refresh_existing_models() + def update_preview_viewer_controls(self) -> None: + if not hasattr(self, "open_preview_viewer_button"): + return + viewer = self.preview_viewer.get().strip() + if viewer == "pyorerun": + self.open_preview_viewer_button.configure(state="normal", text="Open pyorerun") + else: + self.open_preview_viewer_button.configure(state="disabled", text="Built-in viewer") + + def _pyorerun_states_path(self, biomod_path: Path) -> Path: + return biomod_path.parent / "preview_pyorerun_states.npz" + + def _write_pyorerun_preview_states(self, states_path: Path) -> bool: + q_values = self.preview_q_current + if q_values is None or np.asarray(q_values, dtype=float).size == 0: + return False + q_row = np.asarray(q_values, dtype=float).reshape(1, -1) + if not np.all(np.isfinite(q_row)): + return False + q_series = np.repeat(q_row, 2, axis=0) + states_path.parent.mkdir(parents=True, exist_ok=True) + np.savez(states_path, q=q_series) + return True + + def open_preview_in_viewer(self) -> None: + viewer = self.preview_viewer.get().strip() + if viewer != "pyorerun": + return + try: + biomod_path = self._preview_model_path(use_selected_model=True) + if not biomod_path.exists(): + biomod_path = self._preview_model_path(use_selected_model=False) + if not biomod_path.exists(): + raise ValueError("No bioMod available to open in pyorerun.") + states_path = self._pyorerun_states_path(biomod_path) + has_states = self._write_pyorerun_preview_states(states_path) + cmd = [ + sys.executable, + "tools/show_biomod_pyorerun.py", + "--biomod", + display_path(biomod_path), + "--fps", + self.state.fps_var.get(), + ] + if has_states: + cmd.extend(["--mode", "trajectory", "--states", display_path(states_path)]) + else: + cmd.extend(["--mode", "neutral"]) + subprocess.Popen(cmd, cwd=ROOT) + except Exception as exc: + messagebox.showerror("pyorerun", str(exc)) + def refresh_existing_models(self) -> None: for item in self.model_tree.get_children(): self.model_tree.delete(item) @@ -5248,6 +8861,7 @@ def refresh_existing_models(self) -> None: expected_mode = self.pose_data_mode.get() expected_correction = self.current_pose_correction_mode() expected_model_variant = self.model_variant.get() if hasattr(self, "model_variant") else DEFAULT_MODEL_VARIANT + expected_symmetrize_limbs = self.symmetrize_limbs_var.get() if hasattr(self, "symmetrize_limbs_var") else True expected_window = int(self.state.pose_filter_window_var.get()) expected_ratio = float(self.state.pose_outlier_ratio_var.get()) expected_p_low = float(self.state.pose_p_low_var.get()) @@ -5263,6 +8877,7 @@ def refresh_existing_models(self) -> None: expected_mode, expected_correction, expected_model_variant, + expected_symmetrize_limbs, expected_window, expected_ratio, expected_p_low, @@ -5300,13 +8915,13 @@ def parse_displayed_path(raw: str) -> Path: if selected: for item in selected: values = self.model_tree.item(item, "values") - if len(values) >= 2 and values[1] and not str(values[1]).startswith("No existing"): - paths.append(parse_displayed_path(str(values[1]))) + if len(values) >= 3 and values[2] and not str(values[2]).startswith("No existing"): + paths.append(parse_displayed_path(str(values[2]))) else: for item in self.model_tree.get_children(): values = self.model_tree.item(item, "values") - if len(values) >= 2 and values[1] and not str(values[1]).startswith("No existing"): - paths.append(parse_displayed_path(str(values[1]))) + if len(values) >= 3 and values[2] and not str(values[2]).startswith("No existing"): + paths.append(parse_displayed_path(str(values[2]))) model_dirs = sorted({path.parent for path in paths if path.exists()}) if not model_dirs: @@ -5354,6 +8969,7 @@ def _model_matches_selected_2d_data( expected_mode: str, expected_correction: str, expected_model_variant: str, + expected_symmetrize_limbs: bool, expected_window: int, expected_ratio: float, expected_p_low: float, @@ -5371,6 +8987,8 @@ def _model_matches_selected_2d_data( reconstruction_metadata.get("pose_data_mode") == expected_mode and str(reconstruction_metadata.get("pose_correction_mode", "none")) == expected_correction and str(stage_metadata.get("model_variant", DEFAULT_MODEL_VARIANT)) == expected_model_variant + and bool(stage_metadata.get("symmetrize_limbs", DEFAULT_MODEL_SYMMETRIZE_LIMBS)) + == bool(expected_symmetrize_limbs) and int(reconstruction_metadata.get("pose_filter_window", -1)) == expected_window and math.isclose( float(reconstruction_metadata.get("pose_outlier_threshold_ratio", math.nan)), @@ -5455,6 +9073,8 @@ def build_command(self) -> list[str]: selected_cameras = current_selected_camera_names(self.state) if selected_cameras: cmd.extend(["--camera-names", ",".join(selected_cameras)]) + if not self.symmetrize_limbs_var.get(): + cmd.append("--no-symmetrize-limbs") if self.frame_start.get(): cmd.extend(["--frame-start", self.frame_start.get()]) if self.frame_end.get(): @@ -5463,6 +9083,9 @@ def build_command(self) -> list[str]: cmd.extend(["--max-frames", self.max_frames.get()]) if self.initial_rot_var.get(): cmd.append("--initial-rotation-correction") + min_cameras = self._model_min_cameras_for_triangulation() + if min_cameras != DEFAULT_MIN_CAMERAS_FOR_TRIANGULATION: + cmd.extend(["--min-cameras-for-triangulation", str(min_cameras)]) cmd.extend(self.parse_extra_args(self.extra.get())) return cmd @@ -5475,6 +9098,7 @@ def derived_model_dir(self) -> Path: pose_data_mode=self.pose_data_mode.get(), triangulation_method=self.triang_method.get(), model_variant=self.model_variant.get(), + symmetrize_limbs=self.symmetrize_limbs_var.get(), pose_correction_mode=self.current_pose_correction_mode(), initial_rotation_correction=self.initial_rot_var.get(), max_frames=int(self.max_frames.get()) if self.max_frames.get() else None, @@ -5497,6 +9121,7 @@ def derived_biomod_path(self) -> str: pose_data_mode=self.pose_data_mode.get(), triangulation_method=self.triang_method.get(), model_variant=self.model_variant.get(), + symmetrize_limbs=self.symmetrize_limbs_var.get(), pose_correction_mode=self.current_pose_correction_mode(), initial_rotation_correction=self.initial_rot_var.get(), max_frames=int(self.max_frames.get()) if self.max_frames.get() else None, @@ -5567,7 +9192,7 @@ def _try_load_existing_triangulation_cache(self, pose_data, model_dir: Path): metadata = reconstruction_cache_metadata( pose_data=pose_data, error_threshold_px=DEFAULT_REPROJECTION_THRESHOLD_PX, - min_cameras_for_triangulation=DEFAULT_MIN_CAMERAS_FOR_TRIANGULATION, + min_cameras_for_triangulation=self._model_min_cameras_for_triangulation(), epipolar_threshold_px=DEFAULT_EPIPOLAR_THRESHOLD_PX, triangulation_method=self.triang_method.get(), pose_data_mode=self.pose_data_mode.get(), @@ -5597,7 +9222,7 @@ def _first_valid_preview_reconstruction(self, calibrations, pose_data, model_dir coherence_method=DEFAULT_COHERENCE_METHOD, triangulation_method=self.triang_method.get(), reprojection_threshold_px=DEFAULT_REPROJECTION_THRESHOLD_PX, - min_cameras_for_triangulation=DEFAULT_MIN_CAMERAS_FOR_TRIANGULATION, + min_cameras_for_triangulation=self._model_min_cameras_for_triangulation(), epipolar_threshold_px=DEFAULT_EPIPOLAR_THRESHOLD_PX, triangulation_workers=max(1, int(self.state.workers_var.get() or "1")), pose_data_mode=self.pose_data_mode.get(), @@ -5833,6 +9458,8 @@ def refresh_preview(self) -> None: draw_skeleton_3d(ax, self.preview_support_points, "#b8c4d6", "Triangulation") points_dict["triangulation"] = self.preview_support_points[np.newaxis, :, :] draw_skeleton_3d(ax, self.preview_points, "#4c72b0", "Model") + if has_segmented_back_visualization(segment_frames=self.preview_segment_frames, q_names=self.preview_q_names): + draw_upper_back_preview(ax, self.preview_points, self.preview_segment_frames) points_dict["model"] = self.preview_points[np.newaxis, :, :] valid = self.preview_points[np.all(np.isfinite(self.preview_points), axis=1)] if valid.size: @@ -5922,9 +9549,15 @@ def __init__(self, master, state: SharedAppState): form = ttk.LabelFrame(self.main, text="Profils de reconstruction") form.pack(fill=tk.X, pady=(0, 8), before=self.output) - self.config_path = LabeledEntry(form, "Config JSON", browse=True) + self.config_path = LabeledEntry( + form, + "Config JSON", + browse=True, + on_browse_selected=self._on_profiles_path_browsed, + ) self.config_path.var = state.profiles_config_var self.config_path.entry_widget.configure(textvariable=self.config_path.var) + self.config_path.entry_widget.bind("", lambda _event: self.load_profiles_from_json()) self.config_path.pack(fill=tk.X, padx=8, pady=4) self.config_path.set_tooltip("Fichier JSON dans lequel charger ou sauvegarder les profils.") @@ -5970,6 +9603,7 @@ def __init__(self, master, state: SharedAppState): width=40, ) self.profile_cameras_list.pack(fill=tk.BOTH, expand=True) + bind_extended_listbox_shortcuts(self.profile_cameras_list) self.pose_mode_frame = ttk.Frame(form) mode_label = ttk.Label(self.pose_mode_frame, text="2D mode", width=10) @@ -5978,7 +9612,7 @@ def __init__(self, master, state: SharedAppState): pose_mode_box = ttk.Combobox( self.pose_mode_frame, textvariable=self.pose_data_mode, - values=["raw", "cleaned"], + values=["raw", "annotated", "cleaned"], width=12, state="readonly", ) @@ -5999,13 +9633,23 @@ def __init__(self, master, state: SharedAppState): self.common_frame = ttk.Frame(form) self.common_frame.pack(fill=tk.X, padx=8, pady=4) self.initial_rot_var = state.initial_rotation_correction_var - self.unwrap_var = tk.BooleanVar(value=False) initial_rot_check = ttk.Checkbutton( self.common_frame, text="initial-rotation-correction", variable=self.initial_rot_var ) - initial_rot_check.pack(side=tk.LEFT, padx=(0, 12)) - unwrap_check = ttk.Checkbutton(self.common_frame, text="no-root-unwrap", variable=self.unwrap_var) - unwrap_check.pack(side=tk.LEFT) + initial_rot_check.pack(side=tk.LEFT) + reproj_threshold_label = ttk.Label(self.common_frame, text="Reproj px", width=9) + reproj_threshold_label.pack(side=tk.LEFT, padx=(12, 0)) + self.reprojection_threshold_var = tk.StringVar( + value=reprojection_threshold_display_value(DEFAULT_REPROJECTION_THRESHOLD_PX) + ) + reproj_threshold_box = ttk.Combobox( + self.common_frame, + textvariable=self.reprojection_threshold_var, + values=list(REPROJECTION_THRESHOLD_DISPLAY_VALUES), + width=7, + state="readonly", + ) + reproj_threshold_box.pack(side=tk.LEFT, padx=(4, 0)) self.triang_frame = ttk.Frame(form) triang_label = ttk.Label(self.triang_frame, text="Triangulation", width=12) @@ -6046,25 +9690,37 @@ def __init__(self, master, state: SharedAppState): predictor_label.pack(side=tk.LEFT) self.predictor = tk.StringVar(value="acc") predictor_box = ttk.Combobox( - self.ekf2d_frame, textvariable=self.predictor, values=["acc", "dyn"], width=8, state="readonly" + self.ekf2d_frame, + textvariable=self.predictor, + values=["acc", "dyn", "history3", "dyn_history3"], + width=12, + state="readonly", ) predictor_box.pack(side=tk.LEFT, padx=(0, 8)) self.measurement_noise = LabeledEntry( self.ekf2d_frame, "EKF2D meas", "1.5", - label_width=10, - entry_width=4, + label_width=8, + entry_width=3, ) self.measurement_noise.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6)) self.process_noise = LabeledEntry( self.ekf2d_frame, "EKF2D proc", "1.0", - label_width=10, - entry_width=4, + label_width=8, + entry_width=3, + ) + self.process_noise.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6)) + self.upper_back_pseudo_std_deg = LabeledEntry( + self.ekf2d_frame, + "3D pseudo-obs DOF", + "10", + label_width=15, + entry_width=3, ) - self.process_noise.pack(side=tk.LEFT, fill=tk.X, expand=True) + self.upper_back_pseudo_std_deg.pack(side=tk.LEFT, fill=tk.X, expand=True) self.ekf2d_observation_frame = ttk.Frame(form) ekf2d_flip_method_label = ttk.Label(self.ekf2d_observation_frame, text="Flip left/right method", width=20) @@ -6122,9 +9778,32 @@ def __init__(self, master, state: SharedAppState): entry_width=4, ) self.ekf2d_bootstrap_passes.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6)) + self.upper_back_sagittal_gain = LabeledEntry( + self.ekf2d_params_frame, + "Back gain", + "0.2", + label_width=8, + entry_width=4, + ) + self.upper_back_sagittal_gain.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6)) + self.ankle_bed_pseudo_obs_var = tk.BooleanVar(value=False) + self.ankle_bed_pseudo_obs_check = ttk.Checkbutton( + self.ekf2d_params_frame, + text="3D pseudo-obs (ankle-bed)", + variable=self.ankle_bed_pseudo_obs_var, + ) + self.ankle_bed_pseudo_obs_check.pack(side=tk.LEFT, padx=(0, 6)) + self.ankle_bed_pseudo_std_m = LabeledEntry( + self.ekf2d_params_frame, + "Std m", + "0.02", + label_width=6, + entry_width=5, + ) + self.ankle_bed_pseudo_std_m.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6)) self.lock_var = tk.BooleanVar(value=False) - lock_check = ttk.Checkbutton(self.ekf2d_params_frame, text="dof_locking", variable=self.lock_var) - lock_check.pack(side=tk.LEFT, padx=(0, 8)) + self.ekf2d_lock_check = ttk.Checkbutton(self.ekf2d_params_frame, text="dof_locking", variable=self.lock_var) + self.ekf2d_lock_check.pack(side=tk.LEFT, padx=(0, 8)) self.ekf2d_initial_frame_info = ttk.Label( self.ekf2d_params_frame, text="q0 support: first valid frame only", @@ -6150,9 +9829,21 @@ def __init__(self, master, state: SharedAppState): state="readonly", ) ekf3d_init_box.pack(side=tk.LEFT, padx=(0, 8)) - self.biorbd_noise = LabeledEntry(self.ekf3d_frame, "EKF3D noise", "1e-8") + self.biorbd_noise = LabeledEntry( + self.ekf3d_frame, + "EKF3D noise", + "1e-8", + label_width=10, + entry_width=6, + ) self.biorbd_noise.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6)) - self.biorbd_error = LabeledEntry(self.ekf3d_frame, "EKF3D error", "1e-4") + self.biorbd_error = LabeledEntry( + self.ekf3d_frame, + "EKF3D error", + "1e-4", + label_width=10, + entry_width=6, + ) self.biorbd_error.pack(side=tk.LEFT, fill=tk.X, expand=True) self.ekf_model_frame = ttk.Frame(self.profile_source_row) @@ -6171,18 +9862,6 @@ def __init__(self, master, state: SharedAppState): models_body, selectmode="browse", exportselection=False, height=5, width=40 ) self.profile_models_list.pack(fill=tk.BOTH, expand=True) - model_variant_row = ttk.Frame(self.ekf_model_frame) - model_variant_row.pack(fill=tk.X, pady=(4, 0)) - ttk.Label(model_variant_row, text="Model variant", width=16).pack(side=tk.LEFT) - self.profile_model_variant = tk.StringVar(value=DEFAULT_MODEL_VARIANT) - self.profile_model_variant_box = ttk.Combobox( - model_variant_row, - textvariable=self.profile_model_variant, - values=list(SUPPORTED_MODEL_VARIANTS), - width=18, - state="readonly", - ) - self.profile_model_variant_box.pack(side=tk.LEFT, padx=(0, 8)) self.ekf_model_info_var = tk.StringVar(value="used by EKF profiles only") ttk.Label(self.ekf_model_frame, textvariable=self.ekf_model_info_var, foreground="#4f5b66").pack( side=tk.TOP, @@ -6193,7 +9872,9 @@ def __init__(self, master, state: SharedAppState): ) self.ekf_model_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(12, 0)) - self.config_path.set_tooltip("Fichier JSON contenant les profils de reconstruction sauvegardes.") + self.config_path.set_tooltip( + "Fichier JSON contenant les profils de reconstruction sauvegardes. Browse charge le fichier immédiatement; appuyez sur Entrée après une modification manuelle du chemin." + ) self.profile_name.set_tooltip("Nom lisible du profil de reconstruction.") attach_tooltip(family_label, "Famille d'algorithme a configurer dans le profil.") attach_tooltip(family_box, "Famille d'algorithme a configurer dans le profil.") @@ -6219,12 +9900,19 @@ def __init__(self, master, state: SharedAppState): initial_rot_check, "Active l'alignement horizontal de la racine autour de Z avant generation du modele ou extraction geometrique.", ) - attach_tooltip(unwrap_check, "Desactive l'unwrap temporel de la racine dans les reconstructions geometriques.") attach_tooltip(triang_label, "Choisit la variante de triangulation 3D: once, greedy, ou exhaustive.") attach_tooltip( triang_box, "once: une seule DLT pondérée. greedy: suppression gloutonne. exhaustive: la plus robuste mais plus coûteuse.", ) + attach_tooltip( + reproj_threshold_label, + "Seuil de rejet par erreur de reprojection pour la triangulation. 'none' désactive ce rejet final en mode once.", + ) + attach_tooltip( + reproj_threshold_box, + "Seuil de rejet par erreur de reprojection pour la triangulation. 'none' désactive ce rejet final en mode once.", + ) attach_tooltip( flip_method_label, "Technique de flip L/R. Choisissez 'None' pour désactiver la correction. ekf_prediction_gate agit dans l'update EKF 2D; les variantes triangulation_* coûtent nettement plus cher que les variantes épipolaires.", @@ -6243,7 +9931,7 @@ def __init__(self, master, state: SharedAppState): self.coherence_box, "Epipolar (precomputed): cohérence Sampson précalculée sur la séquence. Epipolar fast (precomputed): distance symétrique précalculée. Epipolar (framewise): Sampson recalculé à chaque frame. Epipolar fast (framewise): distance symétrique recalculée à chaque frame.", ) - attach_tooltip(lock_check, "Verrouille certains DoF pour stabiliser l'EKF 2D.") + attach_tooltip(self.ekf2d_lock_check, "Verrouille certains DoF pour stabiliser l'EKF 2D.") attach_tooltip( q0_method_label, "Methode pour trouver q0: IK 3D sur la triangulation, bootstrap EKF classique, ou bootstrap depuis une pose racine geometrique extraite des hanches/epaules.", @@ -6255,6 +9943,19 @@ def __init__(self, master, state: SharedAppState): self.ekf2d_bootstrap_passes.set_tooltip( "Nombre de passes EKF 2D utilisées pour affiner q0 sur la première frame valide quand le bootstrap est actif." ) + self.upper_back_sagittal_gain.set_tooltip( + "Fraction de la flexion moyenne des hanches utilisée comme cible douce pour le DoF sagittal du dos (UPPER_BACK:RotY ou LOWER_TRUNK:RotY selon le modèle)." + ) + self.upper_back_pseudo_std_deg.set_tooltip( + "Ecart-type angulaire (en degrés) de la pseudo-observation du dos. Plus petit = contrainte plus forte." + ) + attach_tooltip( + self.ankle_bed_pseudo_obs_check, + "Ajoute une pseudo-observation 3D sur X/Z des chevilles pendant les phases toile (hors phase aérienne) pour aider l'EKF 2D.", + ) + self.ankle_bed_pseudo_std_m.set_tooltip( + "Ecart-type en mètres de la pseudo-observation ankle-bed. Plus petit = chevilles davantage maintenues sur la trajectoire 3D de support." + ) self.measurement_noise.set_tooltip( "Bruit de mesure de l'EKF 2D. Plus grand = moins de confiance dans les keypoints 2D." ) @@ -6276,11 +9977,7 @@ def __init__(self, master, state: SharedAppState): ) attach_tooltip( self.profile_models_list, - "Choisit un bioMod existant pour EKF 2D/3D. 'auto' reconstruit le modèle à partir des données 2D; choisir un modèle existant évite cette étape et réduit le temps de calcul.", - ) - attach_tooltip( - self.profile_model_variant_box, - "Variante du modèle utilisée seulement si le profil reconstruit son bioMod au lieu d'en réutiliser un existant.", + "Choisit un bioMod existant pour EKF 2D/3D. 'auto' reconstruit le modèle à partir des données 2D; choisir un modèle existant évite cette étape et réduit le temps de calcul.", ) actions = ttk.Frame(form) actions.pack(fill=tk.X, padx=8, pady=6) @@ -6293,10 +9990,9 @@ def __init__(self, master, state: SharedAppState): ttk.Button(actions, text="Generate all supported", command=self.generate_all_supported).pack( side=tk.LEFT, padx=(8, 0) ) - ttk.Button(actions, text="Load JSON", command=self.load_profiles_from_json).pack(side=tk.LEFT, padx=(8, 0)) ttk.Button(actions, text="Save JSON", command=self.save_profiles_to_json).pack(side=tk.LEFT, padx=(8, 0)) - cols = ("enabled", "name", "family", "mode", "triang", "flags") + cols = ("enabled", "name", "family", "mode", "triang", "flip", "flags") self.profile_tree = ttk.Treeview(form, columns=cols, show="headings", height=8, selectmode="extended") headings = { "enabled": "Use", @@ -6304,28 +10000,35 @@ def __init__(self, master, state: SharedAppState): "family": "Family", "mode": "2D mode", "triang": "Triang", + "flip": "Flip", "flags": "Flags", } - widths = {"enabled": 50, "name": 240, "family": 90, "mode": 90, "triang": 100, "flags": 320} + widths = {"enabled": 50, "name": 240, "family": 90, "mode": 90, "triang": 100, "flip": 150, "flags": 260} for col in cols: self.profile_tree.heading(col, text=headings[col]) self.profile_tree.column(col, width=widths[col], anchor="w") self.profile_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=4) + bind_extended_treeview_shortcuts(self.profile_tree) self.profile_tree.bind("", lambda _event: self.remove_selected_profiles()) self.profile_tree.bind("", lambda _event: self.remove_selected_profiles()) + self.profile_tree.bind("", self.load_selected_profile_from_tree) self.family.trace_add("write", lambda *_args: self.update_family_controls()) self.family.trace_add("write", lambda *_args: self.sync_profile_name()) self.family.trace_add("write", lambda *_args: self.refresh_profile_model_choices()) self.pose_data_mode.trace_add("write", lambda *_args: self.sync_profile_name()) self.frame_stride.trace_add("write", lambda *_args: self.sync_profile_name()) - self.triang_method.trace_add("write", lambda *_args: self.sync_profile_name()) + self.reprojection_threshold_var.trace_add("write", lambda *_args: self.sync_profile_name()) + self.triang_method.trace_add("write", lambda *_args: self.on_profile_triangulation_method_changed()) self.coherence_method.trace_add("write", lambda *_args: self.sync_profile_name()) self.predictor.trace_add("write", lambda *_args: self.sync_profile_name()) - self.profile_model_variant.trace_add("write", lambda *_args: self.sync_profile_name()) self.ekf2d_initial_state_method.trace_add("write", lambda *_args: self.sync_profile_name()) self.biorbd_kalman_init_method.trace_add("write", lambda *_args: self.sync_profile_name()) self.ekf2d_bootstrap_passes.var.trace_add("write", lambda *_args: self.sync_profile_name()) + self.upper_back_sagittal_gain.var.trace_add("write", lambda *_args: self.sync_profile_name()) + self.upper_back_pseudo_std_deg.var.trace_add("write", lambda *_args: self.sync_profile_name()) + self.ankle_bed_pseudo_obs_var.trace_add("write", lambda *_args: self.sync_profile_name()) + self.ankle_bed_pseudo_std_m.var.trace_add("write", lambda *_args: self.sync_profile_name()) self.flip_method.trace_add("write", lambda *_args: self.sync_profile_name()) self.lock_var.trace_add("write", lambda *_args: self.sync_profile_name()) self.initial_rot_var.trace_add("write", lambda *_args: self.sync_profile_name()) @@ -6357,6 +10060,11 @@ def on_flip_method_changed(self) -> None: self.flip_method_label_var.set(flip_method_display_name(self.flip_method.get())) + def on_profile_triangulation_method_changed(self) -> None: + if self.triang_method.get() != "once" and self.reprojection_threshold_var.get().strip().lower() == "none": + self.reprojection_threshold_var.set(reprojection_threshold_display_value(DEFAULT_REPROJECTION_THRESHOLD_PX)) + self.sync_profile_name() + def selected_profile_flip_method(self) -> str | None: """Return the selected flip method or ``None`` when flip correction is disabled.""" @@ -6389,6 +10097,7 @@ def update_family_controls(self) -> None: self.ekf2d_params_frame.pack(fill=tk.X, padx=8, pady=4) if family == "ekf_3d": self.ekf3d_frame.pack(fill=tk.X, padx=8, pady=4) + self.update_upper_back_option_visibility() self.update_profile_model_info() self.update_add_profile_button_state() @@ -6399,6 +10108,13 @@ def selected_profile_camera_names(self) -> list[str] | None: camera_names = [str(self.profile_cameras_list.get(index)) for index in indices] return camera_names or None + def profile_uses_all_cameras(self) -> bool: + selected = self.selected_profile_camera_names() or [] + if not hasattr(self, "profile_cameras_list"): + return False + available = self.profile_cameras_list.size() + return available > 0 and len(selected) == available + def _set_profile_camera_selection(self, camera_names: list[str] | None) -> None: requested = set(camera_names or []) self.profile_cameras_list.selection_clear(0, tk.END) @@ -6461,6 +10177,7 @@ def refresh_profile_model_choices(self) -> None: target_value = selected_value if selected_value in self._profile_model_choices else fallback_value self._set_profile_model_selection_by_label(target_value) self.update_profile_model_summary() + self.update_upper_back_option_visibility() self.update_profile_model_info() self.update_add_profile_button_state() self.sync_profile_name() @@ -6487,6 +10204,26 @@ def _set_profile_model_selection_by_label(self, label: str | None) -> None: self.profile_models_list.see(index) break + def _set_profile_model_selection_by_path(self, model_path: str | None) -> None: + if model_path is None: + self._set_profile_model_selection_by_label(None) + return + requested = str(model_path) + requested_resolved = str(Path(requested).resolve()) + for label, value in self._profile_model_choices.items(): + if value is None: + continue + if str(value) == requested: + self._set_profile_model_selection_by_label(label) + return + try: + if str(Path(str(value)).resolve()) == requested_resolved: + self._set_profile_model_selection_by_label(label) + return + except Exception: + continue + self._set_profile_model_selection_by_label(None) + def update_profile_model_summary(self) -> None: selected_label = self.selected_profile_model_label() if selected_label is None: @@ -6508,18 +10245,48 @@ def update_profile_model_info(self) -> None: return selected_model = self.selected_profile_model_path() if selected_model: - self.ekf_model_info_var.set("reuse existing model (faster)") + variant = infer_model_variant_from_biomod(selected_model) + self.ekf_model_info_var.set(f"reuse existing {variant} model (faster)") else: - model_variant = ( - self.profile_model_variant.get() if hasattr(self, "profile_model_variant") else DEFAULT_MODEL_VARIANT - ) - if model_variant == DEFAULT_MODEL_VARIANT: - self.ekf_model_info_var.set("auto-build model from current 2D data (slower)") - else: - self.ekf_model_info_var.set(f"auto-build {model_variant} model from current 2D data") + self.ekf_model_info_var.set("auto-build single_trunk model from current 2D data (slower)") + + def selected_profile_model_variant(self) -> str: + return infer_model_variant_from_biomod(self.selected_profile_model_path()) + + def update_upper_back_option_visibility(self) -> None: + if not hasattr(self, "upper_back_pseudo_std_deg") or not hasattr(self, "upper_back_sagittal_gain"): + return + if not hasattr(self, "profile_models_list"): + return + supports_upper_back = self.family.get() == "ekf_2d" and biomod_supports_upper_back_options( + self.selected_profile_model_path() + ) + upper_back_widgets = [ + (self.upper_back_pseudo_std_deg, {"side": tk.LEFT, "fill": tk.X, "expand": True}), + ( + self.upper_back_sagittal_gain, + { + "side": tk.LEFT, + "fill": tk.X, + "expand": True, + "padx": (0, 6), + "before": getattr(self, "ekf2d_lock_check", None), + }, + ), + ] + for widget, pack_kwargs in upper_back_widgets: + if not hasattr(widget, "pack_info") or not hasattr(widget, "pack_forget"): + continue + visible = bool(widget.winfo_manager()) + if supports_upper_back and not visible: + pack_kwargs = {key: value for key, value in pack_kwargs.items() if value is not None} + widget.pack(**pack_kwargs) + elif not supports_upper_back and visible: + widget.pack_forget() def on_profile_model_changed(self) -> None: self.update_profile_model_summary() + self.update_upper_back_option_visibility() self.update_profile_model_info() self.update_add_profile_button_state() self.sync_profile_name() @@ -6563,12 +10330,13 @@ def current_profile(self, *, include_name: bool = True) -> ReconstructionProfile if family == "ekf_2d" and not selected_model_path: raise ValueError("EKF 2D requires selecting an existing bioMod.") model_variant = ( - self.profile_model_variant.get() if hasattr(self, "profile_model_variant") else DEFAULT_MODEL_VARIANT + infer_model_variant_from_biomod(selected_model_path) if selected_model_path else DEFAULT_MODEL_VARIANT ) profile = ReconstructionProfile( name=self.profile_name.get() if include_name else "", family=family, - camera_names=self.selected_profile_camera_names(), + camera_names=None if self.profile_uses_all_cameras() else self.selected_profile_camera_names(), + use_all_cameras=self.profile_uses_all_cameras(), ekf_model_path=selected_model_path, model_variant=model_variant if family in ("ekf_2d", "ekf_3d") else DEFAULT_MODEL_VARIANT, predictor=self.predictor.get() if family == "ekf_2d" else None, @@ -6598,10 +10366,16 @@ def current_profile(self, *, include_name: bool = True) -> ReconstructionProfile triangulation_method=( self.triang_method.get() if family in ("triangulation", "ekf_3d", "ekf_2d") else "exhaustive" ), + reprojection_threshold_px=( + reprojection_threshold_from_display_value(self.reprojection_threshold_var.get()) + if family in ("triangulation", "ekf_3d", "ekf_2d") + else DEFAULT_REPROJECTION_THRESHOLD_PX + ), coherence_method=( coherence_method_from_display_name(self.coherence_method.get()) if family == "ekf_2d" else "epipolar" ), - no_root_unwrap=self.unwrap_var.get(), + no_root_unwrap=True, + root_unwrap_mode="off", biorbd_kalman_noise_factor=float(self.biorbd_noise.get()), biorbd_kalman_error_factor=float(self.biorbd_error.get()), biorbd_kalman_init_method=( @@ -6610,6 +10384,18 @@ def current_profile(self, *, include_name: bool = True) -> ReconstructionProfile measurement_noise_scale=float(self.measurement_noise.get()), process_noise_scale=float(self.process_noise.get()), coherence_confidence_floor=float(self.coherence_floor.get()), + upper_back_sagittal_gain=( + float(self.upper_back_sagittal_gain.get()) if hasattr(self, "upper_back_sagittal_gain") else 0.2 + ), + upper_back_pseudo_std_deg=( + float(self.upper_back_pseudo_std_deg.get()) if hasattr(self, "upper_back_pseudo_std_deg") else 10.0 + ), + ankle_bed_pseudo_obs=( + bool(self.ankle_bed_pseudo_obs_var.get()) if hasattr(self, "ankle_bed_pseudo_obs_var") else False + ), + ankle_bed_pseudo_std_m=( + float(self.ankle_bed_pseudo_std_m.get()) if hasattr(self, "ankle_bed_pseudo_std_m") else 0.02 + ), pose_filter_window=int(self.state.pose_filter_window_var.get()), pose_outlier_threshold_ratio=float(self.state.pose_outlier_ratio_var.get()), pose_amplitude_lower_percentile=float(self.state.pose_p_low_var.get()), @@ -6645,6 +10431,15 @@ def refresh_profile_tree(self) -> None: flags.append("rootq0") elif int(getattr(profile, "ekf2d_bootstrap_passes", 5)) != 5: flags.append(f"boot{int(getattr(profile, 'ekf2d_bootstrap_passes', 5))}") + if abs(float(getattr(profile, "upper_back_sagittal_gain", 0.2)) - 0.2) > 1e-9: + flags.append(f"ubg:{float(getattr(profile, 'upper_back_sagittal_gain', 0.2)):.2f}") + if bool(getattr(profile, "ankle_bed_pseudo_obs", False)): + flags.append("ankbed") + reprojection_threshold_px = getattr(profile, "reprojection_threshold_px", DEFAULT_REPROJECTION_THRESHOLD_PX) + if reprojection_threshold_px is None: + flags.append("tau:none") + elif abs(float(reprojection_threshold_px) - float(DEFAULT_REPROJECTION_THRESHOLD_PX)) > 1e-9: + flags.append(f"tau:{float(reprojection_threshold_px):g}") if profile.family == "ekf_3d": init_method = getattr(profile, "biorbd_kalman_init_method", "triangulation_ik_root_translation") if init_method == "triangulation_ik": @@ -6665,9 +10460,9 @@ def refresh_profile_tree(self) -> None: flags.append("flip") if profile.dof_locking: flags.append("lock") - if profile.no_root_unwrap: - flags.append("no_unwrap") - if getattr(profile, "camera_names", None): + if getattr(profile, "use_all_cameras", False): + flags.append("cams[all]") + elif getattr(profile, "camera_names", None): flags.append(f"cams[{format_camera_names(profile.camera_names)}]") if int(getattr(profile, "frame_stride", 1)) != 1: flags.append(f"1/{int(getattr(profile, 'frame_stride', 1))}") @@ -6677,6 +10472,9 @@ def refresh_profile_tree(self) -> None: triang_value = ( profile.triangulation_method if profile.family in ("triangulation", "ekf_3d", "ekf_2d") else "-" ) + flip_value = ( + flip_method_display_name(getattr(profile, "flip_method", "epipolar")) if profile.flip else "None" + ) self.profile_tree.insert( "", "end", @@ -6687,10 +10485,109 @@ def refresh_profile_tree(self) -> None: profile.family, mode_value, triang_value, + flip_value, ",".join(flags), ), ) + def load_selected_profile_from_tree(self, event=None) -> str | None: + row_id = None + if event is not None: + try: + row_id = self.profile_tree.identify_row(event.y) + except Exception: + row_id = None + selected = self.profile_tree.selection() + if row_id: + try: + self.profile_tree.selection_set((row_id,)) + self.profile_tree.focus(row_id) + except Exception: + pass + elif selected: + row_id = selected[0] + if not row_id: + return None + try: + profile = self.state.profiles[int(row_id)] + except Exception: + return None + self.load_profile_into_form(profile) + return "break" + + def load_profile_into_form(self, profile: ReconstructionProfile) -> None: + self._updating_profile_name = True + try: + self.family.set(profile.family) + self.refresh_profile_camera_choices() + self.refresh_profile_model_choices() + + self.pose_data_mode.set(profile.pose_data_mode) + self.frame_stride.set(str(int(getattr(profile, "frame_stride", 1)))) + self.triang_method.set(getattr(profile, "triangulation_method", "exhaustive")) + self.reprojection_threshold_var.set( + reprojection_threshold_display_value( + getattr(profile, "reprojection_threshold_px", DEFAULT_REPROJECTION_THRESHOLD_PX) + ) + ) + self.predictor.set(getattr(profile, "predictor", "acc") or "acc") + self.coherence_method.set(coherence_method_display_name(getattr(profile, "coherence_method", "epipolar"))) + self.ekf2d_initial_state_method.set(getattr(profile, "ekf2d_initial_state_method", "ekf_bootstrap")) + self.ekf2d_bootstrap_passes.var.set(str(int(getattr(profile, "ekf2d_bootstrap_passes", 5)))) + self.upper_back_sagittal_gain.var.set(f"{float(getattr(profile, 'upper_back_sagittal_gain', 0.2)):g}") + self.upper_back_pseudo_std_deg.var.set(f"{float(getattr(profile, 'upper_back_pseudo_std_deg', 10.0)):g}") + self.ankle_bed_pseudo_obs_var.set(bool(getattr(profile, "ankle_bed_pseudo_obs", False))) + self.ankle_bed_pseudo_std_m.var.set(f"{float(getattr(profile, 'ankle_bed_pseudo_std_m', 0.02)):g}") + self.flip_method.set(getattr(profile, "flip_method", "epipolar") if profile.flip else "none") + self.on_flip_method_changed() + self.lock_var.set(bool(getattr(profile, "dof_locking", False))) + self.initial_rot_var.set(bool(getattr(profile, "initial_rotation_correction", False))) + self.state.pose_filter_window_var.set(str(int(getattr(profile, "pose_filter_window", 9)))) + self.state.pose_outlier_ratio_var.set(f"{float(getattr(profile, 'pose_outlier_threshold_ratio', 0.10)):g}") + self.state.pose_p_low_var.set(f"{float(getattr(profile, 'pose_amplitude_lower_percentile', 5.0)):g}") + self.state.pose_p_high_var.set(f"{float(getattr(profile, 'pose_amplitude_upper_percentile', 95.0)):g}") + self.state.flip_improvement_ratio_var.set(f"{float(getattr(profile, 'flip_improvement_ratio', 0.7)):g}") + self.state.flip_min_gain_px_var.set(f"{float(getattr(profile, 'flip_min_gain_px', 3.0)):g}") + self.state.flip_min_other_cameras_var.set(str(int(getattr(profile, "flip_min_other_cameras", 2)))) + self.state.flip_restrict_to_outliers_var.set(bool(getattr(profile, "flip_restrict_to_outliers", True))) + self.state.flip_outlier_percentile_var.set(f"{float(getattr(profile, 'flip_outlier_percentile', 85.0)):g}") + self.state.flip_outlier_floor_px_var.set(f"{float(getattr(profile, 'flip_outlier_floor_px', 5.0)):g}") + self.state.flip_temporal_weight_var.set(f"{float(getattr(profile, 'flip_temporal_weight', 0.35)):g}") + self.state.flip_temporal_tau_px_var.set(f"{float(getattr(profile, 'flip_temporal_tau_px', 20.0)):g}") + self.biorbd_noise.var.set(f"{float(getattr(profile, 'biorbd_kalman_noise_factor', 1e-8)):g}") + self.biorbd_error.var.set(f"{float(getattr(profile, 'biorbd_kalman_error_factor', 1e-4)):g}") + self.biorbd_kalman_init_method.set( + getattr(profile, "biorbd_kalman_init_method", "triangulation_ik_root_translation") + ) + self.measurement_noise.var.set(f"{float(getattr(profile, 'measurement_noise_scale', 1.5)):g}") + self.process_noise.var.set(f"{float(getattr(profile, 'process_noise_scale', 1.0)):g}") + self.coherence_floor.var.set(f"{float(getattr(profile, 'coherence_confidence_floor', 0.35)):g}") + + if getattr(profile, "use_all_cameras", False) or getattr(profile, "camera_names", None) is None: + self._set_profile_camera_selection( + [str(self.profile_cameras_list.get(i)) for i in range(self.profile_cameras_list.size())] + ) + else: + self._set_profile_camera_selection(list(profile.camera_names or [])) + + selected_model_path = getattr(profile, "ekf_model_path", None) + if selected_model_path: + self._set_profile_model_selection_by_path(str(selected_model_path)) + elif profile.family != "ekf_2d": + self._set_profile_model_selection_by_label("auto") + else: + self._set_profile_model_selection_by_label(None) + finally: + self._updating_profile_name = False + + self.profile_name.var.set(profile.name) + self.update_family_controls() + self.update_profile_camera_summary() + self.update_profile_model_summary() + self.update_upper_back_option_visibility() + self.update_profile_model_info() + self.update_add_profile_button_state() + def add_current_profile(self) -> None: try: profile = self.current_profile() @@ -6700,7 +10597,7 @@ def add_current_profile(self) -> None: profiles = [existing for existing in self.state.profiles if existing.name != profile.name] profiles.append(profile) profiles.sort(key=lambda item: item.name) - self.state.set_profiles(profiles) + self.state.set_profiles(profiles, mark_dirty=True) def remove_selected_profiles(self) -> None: selected = self.profile_tree.selection() @@ -6711,27 +10608,32 @@ def remove_selected_profiles(self) -> None: for idx in indices: if 0 <= idx < len(profiles): del profiles[idx] - self.state.set_profiles(profiles) + self.state.set_profiles(profiles, mark_dirty=True) def generate_examples(self) -> None: - self.state.set_profiles(example_profiles()) + self.state.set_profiles(example_profiles(), mark_dirty=True) synchronize_profiles_initial_rotation_correction(self.state) def generate_all_supported(self) -> None: - self.state.set_profiles(generate_supported_profiles()) + self.state.set_profiles(generate_supported_profiles(), mark_dirty=True) synchronize_profiles_initial_rotation_correction(self.state) def load_profiles_from_json(self) -> None: try: - self.state.set_profiles(load_profiles_json(ROOT / self.config_path.get())) + self.state.set_profiles(load_profiles_json(ROOT / self.config_path.get()), mark_dirty=False) synchronize_profiles_initial_rotation_correction(self.state) + self.state.clear_profiles_dirty() except Exception as exc: messagebox.showerror("Profiles", str(exc)) + def _on_profiles_path_browsed(self, _path: str) -> None: + self.load_profiles_from_json() + def save_profiles_to_json(self) -> None: try: synchronize_profiles_initial_rotation_correction(self.state) save_profiles_json(ROOT / self.config_path.get(), self.state.profiles) + self.state.clear_profiles_dirty() except Exception as exc: messagebox.showerror("Profiles", str(exc)) @@ -6743,6 +10645,7 @@ def selected_profiles(self) -> list[ReconstructionProfile]: return [self.state.profiles[int(item)] for item in selected] def build_command(self) -> list[str]: + selected_profiles = self.selected_profiles() runtime_config_path = write_runtime_profiles_config(self.state) cmd = [ sys.executable, @@ -6767,7 +10670,232 @@ def build_command(self) -> list[str]: cmd.extend(["--camera-names", ",".join(selected_cameras)]) if self.state.pose2sim_trc_var.get().strip(): cmd.extend(["--trc-file", self.state.pose2sim_trc_var.get()]) - for profile in self.selected_profiles(): + annotation_var = getattr(self.state, "annotation_path_var", None) + annotations_path = "" if annotation_var is None else str(annotation_var.get()).strip() + if annotations_path and any(profile.pose_data_mode == "annotated" for profile in selected_profiles): + cmd.extend(["--annotations-path", annotations_path]) + for profile in selected_profiles: + cmd.extend(["--profile", profile.name]) + return cmd + + def on_command_success(self) -> None: + self.state.notify_reconstructions_updated() + + +class BatchTab(CommandTab): + def __init__(self, master, state: SharedAppState): + super().__init__(master, "Batch", show_command_preview=False, show_output=True) + self.state = state + self.batch_profiles: list[ReconstructionProfile] = [] + + form = ttk.LabelFrame(self.main, text="Batch reconstruction runner") + form.pack(fill=tk.BOTH, expand=False, pady=(0, 8), before=self.output) + + paths_box = ttk.Frame(form) + paths_box.pack(fill=tk.X, padx=8, pady=(8, 4)) + + self.keypoints_glob_entry = LabeledEntry( + paths_box, + "Source keypoints", + DEFAULT_KEYPOINTS_GLOB, + label_width=18, + entry_width=64, + ) + self.keypoints_glob_entry.pack(fill=tk.X, pady=(0, 4)) + self.keypoints_glob_entry.set_tooltip("Glob or explicit path for source keypoints JSON files.") + + self.config_path = LabeledEntry( + paths_box, + "Profiles JSON", + state.profiles_config_var.get(), + browse=True, + label_width=18, + entry_width=64, + filetypes=(("Profiles JSON", "*_profiles.json"), ("JSON files", "*.json"), ("All files", "*.*")), + on_browse_selected=lambda _value: self.load_profiles_from_config(), + ) + self.config_path.pack(fill=tk.X, pady=(0, 4)) + + self.excel_output_entry = LabeledEntry( + paths_box, + "Excel summary", + str(DEFAULT_EXCEL_OUTPUT), + browse=True, + label_width=18, + entry_width=64, + filetypes=(("Excel workbooks", "*.xlsx"), ("All files", "*.*")), + ) + self.excel_output_entry.pack(fill=tk.X, pady=(0, 4)) + + batch_row = ttk.Frame(paths_box) + batch_row.pack(fill=tk.X, pady=(0, 4)) + ttk.Label(batch_row, text="Batch name", width=18).pack(side=tk.LEFT, padx=(0, 6)) + self.batch_name_entry = ttk.Entry(batch_row, width=32) + self.batch_name_entry.pack(side=tk.LEFT) + self.continue_on_error_var = tk.BooleanVar(value=True) + ttk.Checkbutton(batch_row, text="Continue on error", variable=self.continue_on_error_var).pack( + side=tk.LEFT, padx=(12, 0) + ) + self.export_only_var = tk.BooleanVar(value=False) + ttk.Checkbutton(batch_row, text="Export only", variable=self.export_only_var).pack(side=tk.LEFT, padx=(12, 0)) + + controls = ttk.Frame(form) + controls.pack(fill=tk.X, padx=8, pady=(0, 4)) + ttk.Button(controls, text="Scan keypoints", command=self.scan_keypoints_files).pack(side=tk.LEFT) + ttk.Button(controls, text="Reload profiles", command=self.load_profiles_from_config).pack( + side=tk.LEFT, padx=(8, 0) + ) + + body = ttk.Panedwindow(form, orient=tk.HORIZONTAL) + body.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8)) + + keypoints_box = ttk.LabelFrame(body, text="Datasets") + profiles_box = ttk.LabelFrame(body, text="Profiles") + body.add(keypoints_box, weight=3) + body.add(profiles_box, weight=2) + + self.keypoints_tree = ttk.Treeview( + keypoints_box, + columns=("dataset", "keypoints", "annotations", "trc"), + show="headings", + height=8, + selectmode="extended", + ) + for col, label, width in [ + ("dataset", "Dataset", 130), + ("keypoints", "Keypoints", 280), + ("annotations", "Annotations", 180), + ("trc", "TRC", 150), + ]: + self.keypoints_tree.heading(col, text=label) + self.keypoints_tree.column(col, width=width, anchor="w") + self.keypoints_tree.pack(fill=tk.BOTH, expand=True, padx=6, pady=6) + bind_extended_treeview_shortcuts(self.keypoints_tree) + + self.batch_profile_tree = ttk.Treeview( + profiles_box, + columns=("name", "family", "mode"), + show="headings", + height=8, + selectmode="extended", + ) + for col, label, width in [ + ("name", "Name", 220), + ("family", "Family", 90), + ("mode", "2D mode", 90), + ]: + self.batch_profile_tree.heading(col, text=label) + self.batch_profile_tree.column(col, width=width, anchor="w") + self.batch_profile_tree.pack(fill=tk.BOTH, expand=True, padx=6, pady=6) + bind_extended_treeview_shortcuts(self.batch_profile_tree) + + attach_tooltip(self.keypoints_tree, "Select one or more datasets for the batch run. Empty selection means all.") + attach_tooltip(self.batch_profile_tree, "Select one or more profiles. Empty selection means all profiles.") + + self.hide_preview_copy_buttons() + self.config_path.entry_widget.bind("", lambda _event: self.load_profiles_from_config()) + self.keypoints_glob_entry.entry_widget.bind("", lambda _event: self.scan_keypoints_files()) + self.load_profiles_from_config() + self.scan_keypoints_files() + + def load_profiles_from_config(self) -> None: + raw_path = self.config_path.get() + config_path = Path(raw_path) + if not config_path.is_absolute(): + config_path = ROOT / config_path + profiles: list[ReconstructionProfile] = [] + if config_path.exists(): + try: + profiles = load_profiles_json(config_path) + except Exception as exc: + messagebox.showerror("Batch", f"Unable to load profiles:\n{config_path}\n\n{exc}") + profiles = [] + self.batch_profiles = profiles + self.refresh_batch_profile_tree() + + def refresh_batch_profile_tree(self) -> None: + previous = set(self.batch_profile_tree.selection()) + for item in self.batch_profile_tree.get_children(): + self.batch_profile_tree.delete(item) + for idx, profile in enumerate(self.batch_profiles): + iid = str(idx) + self.batch_profile_tree.insert( + "", "end", iid=iid, values=(profile.name, profile.family, profile.pose_data_mode) + ) + if iid in previous: + self.batch_profile_tree.selection_add(iid) + + def scan_keypoints_files(self) -> None: + raw_pattern = self.keypoints_glob_entry.get().strip() + patterns = [part.strip() for part in raw_pattern.split(";") if part.strip()] or [DEFAULT_KEYPOINTS_GLOB] + discovered = batch_discover_keypoints_files(patterns, root=ROOT) + previous = set(self.keypoints_tree.selection()) + for item in self.keypoints_tree.get_children(): + self.keypoints_tree.delete(item) + for keypoints_path in discovered: + dataset_name = infer_dataset_name(keypoints_path=keypoints_path) + iid = str(keypoints_path) + annotations_path = batch_infer_annotations_for_keypoints(keypoints_path) + trc_path = batch_infer_pose2sim_trc_for_keypoints(keypoints_path) + self.keypoints_tree.insert( + "", + "end", + iid=iid, + values=( + dataset_name, + display_path(keypoints_path), + "-" if annotations_path is None else display_path(annotations_path), + "-" if trc_path is None else display_path(trc_path), + ), + ) + if iid in previous: + self.keypoints_tree.selection_add(iid) + + def selected_batch_keypoints_paths(self) -> list[Path]: + selected = list(self.keypoints_tree.selection()) + if selected: + return [Path(item) for item in selected] + return [Path(item) for item in self.keypoints_tree.get_children("")] + + def selected_batch_profiles(self) -> list[ReconstructionProfile]: + selected = list(self.batch_profile_tree.selection()) + if selected: + return [self.batch_profiles[int(item)] for item in selected] + return list(self.batch_profiles) + + def build_command(self) -> list[str]: + cmd = [ + sys.executable, + "batch_run.py", + "--config", + self.config_path.get(), + "--output-root", + self.state.output_root_var.get(), + "--calib", + self.state.calib_var.get(), + "--fps", + self.state.fps_var.get(), + "--triangulation-workers", + self.state.workers_var.get(), + "--excel-output", + self.excel_output_entry.get(), + ] + batch_name = self.batch_name_entry.get().strip() + if batch_name: + cmd.extend(["--batch-name", batch_name]) + if self.continue_on_error_var.get(): + cmd.append("--continue-on-error") + if self.export_only_var.get(): + cmd.append("--export-only") + + selected_keypoints = self.selected_batch_keypoints_paths() + if selected_keypoints: + for keypoints_path in selected_keypoints: + cmd.extend(["--keypoints-glob", display_path(keypoints_path)]) + else: + cmd.extend(["--keypoints-glob", self.keypoints_glob_entry.get()]) + + for profile in self.selected_batch_profiles(): cmd.extend(["--profile", profile.name]) return cmd @@ -6777,20 +10905,14 @@ def on_command_success(self) -> None: class ReconstructionsTab(CommandTab): def __init__(self, master, state: SharedAppState): - super().__init__(master, "Reconstructions") + super().__init__(master, "Reconstructions", show_command_preview=False, show_output=False) self.state = state self.status_summaries: dict[str, dict[str, object]] = {} self.uses_shared_reconstruction_panel = True self.shared_reconstruction_selectmode = "browse" form = ttk.LabelFrame(self.main, text="Lancer les reconstructions depuis les profils") - form.pack(fill=tk.X, pady=(0, 8), before=self.output) - - info = ttk.Label( - form, - text="Les options détaillées se règlent dans l'onglet Profiles. Ici on choisit simplement quoi lancer et on inspecte les caches du dataset courant.", - ) - info.pack(fill=tk.X, padx=8, pady=(4, 6)) + form.pack(fill=tk.X, pady=(0, 8)) controls = ttk.Frame(form) controls.pack(fill=tk.X, padx=8, pady=6) @@ -6801,27 +10923,43 @@ def __init__(self, master, state: SharedAppState): ) self.export_trc_button = ttk.Button(controls, text="Export TRC from q", command=self.export_selected_trc_from_q) self.export_trc_button.pack(side=tk.LEFT, padx=(8, 0)) + self.export_pseudo_root_button = ttk.Button( + controls, + text="Export pseudo q root", + command=self.export_selected_pseudo_root_from_points, + ) + self.export_pseudo_root_button.pack(side=tk.LEFT, padx=(8, 0)) self.profile_tree = ttk.Treeview( - form, columns=("name", "family", "mode", "flags"), show="headings", height=6, selectmode="extended" + form, + columns=("name", "family", "mode", "flip", "flags"), + show="headings", + height=6, + selectmode="extended", ) for col, label, width in [ ("name", "Name", 260), ("family", "Family", 90), ("mode", "2D mode", 90), - ("flags", "Flags", 420), + ("flip", "Flip", 150), + ("flags", "Flags", 300), ]: self.profile_tree.heading(col, text=label) self.profile_tree.column(col, width=width, anchor="w") self.profile_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8)) + bind_extended_treeview_shortcuts(self.profile_tree) attach_tooltip(self.profile_tree, "Selectionnez les profils a executer pour le dataset courant.") attach_tooltip( self.export_trc_button, "Reconstruit les marqueurs du modèle depuis q pour la reconstruction sélectionnée et écrit un fichier TRC dans son dossier.", ) + attach_tooltip( + self.export_pseudo_root_button, + "Exporte les pseudo q de la racine obtenus géométriquement depuis les marqueurs du tronc de la reconstruction sélectionnée.", + ) timing_box = ttk.LabelFrame(self.main, text="Durées détaillées de la reconstruction sélectionnée") - timing_box.pack(fill=tk.BOTH, expand=False, pady=(0, 8), before=self.output) + timing_box.pack(fill=tk.BOTH, expand=False, pady=(0, 8)) self.timing_details = ScrolledText(timing_box, height=10, wrap=tk.WORD) self.timing_details.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) self.timing_details.insert("1.0", "Select a reconstruction to inspect timing details.") @@ -6879,8 +11017,14 @@ def refresh_profile_tree(self) -> None: flags.append("rotfix") if int(getattr(profile, "frame_stride", 1)) != 1: flags.append(f"1/{int(getattr(profile, 'frame_stride', 1))}") + flip_value = ( + flip_method_display_name(getattr(profile, "flip_method", "epipolar")) if profile.flip else "None" + ) self.profile_tree.insert( - "", "end", iid=str(idx), values=(profile.name, profile.family, profile.pose_data_mode, ",".join(flags)) + "", + "end", + iid=str(idx), + values=(profile.name, profile.family, profile.pose_data_mode, flip_value, ",".join(flags)), ) def refresh_status_rows(self) -> None: @@ -6980,6 +11124,81 @@ def export_selected_trc_from_q(self) -> None: write_trc_root_kinematics_sidecar(output_path, q_root, qdot_root, frames, time_s) messagebox.showinfo("Export TRC from q", f"TRC written to:\n{output_path}") + def export_selected_pseudo_root_from_points(self) -> None: + """Export root pseudo-q derived geometrically from trunk markers.""" + + recon_dir = self._selected_reconstruction_dir() + if recon_dir is None: + messagebox.showinfo("Export pseudo q root", "Select one reconstruction first.") + return + bundle_path = recon_dir / "reconstruction_bundle.npz" + if not bundle_path.exists(): + messagebox.showerror("Export pseudo q root", f"Bundle not found:\n{bundle_path}") + return + + data = np.load(bundle_path, allow_pickle=True) + if "points_3d" not in data: + messagebox.showinfo( + "Export pseudo q root", + "The selected reconstruction does not contain 3D markers/points.", + ) + return + + points_3d = np.asarray(data["points_3d"], dtype=float) + frames = np.asarray(data["frames"], dtype=int) if "frames" in data else np.arange(points_3d.shape[0], dtype=int) + fps = max(float(self.state.fps_var.get()), 1.0) + time_s = ( + np.asarray(data["time_s"], dtype=float) + if "time_s" in data + else np.arange(points_3d.shape[0], dtype=float) / fps + ) + if time_s.shape[0] > 1 and np.all(np.isfinite(time_s)): + dt = np.diff(time_s) + positive_dt = dt[np.isfinite(dt) & (dt > 0)] + fps = float(1.0 / np.median(positive_dt)) if positive_dt.size else fps + + summary = self.status_summaries.get(recon_dir.name, {}) + initial_rotation_correction = bool( + summary.get( + "initial_rotation_correction_applied", + summary.get("initial_rotation_correction_requested", self.state.initial_rotation_correction_var.get()), + ) + ) + max_interp_gap_frames = max(1, int(round(0.1 * float(fps)))) + trunk_points = np.array(points_3d, copy=True) + trunk_marker_indices = [ + KP_INDEX["left_hip"], + KP_INDEX["right_hip"], + KP_INDEX["left_shoulder"], + KP_INDEX["right_shoulder"], + ] + trunk_marker_values = trunk_points[:, trunk_marker_indices, :].reshape(points_3d.shape[0], -1) + trunk_marker_values = interpolate_short_nan_runs(trunk_marker_values, max_interp_gap_frames) + trunk_marker_values = fill_short_edge_nan_runs(trunk_marker_values, max_interp_gap_frames) + trunk_points[:, trunk_marker_indices, :] = trunk_marker_values.reshape(points_3d.shape[0], 4, 3) + q_root, correction_applied = extract_root_from_points( + trunk_points, + initial_rotation_correction, + False, + ) + qdot_root = centered_finite_difference(q_root, 1.0 / max(fps, 1.0)) + qddot_root = centered_finite_difference(qdot_root, 1.0 / max(fps, 1.0)) + output_path = recon_dir / f"{recon_dir.name}_pseudo_root_q.npz" + np.savez( + output_path, + name=recon_dir.name, + family="pseudo_root", + source="geometric_trunk_markers", + q=q_root, + qdot=qdot_root, + qddot=qddot_root, + q_names=np.asarray(ROOT_Q_NAMES, dtype=object), + frames=frames, + time_s=time_s, + initial_rotation_correction_applied=correction_applied, + ) + messagebox.showinfo("Export pseudo q root", f"Pseudo root q written to:\n{output_path}") + def refresh_timing_details(self) -> None: text = "Select a reconstruction to inspect timing details." selected = list(self.state.shared_reconstruction_selection) @@ -6993,14 +11212,20 @@ def refresh_timing_details(self) -> None: self.timing_details.configure(state=tk.DISABLED) def clear_reconstructions(self) -> None: + selected_names = [str(name) for name in getattr(self.state, "shared_reconstruction_selection", []) if str(name)] + if not selected_names: + messagebox.showinfo("Reconstructions", "Select one or more reconstructions in the top panel first.") + return dataset_dir = current_dataset_dir(self.state) - recon_dirs = reconstruction_dirs_for_path(dataset_dir) + available_dirs = {recon_dir.name: recon_dir for recon_dir in reconstruction_dirs_for_path(dataset_dir)} + recon_dirs = [available_dirs[name] for name in selected_names if name in available_dirs] if not recon_dirs: - messagebox.showinfo("Reconstructions", f"Aucune reconstruction à supprimer dans {dataset_dir}.") + messagebox.showinfo("Reconstructions", "The selected reconstructions are no longer available.") return confirmed = messagebox.askyesno( "Clear reconstructions", - f"Supprimer {len(recon_dirs)} reconstruction(s) du dataset courant ?\n\n{dataset_dir}", + "Supprimer les reconstruction(s) sélectionnée(s) ?\n\n" + + "\n".join(f"- {recon_dir.name}" for recon_dir in recon_dirs), icon="warning", ) if not confirmed: @@ -7011,6 +11236,9 @@ def clear_reconstructions(self) -> None: shutil.rmtree(recon_dir) except Exception as exc: errors.append(f"{recon_dir.name}: {exc}") + self.state.shared_reconstruction_selection = [ + name for name in getattr(self.state, "shared_reconstruction_selection", []) if name not in selected_names + ] self.refresh_status_rows() self.state.notify_reconstructions_updated() if errors: @@ -7026,6 +11254,7 @@ def selected_profiles(self) -> list[ReconstructionProfile]: return [self.state.profiles[int(item)] for item in selected] def build_command(self) -> list[str]: + selected_profiles = self.selected_profiles() runtime_config_path = write_runtime_profiles_config(self.state) cmd = [ sys.executable, @@ -7047,7 +11276,11 @@ def build_command(self) -> list[str]: ] if self.state.pose2sim_trc_var.get().strip(): cmd.extend(["--trc-file", self.state.pose2sim_trc_var.get()]) - for profile in self.selected_profiles(): + annotation_var = getattr(self.state, "annotation_path_var", None) + annotations_path = "" if annotation_var is None else str(annotation_var.get()).strip() + if annotations_path and any(profile.pose_data_mode == "annotated" for profile in selected_profiles): + cmd.extend(["--annotations-path", annotations_path]) + for profile in selected_profiles: cmd.extend(["--profile", profile.name]) return cmd @@ -7095,21 +11328,30 @@ def __init__(self, master, state: SharedAppState): rotation_unit_box = ttk.Combobox( row, textvariable=self.rotation_unit, values=["rad", "deg", "turns"], width=10, state="readonly" ) - rotation_unit_box.pack(side=tk.LEFT) + rotation_unit_box.pack(side=tk.LEFT, padx=(0, 6)) + root_unwrap_label = ttk.Label(row, text="Root unwrap", width=11) + root_unwrap_label.pack(side=tk.LEFT) + self.root_unwrap_mode_var = tk.StringVar(value=root_unwrap_mode_display_name("off")) + root_unwrap_box = ttk.Combobox( + row, + textvariable=self.root_unwrap_mode_var, + values=[root_unwrap_mode_display_name(mode) for mode in ("off", "single", "double")], + width=14, + state="readonly", + ) + root_unwrap_box.pack(side=tk.LEFT) row2 = ttk.Frame(controls) row2.pack(fill=tk.X, padx=8, pady=4) - self.unwrap_var = tk.BooleanVar(value=True) self.reextract_var = tk.BooleanVar(value=True) self.fd_qdot_var = tk.BooleanVar(value=True) self.matrix_ignore_alpha_var = tk.BooleanVar(value=False) self.common_geometric_root_var = tk.BooleanVar(value=False) - unwrap_check = ttk.Checkbutton(row2, text="unwrap rotations", variable=self.unwrap_var) - unwrap_check.pack(side=tk.LEFT) + self.interpolate_root_var = tk.BooleanVar(value=False) reextract_check = ttk.Checkbutton( row2, text="recalcul matrice + re-extraction Euler", variable=self.reextract_var ) - reextract_check.pack(side=tk.LEFT, padx=(12, 0)) + reextract_check.pack(side=tk.LEFT) fd_qdot_check = ttk.Checkbutton(row2, text="qdot par différence finie", variable=self.fd_qdot_var) fd_qdot_check.pack(side=tk.LEFT, padx=(12, 0)) matrix_ignore_alpha_check = ttk.Checkbutton( @@ -7122,6 +11364,8 @@ def __init__(self, master, state: SharedAppState): variable=self.common_geometric_root_var, ) common_geometric_root_check.pack(side=tk.LEFT, padx=(12, 0)) + interpolate_root_check = ttk.Checkbutton(row2, text="Interpolation", variable=self.interpolate_root_var) + interpolate_root_check.pack(side=tk.LEFT, padx=(12, 0)) ttk.Button(row2, text="Load / refresh", command=self.refresh_plot).pack(side=tk.LEFT, padx=(12, 0)) attach_tooltip(family_label, "Choisit si l'on compare les translations ou les rotations de la racine.") @@ -7139,7 +11383,14 @@ def __init__(self, master, state: SharedAppState): attach_tooltip( rotation_unit_box, "Unité d'affichage des trois rotations de racine. Les translations restent en m ou m/s." ) - attach_tooltip(unwrap_check, "Applique un unwrap temporel aux angles de racine pour eviter les sauts a +/-pi.") + attach_tooltip( + root_unwrap_label, + "Stabilise seulement l'affichage des rotations de racine: off, single, ou double unwrap.", + ) + attach_tooltip( + root_unwrap_box, + "Cette option n'affecte pas les reconstructions; elle sert uniquement à l'affichage des rotations de racine.", + ) attach_tooltip( reextract_check, "Recalcule les angles Euler via la matrice de rotation du tronc avant affichage." ) @@ -7154,6 +11405,10 @@ def __init__(self, master, state: SharedAppState): common_geometric_root_check, "Pour les reconstructions basées sur q, reconstruit d'abord les marqueurs du modèle puis ré-extrait la racine géométrique avec la même méthode que triangulation/TRC file.", ) + attach_tooltip( + interpolate_root_check, + "En affichage seulement, interpole les trous courts de NaN jusqu'à environ 0.1 s (0.1 * fps).", + ) plot_box = ttk.LabelFrame(self, text="Comparaison racine") plot_box.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) @@ -7167,11 +11422,12 @@ def __init__(self, master, state: SharedAppState): self.quantity.trace_add("write", lambda *_args: self.refresh_plot()) self.rotation_plot_mode.trace_add("write", lambda *_args: self.refresh_plot()) self.rotation_unit.trace_add("write", lambda *_args: self.refresh_plot()) - self.unwrap_var.trace_add("write", lambda *_args: self.refresh_plot()) + self.root_unwrap_mode_var.trace_add("write", lambda *_args: self.refresh_plot()) self.reextract_var.trace_add("write", lambda *_args: self.refresh_plot()) self.fd_qdot_var.trace_add("write", lambda *_args: self.refresh_plot()) self.matrix_ignore_alpha_var.trace_add("write", lambda *_args: self.refresh_plot()) self.common_geometric_root_var.trace_add("write", lambda *_args: self.refresh_plot()) + self.interpolate_root_var.trace_add("write", lambda *_args: self.refresh_plot()) self.after_idle(self.refresh_available_reconstructions) def configure_shared_reconstruction_panel(self, panel: SharedReconstructionPanel) -> None: @@ -7272,8 +11528,16 @@ def refresh_plot(self) -> None: recon_summary = self.bundle.get("recon_summary", {}) q_names = self.bundle["q_names"] dt = 1.0 / float(self.state.fps_var.get()) + max_interp_gap_frames = max(1, int(round(0.1 * float(self.state.fps_var.get())))) + apply_interpolation = bool(self.interpolate_root_var.get()) family_is_translation = self.family.get() == "translations" matrix_mode = (not family_is_translation) and self.rotation_plot_mode.get() == "matrix" + root_unwrap_mode = ( + root_unwrap_mode_from_display_name(self.root_unwrap_mode_var.get()) + if not family_is_translation + else "off" + ) + apply_root_unwrap = (not family_is_translation) and root_unwrap_mode != "off" family_slice = slice(0, 3) if family_is_translation else slice(3, 6) axis_display_labels = root_axis_display_labels(self.family.get()) quantity = self.quantity.get() @@ -7293,6 +11557,7 @@ def refresh_plot(self) -> None: for name in selected_names: series = None + interpolation_applied_before_extraction = False summary = recon_summary.get(name, {}) if isinstance(recon_summary, dict) else {} summary_family = str(summary.get("family", "")) geometric_family = summary_family in {"pose2sim", "triangulation"} @@ -7311,6 +11576,11 @@ def refresh_plot(self) -> None: ) if model_marker_points is not None: model_biomod_path = resolve_reconstruction_biomod(current_dataset_dir(self.state), name) + root_translation_origin = ( + "upper_trunk" + if infer_model_variant_from_biomod(model_biomod_path).startswith("upper_root_") + else "pelvis" + ) series, model_marker_points = root_series_from_model_markers( np.asarray(recon_q[name], dtype=float), biomod_path=model_biomod_path, @@ -7319,17 +11589,30 @@ def refresh_plot(self) -> None: quantity=effective_quantity, dt=dt, initial_rotation_correction=bool(self.state.initial_rotation_correction_var.get()), - unwrap_rotations=bool(self.unwrap_var.get()), + unwrap_rotations=apply_root_unwrap, + unwrap_mode=root_unwrap_mode, + translation_origin=root_translation_origin, + interpolate_gap_frames=(max_interp_gap_frames if apply_interpolation else None), ) + interpolation_applied_before_extraction = bool(apply_interpolation) geometric_family = True if series is None and geometric_family and name in recon_3d: + model_biomod_path = resolve_reconstruction_biomod(current_dataset_dir(self.state), name) series = root_series_from_points( np.asarray(recon_3d[name], dtype=float), quantity=effective_quantity, dt=dt, initial_rotation_correction=bool(self.state.initial_rotation_correction_var.get()), - unwrap_rotations=bool(self.unwrap_var.get()), + unwrap_rotations=apply_root_unwrap, + unwrap_mode=root_unwrap_mode, + translation_origin=( + "upper_trunk" + if infer_model_variant_from_biomod(model_biomod_path).startswith("upper_root_") + else "pelvis" + ), + interpolate_gap_frames=(max_interp_gap_frames if apply_interpolation else None), ) + interpolation_applied_before_extraction = bool(apply_interpolation) elif series is None and name in recon_q: series = root_series_from_q( q_names, @@ -7338,17 +11621,27 @@ def refresh_plot(self) -> None: dt=dt, qdot=recon_qdot.get(name), fd_qdot=bool(self.fd_qdot_var.get()), - unwrap_rotations=bool(self.unwrap_var.get()), + unwrap_rotations=apply_root_unwrap, renormalize_rotations=bool(self.reextract_var.get()), + unwrap_mode=root_unwrap_mode, ) elif series is None and name in recon_3d: + model_biomod_path = resolve_reconstruction_biomod(current_dataset_dir(self.state), name) series = root_series_from_points( np.asarray(recon_3d[name], dtype=float), quantity=effective_quantity, dt=dt, initial_rotation_correction=bool(self.state.initial_rotation_correction_var.get()), - unwrap_rotations=bool(self.unwrap_var.get()), + unwrap_rotations=apply_root_unwrap, + unwrap_mode=root_unwrap_mode, + translation_origin=( + "upper_trunk" + if infer_model_variant_from_biomod(model_biomod_path).startswith("upper_root_") + else "pelvis" + ), + interpolate_gap_frames=(max_interp_gap_frames if apply_interpolation else None), ) + interpolation_applied_before_extraction = bool(apply_interpolation) elif series is None and name in recon_q_root and not geometric_rotfix_mismatch: series = root_series_from_precomputed( np.asarray(recon_q_root[name], dtype=float), @@ -7359,10 +11652,14 @@ def refresh_plot(self) -> None: ) if series is None: continue + series_array = np.asarray(series, dtype=float) + if apply_interpolation and not interpolation_applied_before_extraction: + series_array = interpolate_short_nan_runs(series_array, max_interp_gap_frames) + series_array = fill_short_edge_nan_runs(series_array, max_interp_gap_frames) frame_slice = analysis_frame_slice(np.asarray(series).shape[0]) - if frame_slice.start >= np.asarray(series).shape[0]: + if frame_slice.start >= series_array.shape[0]: continue - frame_indices = np.arange(np.asarray(series).shape[0], dtype=float)[frame_slice] + frame_indices = np.arange(series_array.shape[0], dtype=float)[frame_slice] t = frame_indices * dt if matrix_mode: if model_marker_points is not None: @@ -7374,8 +11671,11 @@ def refresh_plot(self) -> None: ), ) elif geometric_family and name in recon_3d: + matrix_points = np.asarray(recon_3d[name], dtype=float) + if apply_interpolation: + matrix_points = interpolate_trunk_marker_gaps_for_root(matrix_points, max_interp_gap_frames) matrices = root_rotation_matrices_from_points( - np.asarray(recon_3d[name], dtype=float), + matrix_points, initial_rotation_correction=( bool(self.state.initial_rotation_correction_var.get()) and not bool(self.matrix_ignore_alpha_var.get()) @@ -7392,9 +11692,14 @@ def refresh_plot(self) -> None: except (TypeError, ValueError): initial_rotation_correction_angle_rad = 0.0 matrices = root_rotation_matrices_from_series( - np.asarray(series, dtype=float), + series_array, initial_rotation_correction_angle_rad=initial_rotation_correction_angle_rad, ) + if apply_interpolation: + matrices = interpolate_short_nan_runs( + np.asarray(matrices, dtype=float).reshape(matrices.shape[0], -1), + max_interp_gap_frames, + ).reshape(matrices.shape) matrices = matrices[frame_slice] for row_idx in range(3): for col_idx in range(3): @@ -7409,9 +11714,9 @@ def refresh_plot(self) -> None: ax.set_title(component_labels[row_idx][col_idx], fontsize=9) ax.grid(alpha=0.25) else: - series_to_plot = scale_root_series_rotations( - np.asarray(series, dtype=float), family_is_translation, rotation_unit - )[frame_slice] + series_to_plot = scale_root_series_rotations(series_array, family_is_translation, rotation_unit)[ + frame_slice + ] for axis_idx, ax in enumerate(axes): ax.plot( t, @@ -7489,6 +11794,7 @@ def __init__(self, master, state: SharedAppState): self.pair_list = tk.Listbox(controls, selectmode=tk.MULTIPLE, exportselection=False, height=6) self.pair_list.pack(fill=tk.X, padx=8, pady=4) + bind_extended_listbox_shortcuts(self.pair_list) attach_tooltip(quantity_label, "Choisit entre positions q et vitesses qdot pour les autres DoF.") attach_tooltip(quantity_box, "Choisit entre positions q et vitesses qdot pour les autres DoF.") attach_tooltip(rot_unit_label, "Choisit l'unité d'affichage des DoF de rotation.") @@ -7560,6 +11866,32 @@ def _joint_dof_unit_label(self, dof_name: str) -> str: return rotation_unit_label(self.rotation_unit.get(), self.quantity.get()) return "m" if self.quantity.get() == "q" else "m/s" + @staticmethod + def _upper_back_target_series( + series: np.ndarray, name_to_index: dict[str, int], dof_name: str + ) -> np.ndarray | None: + """Return the default pseudo-observation target for one upper-back DoF.""" + + if dof_name.endswith(":RotY") and dof_name.startswith(("UPPER_BACK:", "LOWER_TRUNK:")): + hip_candidates = ( + "LEFT_THIGH:RotY", + "RIGHT_THIGH:RotY", + "LEFT_THIGH:RotX", + "RIGHT_THIGH:RotX", + ) + hip_indices = [name_to_index[name] for name in hip_candidates if name in name_to_index] + if len(hip_indices) < 2: + return None + hip_values = np.asarray(series[:, hip_indices], dtype=float) + if hip_values.ndim != 2 or hip_values.shape[1] < 2: + return None + with np.errstate(invalid="ignore"): + return 0.2 * np.nanmean(hip_values, axis=1) + if dof_name.endswith(":RotX") or dof_name.endswith(":RotZ"): + if dof_name.startswith(("UPPER_BACK:", "LOWER_TRUNK:")): + return np.zeros(series.shape[0], dtype=float) + return None + def sync_dataset_dir(self) -> None: self.refresh_available_reconstructions() @@ -7656,13 +11988,10 @@ def refresh_plot(self) -> None: frame_indices = np.arange(frame_slice.start, frame_slice.stop, dtype=float) time_s = frame_indices * dt left_idx = name_to_index[left_name] - right_idx = name_to_index[right_name] color = reconstruction_display_color(self.state, recon_name) legend_label = reconstruction_legend_label(self.state, recon_name) left_style = side_styles["left"] - right_style = side_styles["right"] left_values = self._scale_joint_dof_series(series[:, left_idx], left_name) - right_values = self._scale_joint_dof_series(series[:, right_idx], right_name) ax.plot( time_s, left_values, @@ -7672,19 +12001,34 @@ def refresh_plot(self) -> None: marker=left_style["marker"], markevery=max(1, len(time_s) // 24) if len(time_s) else 1, markersize=3.0, - label=f"{legend_label} | L", - ) - ax.plot( - time_s, - right_values, - color=color, - linewidth=1.7, - linestyle=right_style["linestyle"], - marker=right_style["marker"], - markevery=max(1, len(time_s) // 24) if len(time_s) else 1, - markersize=3.0, - label=f"{legend_label} | R", + label=f"{legend_label} | L" if right_name is not None else legend_label, ) + if right_name is not None: + right_idx = name_to_index[right_name] + right_style = side_styles["right"] + right_values = self._scale_joint_dof_series(series[:, right_idx], right_name) + ax.plot( + time_s, + right_values, + color=color, + linewidth=1.7, + linestyle=right_style["linestyle"], + marker=right_style["marker"], + markevery=max(1, len(time_s) // 24) if len(time_s) else 1, + markersize=3.0, + label=f"{legend_label} | R", + ) + elif left_name.startswith(("UPPER_BACK:", "LOWER_TRUNK:")): + target_values = self._upper_back_target_series(series, name_to_index, left_name) + if target_values is not None: + ax.plot( + time_s, + self._scale_joint_dof_series(target_values, left_name), + color="#202020", + linewidth=1.2, + linestyle=":", + label=f"{legend_label} | target", + ) plotted_series = True ax.set_title(pair_label) ax.grid(alpha=0.25) @@ -8107,6 +12451,13 @@ def refresh_plot(self) -> None: momentum_plotted = 0 time_s_bundle = np.asarray(self.bundle.get("time_s", np.array([], dtype=float)), dtype=float) + momentum_jump_source: str | None = None + momentum_jump_analysis: DDSessionAnalysis | None = None + for candidate_name in reversed(selected_names): + momentum_jump_analysis = shared_jump_analysis_for_reconstruction(self.state, candidate_name) + if momentum_jump_analysis is not None: + momentum_jump_source = candidate_name + break for recon_name in selected_names: q = self.bundle.get("recon_q", {}).get(recon_name) if q is None: @@ -8140,6 +12491,9 @@ def refresh_plot(self) -> None: plot_data = angular_momentum_plot_data(model, q, qdot, time_s) color = reconstruction_display_color(self.state, recon_name) label = reconstruction_legend_label(self.state, recon_name) + if momentum_plotted == 0: + draw_jump_phase_spans(comp_ax, time_s=plot_data.time_s, analysis=momentum_jump_analysis) + draw_jump_phase_spans(norm_ax, time_s=plot_data.time_s, analysis=momentum_jump_analysis) component_styles = [("-", "Hx"), ("--", "Hy"), (":", "Hz")] for component_idx, (linestyle, axis_label) in enumerate(component_styles): comp_ax.plot( @@ -8159,16 +12513,25 @@ def refresh_plot(self) -> None: ) if momentum_plotted: - comp_ax.set_title("3D angular momentum components") + title_suffix = ( + f" | jump phases from {reconstruction_label(momentum_jump_source)}" + if momentum_jump_source + else "" + ) + comp_ax.set_title(f"3D angular momentum components{title_suffix}") comp_ax.set_ylabel("kg.m²/s") comp_ax.grid(alpha=0.25) comp_ax.legend(loc="upper right", fontsize=8, ncol=2) - norm_ax.set_title("3D angular momentum norm") + norm_ax.set_title(f"3D angular momentum norm{title_suffix}") norm_ax.set_ylabel("kg.m²/s") norm_ax.grid(alpha=0.25) norm_ax.legend(loc="upper right", fontsize=8) comp_ax.set_xlabel("Time (s)") norm_ax.set_xlabel("Time (s)") + if momentum_jump_source is not None: + summary_lines.append( + f"Jump phases on angular momentum plots: {reconstruction_label(momentum_jump_source)}" + ) else: comp_ax.axis("off") comp_ax.text( @@ -8338,99 +12701,119 @@ def _show_empty_plot(self, message: str) -> None: """Render a placeholder when execution localization cannot be shown.""" show_placeholder_figure(self.figure, self.canvas, message) - self.summary_text.delete("1.0", tk.END) - self.summary_text.insert("1.0", message) - - def _update_camera_choices(self) -> None: - camera_names: list[str] = [] - if self.pose_data is not None and getattr(self.pose_data, "camera_names", None) is not None: - camera_names = [str(name) for name in self.pose_data.camera_names] - elif isinstance(self.calibrations, dict): - camera_names = [str(name) for name in self.calibrations.keys()] - current = self.camera_name.get().strip() - self.camera_box.configure(values=camera_names) - if camera_names: - self.camera_name.set(current if current in camera_names else camera_names[0]) - else: - self.camera_name.set("") - - def refresh_analysis(self) -> None: - try: - dataset_dir = current_dataset_dir(self.state) - biomod_path = resolve_preview_biomod(dataset_dir) - pose2sim_trc = ( - ROOT / self.state.pose2sim_trc_var.get() if self.state.pose2sim_trc_var.get().strip() else None - ) - self.bundle = get_cached_preview_bundle( - self.state, dataset_dir, biomod_path, pose2sim_trc, align_root=False - ) - selected_name = self._selected_reconstruction() - self.current_reconstruction_name = selected_name - if selected_name is None: - self.execution_analysis = None - self._populate_jump_list() - self._populate_deduction_tree() - self._show_empty_plot("Select one reconstruction to inspect execution deductions.") - return - recon_q = self.bundle.get("recon_q", {}) - recon_points = self.bundle.get("recon_3d", {}) - q = recon_q.get(selected_name) - points_3d = recon_points.get(selected_name) - if q is None or points_3d is None: - self.execution_analysis = None - self._populate_jump_list() - self._populate_deduction_tree() - self._show_empty_plot("Execution analysis requires q and 3D markers for the selected reconstruction.") - return - q = np.asarray(q, dtype=float) - points_3d = np.asarray(points_3d, dtype=float) - qdot = self.bundle.get("recon_qdot", {}).get(selected_name) - if qdot is not None: - qdot = np.asarray(qdot, dtype=float) - root_q, _full_q, q_name_list = preview_root_series_for_reconstruction( - bundle=self.bundle, - name=selected_name, - initial_rotation_correction=bool(self.state.initial_rotation_correction_var.get()), - ) - if root_q is None or q_name_list is None: - self.execution_analysis = None - self._populate_jump_list() - self._populate_deduction_tree() - self._show_empty_plot("Unable to derive root kinematics for execution analysis.") - return - fps = float(self.state.fps_var.get()) - self.dd_analysis = analyze_dd_session( - np.asarray(root_q, dtype=float), - fps, - height_values=jump_segmentation_height_series(points_3d, np.asarray(root_q, dtype=float)), - height_threshold=TRAMPOLINE_BED_HEIGHT_M, - angle_mode="euler", - analysis_start_frame=ANALYSIS_START_FRAME, - require_complete_jumps=True, - ) - self.execution_analysis = analyze_execution_session( - self.dd_analysis, - q, - qdot, - q_name_list, - points_3d, - fps, - ) - try: - self.calibrations, self.pose_data, _diagnostics = get_calibration_pose_data( + self.summary_text.delete("1.0", tk.END) + self.summary_text.insert("1.0", message) + + def _update_camera_choices(self) -> None: + camera_names: list[str] = [] + if self.pose_data is not None and getattr(self.pose_data, "camera_names", None) is not None: + camera_names = [str(name) for name in self.pose_data.camera_names] + elif isinstance(self.calibrations, dict): + camera_names = [str(name) for name in self.calibrations.keys()] + current = self.camera_name.get().strip() + self.camera_box.configure(values=camera_names) + if camera_names: + self.camera_name.set(current if current in camera_names else camera_names[0]) + else: + self.camera_name.set("") + + def refresh_analysis(self) -> None: + try: + with gui_busy_popup(self, title="Execution", message="Calcul des déductions d'exécution...") as popup: + dataset_dir = current_dataset_dir(self.state) + biomod_path = resolve_preview_biomod(dataset_dir) + pose2sim_trc = ( + ROOT / self.state.pose2sim_trc_var.get() if self.state.pose2sim_trc_var.get().strip() else None + ) + popup.set_status("Chargement du bundle partagé...") + self.bundle = get_cached_preview_bundle( + self.state, dataset_dir, biomod_path, pose2sim_trc, align_root=False + ) + selected_name = self._selected_reconstruction() + self.current_reconstruction_name = selected_name + if selected_name is None: + self.execution_analysis = None + self._populate_jump_list() + self._populate_deduction_tree() + self._show_empty_plot("Select one reconstruction to inspect execution deductions.") + return + recon_q = self.bundle.get("recon_q", {}) + recon_points = self.bundle.get("recon_3d", {}) + q = recon_q.get(selected_name) + points_3d = recon_points.get(selected_name) + if q is None or points_3d is None: + self.execution_analysis = None + self._populate_jump_list() + self._populate_deduction_tree() + self._show_empty_plot( + "Execution analysis requires q and 3D markers for the selected reconstruction." + ) + return + q = np.asarray(q, dtype=float) + points_3d = np.asarray(points_3d, dtype=float) + qdot = self.bundle.get("recon_qdot", {}).get(selected_name) + if qdot is not None: + qdot = np.asarray(qdot, dtype=float) + root_q, _full_q, q_name_list = preview_root_series_for_reconstruction( + bundle=self.bundle, + name=selected_name, + initial_rotation_correction=bool(self.state.initial_rotation_correction_var.get()), + ) + if root_q is None or q_name_list is None: + self.execution_analysis = None + self._populate_jump_list() + self._populate_deduction_tree() + self._show_empty_plot("Unable to derive root kinematics for execution analysis.") + return + fps = float(self.state.fps_var.get()) + popup.set_status("Segmentation partagée des sauts...") + self.dd_analysis = shared_jump_analysis( self.state, - keypoints_path=ROOT / self.state.keypoints_var.get(), - calib_path=ROOT / self.state.calib_var.get(), - **shared_pose_data_kwargs(self.state), + reconstruction_name=selected_name, + root_q=np.asarray(root_q, dtype=float), + points_3d=points_3d, + fps=fps, + height_threshold=TRAMPOLINE_BED_HEIGHT_M, + height_threshold_range_ratio=0.20, + smoothing_window_s=0.15, + min_airtime_s=0.25, + min_gap_s=0.08, + min_peak_prominence_m=0.35, + contact_window_s=0.35, + full_q=None, + q_names=q_name_list, + angle_mode="euler", + analysis_start_frame=ANALYSIS_START_FRAME, + require_complete_jumps=True, ) - except Exception: - self.calibrations, self.pose_data = None, None - self.images_root = infer_execution_images_root(ROOT / self.state.keypoints_var.get()) - self._update_camera_choices() - self._populate_jump_list() - self._populate_deduction_tree() - self.render_summary() - self.refresh_plot() + popup.set_status("Calcul des déductions localisées...") + self.execution_analysis = analyze_execution_session( + self.dd_analysis, + q, + qdot, + q_name_list, + points_3d, + fps, + ) + try: + self.calibrations, self.pose_data, _diagnostics = get_calibration_pose_data( + self.state, + keypoints_path=ROOT / self.state.keypoints_var.get(), + calib_path=ROOT / self.state.calib_var.get(), + **shared_pose_data_kwargs(self.state), + ) + except Exception: + self.calibrations, self.pose_data = None, None + self.images_root = sync_shared_images_root_from_keypoints( + self.state, + ROOT / self.state.keypoints_var.get(), + set_if_empty_only=True, + ) + self._update_camera_choices() + self._populate_jump_list() + self._populate_deduction_tree() + self.render_summary() + self.refresh_plot() except Exception as exc: messagebox.showerror("Execution", str(exc)) @@ -8700,47 +13083,577 @@ def refresh_plot(self) -> None: line_alpha=0.35, line_width_scale=0.9, ) - if np.any(np.isfinite(projected_points)): - draw_skeleton_2d( - view_2d_ax, - projected_points, - reconstruction_display_color(self.state, self.current_reconstruction_name), - "reprojection", - marker_size=12.0, + if np.any(np.isfinite(projected_points)): + draw_skeleton_2d( + view_2d_ax, + projected_points, + reconstruction_display_color(self.state, self.current_reconstruction_name), + "reprojection", + marker_size=12.0, + ) + if keypoint_names: + if np.any(np.isfinite(raw_points)): + self._highlight_keypoints_2d(view_2d_ax, raw_points, keypoint_names, color="#d62728") + if np.any(np.isfinite(projected_points)): + self._highlight_keypoints_2d(view_2d_ax, projected_points, keypoint_names, color="#ff7f0e") + all_points_2d = np.vstack( + [ + raw_points[np.all(np.isfinite(raw_points), axis=1)], + projected_points[np.all(np.isfinite(projected_points), axis=1)], + ] + ) + if all_points_2d.size: + mins = np.min(all_points_2d, axis=0) + maxs = np.max(all_points_2d, axis=0) + margin = max(20.0, 0.1 * float(np.max(maxs - mins))) + view_2d_ax.set_xlim(mins[0] - margin, maxs[0] + margin) + view_2d_ax.set_ylim(maxs[1] + margin, mins[1] - margin) + view_2d_ax.set_aspect("equal", adjustable="box") + view_2d_ax.grid(alpha=0.20) + view_2d_ax.set_title(f"2D localization | {overlay_frame.camera_name or 'no camera'}") + handles, labels = view_2d_ax.get_legend_handles_labels() + if handles: + unique = {} + for handle, label in zip(handles, labels): + unique[label] = handle + view_2d_ax.legend(list(unique.values()), list(unique.keys()), loc="best", fontsize=8) + event_suffix = f"{event.label} | {event.detail}" if event is not None else "No discrete deduction on this jump" + self.figure.suptitle( + f"Execution analysis | {reconstruction_label(self.current_reconstruction_name)} | S{jump.jump_index} | {event_suffix}" + ) + self.figure.tight_layout() + self.canvas.draw_idle() + + +class CalibrationTab(ttk.Frame): + def __init__(self, master, state: SharedAppState): + super().__init__(master) + self.state = state + self.pose_data = None + self.calibrations = None + self.qc = None + self.current_reconstruction_name = None + self.current_reconstruction_payload: dict[str, np.ndarray] = {} + self.current_reconstruction_summary: dict[str, object] = {} + self.jump_analysis: DDSessionAnalysis | None = None + self._scheduled_calibration_load_id = None + self._scheduled_calibration_refresh_id = None + self._scheduled_calibration_recon_id = None + self.uses_shared_reconstruction_panel = True + self.shared_reconstruction_selectmode = "browse" + + controls = ttk.LabelFrame(self, text="Calibration QA") + controls.pack(fill=tk.X, padx=10, pady=10) + row = ttk.Frame(controls) + row.pack(fill=tk.X, padx=8, pady=4) + source_label = ttk.Label(row, text="2D source", width=10) + source_label.pack(side=tk.LEFT) + default_pose_mode = state.pose_data_mode_var.get().strip() + if default_pose_mode not in ("raw", "annotated", "cleaned"): + default_pose_mode = "cleaned" + self.pose_data_mode = tk.StringVar(value=default_pose_mode) + self.pose_mode_box = ttk.Combobox( + row, + textvariable=self.pose_data_mode, + values=["raw", "cleaned"], + width=10, + state="readonly", + ) + self.pose_mode_box.pack(side=tk.LEFT, padx=(0, 8)) + trim_label = ttk.Label(row, text="Trim worst 2D %", width=16) + trim_label.pack(side=tk.LEFT) + self.trim_fraction_var = tk.StringVar(value="15") + self.trim_fraction_box = ttk.Combobox( + row, + textvariable=self.trim_fraction_var, + values=["0", "5", "10", "15", "20"], + width=6, + state="readonly", + ) + self.trim_fraction_box.pack(side=tk.LEFT, padx=(0, 8)) + ttk.Button(row, text="Analyze / refresh", command=self.refresh_analysis).pack(side=tk.LEFT) + self.status_var = tk.StringVar( + value="2D: pairwise epipolar error after trimming the worst samples. 3D: reprojection + spatial uniformity." + ) + ttk.Label(controls, textvariable=self.status_var, foreground="#4f5b66", justify=tk.LEFT).pack( + fill=tk.X, padx=8, pady=(0, 4) + ) + + body = ttk.Panedwindow(self, orient=tk.HORIZONTAL) + body.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) + left = ttk.Frame(body) + right = ttk.Frame(body) + body.add(left, weight=1) + body.add(right, weight=2) + + summary_box = ttk.LabelFrame(left, text="Summary") + summary_box.pack(fill=tk.BOTH, expand=True) + self.summary = ScrolledText(summary_box, height=24, wrap=tk.WORD) + self.summary.pack(fill=tk.BOTH, expand=True, padx=6, pady=6) + + worst_box = ttk.LabelFrame(left, text="Worst frames") + worst_box.pack(fill=tk.BOTH, expand=True, pady=(8, 0)) + ttk.Button(worst_box, text="Open in Cameras", command=self.open_selected_frame_in_cameras).pack( + anchor="w", padx=6, pady=(6, 4) + ) + self.worst_frame_list = tk.Listbox(worst_box, exportselection=False, height=10) + self.worst_frame_list.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6)) + + figure_box = ttk.LabelFrame(right, text="2D / 3D calibration quality") + figure_box.pack(fill=tk.BOTH, expand=True) + self.figure = Figure(figsize=(11, 7)) + self.canvas = FigureCanvasTkAgg(self.figure, master=figure_box) + self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + self.toolbar = NavigationToolbar2Tk(self.canvas, figure_box, pack_toolbar=False) + self.toolbar.update() + self.toolbar.pack(fill=tk.X) + + attach_tooltip( + source_label, + "Version of the 2D observations used for calibration analysis: raw, cleaned, or annotated when available.", + ) + attach_tooltip( + self.pose_mode_box, + "Version of the 2D observations used for calibration analysis. `annotated` is available only when an annotation file exists for the current trial.", + ) + attach_tooltip( + trim_label, + "Globally exclude the worst pairwise 2D epipolar samples before summarizing calibration quality.", + ) + attach_tooltip( + self.trim_fraction_box, + "Globally exclude the worst pairwise 2D epipolar samples before summarizing calibration quality.", + ) + attach_tooltip( + self.summary, + "Compact synthesis of pairwise epipolar consistency, worst frames, reprojection quality, and spatial non-uniformity.", + ) + attach_tooltip( + self.worst_frame_list, + "Frames most likely to reveal calibration problems. Double-click to inspect them in Cameras.", + ) + + self.state.keypoints_var.trace_add("write", lambda *_args: self.sync_dataset_dir()) + self.state.output_root_var.trace_add("write", lambda *_args: self.sync_dataset_dir()) + self.state.calibration_correction_var.trace_add("write", lambda *_args: self.request_load_resources()) + self.pose_data_mode.trace_add("write", lambda *_args: self.request_load_resources()) + self.trim_fraction_var.trace_add("write", lambda *_args: self.request_refresh_analysis()) + self.state.register_reconstruction_listener(self.request_refresh_available_reconstructions) + self.state.register_shared_reconstruction_selection_listener(self._on_reconstruction_selection_changed) + self.worst_frame_list.bind("", lambda _event: self.open_selected_frame_in_cameras()) + self.sync_dataset_dir() + + def configure_shared_reconstruction_panel(self, panel: SharedReconstructionPanel) -> None: + panel.configure_for_consumer( + title="Reconstructions | Calibration", + refresh_callback=self.refresh_available_reconstructions, + selection_callback=self.refresh_analysis, + selectmode=self.shared_reconstruction_selectmode, + ) + self.refresh_available_reconstructions() + + def _publish_reconstruction_rows(self, rows: list[dict[str, object]], defaults: list[str]) -> None: + panel = self.state.shared_reconstruction_panel + if panel is not None and self.state.active_reconstruction_consumer is self: + panel.set_rows(rows, defaults) + + def _on_reconstruction_selection_changed(self) -> None: + self.request_refresh_analysis() + + def request_load_resources(self) -> None: + schedule_after_idle_once(self, "_scheduled_calibration_load_id", self.load_resources) + + def request_refresh_analysis(self) -> None: + schedule_after_idle_once(self, "_scheduled_calibration_refresh_id", self.refresh_analysis) + + def request_refresh_available_reconstructions(self) -> None: + schedule_after_idle_once(self, "_scheduled_calibration_recon_id", self.refresh_available_reconstructions) + + def _selected_reconstruction(self) -> str | None: + selected = list(self.state.shared_reconstruction_selection) + return selected[-1] if selected else None + + def sync_dataset_dir(self) -> None: + self.refresh_pose_mode_choices() + self.request_refresh_available_reconstructions() + self.request_load_resources() + + def _available_pose_modes(self) -> list[str]: + keypoints_value = self.state.keypoints_var.get().strip() + if not keypoints_value: + return ["raw", "cleaned"] + return available_model_pose_modes(self.state, ROOT / keypoints_value) + + def refresh_pose_mode_choices(self) -> None: + modes = self._available_pose_modes() + self.pose_mode_box.configure(values=modes) + current = str(self.pose_data_mode.get()).strip() + if current not in modes: + fallback = "annotated" if "annotated" in modes else ("cleaned" if "cleaned" in modes else modes[0]) + self.pose_data_mode.set(fallback) + + def refresh_available_reconstructions(self) -> None: + cancel_scheduled_after_idle(self, "_scheduled_calibration_recon_id") + try: + _output_dir, _bundle, preview_state = load_shared_reconstruction_preview_state( + self.state, + preferred_names=["triangulation_exhaustive", "triangulation_greedy", "ekf_3d", "ekf_2d_acc"], + fallback_count=4, + include_3d=True, + include_q=True, + include_q_root=True, + ) + self._publish_reconstruction_rows(preview_state.rows, preview_state.defaults[:1]) + except Exception: + pass + + def load_resources(self) -> None: + cancel_scheduled_after_idle(self, "_scheduled_calibration_load_id") + try: + self.refresh_pose_mode_choices() + keypoints_path = ROOT / self.state.keypoints_var.get() + self.calibrations, pose_data = get_cached_pose_data( + self.state, + keypoints_path=keypoints_path, + calib_path=ROOT / self.state.calib_var.get(), + **shared_pose_data_kwargs(self.state, data_mode=self.pose_data_mode.get()), + ) + if str(self.pose_data_mode.get()).strip() == "annotated": + pose_data = annotation_only_pose_data( + pose_data, + keypoints_path=keypoints_path, + annotations_path=existing_annotation_path_for_keypoints(self.state, keypoints_path), + ) + self.pose_data = pose_data + self.request_refresh_analysis() + except Exception as exc: + messagebox.showerror("Calibration", str(exc)) + + def refresh_analysis(self) -> None: + cancel_scheduled_after_idle(self, "_scheduled_calibration_refresh_id") + if self.pose_data is None or self.calibrations is None: + return + with gui_busy_popup(self, title="Calibration", message="Analyse de la qualité de calibration...") as popup: + self.current_reconstruction_name = self._selected_reconstruction() + payload = {} + summary = {} + if self.current_reconstruction_name: + recon_dir = reconstruction_dir_by_name( + current_dataset_dir(self.state), self.current_reconstruction_name + ) + if recon_dir is not None: + payload = load_bundle_payload(recon_dir) + summary = load_bundle_summary(recon_dir) + selected_pose_mode = str(self.pose_data_mode.get()).strip() + reconstruction_pose_mode = str(summary.get("pose_data_mode") or "").strip() + if payload and reconstruction_pose_mode and reconstruction_pose_mode != selected_pose_mode: + payload = {} + self.current_reconstruction_payload = payload + self.current_reconstruction_summary = summary + self.jump_analysis = shared_jump_analysis_for_reconstruction(self.state, self.current_reconstruction_name) + trim_fraction = max(0.0, float(self.trim_fraction_var.get() or "0")) / 100.0 + popup.set_status("Agrégation 2D/3D des métriques de calibration...") + self.qc = compute_calibration_qc( + self.pose_data, + self.calibrations, + reconstruction_payload=payload or None, + trim_fraction=trim_fraction, + spatial_bins=3, + ) + self.status_var.set( + f"Source: {self.pose_data_mode.get()} | 2D trim: " + f"{int(round(self.qc.two_d.trim_fraction * 100.0))}% | Reconstruction: " + f"{self.current_reconstruction_name or 'none selected'}" + + ( + "" + if not self.current_reconstruction_name + or not reconstruction_pose_mode + or reconstruction_pose_mode == selected_pose_mode + else f" | 3D hidden: reconstruction uses {reconstruction_pose_mode}" + ) + ) + self.render_summary() + self.refresh_worst_frame_list() + self.refresh_plot() + + def refresh_worst_frame_list(self) -> None: + self.worst_frame_list.delete(0, tk.END) + if self.qc is None or self.pose_data is None: + return + for frame_number, value in self._worst_frames(self.qc.two_d.per_frame_mean_px, count=5): + self.worst_frame_list.insert(tk.END, f"2D | frame {frame_number} | {value:.2f} px") + if self.qc.three_d is not None: + for frame_number, value in self._worst_frames(self.qc.three_d.per_frame_mean_px, count=5): + self.worst_frame_list.insert(tk.END, f"3D | frame {frame_number} | {value:.2f} px") + + def _selected_worst_frame(self) -> tuple[str, int] | None: + selection = self.worst_frame_list.curselection() + if not selection: + return None + text = str(self.worst_frame_list.get(selection[0])) + match = re.search(r"^(2D|3D)\s+\|\s+frame\s+(\d+)", text) + if match is None: + return None + return match.group(1), int(match.group(2)) + + def open_selected_frame_in_cameras(self) -> None: + selected = self._selected_worst_frame() + if selected is None or self.pose_data is None: + return + metric_kind, frame_number = selected + camera_name = self._worst_camera_for_frame(metric_kind, frame_number) + notebook = self.master if isinstance(self.master, ttk.Notebook) else None + if notebook is None: + return + target_tab = None + for tab_id in notebook.tabs(): + if notebook.tab(tab_id, "text") == "Cameras": + target_tab = notebook.nametowidget(tab_id) + notebook.select(tab_id) + break + if target_tab is not None and hasattr(target_tab, "show_specific_frame"): + target_tab.show_specific_frame(frame_number=frame_number, camera_name=camera_name) + + def _worst_camera_for_frame(self, metric_kind: str, frame_number: int) -> str | None: + if self.pose_data is None: + return None + camera_names = list(self.pose_data.camera_names) + if frame_number not in set(int(frame) for frame in self.pose_data.frames): + return None + frame_idx = int(np.where(np.asarray(self.pose_data.frames, dtype=int) == int(frame_number))[0][0]) + if metric_kind == "3D" and self.current_reconstruction_payload: + errors = np.asarray(self.current_reconstruction_payload.get("reprojection_error_per_view"), dtype=float) + if errors.ndim == 3 and frame_idx < errors.shape[0]: + per_camera = np.nanmean(errors[frame_idx], axis=0) + finite = np.isfinite(per_camera) + if np.any(finite): + return camera_names[int(np.nanargmax(per_camera))] + per_camera_values = [] + for cam_idx, camera_name in enumerate(camera_names): + values = frame_camera_epipolar_errors( + self.pose_data, + self.calibrations, + frame_idx=frame_idx, + camera_idx=cam_idx, + ) + per_camera_values.append(np.nanmean(values)) + per_camera_values = np.asarray(per_camera_values, dtype=float) + finite = np.isfinite(per_camera_values) + if not np.any(finite): + return camera_names[0] if camera_names else None + return camera_names[int(np.nanargmax(per_camera_values))] + + def render_summary(self) -> None: + self.summary.delete("1.0", tk.END) + if self.qc is None or self.pose_data is None: + self.summary.insert(tk.END, "No calibration analysis available.\n") + return + two_d = self.qc.two_d + lines = [ + "2D calibration quality", + f"- Trimmed worst 2D fraction: {int(round(two_d.trim_fraction * 100.0))}%", + ( + "- Trim threshold: " + + ("none" if two_d.trim_threshold_px is None else f"{two_d.trim_threshold_px:.2f} px") + ), + f"- Kept pairwise 2D samples: {two_d.kept_ratio * 100.0:.1f}%", + ] + worst_pairs = self._worst_camera_pairs(two_d.pairwise_median_px) + if worst_pairs: + lines.append("- Worst camera pairs (median epipolar px):") + lines.extend(f" - {cam_a} / {cam_b}: {value:.2f} px" for cam_a, cam_b, value in worst_pairs[:3]) + worst_frames_2d = self._worst_frames(two_d.per_frame_mean_px, count=5) + if worst_frames_2d: + lines.append("- Worst 2D frames (mean epipolar px):") + lines.extend(f" - frame {frame}: {value:.2f} px" for frame, value in worst_frames_2d) + + if self.qc.three_d is not None: + three_d = self.qc.three_d + reproj_mean = three_d.reprojection_summary.get("mean_px") + reproj_std = three_d.reprojection_summary.get("std_px") + lines.extend( + [ + "", + "3D calibration quality", + "- Reprojection mean/std: " + + ( + "none" + if reproj_mean is None or reproj_std is None + else f"{float(reproj_mean):.2f} +/- {float(reproj_std):.2f} px" + ), + "- Spatial non-uniformity (CV / range): " + + ( + "none" + if three_d.spatial_uniformity_cv is None or three_d.spatial_uniformity_range_px is None + else f"{three_d.spatial_uniformity_cv:.3f} / {three_d.spatial_uniformity_range_px:.2f} px" + ), + ] + ) + worst_frames_3d = self._worst_frames(three_d.per_frame_mean_px, count=5) + if worst_frames_3d: + lines.append("- Worst 3D frames (mean reprojection px):") + lines.extend(f" - frame {frame}: {value:.2f} px" for frame, value in worst_frames_3d) + elif self.current_reconstruction_name and self.current_reconstruction_summary: + reconstruction_pose_mode = str(self.current_reconstruction_summary.get("pose_data_mode") or "").strip() + if reconstruction_pose_mode and reconstruction_pose_mode != str(self.pose_data_mode.get()).strip(): + lines.extend( + [ + "", + "3D calibration quality", + ( + "- Hidden because the selected reconstruction uses " + f"`{reconstruction_pose_mode}` while the tab uses `{self.pose_data_mode.get()}`." + ), + ] + ) + + self.summary.insert(tk.END, "\n".join(lines) + "\n") + + def refresh_plot(self) -> None: + self.figure.clear() + axes = self.figure.subplots(2, 2) + if self.qc is None or self.pose_data is None: + for ax in axes.flat: + ax.axis("off") + self.canvas.draw_idle() + return + two_d = self.qc.two_d + camera_names = list(self.pose_data.camera_names) + + ax = axes[0, 0] + matrix = np.asarray(two_d.pairwise_median_px, dtype=float) + if np.isfinite(matrix).any(): + image = ax.imshow(matrix, cmap="magma") + ax.set_xticks(range(len(camera_names)), camera_names, rotation=45, ha="right") + ax.set_yticks(range(len(camera_names)), camera_names) + ax.set_title("Pairwise epipolar median (px)") + self.figure.colorbar(image, ax=ax, fraction=0.046, pad=0.04) + else: + ax.axis("off") + ax.text(0.5, 0.5, "No finite pairwise epipolar data", ha="center", va="center", transform=ax.transAxes) + + ax = axes[0, 1] + x = np.arange(len(camera_names)) + width = 0.38 + ax.bar(x - width / 2, np.nan_to_num(two_d.per_camera_median_px, nan=0.0), width=width, label="2D epi median") + if self.qc.three_d is not None: + per_camera = self.qc.three_d.reprojection_summary.get("per_camera", {}) + reproj_values = np.array( + [float(per_camera.get(name, {}).get("mean_px") or 0.0) for name in camera_names], + dtype=float, + ) + ax.bar(x + width / 2, reproj_values, width=width, label="3D reproj mean") + ax.set_xticks(x, camera_names, rotation=45, ha="right") + ax.set_ylabel("px") + ax.set_title("Per-camera quality") + ax.legend(loc="best", fontsize=8) + ax.grid(alpha=0.25, axis="y") + + ax = axes[1, 0] + frames = np.asarray(self.pose_data.frames, dtype=int) + plot_start_idx = min(5, len(frames)) + plot_frames = frames[plot_start_idx:] + ax.plot( + plot_frames, + np.asarray(two_d.per_frame_mean_px, dtype=float)[plot_start_idx:], + label="2D epipolar mean", + linewidth=1.2, + ) + if self.qc.three_d is not None: + ax.plot( + plot_frames, + np.asarray(self.qc.three_d.per_frame_mean_px, dtype=float)[plot_start_idx:], + label="3D reproj mean", + linewidth=1.2, + ) + if self.jump_analysis is not None: + for idx, segment in enumerate(self.jump_analysis.jump_segments): + if int(segment.end) < int(plot_frames[0]) or int(segment.start) > int(plot_frames[-1]): + continue + ax.axvspan( + max(int(segment.start), int(plot_frames[0])), + min(int(segment.end), int(plot_frames[-1])), + color="#4c72b0", + alpha=0.06, + label="Detected jumps" if idx == 0 else None, + ) + ax.set_title("Worst frames over time") + ax.set_xlabel("Frame") + ax.set_ylabel("px") + ax.grid(alpha=0.25) + ax.legend(loc="best", fontsize=8) + + ax = axes[1, 1] + if self.qc.three_d is None or self.qc.three_d.point_positions.size == 0: + ax.axis("off") + ax.text( + 0.5, + 0.5, + "Select a reconstruction with 3D points to inspect spatial calibration quality.", + ha="center", + va="center", + transform=ax.transAxes, + ) + else: + cv_text = ( + "n/a" + if self.qc.three_d.spatial_uniformity_cv is None + else f"{self.qc.three_d.spatial_uniformity_cv:.3f}" ) - if keypoint_names: - if np.any(np.isfinite(raw_points)): - self._highlight_keypoints_2d(view_2d_ax, raw_points, keypoint_names, color="#d62728") - if np.any(np.isfinite(projected_points)): - self._highlight_keypoints_2d(view_2d_ax, projected_points, keypoint_names, color="#ff7f0e") - all_points_2d = np.vstack( - [ - raw_points[np.all(np.isfinite(raw_points), axis=1)], - projected_points[np.all(np.isfinite(projected_points), axis=1)], - ] - ) - if all_points_2d.size: - mins = np.min(all_points_2d, axis=0) - maxs = np.max(all_points_2d, axis=0) - margin = max(20.0, 0.1 * float(np.max(maxs - mins))) - view_2d_ax.set_xlim(mins[0] - margin, maxs[0] + margin) - view_2d_ax.set_ylim(maxs[1] + margin, mins[1] - margin) - view_2d_ax.set_aspect("equal", adjustable="box") - view_2d_ax.grid(alpha=0.20) - view_2d_ax.set_title(f"2D localization | {overlay_frame.camera_name or 'no camera'}") - handles, labels = view_2d_ax.get_legend_handles_labels() - if handles: - unique = {} - for handle, label in zip(handles, labels): - unique[label] = handle - view_2d_ax.legend(list(unique.values()), list(unique.keys()), loc="best", fontsize=8) - event_suffix = f"{event.label} | {event.detail}" if event is not None else "No discrete deduction on this jump" - self.figure.suptitle( - f"Execution analysis | {reconstruction_label(self.current_reconstruction_name)} | S{jump.jump_index} | {event_suffix}" - ) + spatial_map = np.asarray(self.qc.three_d.spatial_xz_mean_px, dtype=float) + if np.isfinite(spatial_map).any(): + image = ax.imshow(spatial_map.T, origin="lower", cmap="turbo", aspect="auto") + ax.set_title("Spatial reprojection map (X/Z bins)\n" f"CV={cv_text}") + ax.set_xlabel("X bins") + ax.set_ylabel("Z bins") + ax.set_xticks(range(spatial_map.shape[0])) + ax.set_yticks(range(spatial_map.shape[1])) + mean_value = float(np.nanmean(spatial_map)) if np.isfinite(spatial_map).any() else np.nan + for x_idx in range(spatial_map.shape[0]): + for z_idx in range(spatial_map.shape[1]): + value = float(spatial_map[x_idx, z_idx]) + count = int(self.qc.three_d.spatial_xz_count[x_idx, z_idx]) + if np.isfinite(value) and count > 0: + ax.text( + x_idx, + z_idx, + f"{value:.1f}\n(n={count})", + ha="center", + va="center", + fontsize=7, + color=("white" if np.isfinite(mean_value) and value > mean_value else "black"), + ) + self.figure.colorbar(image, ax=ax, fraction=0.046, pad=0.04, label="px") + else: + ax.axis("off") + ax.text(0.5, 0.5, "No occupied spatial bins", ha="center", va="center", transform=ax.transAxes) + self.figure.tight_layout() self.canvas.draw_idle() + def _worst_camera_pairs(self, matrix: np.ndarray) -> list[tuple[str, str, float]]: + if self.pose_data is None: + return [] + pairs = [] + camera_names = list(self.pose_data.camera_names) + for i in range(len(camera_names)): + for j in range(i + 1, len(camera_names)): + value = float(matrix[i, j]) + if np.isfinite(value): + pairs.append((camera_names[i], camera_names[j], value)) + pairs.sort(key=lambda item: item[2], reverse=True) + return pairs + + def _worst_frames(self, values: np.ndarray, *, count: int) -> list[tuple[int, float]]: + if self.pose_data is None: + return [] + array = np.asarray(values, dtype=float) + finite_idx = np.flatnonzero(np.isfinite(array)) + if finite_idx.size == 0: + return [] + order = finite_idx[np.argsort(array[finite_idx])[::-1]] + return [(int(self.pose_data.frames[idx]), float(array[idx])) for idx in order[:count]] + class CameraToolsTab(ttk.Frame): def __init__(self, master, state: SharedAppState): @@ -8754,6 +13667,10 @@ def __init__(self, master, state: SharedAppState): self.flip_diagnostics: dict[str, dict[str, object]] = {} self.flip_detail_arrays: dict[str, dict[str, np.ndarray]] = {} self.flip_frame_local_indices: list[int] = [] + self.images_root: Path | None = None + self._dragging_flip_frame_scale = False + self._scheduled_camera_load_id = None + self._scheduled_camera_recon_id = None self.uses_shared_reconstruction_panel = True self.shared_reconstruction_selectmode = "browse" @@ -8782,18 +13699,6 @@ def __init__(self, master, state: SharedAppState): ttk.Label(controls, textvariable=self.calibration_pose_status_var, foreground="#4f5b66", justify=tk.LEFT).pack( fill=tk.X, padx=8, pady=(0, 2) ) - ttk.Label( - controls, - text=( - "Scores shown: valid 2D coverage, detector confidence, epipolar coherence, confidence x coherence, " - "reprojection quality, triangulation usage, epipolar decision support, and flip rates.\n" - "Additional useful literature criteria: calibration uncertainty, baseline diversity, occlusion persistence, " - "view angle to the motion plane, and temporal stability of 2D tracks." - ), - foreground="#4f5b66", - justify=tk.LEFT, - wraplength=1200, - ).pack(fill=tk.X, padx=8, pady=(0, 4)) body = ttk.Panedwindow(self, orient=tk.HORIZONTAL) body.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) @@ -8886,10 +13791,7 @@ def __init__(self, master, state: SharedAppState): camera_label = ttk.Label(inspector_controls, text="Camera", width=8) camera_label.pack(side=tk.LEFT) self.flip_camera_var = tk.StringVar(value="") - self.flip_camera_box = ttk.Combobox( - inspector_controls, textvariable=self.flip_camera_var, width=18, state="readonly" - ) - self.flip_camera_box.pack(side=tk.LEFT, padx=(0, 8)) + ttk.Label(inspector_controls, textvariable=self.flip_camera_var, width=18).pack(side=tk.LEFT, padx=(0, 8)) self.flip_applied_var = tk.BooleanVar(value=False) self.flip_check = ttk.Checkbutton( inspector_controls, @@ -8903,6 +13805,30 @@ def __init__(self, master, state: SharedAppState): side=tk.LEFT, fill=tk.X, expand=True ) + image_controls = ttk.Frame(inspector_box) + image_controls.pack(fill=tk.X, padx=8, pady=(0, 4)) + self.show_images_var = tk.BooleanVar(value=False) + ttk.Checkbutton( + image_controls, + text="Show images", + variable=self.show_images_var, + command=self.render_flip_preview, + ).pack(side=tk.LEFT, padx=(0, 8)) + overlay_label = ttk.Label(image_controls, text="QA overlay", width=10) + overlay_label.pack(side=tk.LEFT, padx=(0, 0)) + self.qa_overlay_var = tk.StringVar(value="none") + self.qa_overlay_box = ttk.Combobox( + image_controls, + textvariable=self.qa_overlay_var, + values=["none", "2D epipolar", "3D reproj", "3D excluded"], + width=14, + state="readonly", + ) + self.qa_overlay_box.pack(side=tk.LEFT, padx=(0, 8)) + + frame_slider_box = ttk.Frame(inspector_box) + frame_slider_box.pack(fill=tk.X, padx=8, pady=(0, 8)) + frame_slider_box.columnconfigure(1, weight=1) inspector_body = ttk.Panedwindow(inspector_box, orient=tk.HORIZONTAL) inspector_body.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8)) frame_panel = ttk.Frame(inspector_body) @@ -8910,6 +13836,25 @@ def __init__(self, master, state: SharedAppState): inspector_body.add(frame_panel, weight=1) inspector_body.add(preview_panel, weight=2) + ttk.Label(frame_slider_box, text="Frame", width=8).grid(row=0, column=0, sticky="w", pady=(0, 6)) + self.flip_frame_var = tk.DoubleVar(value=0.0) + self.flip_frame_scale = ttk.Scale( + frame_slider_box, + from_=0, + to=0, + variable=self.flip_frame_var, + orient=tk.HORIZONTAL, + command=self._on_flip_frame_scale_changed, + takefocus=True, + ) + self.flip_frame_scale.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=(0, 6)) + self.flip_frame_label = ttk.Label(frame_slider_box, text="frame -", width=10) + self.flip_frame_label.grid(row=0, column=2, sticky="e", pady=(0, 6)) + self.flip_frame_marks = tk.Canvas(frame_slider_box, height=10, highlightthickness=0, bd=0) + self.flip_frame_marks.grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=(0, 6)) + self._bind_flip_frame_navigation(self.flip_frame_scale) + self._bind_flip_frame_navigation(self.flip_frame_marks) + ttk.Label(frame_panel, text="Frames suspectes / candidates").pack(anchor="w", pady=(0, 4)) self.flip_frame_list = tk.Listbox(frame_panel, exportselection=False, height=12) self.flip_frame_list.pack(fill=tk.BOTH, expand=True) @@ -8938,40 +13883,66 @@ def __init__(self, master, state: SharedAppState): self.flip_method_box, "Méthode de diagnostic de flip L/R: cohérence épipolaire Sampson, cohérence épipolaire rapide par distance symétrique, ou triangulation/reprojection.", ) - attach_tooltip(camera_label, "Caméra isolée à inspecter pour les frames suspectes.") - attach_tooltip(self.flip_camera_box, "Caméra isolée à inspecter pour les frames suspectes.") + attach_tooltip(camera_label, "Caméra inspectée, suivie automatiquement depuis le tableau de gauche.") attach_tooltip( self.flip_check, "Permute gauche/droite sur les données 2D brutes affichées. Raccourci clavier: F." ) + attach_tooltip( + image_controls, + "Le fond image utilise le chemin partagé 'Images root' défini dans l'onglet 2D analysis.", + ) + attach_tooltip( + self.qa_overlay_box, + "Overlay calibration QA on the 2D image: local 2D epipolar error, selected 3D reprojection error, or 3D excluded keypoints.", + ) + attach_tooltip(self.flip_frame_scale, "Navigation continue entre les frames pour la caméra inspectée.") + attach_tooltip( + self.flip_frame_marks, + "Repères de frames suspectes de flip pour la caméra et la méthode courantes.", + ) attach_tooltip(self.flip_frame_list, "Frames suspectes ou candidates pour la caméra et la méthode choisies.") attach_tooltip( self.flip_details, "Détails des coûts géométriques, temporels et combinés pour la frame sélectionnée." ) self.flip_method_var.trace_add("write", lambda *_args: self.refresh_flip_frame_list()) - self.flip_camera_var.trace_add("write", lambda *_args: self.refresh_flip_frame_list()) - self.flip_frame_list.bind("<>", lambda _event: self.render_flip_preview()) - for widget in (self.flip_frame_list, self.flip_canvas_widget, self.flip_method_box, self.flip_camera_box): + self.qa_overlay_var.trace_add("write", lambda *_args: self.render_flip_preview()) + self.flip_frame_list.bind("<>", lambda _event: self._on_flip_frame_list_selection_changed()) + self.flip_frame_marks.bind("", lambda _event: self._render_flip_frame_markers()) + self.metrics_tree.bind("<>", lambda _event: self._on_metrics_tree_selection_changed()) + for widget in (self.flip_frame_list, self.flip_canvas_widget, self.flip_method_box, self.metrics_tree): widget.bind("", self.toggle_flip_current_frame) widget.bind("", lambda _event, w=widget: w.focus_set()) self.state.keypoints_var.trace_add("write", lambda *_args: self.sync_dataset_dir()) self.state.output_root_var.trace_add("write", lambda *_args: self.sync_dataset_dir()) - self.state.pose_data_mode_var.trace_add("write", lambda *_args: self.load_resources()) - self.state.calibration_correction_var.trace_add("write", lambda *_args: self.load_resources()) - self.state.register_reconstruction_listener(lambda: self.after_idle(self.refresh_available_reconstructions)) + self.state.shared_images_root_var.trace_add("write", lambda *_args: self.render_flip_preview()) + self.state.pose_data_mode_var.trace_add("write", lambda *_args: self.request_load_resources()) + self.state.calibration_correction_var.trace_add("write", lambda *_args: self.request_load_resources()) + self.state.register_reconstruction_listener(self.request_refresh_available_reconstructions) self.state.register_shared_reconstruction_selection_listener(self._on_reconstruction_selection_changed) self.state.selected_camera_names_var.trace_add("write", lambda *_args: self.update_camera_filter_status()) self.sync_dataset_dir() + def request_load_resources(self) -> None: + schedule_after_idle_once(self, "_scheduled_camera_load_id", self.load_resources) + + def request_refresh_available_reconstructions(self) -> None: + schedule_after_idle_once(self, "_scheduled_camera_recon_id", self.refresh_available_reconstructions) + def toggle_flip_current_frame(self, _event=None) -> str: self.flip_applied_var.set(not self.flip_applied_var.get()) self.render_flip_preview() return "break" def sync_dataset_dir(self) -> None: - self.refresh_available_reconstructions() + self.images_root = sync_shared_images_root_from_keypoints( + self.state, + ROOT / self.state.keypoints_var.get(), + set_if_empty_only=True, + ) + self.request_refresh_available_reconstructions() self.update_camera_filter_status() - self.load_resources() + self.request_load_resources() def update_camera_filter_status(self) -> None: selected = current_selected_camera_names(self.state) @@ -8985,6 +13956,7 @@ def update_camera_filter_status(self) -> None: for camera_name in selected: if self.metrics_tree.exists(camera_name): self.metrics_tree.selection_add(camera_name) + self._sync_inspector_camera_name() def _on_reconstruction_selection_changed(self) -> None: if self.pose_data is None: @@ -8992,16 +13964,35 @@ def _on_reconstruction_selection_changed(self) -> None: self.refresh_metrics() self.refresh_flip_frame_list() + def _on_metrics_tree_selection_changed(self) -> None: + """Refresh the inspector when the camera table selection changes.""" + + self._sync_inspector_camera_name() + self.refresh_flip_frame_list() + + def _on_flip_frame_list_selection_changed(self) -> None: + """Synchronize the camera-frame slider when the suspect-frame list changes.""" + + selection = self.flip_frame_list.curselection() + if selection: + list_idx = int(selection[0]) + if 0 <= list_idx < len(self.flip_frame_local_indices): + self.flip_frame_var.set(float(self.flip_frame_local_indices[list_idx])) + self._update_flip_frame_label() + self.render_flip_preview() + def configure_shared_reconstruction_panel(self, panel: SharedReconstructionPanel) -> None: panel.configure_for_consumer( title="Reconstructions | Caméras", refresh_callback=self.refresh_available_reconstructions, selection_callback=self._on_reconstruction_selection_changed, selectmode=self.shared_reconstruction_selectmode, + allow_empty_selection=True, ) self.refresh_available_reconstructions() def refresh_available_reconstructions(self) -> None: + cancel_scheduled_after_idle(self, "_scheduled_camera_recon_id") try: _output_dir, _bundle, preview_state = load_shared_reconstruction_preview_state( self.state, @@ -9022,6 +14013,7 @@ def _selected_reconstruction(self) -> str | None: return selected[-1] if selected else None def load_resources(self) -> None: + cancel_scheduled_after_idle(self, "_scheduled_camera_load_id") try: self.calibrations, self.base_pose_data = get_cached_pose_data( self.state, @@ -9178,22 +14170,51 @@ def select_best_cameras(self) -> None: def refresh_flip_controls(self) -> None: if self.base_pose_data is None: return - camera_names = list(self.base_pose_data.camera_names) - self.flip_camera_box.configure(values=camera_names) - if self.flip_camera_var.get() not in camera_names: - self.flip_camera_var.set(camera_names[0] if camera_names else "") + self._sync_inspector_camera_name() + self._configure_flip_frame_slider() self.refresh_flip_frame_list() + def _selected_inspector_camera_name(self) -> str: + """Return the camera currently inspected in the right-hand preview.""" + + camera_names = list(getattr(getattr(self, "base_pose_data", None), "camera_names", [])) + if not camera_names: + return "" + tree = getattr(self, "metrics_tree", None) + selection = list(getattr(tree, "selection", lambda: ())()) + focus = str(getattr(tree, "focus", lambda: "")() or "") + if focus in selection and focus in camera_names: + return focus + for camera_name in selection: + if camera_name in camera_names: + return str(camera_name) + current = str(self.flip_camera_var.get()).strip() + if current in camera_names: + return current + return str(camera_names[0]) + + def _sync_inspector_camera_name(self) -> None: + """Mirror the selected table camera into the inspector header.""" + + if not hasattr(self, "flip_camera_var"): + return + self.flip_camera_var.set(self._selected_inspector_camera_name()) + def refresh_flip_frame_list(self) -> None: + current_local_idx = self._selected_flip_frame_local_idx() self.flip_frame_local_indices = [] self.flip_frame_list.delete(0, tk.END) self.flip_details.delete("1.0", tk.END) if self.base_pose_data is None: + self._configure_flip_frame_slider() + self._render_flip_frame_markers() self.render_flip_preview() return method = self.flip_method_var.get() - camera_name = self.flip_camera_var.get() + camera_name = self._selected_inspector_camera_name() if method not in self.flip_masks or camera_name not in self.base_pose_data.camera_names: + self._configure_flip_frame_slider() + self._render_flip_frame_markers() self.render_flip_preview() return cam_idx = list(self.base_pose_data.camera_names).index(camera_name) @@ -9221,17 +14242,245 @@ def refresh_flip_frame_list(self) -> None: f"dec {self._fmt_float(decision_score)} / sm {self._fmt_float(smoothed_score)}" ) self.flip_frame_list.insert(tk.END, label) + self._configure_flip_frame_slider() if local_indices: - self.flip_frame_list.selection_set(0) + preferred_local_idx = current_local_idx if current_local_idx in local_indices else local_indices[0] + list_idx = local_indices.index(preferred_local_idx) + self.flip_frame_list.selection_set(list_idx) + self.flip_frame_list.see(list_idx) + self.flip_frame_var.set(float(preferred_local_idx)) + elif current_local_idx is not None: + self.flip_frame_var.set(float(current_local_idx)) + self._update_flip_frame_label() + self._render_flip_frame_markers() + self.render_flip_preview() + + def _configure_flip_frame_slider(self) -> None: + """Configure the frame slider to cover the currently loaded trial.""" + + if not hasattr(self, "flip_frame_scale") or not hasattr(self, "flip_frame_var"): + return + n_frames = int(len(getattr(self.base_pose_data, "frames", []))) + max_frame = max(n_frames - 1, 0) + self.flip_frame_scale.configure(to=max_frame) + current = int(round(float(getattr(self.flip_frame_var, "get", lambda: 0.0)()))) + self.flip_frame_var.set(float(clamp_frame_index(current, max_frame) if n_frames else 0)) + self._update_flip_frame_label() + + def _bind_flip_frame_navigation(self, widget: tk.Widget) -> None: + """Attach click, drag, and keyboard navigation to the camera-frame slider.""" + + if widget is self.flip_frame_scale: + widget.bind("", self._on_flip_frame_scale_click) + widget.bind("", self._on_flip_frame_scale_drag) + widget.bind("", self._on_flip_frame_scale_release) + widget.bind("", lambda _event: widget.focus_set()) + widget.bind("", lambda _event: self.step_flip_frame(-1)) + widget.bind("", lambda _event: self.step_flip_frame(1)) + + def _flip_frame_from_scale_event(self, event) -> int: + """Map a mouse event on the camera slider to one local frame index.""" + + return frame_from_slider_click( + x=event.x, + width=self.flip_frame_scale.winfo_width(), + from_value=self.flip_frame_scale.cget("from"), + to_value=self.flip_frame_scale.cget("to"), + ) + + def _on_flip_frame_scale_click(self, event) -> str: + """Jump the camera inspector to the clicked frame position.""" + + self._dragging_flip_frame_scale = True + self.flip_frame_scale.focus_set() + self.flip_frame_var.set(float(self._flip_frame_from_scale_event(event))) + self._on_flip_frame_scale_changed(None) + return "break" + + def _on_flip_frame_scale_drag(self, event) -> str: + """Drag the camera inspector continuously across frames.""" + + if not self._dragging_flip_frame_scale: + return "break" + self.flip_frame_var.set(float(self._flip_frame_from_scale_event(event))) + self._on_flip_frame_scale_changed(None) + return "break" + + def _on_flip_frame_scale_release(self, event) -> str: + """End one drag interaction on the camera-frame slider.""" + + if not self._dragging_flip_frame_scale: + return "break" + self._dragging_flip_frame_scale = False + self.flip_frame_var.set(float(self._flip_frame_from_scale_event(event))) + self._on_flip_frame_scale_changed(None) + return "break" + + def _on_flip_frame_scale_changed(self, _value) -> None: + """Refresh the camera preview when the user scrubs the frame slider.""" + + if self.base_pose_data is None: + return + slider_idx = clamp_frame_index( + int(round(float(self.flip_frame_var.get()))), + len(self.base_pose_data.frames) - 1, + ) + self.flip_frame_var.set(float(slider_idx)) + if slider_idx in self.flip_frame_local_indices: + list_idx = self.flip_frame_local_indices.index(slider_idx) + self.flip_frame_list.selection_clear(0, tk.END) + self.flip_frame_list.selection_set(list_idx) + self.flip_frame_list.see(list_idx) + else: + self.flip_frame_list.selection_clear(0, tk.END) + self._update_flip_frame_label() + if slider_idx is None: + return self.render_flip_preview() + def step_flip_frame(self, delta: int) -> str: + """Move the camera inspector backward or forward by one frame.""" + + if self.base_pose_data is None or len(getattr(self.base_pose_data, "frames", [])) == 0: + return "break" + current_idx = self._selected_flip_frame_local_idx() + if current_idx is None: + return "break" + next_idx = step_frame_index( + current=int(current_idx), + delta=int(delta), + max_frame=len(self.base_pose_data.frames) - 1, + ) + self.flip_frame_var.set(float(next_idx)) + self._on_flip_frame_scale_changed(None) + return "break" + + def _update_flip_frame_label(self) -> None: + """Display the current frame number beside the slider.""" + + if not hasattr(self, "flip_frame_label"): + return + if self.base_pose_data is None or len(getattr(self.base_pose_data, "frames", [])) == 0: + self.flip_frame_label.configure(text="frame -") + return + local_idx = self._selected_flip_frame_local_idx() + if local_idx is None: + self.flip_frame_label.configure(text="frame -") + return + frame_number = int(np.asarray(self.base_pose_data.frames, dtype=int)[local_idx]) + self.flip_frame_label.configure(text=f"frame {frame_number}") + + def _render_flip_frame_markers(self) -> None: + """Draw small markers on the frame slider for suspect and candidate flips.""" + + canvas = getattr(self, "flip_frame_marks", None) + if canvas is None: + return + canvas.delete("all") + camera_name = self._selected_inspector_camera_name() + if self.base_pose_data is None or camera_name not in getattr(self.base_pose_data, "camera_names", []): + return + width = max(int(canvas.winfo_width()), 1) + height = max(int(canvas.winfo_height()), 1) + n_frames = int(len(getattr(self.base_pose_data, "frames", []))) + if n_frames <= 1: + return + method = str(self.flip_method_var.get()).strip() + if method not in self.flip_masks: + return + cam_idx = list(self.base_pose_data.camera_names).index(camera_name) + suspect_mask = np.asarray(self.flip_masks.get(method), dtype=bool) + candidate_arrays = self.flip_detail_arrays.get(method, {}) + candidate_mask = ( + np.asarray(candidate_arrays.get("candidate_mask"), dtype=bool) + if "candidate_mask" in candidate_arrays + else None + ) + if candidate_mask is not None and candidate_mask.ndim == 2 and cam_idx < candidate_mask.shape[0]: + candidate_idx = np.flatnonzero(candidate_mask[cam_idx]) + for local_idx in candidate_idx: + x = round(local_idx * (width - 1) / max(n_frames - 1, 1)) + canvas.create_line(x, 3, x, height - 1, fill="#9aa5b1") + if suspect_mask.ndim == 2 and cam_idx < suspect_mask.shape[0]: + suspect_idx = np.flatnonzero(suspect_mask[cam_idx]) + for local_idx in suspect_idx: + x = round(local_idx * (width - 1) / max(n_frames - 1, 1)) + canvas.create_line(x, 0, x, height - 1, fill="#dd8452", width=2) + def _selected_flip_frame_local_idx(self) -> int | None: selection = self.flip_frame_list.curselection() if selection: idx = int(selection[0]) if idx < len(self.flip_frame_local_indices): return self.flip_frame_local_indices[idx] - return self.flip_frame_local_indices[0] if self.flip_frame_local_indices else None + if self.base_pose_data is None or len(getattr(self.base_pose_data, "frames", [])) == 0: + return None + if not hasattr(self, "flip_frame_var"): + return None + max_frame = len(self.base_pose_data.frames) - 1 + return clamp_frame_index(int(round(float(self.flip_frame_var.get()))), max_frame) + + def show_specific_frame(self, *, frame_number: int, camera_name: str | None = None) -> None: + if self.base_pose_data is None: + return + frames = np.asarray(self.base_pose_data.frames, dtype=int) + matches = np.flatnonzero(frames == int(frame_number)) + if matches.size == 0: + return + tree = getattr(self, "metrics_tree", None) + if ( + camera_name + and camera_name in self.base_pose_data.camera_names + and tree is not None + and getattr(tree, "exists", lambda _name: False)(camera_name) + ): + clear = getattr(tree, "selection_clear", None) + if callable(clear): + clear() + tree.selection_set((camera_name,)) + tree.focus(camera_name) + self._sync_inspector_camera_name() + local_idx = int(matches[0]) + self.flip_frame_local_indices = [local_idx] + self.flip_frame_list.delete(0, tk.END) + selected_camera = self._selected_inspector_camera_name() + self.flip_frame_list.insert(tk.END, f"manual | frame {int(frame_number)} | {selected_camera}") + self.flip_frame_list.selection_set(0) + self.flip_frame_var.set(float(local_idx)) + self._update_flip_frame_label() + self._render_flip_frame_markers() + self.render_flip_preview() + + def _qa_overlay_data( + self, camera_name: str, frame_local_idx: int + ) -> tuple[str, np.ndarray | None, np.ndarray | None, str | None]: + mode = str(getattr(getattr(self, "qa_overlay_var", None), "get", lambda: "none")()).strip().lower() + if mode == "2d epipolar": + cam_idx = list(self.pose_data.camera_names).index(camera_name) + values = frame_camera_epipolar_errors( + self.pose_data, self.calibrations, frame_idx=frame_local_idx, camera_idx=cam_idx + ) + return "2D epipolar", values, None, "turbo" + if mode not in {"3d reproj", "3d excluded"}: + return "none", None, None, None + payload = self._reference_payload() + if mode == "3d reproj": + errors = payload.get("reprojection_error_per_view") + if errors is None: + return "3D reproj", None, None, None + errors = np.asarray(errors, dtype=float) + cam_idx = list(self.pose_data.camera_names).index(camera_name) + if errors.ndim == 3 and frame_local_idx < errors.shape[0] and cam_idx < errors.shape[2]: + return "3D reproj", np.asarray(errors[frame_local_idx, :, cam_idx], dtype=float), None, "turbo" + if mode == "3d excluded": + excluded = payload.get("excluded_views") + if excluded is None: + return "3D excluded", None, None, None + excluded = np.asarray(excluded, dtype=bool) + cam_idx = list(self.pose_data.camera_names).index(camera_name) + if excluded.ndim == 3 and frame_local_idx < excluded.shape[0] and cam_idx < excluded.shape[2]: + return "3D excluded", None, np.asarray(excluded[frame_local_idx, :, cam_idx], dtype=bool), None + return "none", None, None, None def _reference_projection(self, camera_name: str, frame_local_idx: int) -> tuple[np.ndarray | None, str, str]: reference_name = (self._selected_reconstruction() or "").strip() @@ -9261,7 +14510,7 @@ def render_flip_preview(self) -> None: self.flip_canvas.draw_idle() return method = self.flip_method_var.get() - camera_name = self.flip_camera_var.get() + camera_name = self._selected_inspector_camera_name() frame_local_idx = self._selected_flip_frame_local_idx() if frame_local_idx is None or camera_name not in self.base_pose_data.camera_names: ax = self.flip_figure.subplots(1, 1) @@ -9272,6 +14521,7 @@ def render_flip_preview(self) -> None: self.flip_canvas.draw_idle() return cam_idx = list(self.base_pose_data.camera_names).index(camera_name) + frame_number = int(self.base_pose_data.frames[frame_local_idx]) raw_points = np.asarray(self.base_pose_data.keypoints[cam_idx, frame_local_idx], dtype=float) display_raw_points = swap_left_right_keypoints(raw_points) if self.flip_applied_var.get() else raw_points projected_points, projected_label, projected_color = self._reference_projection(camera_name, frame_local_idx) @@ -9287,48 +14537,99 @@ def render_flip_preview(self) -> None: if (finite_raw.size or finite_projected.size) else np.empty((0, 2)) ) - if finite.size: - xmin, ymin = np.min(finite, axis=0) - xmax, ymax = np.max(finite, axis=0) - margin_x = max(20.0, 0.15 * float(xmax - xmin)) - margin_y = max(20.0, 0.15 * float(ymax - ymin)) - x_limits = (max(0.0, float(xmin - margin_x)), min(float(width), float(xmax + margin_x))) - y_limits = (min(float(height), float(ymax + margin_y)), max(0.0, float(ymin - margin_y))) - else: - x_limits = (0.0, float(width)) - y_limits = (float(height), 0.0) + x_limits, y_limits = crop_limits_from_points(finite, width=float(width), height=float(height), margin=0.2) ax = self.flip_figure.subplots(1, 1) - draw_skeleton_2d( + images_root = shared_images_root_path(self.state) or self.images_root + background_image = ( + load_camera_background_image( + images_root, + camera_name, + frame_number, + image_reader=plt.imread, + ) + if self.show_images_var.get() + else None + ) + layers = [ + SkeletonLayer2D( + points=display_raw_points, + color=("white" if background_image is not None else "#000000"), + label="Raw 2D", + marker_size=28.0, + marker_fill=False, + marker_edge_width=1.9, + line_alpha=0.55, + line_style=(0, (2.0, 2.2)), + line_width_scale=0.6, + ) + ] + if projected_points is not None: + layers.append( + SkeletonLayer2D( + points=projected_points, + color=projected_color, + label=projected_label, + marker_size=20.0, + ) + ) + render_camera_frame_2d( + ax, + width=width, + height=height, + title=f"Raw {'(swapped)' if self.flip_applied_var.get() else ''} + reprojection", + layers=layers, + draw_skeleton_fn=draw_skeleton_2d, + background_image=background_image, + draw_background_fn=draw_2d_background_image, + x_limits=x_limits, + y_limits=y_limits, + hide_axes=False, + show_grid=True, + grid_alpha=0.18, + xlabel="x (px)", + ylabel="y (px)", + ) + overlay_label, overlay_values, overlay_mask, overlay_cmap = self._qa_overlay_data(camera_name, frame_local_idx) + overlay_scatter = draw_point_value_overlay( ax, - display_raw_points, - "#000000", - "Raw 2D", - marker_size=28.0, - marker_fill=False, - marker_edge_width=1.9, - line_alpha=0.55, - line_style=(0, (2.0, 2.2)), - line_width_scale=0.6, + PointValueOverlay2D( + label=overlay_label, + points=display_raw_points, + values=overlay_values, + mask=overlay_mask, + cmap=overlay_cmap, + ), ) - if projected_points is not None: - draw_skeleton_2d(ax, projected_points, projected_color, projected_label, marker_size=20.0) - ax.set_xlim(*x_limits) - ax.set_ylim(*y_limits) - ax.set_aspect("equal", adjustable="box") - ax.set_title(f"Raw {'(swapped)' if self.flip_applied_var.get() else ''} + reprojection") - ax.grid(alpha=0.18) - ax.set_xlabel("x (px)") - ax.set_ylabel("y (px)") - handles, labels = ax.get_legend_handles_labels() - if handles: - uniq = {} - for handle, label in zip(handles, labels): - uniq[label] = handle - ax.legend(list(uniq.values()), list(uniq.keys()), loc="best", fontsize=8) + if overlay_scatter is not None: + self.flip_figure.colorbar(overlay_scatter, ax=ax, fraction=0.046, pad=0.04, label=overlay_label) + side_handles = [ + plt.Line2D( + [], + [], + color="#666666", + marker="^", + linestyle="None", + markersize=7, + markerfacecolor="none", + markeredgewidth=1.4, + label="Left side", + ), + plt.Line2D( + [], + [], + color="#666666", + marker="s", + linestyle="None", + markersize=7, + markerfacecolor="none", + markeredgewidth=1.4, + label="Right side", + ), + ] + ax.legend(side_handles, ["Left side", "Right side"], loc="best", fontsize=8) detail_arrays = self.flip_detail_arrays.get(method, {}) suspect = bool(self.flip_masks.get(method, np.zeros((0, 0), dtype=bool))[cam_idx, frame_local_idx]) - frame_number = int(self.base_pose_data.frames[frame_local_idx]) self.flip_figure.suptitle( f"{camera_name} | frame {frame_number} | {method} | suspect={'yes' if suspect else 'no'} | " f"reference={projected_label if projected_points is not None else 'none'}" @@ -9346,6 +14647,7 @@ def render_flip_preview(self) -> None: f"method={method}", f"raw_swapped={'yes' if self.flip_applied_var.get() else 'no'}", f"reference={projected_label if projected_points is not None else 'none'}", + f"qa_overlay={overlay_label}", f"suspect={'yes' if suspect else 'no'}", f"candidate={'yes' if self._flip_flag(detail_arrays, 'candidate_mask', cam_idx, frame_local_idx) else 'no'}", f"temporal_support={'yes' if self._flip_flag(detail_arrays, 'temporal_support_mask', cam_idx, frame_local_idx) else 'no'}", @@ -9487,49 +14789,55 @@ def _selected_reconstruction(self) -> str | None: def refresh_analysis(self) -> None: try: - self.bundle = get_cached_preview_bundle( - self.state, current_dataset_dir(self.state), None, None, align_root=False - ) - self.current_reconstruction_name = self._selected_reconstruction() - if self.current_reconstruction_name is None: - self.analysis = None - self.contacts = [] + with gui_busy_popup(self, title="Toile", message="Analyse du déplacement sur la toile...") as popup: + self.bundle = get_cached_preview_bundle( + self.state, current_dataset_dir(self.state), None, None, align_root=False + ) + self.current_reconstruction_name = self._selected_reconstruction() + if self.current_reconstruction_name is None: + self.analysis = None + self.contacts = [] + self.render_summary() + self.refresh_plot() + return + root_q, full_q, q_names = preview_root_series_for_reconstruction( + bundle=self.bundle, + name=self.current_reconstruction_name, + initial_rotation_correction=bool(self.state.initial_rotation_correction_var.get()), + ) + if root_q is None: + raise ValueError(f"Aucune cinématique racine disponible pour {self.current_reconstruction_name}.") + fps = float(self.state.fps_var.get()) + recon_3d = self.bundle.get("recon_3d", {}) if isinstance(self.bundle, dict) else {} + points_3d = ( + np.asarray(recon_3d[self.current_reconstruction_name], dtype=float) + if self.current_reconstruction_name in recon_3d + else None + ) + popup.set_status("Segmentation partagée des sauts...") + self.analysis = shared_jump_analysis( + self.state, + reconstruction_name=self.current_reconstruction_name, + root_q=np.asarray(root_q, dtype=float), + points_3d=points_3d, + fps=fps, + height_threshold=TRAMPOLINE_BED_HEIGHT_M, + height_threshold_range_ratio=0.20, + smoothing_window_s=0.15, + min_airtime_s=0.25, + min_gap_s=0.08, + min_peak_prominence_m=0.35, + contact_window_s=0.35, + full_q=None if full_q is None else np.asarray(full_q, dtype=float), + q_names=q_names, + angle_mode="euler", + analysis_start_frame=ANALYSIS_START_FRAME, + require_complete_jumps=True, + ) + contact_series = points_3d if points_3d is not None else np.asarray(root_q[:, :2], dtype=float) + self.contacts = analyze_trampoline_contacts(self.analysis, contact_series) self.render_summary() self.refresh_plot() - return - root_q, _full_q, _q_names = preview_root_series_for_reconstruction( - bundle=self.bundle, - name=self.current_reconstruction_name, - initial_rotation_correction=bool(self.state.initial_rotation_correction_var.get()), - ) - if root_q is None: - raise ValueError(f"Aucune cinématique racine disponible pour {self.current_reconstruction_name}.") - fps = float(self.state.fps_var.get()) - recon_3d = self.bundle.get("recon_3d", {}) if isinstance(self.bundle, dict) else {} - self.analysis = analyze_dd_session( - np.asarray(root_q, dtype=float), - fps, - height_values=jump_segmentation_height_series( - ( - np.asarray(recon_3d[self.current_reconstruction_name], dtype=float) - if self.current_reconstruction_name in recon_3d - else None - ), - np.asarray(root_q, dtype=float), - ), - height_threshold=TRAMPOLINE_BED_HEIGHT_M, - angle_mode="euler", - analysis_start_frame=ANALYSIS_START_FRAME, - require_complete_jumps=True, - ) - recon_3d = self.bundle.get("recon_3d", {}) if isinstance(self.bundle, dict) else {} - if self.current_reconstruction_name in recon_3d: - contact_series = np.asarray(recon_3d[self.current_reconstruction_name], dtype=float) - else: - contact_series = np.asarray(root_q[:, :2], dtype=float) - self.contacts = analyze_trampoline_contacts(self.analysis, contact_series) - self.render_summary() - self.refresh_plot() except Exception as exc: messagebox.showerror("Déplacement toile", str(exc)) @@ -9958,92 +15266,94 @@ def _selected_jump_index(self) -> int: def refresh_analysis(self) -> None: try: - gui_debug(f"DD refresh_analysis start dataset={current_dataset_dir(self.state)}") - self.bundle = get_cached_preview_bundle( - self.state, current_dataset_dir(self.state), None, None, align_root=False - ) - available_names = bundle_available_reconstruction_names( - self.bundle, include_3d=True, include_q=True, include_q_root=True - ) - selected_name = self._selected_reconstruction() - self.current_reconstruction_name = selected_name - gui_debug(f"DD selected reconstruction={selected_name}") - self.expected_dd_codes = self._load_expected_dd_codes() - if selected_name is None: - self.analysis = None + with gui_busy_popup(self, title="DD", message="Analyse des sauts et des codes DD...") as popup: + gui_debug(f"DD refresh_analysis start dataset={current_dataset_dir(self.state)}") + self.bundle = get_cached_preview_bundle( + self.state, current_dataset_dir(self.state), None, None, align_root=False + ) + available_names = bundle_available_reconstruction_names( + self.bundle, include_3d=True, include_q=True, include_q_root=True + ) + selected_name = self._selected_reconstruction() + self.current_reconstruction_name = selected_name + gui_debug(f"DD selected reconstruction={selected_name}") + self.expected_dd_codes = self._load_expected_dd_codes() + if selected_name is None: + self.analysis = None + self.analysis_by_name = {} + self.render_comparison_table(available_names) + self.render_summary() + self.refresh_plot() + return + + recon_q = self.bundle.get("recon_q", {}) + recon_q_root = self.bundle.get("recon_q_root", {}) + recon_3d = self.bundle.get("recon_3d", {}) + q_names = np.asarray(self.bundle.get("q_names", np.array([], dtype=object)), dtype=object) + root_q, full_q, q_name_list = self._root_series_for_reconstruction( + selected_name, recon_q, recon_q_root, recon_3d, q_names + ) + if root_q is None: + raise ValueError(f"Aucune cinématique racine disponible pour {selected_name}.") + gui_debug( + "DD root series ready " + f"name={selected_name} root_shape={root_q.shape} " + f"full_q={'yes' if full_q is not None else 'no'}" + ) + self._update_height_dof_choices(q_name_list) + fps = float(self.state.fps_var.get()) + height_threshold_abs = self.height_threshold_abs.get().strip() + popup.set_status("Segmentation partagée et classification DD...") + gui_debug( + "DD analyze_dd_session " + f"fps={fps} height_dof={self.height_dof.get()} " + f"smooth={self.smoothing_window_s.get()} thr_ratio={self.height_threshold_ratio.get()} " + f"thr_abs={height_threshold_abs or '-'}" + ) self.analysis_by_name = {} + for name in available_names: + name_root_q, name_full_q, name_q_name_list = self._root_series_for_reconstruction( + name, recon_q, recon_q_root, recon_3d, q_names + ) + if name_root_q is None: + continue + try: + points_3d = np.asarray(recon_3d[name], dtype=float) if name in recon_3d else None + self.analysis_by_name[name] = shared_jump_analysis( + self.state, + reconstruction_name=name, + root_q=np.asarray(name_root_q, dtype=float), + points_3d=points_3d, + fps=fps, + height_threshold=( + float(height_threshold_abs) if height_threshold_abs else TRAMPOLINE_BED_HEIGHT_M + ), + height_threshold_range_ratio=float(self.height_threshold_ratio.get()), + smoothing_window_s=float(self.smoothing_window_s.get()), + min_airtime_s=float(self.min_airtime_s.get()), + min_gap_s=float(self.min_gap_s.get()), + min_peak_prominence_m=float(self.min_peak_prominence_m.get()), + contact_window_s=float(self.contact_window_s.get()), + full_q=name_full_q, + q_names=name_q_name_list, + angle_mode=self.angle_mode.get(), + analysis_start_frame=ANALYSIS_START_FRAME, + require_complete_jumps=True, + ) + except Exception: + continue + self.analysis = self.analysis_by_name.get(selected_name) self.render_comparison_table(available_names) + if self.analysis is None: + raise ValueError(f"Aucune analyse DD disponible pour {selected_name}.") + gui_debug( + "DD analyze_dd_session done " + f"jumps={len(self.analysis.jumps)} threshold={self.analysis.height_threshold:.4f}" + ) + self.render_jump_list() self.render_summary() self.refresh_plot() - return - - recon_q = self.bundle.get("recon_q", {}) - recon_q_root = self.bundle.get("recon_q_root", {}) - recon_3d = self.bundle.get("recon_3d", {}) - q_names = np.asarray(self.bundle.get("q_names", np.array([], dtype=object)), dtype=object) - root_q, full_q, q_name_list = self._root_series_for_reconstruction( - selected_name, recon_q, recon_q_root, recon_3d, q_names - ) - if root_q is None: - raise ValueError(f"Aucune cinématique racine disponible pour {selected_name}.") - gui_debug( - "DD root series ready " - f"name={selected_name} root_shape={root_q.shape} " - f"full_q={'yes' if full_q is not None else 'no'}" - ) - self._update_height_dof_choices(q_name_list) - fps = float(self.state.fps_var.get()) - height_threshold_abs = self.height_threshold_abs.get().strip() - gui_debug( - "DD analyze_dd_session " - f"fps={fps} height_dof={self.height_dof.get()} " - f"smooth={self.smoothing_window_s.get()} thr_ratio={self.height_threshold_ratio.get()} " - f"thr_abs={height_threshold_abs or '-'}" - ) - self.analysis_by_name = {} - for name in available_names: - name_root_q, name_full_q, name_q_name_list = self._root_series_for_reconstruction( - name, recon_q, recon_q_root, recon_3d, q_names - ) - if name_root_q is None: - continue - try: - self.analysis_by_name[name] = analyze_dd_session( - np.asarray(name_root_q, dtype=float), - fps, - height_values=jump_segmentation_height_series( - np.asarray(recon_3d[name], dtype=float) if name in recon_3d else None, - np.asarray(name_root_q, dtype=float), - ), - height_threshold=( - float(height_threshold_abs) if height_threshold_abs else TRAMPOLINE_BED_HEIGHT_M - ), - height_threshold_range_ratio=float(self.height_threshold_ratio.get()), - smoothing_window_s=float(self.smoothing_window_s.get()), - min_airtime_s=float(self.min_airtime_s.get()), - min_gap_s=float(self.min_gap_s.get()), - min_peak_prominence_m=float(self.min_peak_prominence_m.get()), - contact_window_s=float(self.contact_window_s.get()), - full_q=name_full_q, - q_names=name_q_name_list, - angle_mode=self.angle_mode.get(), - analysis_start_frame=ANALYSIS_START_FRAME, - require_complete_jumps=True, - ) - except Exception: - continue - self.analysis = self.analysis_by_name.get(selected_name) - self.render_comparison_table(available_names) - if self.analysis is None: - raise ValueError(f"Aucune analyse DD disponible pour {selected_name}.") - gui_debug( - "DD analyze_dd_session done " - f"jumps={len(self.analysis.jumps)} threshold={self.analysis.height_threshold:.4f}" - ) - self.render_jump_list() - self.render_summary() - self.refresh_plot() - gui_debug("DD refresh_analysis done") + gui_debug("DD refresh_analysis done") except Exception as exc: gui_debug(f"DD refresh_analysis error: {exc}") messagebox.showerror("Analyse DD", str(exc)) @@ -10312,8 +15622,8 @@ def refresh_plot(self) -> None: label="knee flex", ) for phase_name, mask, color_fill in ( - ("grouped", selected_jump.grouped_mask, "#dd8452"), - ("piked", selected_jump.piked_mask, "#4c72b0"), + ("groupé", selected_jump.grouped_mask, "#dd8452"), + ("carpé", selected_jump.piked_mask, "#4c72b0"), ): phase_regions = contiguous_true_regions(mask) for region_idx, (start_idx, end_idx) in enumerate(phase_regions): @@ -10339,15 +15649,79 @@ def refresh_plot(self) -> None: gui_debug("DD refresh_plot done " f"selected_jump={jump_idx + 1 if selected_jump is not None else 0}") +class StartupStatusWindow(tk.Toplevel): + """Small splash window shown while the main GUI is being prepared.""" + + def __init__(self, master: tk.Tk): + super().__init__(master) + self.title("Starting GUI") + self.resizable(False, False) + self.transient(master) + self.protocol("WM_DELETE_WINDOW", lambda: None) + + self.status_var = tk.StringVar(value="Starting...") + self.progress_var = tk.DoubleVar(value=0.0) + + body = ttk.Frame(self, padding=16) + body.pack(fill=tk.BOTH, expand=True) + ttk.Label(body, text="Preparing VitPose / EKF launcher", font=("", 14, "bold")).pack(anchor="w") + ttk.Label(body, text="Please wait while the GUI loads caches and initial data.").pack(anchor="w", pady=(6, 10)) + ttk.Label(body, textvariable=self.status_var, wraplength=420).pack(anchor="w") + self.progress = ttk.Progressbar(body, mode="determinate", length=420, maximum=1.0, variable=self.progress_var) + self.progress.pack(fill=tk.X, pady=(12, 0)) + self.geometry("470x150") + + def set_status(self, message: str, progress_ratio: float | None = None) -> None: + self.status_var.set(str(message)) + if progress_ratio is not None: + self.progress_var.set(float(np.clip(progress_ratio, 0.0, 1.0))) + self.update_idletasks() + + class LauncherApp(tk.Tk): + """Main Tk application hosting the full VitPose / EKF GUI.""" + def __init__(self): super().__init__() + self.withdraw() self.title("VitPose / EKF launcher") self.geometry("1450x950") + self.protocol("WM_DELETE_WINDOW", self._on_request_close) + self.startup_window = StartupStatusWindow(self) + tab_specs = [ + ("2D analysis", DataExplorer2DTab), + ("Cameras", CameraToolsTab), + ("Calibration", CalibrationTab), + ("Annotation", AnnotationTab), + ("Models", ModelTab), + ("Profiles", ProfilesTab), + ("Reconstructions", ReconstructionsTab), + ("Batch", BatchTab), + ("3D animation", DualAnimationTab), + ("2D multiview", MultiViewTab), + ("Execution", ExecutionTab), + ("DD", DDTab), + ("Toile", TrampolineTab), + ("Racine", RootKinematicsTab), + ("Autres DoF", JointKinematicsTab), + ("3D analysis", Analysis3DTab), + ("Observabilité", ObservabilityTab), + ] + self._startup_total_steps = 4 + len(tab_specs) + 1 + self._startup_step = 0 + + def advance_startup(message: str) -> None: + self._startup_step += 1 + self._set_startup_status(message, progress_ratio=self._startup_step / self._startup_total_steps) + + advance_startup("Creating shared application state") state = SharedAppState( calib_var=tk.StringVar(value=DEFAULT_GUI_CALIB_PATH), keypoints_var=tk.StringVar(value=DEFAULT_GUI_KEYPOINTS_PATH), + annotation_path_var=tk.StringVar( + value=display_path(default_annotation_path(ROOT / DEFAULT_GUI_KEYPOINTS_PATH)) + ), pose2sim_trc_var=tk.StringVar(value=DEFAULT_GUI_TRC_PATH), fps_var=tk.StringVar(value="120"), workers_var=tk.StringVar(value="6"), @@ -10365,48 +15739,63 @@ def __init__(self): flip_temporal_weight_var=tk.StringVar(value=str(DEFAULT_FLIP_TEMPORAL_WEIGHT)), flip_temporal_tau_px_var=tk.StringVar(value=str(DEFAULT_FLIP_TEMPORAL_TAU_PX)), calibration_correction_var=tk.StringVar(value="none"), + shared_images_root_var=tk.StringVar(value=""), initial_rotation_correction_var=tk.BooleanVar(value=True), selected_camera_names_var=tk.StringVar(value=""), output_root_var=tk.StringVar(value="output"), profiles_config_var=tk.StringVar(value=DEFAULT_GUI_PROFILES_CONFIG), ) + state.startup_status_callback = self._set_startup_status state.output_root_var.set(display_path(normalize_output_root(state.output_root_var.get()))) + advance_startup("Loading saved reconstruction profiles") profiles_path = ROOT / state.profiles_config_var.get() if profiles_path.exists(): try: - state.set_profiles(load_profiles_json(profiles_path)) + state.set_profiles(load_profiles_json(profiles_path), mark_dirty=False) except Exception: - state.set_profiles(example_profiles()) + state.set_profiles(example_profiles(), mark_dirty=False) else: - state.set_profiles(example_profiles()) + state.set_profiles(example_profiles(), mark_dirty=False) + state.clear_profiles_dirty() synchronize_profiles_initial_rotation_correction(state) self.state = state + advance_startup("Building main window layout") container = ttk.Frame(self) container.pack(fill=tk.BOTH, expand=True) + advance_startup("Preparing shared reconstruction selector") self.shared_reconstruction_panel = SharedReconstructionPanel(container, state, tooltip_fn=attach_tooltip) self.shared_reconstruction_panel.pack(fill=tk.X, padx=10, pady=(10, 0)) self.state.shared_reconstruction_panel = self.shared_reconstruction_panel self.notebook = ttk.Notebook(container) self.notebook.pack(fill=tk.BOTH, expand=True) - self.notebook.add(DataExplorer2DTab(self.notebook, state), text="2D analysis") - self.notebook.add(CameraToolsTab(self.notebook, state), text="Caméras") - self.notebook.add(ModelTab(self.notebook, state), text="Models") - self.notebook.add(ProfilesTab(self.notebook, state), text="Profiles") - self.notebook.add(ReconstructionsTab(self.notebook, state), text="Reconstructions") - self.notebook.add(DualAnimationTab(self.notebook, state), text="3D animation") - self.notebook.add(MultiViewTab(self.notebook, state), text="2D multiview") - self.notebook.add(ExecutionTab(self.notebook, state), text="Execution") - self.notebook.add(DDTab(self.notebook, state), text="DD") - self.notebook.add(TrampolineTab(self.notebook, state), text="Toile") - self.notebook.add(RootKinematicsTab(self.notebook, state), text="Racine") - self.notebook.add(JointKinematicsTab(self.notebook, state), text="Autres DoF") - self.notebook.add(Analysis3DTab(self.notebook, state), text="3D analysis") - self.notebook.add(ObservabilityTab(self.notebook, state), text="Observabilité") + for tab_label, tab_cls in tab_specs: + advance_startup(f"Preparing {tab_label} tab") + self.notebook.add(tab_cls(self.notebook, state), text=tab_label) self.notebook.bind("<>", self._on_tab_changed) + advance_startup("Finalizing GUI") self.after_idle(self._refresh_active_reconstruction_panel) + self.after_idle(self._finish_startup) + + def _set_startup_status(self, message: str, progress_ratio: float | None = None) -> None: + """Update the startup splash message while the GUI is still loading.""" + + startup_window = getattr(self, "startup_window", None) + if startup_window is None or not startup_window.winfo_exists(): + return + startup_window.set_status(message, progress_ratio=progress_ratio) + self.update_idletasks() + + def _finish_startup(self) -> None: + """Close the splash window and reveal the fully initialized GUI.""" + + self.state.startup_status_callback = None + startup_window = getattr(self, "startup_window", None) + if startup_window is not None and startup_window.winfo_exists(): + startup_window.destroy() + self.deiconify() def _refresh_active_reconstruction_panel(self) -> None: """Bind the top reconstruction selector to the active tab when supported.""" @@ -10426,6 +15815,45 @@ def _refresh_active_reconstruction_panel(self) -> None: def _on_tab_changed(self, _event=None) -> None: self._refresh_active_reconstruction_panel() + def _unsaved_change_sources(self) -> list[str]: + """Return the list of unsaved logical resources currently open in the GUI.""" + + sources: list[str] = [] + if bool(getattr(self.state, "profiles_dirty", False)): + sources.append("reconstruction profiles") + notebook = getattr(self, "notebook", None) + if notebook is None: + return sources + for tab_id in notebook.tabs(): + try: + tab = self.nametowidget(tab_id) + except Exception: + continue + if hasattr(tab, "has_unsaved_changes"): + try: + if bool(tab.has_unsaved_changes()): + label = getattr(tab, "unsaved_changes_label", "annotations") + if label not in sources: + sources.append(str(label)) + except Exception: + continue + return sources + + def _on_request_close(self) -> None: + """Confirm application shutdown when unsaved changes are detected.""" + + unsaved_sources = self._unsaved_change_sources() + if unsaved_sources: + summary = ", ".join(unsaved_sources) + should_quit = messagebox.askyesno( + "Quit GUI", + f"Unsaved changes were detected in: {summary}.\n\nDo you want to quit without saving?", + parent=self, + ) + if not should_quit: + return + self.destroy() + def main() -> None: app = LauncherApp() diff --git a/preview/__init__.py b/preview/__init__.py index e69de29..fbb8f43 100644 --- a/preview/__init__.py +++ b/preview/__init__.py @@ -0,0 +1 @@ +"""Shared preview helpers used by GUI tabs and export scripts.""" diff --git a/preview/frame_2d_render.py b/preview/frame_2d_render.py new file mode 100644 index 0000000..b401a4b --- /dev/null +++ b/preview/frame_2d_render.py @@ -0,0 +1,162 @@ +"""Shared 2D camera-frame rendering primitives for previews and exports.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +import numpy as np + + +@dataclass(frozen=True) +class SkeletonLayer2D: + """One styled 2D skeleton layer rendered on top of a camera frame.""" + + points: np.ndarray + color: str + label: str | None = None + marker_size: float = 12.0 + marker_fill: bool = True + marker_edge_width: float = 1.4 + line_alpha: float = 0.85 + line_style: str = "-" + line_width_scale: float = 1.0 + + +@dataclass(frozen=True) +class PointValueOverlay2D: + """Per-point scalar overlay with optional excluded-point markers.""" + + label: str + points: np.ndarray + values: np.ndarray | None = None + mask: np.ndarray | None = None + cmap: str | None = "turbo" + size: float = 18.0 + edgecolors: str = "white" + linewidths: float = 0.8 + alpha: float = 0.95 + excluded_color: str = "#111111" + excluded_marker: str = "x" + excluded_size: float = 34.0 + excluded_linewidths: float = 1.6 + + +def draw_point_value_overlay(ax, overlay: PointValueOverlay2D): + """Draw an optional per-point overlay and return the color-mapped scatter when present.""" + + points = np.asarray(overlay.points, dtype=float) + finite_points_mask = np.all(np.isfinite(points), axis=1) + scatter = None + if overlay.values is not None: + values = np.asarray(overlay.values, dtype=float).reshape(-1) + finite_overlay = finite_points_mask & np.isfinite(values) + if np.any(finite_overlay): + scatter = ax.scatter( + points[finite_overlay, 0], + points[finite_overlay, 1], + c=np.asarray(values[finite_overlay], dtype=float), + cmap=overlay.cmap or "turbo", + s=float(overlay.size), + linewidths=float(overlay.linewidths), + edgecolors=overlay.edgecolors, + alpha=float(overlay.alpha), + zorder=6, + ) + if overlay.mask is not None: + mask = finite_points_mask & np.asarray(overlay.mask, dtype=bool).reshape(-1) + if np.any(mask): + ax.scatter( + points[mask, 0], + points[mask, 1], + marker=overlay.excluded_marker, + s=float(overlay.excluded_size), + linewidths=float(overlay.excluded_linewidths), + c=overlay.excluded_color, + alpha=float(overlay.alpha), + zorder=7, + label=overlay.label if overlay.values is None else None, + ) + return scatter + + +def render_camera_frame_2d( + ax, + *, + width: float, + height: float, + title: str, + layers: list[SkeletonLayer2D], + draw_skeleton_fn: Callable[..., None], + background_image: np.ndarray | None = None, + draw_background_fn: Callable[..., None] | None = None, + crop_mode: str = "full", + crop_limits: dict[str, np.ndarray] | None = None, + cam_name: str | None = None, + frame_idx: int = 0, + apply_axis_limits_fn: Callable[..., None] | None = None, + hide_axes: bool = True, + hide_axes_fn: Callable[..., None] | None = None, + x_limits: tuple[float, float] | None = None, + y_limits: tuple[float, float] | None = None, + show_grid: bool = False, + grid_alpha: float = 0.18, + xlabel: str = "", + ylabel: str = "", +) -> bool: + """Render one 2D camera frame with shared background/limits/layer logic.""" + + has_image_background = False + if background_image is not None and draw_background_fn is not None: + draw_background_fn(ax, background_image, width=width, height=height) + has_image_background = True + + if x_limits is not None and y_limits is not None: + ax.set_xlim(*x_limits) + ax.set_ylim(*y_limits) + if hasattr(ax, "set_autoscale_on"): + ax.set_autoscale_on(False) + elif apply_axis_limits_fn is not None and crop_limits is not None and cam_name is not None: + apply_axis_limits_fn( + ax, + crop_mode=crop_mode, + crop_limits=crop_limits, + cam_name=cam_name, + frame_idx=frame_idx, + width=width, + height=height, + ) + else: + ax.set_xlim(0.0, float(width)) + ax.set_ylim(float(height), 0.0) + if hasattr(ax, "set_autoscale_on"): + ax.set_autoscale_on(False) + + ax.set_aspect("equal", adjustable="box") + ax.set_title(title) + + for layer in layers: + draw_skeleton_fn( + ax, + layer.points, + layer.color, + layer.label, + marker_size=layer.marker_size, + marker_fill=layer.marker_fill, + marker_edge_width=layer.marker_edge_width, + line_alpha=layer.line_alpha, + line_style=layer.line_style, + line_width_scale=layer.line_width_scale, + ) + + if hide_axes and hide_axes_fn is not None: + hide_axes_fn(ax) + else: + if show_grid: + ax.grid(alpha=float(grid_alpha)) + if xlabel: + ax.set_xlabel(xlabel) + if ylabel: + ax.set_ylabel(ylabel) + + return has_image_background diff --git a/preview/preview_bundle.py b/preview/preview_bundle.py index 7c70ec8..5935e7a 100644 --- a/preview/preview_bundle.py +++ b/preview/preview_bundle.py @@ -8,10 +8,8 @@ import numpy as np -from reconstruction.reconstruction_dataset import align_array_to_frames, load_bundle_entries from kinematics.root_kinematics import TRUNK_ROTATION_NAMES, TRUNK_TRANSLATION_NAMES -from reconstruction.reconstruction_dataset import preferred_master_name - +from reconstruction.reconstruction_dataset import align_array_to_frames, load_bundle_entries, preferred_master_name PreviewBundle = dict[str, object] diff --git a/preview/shared_reconstruction_panel.py b/preview/shared_reconstruction_panel.py index 902c4b2..e23e9dc 100644 --- a/preview/shared_reconstruction_panel.py +++ b/preview/shared_reconstruction_panel.py @@ -29,6 +29,49 @@ def show_placeholder_figure( canvas.draw_idle() +def _extend_treeview_selection(tree: ttk.Treeview, direction: int) -> str: + rows = list(tree.get_children("")) + if not rows: + return "break" + selected = list(tree.selection()) + if selected: + anchor = rows.index(selected[0]) if direction < 0 else rows.index(selected[-1]) + target_index = anchor + (-1 if direction < 0 else 1) + else: + focus_item = tree.focus() + focus_index = rows.index(focus_item) if focus_item in rows else 0 + target_index = focus_index + (-1 if direction < 0 else 1) + target_index = max(0, min(len(rows) - 1, int(target_index))) + target_item = rows[target_index] + updated = [item for item in selected if item in rows] + if target_item not in updated: + updated.append(target_item) + tree.selection_set(tuple(updated)) + tree.focus(target_item) + try: + tree.see(target_item) + except Exception: + pass + return "break" + + +def _select_all_treeview(tree: ttk.Treeview) -> str: + rows = list(tree.get_children("")) + if rows: + tree.selection_set(tuple(rows)) + tree.focus(rows[0]) + return "break" + + +def bind_extended_treeview_shortcuts(tree: ttk.Treeview) -> None: + """Bind multi-selection keyboard shortcuts to one reconstruction treeview.""" + + tree.bind("", lambda _event: _extend_treeview_selection(tree, -1), add="+") + tree.bind("", lambda _event: _extend_treeview_selection(tree, 1), add="+") + for sequence in ("", "", "", ""): + tree.bind(sequence, lambda _event: _select_all_treeview(tree), add="+") + + class SharedReconstructionPanel(ttk.Frame): """Single reconstruction selector shared by the active analysis tab.""" @@ -45,9 +88,14 @@ def __init__( self._selection_callback = None self._refresh_callback = None self._suspend_selection_callback = False + self._selectmode = "extended" + self._allow_empty_selection = False + self._explicitly_cleared = False header = ttk.Frame(self) header.pack(fill=tk.X, padx=8, pady=(6, 2)) + self.clean_caches_button = ttk.Button(header, text="Clean trial caches", command=lambda: None) + self.clean_caches_button.pack(side=tk.RIGHT, padx=(0, 8)) self.clean_button = ttk.Button(header, text="Clean trial outputs", command=lambda: None) self.clean_button.pack(side=tk.RIGHT, padx=(0, 8)) self.refresh_button = ttk.Button(header, text="Refresh available reconstructions") @@ -55,7 +103,7 @@ def __init__( self.tree = ttk.Treeview( self, - columns=("index", "label", "family", "frames", "reproj", "path"), + columns=("index", "label", "family", "frames", "recon_time", "model_time", "reproj", "path"), show="headings", height=5, selectmode="extended", @@ -64,16 +112,22 @@ def __init__( self.tree.heading("label", text="Reconstruction") self.tree.heading("family", text="Family") self.tree.heading("frames", text="Frames") + self.tree.heading("recon_time", text="Recon (s)") + self.tree.heading("model_time", text="Model (s)") self.tree.heading("reproj", text="Reproj (px)") self.tree.heading("path", text="Path") self.tree.column("index", width=42, anchor="center", stretch=False) self.tree.column("label", width=240, anchor="w") self.tree.column("family", width=90, anchor="w") self.tree.column("frames", width=70, anchor="w") + self.tree.column("recon_time", width=80, anchor="w", stretch=False) + self.tree.column("model_time", width=80, anchor="w", stretch=False) self.tree.column("reproj", width=95, anchor="w") - self.tree.column("path", width=540, anchor="w") + self.tree.column("path", width=380, anchor="w") self.tree.pack(fill=tk.X, expand=False, padx=8, pady=(0, 6)) + bind_extended_treeview_shortcuts(self.tree) self.tree.bind("<>", self._on_selection_changed) + self.tree.bind("", self._on_button_press, add="+") if tooltip_fn is not None: tooltip_fn(self.tree, "Shared reconstruction selector used by the active analysis tab.") self.show_placeholder("Select a tab that uses reconstruction comparisons.") @@ -85,6 +139,7 @@ def configure_for_consumer( refresh_callback, selection_callback, selectmode: str = "extended", + allow_empty_selection: bool = False, ) -> None: """Bind the shared panel to one tab consumer.""" @@ -92,8 +147,13 @@ def configure_for_consumer( self._refresh_callback = refresh_callback self._selection_callback = selection_callback self.refresh_button.configure(command=refresh_callback) + self._selectmode = selectmode + self._allow_empty_selection = bool(allow_empty_selection) + self._explicitly_cleared = False clean_callback = getattr(self.state, "clean_trial_outputs_callback", None) + clean_caches_callback = getattr(self.state, "clean_trial_caches_callback", None) self.clean_button.configure(command=clean_callback or (lambda: None)) + self.clean_caches_button.configure(command=clean_caches_callback or (lambda: None)) self.tree.configure(selectmode=selectmode) def set_rows(self, rows: list[dict[str, object]], default_names: list[str] | None = None) -> None: @@ -101,6 +161,7 @@ def set_rows(self, rows: list[dict[str, object]], default_names: list[str] | Non previous = set(self.selected_names()) self._default_names = list(default_names or []) + self._explicitly_cleared = False self._suspend_selection_callback = True try: for item in self.tree.get_children(): @@ -112,6 +173,8 @@ def set_rows(self, rows: list[dict[str, object]], default_names: list[str] | Non continue row_names.append(name) reproj_mean = row.get("reproj_mean") + recon_compute_s = row.get("compute_s") + model_compute_s = row.get("model_compute_s") row_family = str(row.get("family", "-")) row_index = "" if name == "raw" or row_family == "2d" else str(row_idx) self.tree.insert( @@ -123,6 +186,8 @@ def set_rows(self, rows: list[dict[str, object]], default_names: list[str] | Non str(row.get("label", name)), row_family, row.get("frames", "-"), + "-" if recon_compute_s is None else f"{float(recon_compute_s):.2f}", + "-" if model_compute_s is None else f"{float(model_compute_s):.2f}", "-" if reproj_mean is None else f"{float(reproj_mean):.2f}", str(row.get("path", "")), ), @@ -141,6 +206,8 @@ def selected_names(self) -> list[str]: selected = list(self.tree.selection()) if selected: return selected + if getattr(self, "_allow_empty_selection", False) and getattr(self, "_explicitly_cleared", False): + return [] return [name for name in self._default_names if self.tree.exists(name)] def show_placeholder(self, message: str) -> None: @@ -151,11 +218,12 @@ def show_placeholder(self, message: str) -> None: self._refresh_callback = None self.refresh_button.configure(command=lambda: None) self.clean_button.configure(command=lambda: None) + self.clean_caches_button.configure(command=lambda: None) self._suspend_selection_callback = True try: for item in self.tree.get_children(): self.tree.delete(item) - self.tree.insert("", "end", iid="__placeholder__", values=("", message, "-", "-", "-", "-")) + self.tree.insert("", "end", iid="__placeholder__", values=("", message, "-", "-", "-", "-", "-", "-")) self.tree.selection_remove(self.tree.selection()) finally: self._suspend_selection_callback = False @@ -164,9 +232,40 @@ def show_placeholder(self, message: str) -> None: def _publish_selection(self) -> None: self.state.set_shared_reconstruction_selection(self.selected_names()) + def _clear_selection(self) -> None: + self._suspend_selection_callback = True + try: + self.tree.selection_set(()) + self.tree.focus("") + finally: + self._suspend_selection_callback = False + self._explicitly_cleared = True + self._publish_selection() + if self._selection_callback is not None: + self._selection_callback() + + def _on_button_press(self, event=None): + if not self._allow_empty_selection or self._selectmode != "browse": + return None + row_id = self.tree.identify_row(event.y) if event is not None else "" + current = list(self.tree.selection()) + if row_id == "__placeholder__": + return "break" + if not row_id: + if current: + self._clear_selection() + return "break" + return None + if row_id in current and len(current) == 1: + self._clear_selection() + return "break" + return None + def _on_selection_changed(self, _event=None) -> None: if self._suspend_selection_callback: return + if self.tree.selection(): + self._explicitly_cleared = False self._publish_selection() if self._selection_callback is not None: self._selection_callback() diff --git a/preview/two_d_view.py b/preview/two_d_view.py new file mode 100644 index 0000000..a96ebd1 --- /dev/null +++ b/preview/two_d_view.py @@ -0,0 +1,225 @@ +"""Common 2D-view geometry, image, and crop helpers for previews.""" + +from __future__ import annotations + +import math +from pathlib import Path + +import numpy as np + +from judging.execution import resolve_execution_image_path + + +def camera_layout(n_cameras: int) -> tuple[int, int]: + """Return a compact ``(rows, cols)`` layout for the given camera count.""" + + n_cameras = max(int(n_cameras), 1) + ncols = min(4, max(1, int(math.ceil(math.sqrt(n_cameras))))) + nrows = int(math.ceil(n_cameras / ncols)) + return nrows, ncols + + +def square_crop_bounds( + xmin: float, + xmax: float, + ymin: float, + ymax: float, + width: float, + height: float, + margin: float, +) -> np.ndarray: + """Return a square crop that preserves the image isoview around visible points.""" + + span_x = max(10.0, float(xmax - xmin)) + span_y = max(10.0, float(ymax - ymin)) + half_size = 0.5 * max(span_x, span_y) * (1.0 + float(margin)) + half_size = max(12.0, half_size) + center_x = 0.5 * (float(xmin) + float(xmax)) + center_y = 0.5 * (float(ymin) + float(ymax)) + x0 = center_x - half_size + x1 = center_x + half_size + y0 = center_y - half_size + y1 = center_y + half_size + if x0 < 0.0: + x1 = min(float(width), x1 - x0) + x0 = 0.0 + if x1 > float(width): + x0 = max(0.0, x0 - (x1 - float(width))) + x1 = float(width) + if y0 < 0.0: + y1 = min(float(height), y1 - y0) + y0 = 0.0 + if y1 > float(height): + y0 = max(0.0, y0 - (y1 - float(height))) + y1 = float(height) + final_size = min(float(x1 - x0), float(y1 - y0)) + if final_size <= 0.0: + return np.array([0.0, float(width), float(height), 0.0], dtype=float) + center_x = 0.5 * (x0 + x1) + center_y = 0.5 * (y0 + y1) + half_final = 0.5 * final_size + x0 = max(0.0, center_x - half_final) + x1 = min(float(width), center_x + half_final) + y0 = max(0.0, center_y - half_final) + y1 = min(float(height), center_y + half_final) + return np.array([x0, x1, y1, y0], dtype=float) + + +def compute_pose_crop_limits_2d( + raw_2d: np.ndarray, + calibrations: dict, + camera_names: list[str], + margin: float, +) -> dict[str, np.ndarray]: + """Compute per-frame crop bounds from valid 2D points for each camera.""" + + limits: dict[str, np.ndarray] = {} + for cam_idx, cam_name in enumerate(camera_names): + width, height = calibrations[cam_name].image_size + n_frames = raw_2d.shape[1] + camera_limits = np.full((n_frames, 4), np.nan, dtype=float) + for frame_idx in range(n_frames): + points = raw_2d[cam_idx, frame_idx] + valid = np.all(np.isfinite(points), axis=1) + if not np.any(valid): + continue + xy = points[valid] + xmin, ymin = np.min(xy, axis=0) + xmax, ymax = np.max(xy, axis=0) + camera_limits[frame_idx] = square_crop_bounds( + xmin=xmin, + xmax=xmax, + ymin=ymin, + ymax=ymax, + width=width, + height=height, + margin=margin, + ) + valid_frames = np.flatnonzero(np.all(np.isfinite(camera_limits), axis=1)) + if valid_frames.size == 0: + camera_limits[:] = np.array([0.0, float(width), float(height), 0.0], dtype=float) + else: + first_valid = int(valid_frames[0]) + last_valid = int(valid_frames[-1]) + for frame_idx in range(0, first_valid): + camera_limits[frame_idx] = camera_limits[first_valid] + for frame_idx in range(first_valid + 1, n_frames): + if not np.all(np.isfinite(camera_limits[frame_idx])): + camera_limits[frame_idx] = camera_limits[frame_idx - 1] + for frame_idx in range(last_valid + 1, n_frames): + camera_limits[frame_idx] = camera_limits[last_valid] + limits[cam_name] = camera_limits + return limits + + +def apply_2d_axis_limits( + ax, + *, + crop_mode: str, + crop_limits: dict[str, np.ndarray], + cam_name: str, + frame_idx: int, + width: float, + height: float, +) -> None: + """Apply fixed 2D limits and disable matplotlib autoscale.""" + + if crop_mode == "pose": + x0, x1, y1, y0 = crop_limits[cam_name][frame_idx] + ax.set_xlim(x0, x1) + ax.set_ylim(y1, y0) + else: + ax.set_xlim(0, width) + ax.set_ylim(height, 0) + ax.set_autoscale_on(False) + + +def draw_2d_background_image(ax, image: np.ndarray, width: float, height: float) -> None: + """Display a 2D image in the same pixel coordinate system as the keypoints.""" + + ax.imshow( + image, + extent=(0.0, float(width), float(height), 0.0), + origin="upper", + interpolation="none", + zorder=0, + ) + + +def hide_2d_axes(ax) -> None: + """Hide pixel axes while preserving the title and data coordinates.""" + + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_xlabel("") + ax.set_ylabel("") + for spine in ax.spines.values(): + spine.set_visible(False) + + +def crop_limits_from_points( + points_2d: np.ndarray, + *, + width: float, + height: float, + margin: float, +) -> tuple[tuple[float, float], tuple[float, float]]: + """Return x/y limits from the finite 2D points, or full-image limits.""" + + points_2d = np.asarray(points_2d, dtype=float) + valid = np.all(np.isfinite(points_2d), axis=1) + if not np.any(valid): + return (0.0, float(width)), (float(height), 0.0) + finite = points_2d[valid] + xmin, ymin = np.min(finite, axis=0) + xmax, ymax = np.max(finite, axis=0) + x0, x1, y1, y0 = square_crop_bounds( + xmin=float(xmin), + xmax=float(xmax), + ymin=float(ymin), + ymax=float(ymax), + width=float(width), + height=float(height), + margin=float(margin), + ) + return (x0, x1), (y1, y0) + + +def adjust_image_levels( + image: np.ndarray, + *, + brightness: float = 1.0, + contrast: float = 1.0, +) -> np.ndarray: + """Apply one simple global brightness/contrast adjustment to an RGB(A) image.""" + + adjusted = np.asarray(image, dtype=float) + if adjusted.size == 0: + return adjusted + scale = 255.0 if np.nanmax(adjusted) > 1.5 else 1.0 + adjusted = adjusted / scale + rgb = adjusted[..., :3] + rgb = (rgb - 0.5) * float(contrast) + 0.5 + rgb = rgb * float(brightness) + adjusted[..., :3] = np.clip(rgb, 0.0, 1.0) + if scale > 1.5: + adjusted = np.round(adjusted * scale).astype(image.dtype, copy=False) + return adjusted + + +def load_camera_background_image( + images_root: Path | None, + camera_name: str, + frame_number: int, + *, + image_reader, + brightness: float = 1.0, + contrast: float = 1.0, +) -> np.ndarray | None: + """Resolve and optionally adjust one camera background image.""" + + image_path = resolve_execution_image_path(images_root, camera_name, int(frame_number)) + if image_path is None or not image_path.exists(): + return None + image = image_reader(str(image_path)) + return adjust_image_levels(image, brightness=brightness, contrast=contrast) diff --git a/reconstruction/__init__.py b/reconstruction/__init__.py index e69de29..763cffb 100644 --- a/reconstruction/__init__.py +++ b/reconstruction/__init__.py @@ -0,0 +1 @@ +"""Dataset, cache, bundle, and profile helpers for reconstruction outputs.""" diff --git a/reconstruction/reconstruction_bundle.py b/reconstruction/reconstruction_bundle.py index 72490da..b0f56f3 100644 --- a/reconstruction/reconstruction_bundle.py +++ b/reconstruction/reconstruction_bundle.py @@ -24,7 +24,9 @@ centered_finite_difference, extract_root_from_q, normalize, + normalize_root_unwrap_mode, root_z_correction_angle_from_points, + stabilize_root_rotations, unwrap_with_gaps, ) from reconstruction.reconstruction_dataset import load_trc_root_kinematics_sidecar @@ -32,6 +34,7 @@ from reconstruction.reconstruction_timings import make_timing_stage from vitpose_ekf_pipeline import ( COCO17, + DEFAULT_ANKLE_BED_PSEUDO_STD_M, DEFAULT_BIORBD_KALMAN_ERROR_FACTOR, DEFAULT_BIORBD_KALMAN_INIT_METHOD, DEFAULT_BIORBD_KALMAN_NOISE_FACTOR, @@ -59,10 +62,13 @@ DEFAULT_SUBJECT_MASS_KG, DEFAULT_TRIANGULATION_METHOD, DEFAULT_TRIANGULATION_WORKERS, + DEFAULT_UPPER_BACK_PSEUDO_STD_RAD, + DEFAULT_UPPER_BACK_SAGITTAL_GAIN, KP_INDEX, CameraCalibration, PoseData, ReconstructionResult, + SegmentLengths, apply_left_right_flip_corrections, apply_left_right_flip_to_points, build_biomod, @@ -100,6 +106,8 @@ @dataclass class BundleBuildResult: + """Pair one exported bundle payload with its JSON summary.""" + payload: dict[str, np.ndarray] summary: dict[str, object] @@ -109,11 +117,15 @@ def _jsonable_metadata(metadata: dict[str, object]) -> dict[str, object]: def cache_key(metadata: dict[str, object]) -> str: + """Return a stable short hash for one JSON-serializable cache metadata blob.""" + canonical = json.dumps(_jsonable_metadata(metadata), sort_keys=True, separators=(",", ":")) return hashlib.sha1(canonical.encode("utf-8")).hexdigest()[:16] def dataset_cache_root(output_dir: Path) -> Path: + """Return the shared cache root associated with one dataset output directory.""" + output_dir = Path(output_dir) if output_dir.name in {"models", "reconstructions", "figures"}: dataset_dir = output_dir.parent @@ -125,6 +137,8 @@ def dataset_cache_root(output_dir: Path) -> Path: def cache_entry_dir(output_dir: Path, category: str, metadata: dict[str, object], prefix: str) -> Path: + """Create or reuse the cache directory dedicated to one metadata signature.""" + cache_dir = dataset_cache_root(output_dir) / category / f"{prefix}_{cache_key(metadata)}" cache_dir.mkdir(parents=True, exist_ok=True) (cache_dir / "metadata.json").write_text(json.dumps(_jsonable_metadata(metadata), indent=2), encoding="utf-8") @@ -141,6 +155,8 @@ def epipolar_cache_metadata( pose_amplitude_lower_percentile: float, pose_amplitude_upper_percentile: float, ) -> dict[str, object]: + """Build the cache metadata describing one epipolar-coherence computation.""" + return { "camera_names": list(pose_data.camera_names), "n_frames": int(pose_data.frames.shape[0]), @@ -159,6 +175,8 @@ def epipolar_cache_metadata( def save_epipolar_cache( cache_path: Path, epipolar_coherence: np.ndarray, metadata: dict[str, object], compute_time_s: float ) -> None: + """Persist one epipolar-coherence array and its metadata to disk.""" + cache_path.parent.mkdir(parents=True, exist_ok=True) np.savez( cache_path, @@ -169,6 +187,8 @@ def save_epipolar_cache( def load_epipolar_cache(cache_path: Path) -> tuple[np.ndarray, float]: + """Load cached epipolar coherence and its compute time.""" + with np.load(cache_path, allow_pickle=True) as data: epipolar_coherence = np.asarray(data["epipolar_coherence"], dtype=float) compute_time_s = float(np.asarray(data["compute_time_s"]).item()) if "compute_time_s" in data else 0.0 @@ -188,6 +208,8 @@ def load_or_compute_epipolar_cache( pose_amplitude_lower_percentile: float, pose_amplitude_upper_percentile: float, ) -> tuple[np.ndarray, float, Path, str]: + """Reuse or compute the epipolar-coherence support for one reconstruction.""" + coherence_method = canonical_coherence_method(coherence_method) support_method = support_coherence_method_for_runtime(coherence_method) distance_mode = "symmetric" if support_method == "epipolar_fast" else "sampson" @@ -246,6 +268,8 @@ def flip_cache_metadata( temporal_tau_px: float, temporal_min_valid_keypoints: int, ) -> dict[str, object]: + """Build the cache metadata describing one left/right flip-detection run.""" + return { "camera_names": list(pose_data.camera_names), "n_frames": int(pose_data.frames.shape[0]), @@ -289,6 +313,8 @@ def save_flip_cache( compute_time_s: float, detail_arrays: dict[str, np.ndarray] | None = None, ) -> None: + """Persist left/right flip diagnostics and optional detail arrays.""" + cache_path.parent.mkdir(parents=True, exist_ok=True) payload: dict[str, np.ndarray] = { "suspect_mask": np.asarray(suspect_mask, dtype=bool), @@ -303,6 +329,8 @@ def save_flip_cache( def load_flip_cache(cache_path: Path) -> tuple[np.ndarray, dict[str, object], float]: + """Load cached flip-detection outputs.""" + with np.load(cache_path, allow_pickle=True) as data: suspect_mask = np.asarray(data["suspect_mask"], dtype=bool) diagnostics = json.loads(str(np.asarray(data["diagnostics"]).item())) @@ -332,6 +360,8 @@ def load_or_compute_left_right_flip_cache( temporal_tau_px: float = DEFAULT_FLIP_TEMPORAL_TAU_PX, temporal_min_valid_keypoints: int = DEFAULT_FLIP_TEMPORAL_MIN_VALID_KEYPOINTS, ) -> tuple[np.ndarray, dict[str, object], float, Path, str]: + """Reuse or compute left/right flip diagnostics for one pose-data variant.""" + metadata = flip_cache_metadata( pose_data, method=method, @@ -384,6 +414,8 @@ def load_or_compute_left_right_flip_cache( def slice_pose_data(pose_data: PoseData, frame_indices: list[int] | np.ndarray) -> PoseData: + """Extract one frame subset from a ``PoseData`` instance.""" + idx = np.asarray(frame_indices, dtype=int) return PoseData( camera_names=list(pose_data.camera_names), @@ -423,6 +455,8 @@ def pose_variant_cache_metadata( temporal_tau_px: float | None = None, temporal_min_valid_keypoints: int | None = None, ) -> dict[str, object]: + """Build the cache metadata for one corrected or annotated pose-data variant.""" + metadata = { "camera_names": list(pose_data.camera_names), "n_frames": int(pose_data.frames.shape[0]), @@ -464,6 +498,8 @@ def save_pose_variant_cache( suspect_mask: np.ndarray | None = None, compute_time_s: float = 0.0, ) -> None: + """Persist one derived ``PoseData`` variant and its diagnostics.""" + cache_path.parent.mkdir(parents=True, exist_ok=True) payload: dict[str, np.ndarray] = { "camera_names": np.asarray(list(pose_data.camera_names), dtype=object), @@ -485,6 +521,8 @@ def save_pose_variant_cache( def load_pose_variant_cache(cache_path: Path) -> tuple[PoseData, dict[str, object], np.ndarray | None, float]: + """Load one cached pose-data variant together with diagnostics and timing.""" + with np.load(cache_path, allow_pickle=True) as data: pose_data = PoseData( camera_names=[str(name) for name in np.asarray(data["camera_names"], dtype=object).tolist()], @@ -526,6 +564,8 @@ def load_or_compute_pose_data_variant_cache( temporal_tau_px: float = DEFAULT_FLIP_TEMPORAL_TAU_PX, temporal_min_valid_keypoints: int = DEFAULT_FLIP_TEMPORAL_MIN_VALID_KEYPOINTS, ) -> tuple[PoseData, dict[str, object] | None, float, Path, str]: + """Reuse or compute one corrected 2D pose-data variant.""" + metadata = pose_variant_cache_metadata( pose_data, correction_mode=correction_mode, @@ -624,6 +664,8 @@ def reconstruction_with_full_frame_support( coherence_method: str, bootstrap_frame_global_idx: int, ) -> ReconstructionResult: + """Expand a one-frame bootstrap reconstruction back onto the full frame axis.""" + n_frames = pose_data.frames.shape[0] n_cams = len(pose_data.camera_names) points_3d = np.full((n_frames, len(COCO17), 3), np.nan, dtype=float) @@ -665,6 +707,8 @@ def reconstruction_with_full_frame_support( def estimate_segment_lengths_first_frame(reconstruction: ReconstructionResult) -> tuple[SegmentLengths, int]: + """Estimate segment lengths from the earliest frame with valid 3D support.""" + for frame_idx in range(reconstruction.points_3d.shape[0]): frame_points = reconstruction.points_3d[frame_idx : frame_idx + 1] if np.any(np.isfinite(frame_points)): @@ -697,7 +741,7 @@ def load_or_compute_triangulation_cache( calibrations: dict[str, CameraCalibration], coherence_method: str, triangulation_method: str, - reprojection_threshold_px: float, + reprojection_threshold_px: float | None, min_cameras_for_triangulation: int, epipolar_threshold_px: float, triangulation_workers: int, @@ -707,6 +751,8 @@ def load_or_compute_triangulation_cache( pose_amplitude_lower_percentile: float, pose_amplitude_upper_percentile: float, ) -> tuple[ReconstructionResult, Path, Path, str]: + """Reuse or compute the triangulation support for one reconstruction run.""" + triangulation_method = canonical_triangulation_method(triangulation_method) coherence_method = canonical_coherence_method(coherence_method, triangulation_method) effective_triangulation_method = triangulation_method_from_coherence_method(coherence_method, triangulation_method) @@ -771,7 +817,11 @@ def load_or_build_model_cache( initial_rotation_correction: bool, lengths_mode: str, model_variant: str = "single_trunk", + symmetrize_limbs: bool = True, ) -> tuple[SegmentLengths, Path, Path, int, float, str]: + """Reuse or build the biomechanical model stage associated with one run.""" + + build_start = time.perf_counter() if lengths_mode == "first_frame_only": lengths, bootstrap_frame_idx = estimate_segment_lengths_first_frame(reconstruction) else: @@ -785,6 +835,7 @@ def load_or_build_model_cache( subject_mass_kg, initial_rotation_correction, model_variant=model_variant, + symmetrize_limbs=symmetrize_limbs, ) metadata["lengths_mode"] = lengths_mode metadata["bootstrap_frame_idx"] = int(bootstrap_frame_idx) @@ -795,7 +846,6 @@ def load_or_build_model_cache( cached_lengths, _biomod_path, compute_time_s = load_model_stage(cache_path) return cached_lengths, biomod_cache_path, cache_path, int(bootstrap_frame_idx), float(compute_time_s), "cache" - build_start = time.perf_counter() build_biomod( lengths, biomod_cache_path, @@ -803,6 +853,7 @@ def load_or_build_model_cache( reconstruction=reconstruction, apply_initial_root_rotation_correction=initial_rotation_correction, model_variant=model_variant, + symmetrize_limbs=symmetrize_limbs, ) compute_time_s = time.perf_counter() - build_start save_model_stage(cache_path, lengths, biomod_cache_path, metadata, compute_time_s=compute_time_s) @@ -815,7 +866,7 @@ def prepare_pose_data_for_reconstruction( pose_data: PoseData, calibrations: dict[str, CameraCalibration], coherence_method: str, - reprojection_threshold_px: float, + reprojection_threshold_px: float | None, epipolar_threshold_px: float, pose_data_mode: str, pose_filter_window: int, @@ -834,6 +885,8 @@ def prepare_pose_data_for_reconstruction( flip_temporal_min_valid_keypoints: int, flip_method: str | None = None, ) -> tuple[PoseData, dict[str, object] | None, Path | None, str]: + """Prepare the effective 2D observations used by one reconstruction family.""" + coherence_method = canonical_coherence_method(coherence_method) if not flip_left_right: return pose_data, None, None, "computed_now" @@ -882,6 +935,8 @@ def prepare_pose_data_for_reconstruction( def with_version_info(summary: dict[str, object], family: str) -> dict[str, object]: + """Attach schema and algorithm-version metadata to one bundle summary.""" + latest = latest_version_for_family(family) enriched = dict(summary) enriched["bundle_schema_version"] = int(BUNDLE_SCHEMA_VERSION) @@ -983,6 +1038,8 @@ def parse_trc_points(trc_path: Path) -> tuple[np.ndarray, np.ndarray, np.ndarray def q_names_from_model(model) -> np.ndarray: + """Return the generalized-coordinate names for one ``biorbd`` model.""" + names = [ f"{model.segment(i_seg).name().to_string()}:{model.segment(i_seg).nameDof(i_dof).to_string()}" for i_seg in range(model.nbSegment()) @@ -992,6 +1049,8 @@ def q_names_from_model(model) -> np.ndarray: def should_apply_initial_rotation_correction(points_3d: np.ndarray) -> bool: + """Return whether geometric root-yaw snapping would have a non-zero effect.""" + angle = root_z_correction_angle_from_points( points_3d, left_hip_idx=KP_INDEX["left_hip"], @@ -1006,7 +1065,11 @@ def extract_root_from_points( points_3d: np.ndarray, apply_initial_rotation_correction: bool, unwrap_rotations: bool, + unwrap_mode: str | None = None, + translation_origin: str = "pelvis", ) -> tuple[np.ndarray, bool]: + """Extract root translation and orientation directly from 3D trunk markers.""" + correction_angle = root_z_correction_angle_from_points( points_3d, left_hip_idx=KP_INDEX["left_hip"], @@ -1020,6 +1083,7 @@ def extract_root_from_points( right_hip_idx=KP_INDEX["right_hip"], left_shoulder_idx=KP_INDEX["left_shoulder"], right_shoulder_idx=KP_INDEX["right_shoulder"], + translation_origin=translation_origin, ) correction_detected = abs(correction_angle) > 1e-8 correction_applied = bool(apply_initial_rotation_correction and correction_detected) @@ -1037,8 +1101,9 @@ def extract_root_from_points( degrees=False, ) - if unwrap_rotations: - root_q[:, ROOT_ROTATION_SLICE] = unwrap_with_gaps(root_q[:, ROOT_ROTATION_SLICE]) + effective_unwrap_mode = normalize_root_unwrap_mode(unwrap_mode, legacy_unwrap=unwrap_rotations) + if effective_unwrap_mode != "off": + root_q[:, ROOT_ROTATION_SLICE] = stabilize_root_rotations(root_q[:, ROOT_ROTATION_SLICE], effective_unwrap_mode) return root_q, correction_applied @@ -1051,6 +1116,7 @@ def root_kinematics_from_trc( fps: float, initial_rotation_correction: bool, unwrap_root: bool, + root_unwrap_mode: str = "off", ) -> tuple[np.ndarray, np.ndarray, bool, str]: """Resolve root kinematics for one imported TRC file. @@ -1076,12 +1142,20 @@ def root_kinematics_from_trc( "trc_root_kinematics_sidecar", ) - q_root, correction_applied = extract_root_from_points(points_3d, initial_rotation_correction, unwrap_root) + effective_root_unwrap_mode = normalize_root_unwrap_mode(root_unwrap_mode, legacy_unwrap=unwrap_root) + q_root, correction_applied = extract_root_from_points( + points_3d, + initial_rotation_correction, + unwrap_root=(effective_root_unwrap_mode != "off"), + unwrap_mode=effective_root_unwrap_mode, + ) qdot_root = centered_finite_difference(q_root, 1.0 / fps) return q_root, qdot_root, correction_applied, "geometric_trc_markers" def align_points_to_frames(points_3d: np.ndarray, point_frames: np.ndarray, target_frames: np.ndarray) -> np.ndarray: + """Align frame-indexed 3D points onto a target frame axis.""" + aligned = np.full((len(target_frames), points_3d.shape[1], 3), np.nan, dtype=float) frame_to_index = {int(frame): idx for idx, frame in enumerate(np.asarray(point_frames, dtype=int))} for out_idx, frame in enumerate(np.asarray(target_frames, dtype=int)): @@ -1098,6 +1172,8 @@ def compute_points_reprojection_error_per_view( calibrations: dict[str, CameraCalibration], pose_data: PoseData, ) -> np.ndarray: + """Compute per-view reprojection errors for raw 3D marker trajectories.""" + aligned_points = align_points_to_frames(points_3d, point_frames, pose_data.frames) errors = np.full((pose_data.frames.shape[0], len(COCO17), len(pose_data.camera_names)), np.nan, dtype=float) for cam_idx, cam_name in enumerate(pose_data.camera_names): @@ -1121,6 +1197,8 @@ def compute_model_reprojection_error_per_view( calibrations: dict[str, CameraCalibration], pose_data: PoseData, ) -> np.ndarray: + """Compute per-view reprojection errors for model-driven 3D marker trajectories.""" + marker_names = marker_name_list(model) marker_kp_pairs = [(marker_name, KP_INDEX[marker_name]) for marker_name in marker_names if marker_name in KP_INDEX] errors = np.full((pose_data.keypoints.shape[1], len(COCO17), len(pose_data.camera_names)), np.nan, dtype=float) @@ -1146,6 +1224,8 @@ def compute_model_reprojection_error_per_view( def compute_model_marker_points_3d(model, q_trajectory: np.ndarray) -> np.ndarray: + """Evaluate the model markers corresponding to the COCO17 support for one q trajectory.""" + marker_names = marker_name_list(model) marker_kp_pairs = [(marker_name, KP_INDEX[marker_name]) for marker_name in marker_names if marker_name in KP_INDEX] points_3d = np.full((q_trajectory.shape[0], len(COCO17), 3), np.nan, dtype=float) @@ -1158,11 +1238,35 @@ def compute_model_marker_points_3d(model, q_trajectory: np.ndarray) -> np.ndarra def summarize_reprojection_errors(errors: np.ndarray, camera_names: list[str]) -> dict[str, object]: + """Aggregate reprojection errors by keypoint and by camera.""" + + def _nanmean_without_warning(values: np.ndarray, axis: tuple[int, ...]) -> np.ndarray: + values = np.asarray(values, dtype=float) + finite_counts = np.sum(np.isfinite(values), axis=axis) + sums = np.nansum(values, axis=axis) + means = np.full(sums.shape, np.nan, dtype=float) + valid = finite_counts > 0 + means[valid] = sums[valid] / finite_counts[valid] + return means + + def _nanstd_without_warning(values: np.ndarray, axis: tuple[int, ...]) -> np.ndarray: + means = _nanmean_without_warning(values, axis=axis) + expanded_means = means + for current_axis in sorted(axis): + expanded_means = np.expand_dims(expanded_means, axis=current_axis) + centered = np.asarray(values, dtype=float) - expanded_means + squared = np.where(np.isfinite(values), centered**2, np.nan) + variances = _nanmean_without_warning(squared, axis=axis) + std = np.full(variances.shape, np.nan, dtype=float) + finite = np.isfinite(variances) + std[finite] = np.sqrt(variances[finite]) + return std + finite = errors[np.isfinite(errors)] - per_key_mean = np.nanmean(errors, axis=(0, 2)) - per_key_std = np.nanstd(errors, axis=(0, 2)) - per_camera_mean = np.nanmean(errors, axis=(0, 1)) - per_camera_std = np.nanstd(errors, axis=(0, 1)) + per_key_mean = _nanmean_without_warning(errors, axis=(0, 2)) + per_key_std = _nanstd_without_warning(errors, axis=(0, 2)) + per_camera_mean = _nanmean_without_warning(errors, axis=(0, 1)) + per_camera_std = _nanstd_without_warning(errors, axis=(0, 1)) per_keypoint = { keypoint_name: { "mean_px": float(per_key_mean[idx]) if np.isfinite(per_key_mean[idx]) else None, @@ -1190,12 +1294,38 @@ def summarize_reprojection_errors(errors: np.ndarray, camera_names: list[str]) - } +def summarize_view_usage(excluded_views: np.ndarray | None, camera_names: list[str]) -> dict[str, object]: + """Summarize how often each camera was kept or excluded during triangulation.""" + + if excluded_views is None: + return {"included_ratio": None, "excluded_ratio": None, "per_camera": {}} + mask = np.asarray(excluded_views, dtype=bool) + if mask.ndim != 3 or mask.shape[2] != len(camera_names): + return {"included_ratio": None, "excluded_ratio": None, "per_camera": {}} + included = ~mask + per_camera: dict[str, object] = {} + for idx, camera_name in enumerate(camera_names): + included_ratio = float(np.mean(included[:, :, idx])) if included[:, :, idx].size else None + excluded_ratio = float(np.mean(mask[:, :, idx])) if mask[:, :, idx].size else None + per_camera[camera_name] = { + "included_ratio": included_ratio, + "excluded_ratio": excluded_ratio, + } + return { + "included_ratio": float(np.mean(included)) if included.size else None, + "excluded_ratio": float(np.mean(mask)) if mask.size else None, + "per_camera": per_camera, + } + + def make_reconstruction_from_points( frames: np.ndarray, points_3d: np.ndarray, n_cameras: int, coherence_method: str, ) -> ReconstructionResult: + """Wrap raw 3D points into a minimal ``ReconstructionResult`` container.""" + n_frames = points_3d.shape[0] return ReconstructionResult( frames=np.asarray(frames, dtype=int), @@ -1214,6 +1344,8 @@ def make_reconstruction_from_points( def empty_pose_data() -> PoseData: + """Return an empty ``PoseData`` container with the expected tensor layout.""" + return PoseData( camera_names=[], frames=np.array([], dtype=int), @@ -1223,6 +1355,8 @@ def empty_pose_data() -> PoseData: def duration_from_time(time_s: np.ndarray) -> float: + """Return the duration covered by one time vector.""" + if time_s.size == 0: return 0.0 return float(time_s[-1] - time_s[0]) if time_s.size > 1 else 0.0 @@ -1245,7 +1379,10 @@ def build_bundle_payload( reprojection_errors: np.ndarray, summary: dict[str, object], support_points_3d: np.ndarray | None = None, + excluded_views: np.ndarray | None = None, ) -> dict[str, np.ndarray]: + """Assemble the NPZ payload written for one reconstruction bundle.""" + if q is None: q = np.empty((len(frames), 0), dtype=float) if qdot is None: @@ -1276,12 +1413,16 @@ def build_bundle_payload( "reprojection_error_per_camera_std": np.asarray(reprojection_stats["per_camera_std"], dtype=float), "bundle_summary_json": np.asarray(json.dumps(summary), dtype=object), } + if excluded_views is not None: + payload["excluded_views"] = np.asarray(excluded_views, dtype=bool) if support_points_3d is not None: payload["support_points_3d"] = np.asarray(support_points_3d, dtype=float) return payload def write_bundle(output_dir: Path, payload: dict[str, np.ndarray], summary: dict[str, object]) -> None: + """Write the bundle NPZ and its JSON summaries to disk.""" + output_dir.mkdir(parents=True, exist_ok=True) np.savez(output_dir / "reconstruction_bundle.npz", **payload) summary_json = json.dumps(summary, indent=2) @@ -1295,7 +1436,7 @@ def save_legacy_triangulation( pose_data: PoseData, *, triangulation_method: str, - error_threshold_px: float, + error_threshold_px: float | None, min_cameras_for_triangulation: int, epipolar_threshold_px: float, pose_data_mode: str, @@ -1304,6 +1445,8 @@ def save_legacy_triangulation( pose_amplitude_lower_percentile: float, pose_amplitude_upper_percentile: float, ) -> Path: + """Write the legacy triangulation cache files expected by older tooling.""" + triangulation_method = canonical_triangulation_method(triangulation_method) if triangulation_method == "once": cache_name = "triangulation_pose2sim_like_once.npz" @@ -1335,6 +1478,8 @@ def save_legacy_ekf_2d( flip: bool, marker_points_3d: np.ndarray | None = None, ) -> None: + """Write the legacy EKF2D NPZ files expected by comparison scripts.""" + save_single_ekf_state(output_dir / f"ekf_states_{'flip_' if flip else ''}{predictor}.npz", result) key_prefix = f"q_ekf_2d_{'flip_' if flip else ''}{predictor}" qdot_prefix = f"qdot_ekf_2d_{'flip_' if flip else ''}{predictor}" @@ -1361,6 +1506,8 @@ def save_legacy_ekf_3d( reprojection_stats: dict[str, object], marker_points_3d: np.ndarray | None = None, ) -> None: + """Write the legacy EKF3D comparison NPZ expected by older scripts.""" + np.savez( output_dir / "kalman_comparison.npz", q_ekf_3d=result["q"], @@ -1391,7 +1538,10 @@ def build_pose_data( pose_outlier_threshold_ratio: float, pose_amplitude_lower_percentile: float, pose_amplitude_upper_percentile: float, + annotations_path: Path | None = None, ) -> PoseData: + """Load one ``PoseData`` object from keypoints, smoothing, and annotations settings.""" + return load_pose_data( keypoints_path, calibrations, @@ -1402,6 +1552,7 @@ def build_pose_data( outlier_threshold_ratio=pose_outlier_threshold_ratio, lower_percentile=pose_amplitude_lower_percentile, upper_percentile=pose_amplitude_upper_percentile, + annotations_path=annotations_path, ) @@ -1413,6 +1564,8 @@ def pose_effective_fps(pose_data: PoseData, source_fps: float) -> float: def print_step(step_idx: int, step_total: int, label: str) -> None: + """Print one pipeline step label in the CLI-friendly step format.""" + print(f"[STEP {step_idx}/{step_total}] {label}", flush=True) @@ -1427,7 +1580,10 @@ def build_pose2sim_bundle( fps: float, initial_rotation_correction: bool, unwrap_root: bool, + root_unwrap_mode: str = "off", ) -> BundleBuildResult: + """Build and export the standardized bundle for a Pose2Sim TRC import.""" + print_step(2, 2, "TRC file import + export bundle") t0 = time.perf_counter() frames, time_s, points_3d, trc_rate = parse_trc_points(pose2sim_trc) @@ -1439,6 +1595,7 @@ def build_pose2sim_bundle( fps=fps, initial_rotation_correction=initial_rotation_correction, unwrap_root=unwrap_root, + root_unwrap_mode=root_unwrap_mode, ) reprojection_errors = compute_points_reprojection_error_per_view(points_3d, frames, calibrations, pose_data) reprojection_stats = summarize_reprojection_errors(reprojection_errors, pose_data.camera_names) @@ -1496,6 +1653,7 @@ def build_pose2sim_bundle( }, } summary = with_version_info(summary, "triangulation") + excluded_views = np.zeros((len(frames), len(COCO17), len(pose_data.camera_names)), dtype=bool) payload = build_bundle_payload( name=name, family="pose2sim", @@ -1511,6 +1669,7 @@ def build_pose2sim_bundle( qdot_root=qdot_root, reprojection_errors=reprojection_errors, summary=summary, + excluded_views=excluded_views, ) write_bundle(output_dir, payload, summary) return BundleBuildResult(payload=payload, summary=summary) @@ -1526,8 +1685,9 @@ def build_triangulation_bundle( fps: float, initial_rotation_correction: bool, unwrap_root: bool, + root_unwrap_mode: str = "off", triangulation_method: str, - reprojection_threshold_px: float, + reprojection_threshold_px: float | None, min_cameras_for_triangulation: int, epipolar_threshold_px: float, coherence_method: str, @@ -1549,6 +1709,8 @@ def build_triangulation_bundle( flip_temporal_min_valid_keypoints: int, flip_method: str | None = None, ) -> tuple[BundleBuildResult, ReconstructionResult]: + """Build and export a triangulation-only reconstruction bundle.""" + triangulation_method = canonical_triangulation_method(triangulation_method) coherence_method = canonical_coherence_method(coherence_method, triangulation_method) effective_triangulation_method = triangulation_method_from_coherence_method(coherence_method, triangulation_method) @@ -1617,12 +1779,17 @@ def build_triangulation_bundle( pose_amplitude_upper_percentile=pose_amplitude_upper_percentile, ) time_s = reconstruction.frames / float(fps) + effective_root_unwrap_mode = normalize_root_unwrap_mode(root_unwrap_mode, legacy_unwrap=unwrap_root) q_root, correction_applied = extract_root_from_points( - reconstruction.points_3d, initial_rotation_correction, unwrap_root + reconstruction.points_3d, + initial_rotation_correction, + unwrap_rotations=(effective_root_unwrap_mode != "off"), + unwrap_mode=effective_root_unwrap_mode, ) qdot_root = centered_finite_difference(q_root, 1.0 / effective_fps) reprojection_errors = reconstruction.reprojection_error_per_view reprojection_stats = summarize_reprojection_errors(reprojection_errors, pose_data.camera_names) + view_usage_stats = summarize_view_usage(reconstruction.excluded_views, pose_data.camera_names) correction_angle = root_z_correction_angle_from_points( reconstruction.points_3d, left_hip_idx=KP_INDEX["left_hip"], @@ -1691,6 +1858,7 @@ def build_triangulation_bundle( "initial_rotation_correction_angle_rad": float(correction_angle), "flip_left_right": bool(flip_left_right), "triangulation_method": triangulation_method, + "reprojection_threshold_px": reprojection_threshold_px, "coherence_method": coherence_method, "pose_data_mode": pose_data_mode, "left_right_flip_diagnostics": flip_diagnostics, @@ -1705,6 +1873,7 @@ def build_triangulation_bundle( "per_keypoint": reprojection_stats["per_keypoint"], "per_camera": reprojection_stats["per_camera"], }, + "view_usage": view_usage_stats, "stage_timings_s": { "triangulation_s": time.perf_counter() - t0, "epipolar_coherence_s": float(reconstruction.epipolar_coherence_compute_time_s), @@ -1754,8 +1923,9 @@ def build_ekf_3d_bundle( fps: float, initial_rotation_correction: bool, unwrap_root: bool, + root_unwrap_mode: str = "off", triangulation_method: str, - reprojection_threshold_px: float, + reprojection_threshold_px: float | None, min_cameras_for_triangulation: int, epipolar_threshold_px: float, coherence_method: str, @@ -1782,7 +1952,10 @@ def build_ekf_3d_bundle( biorbd_kalman_init_method: str = DEFAULT_BIORBD_KALMAN_INIT_METHOD, biomod_path: Path | None = None, model_variant: str = "single_trunk", + symmetrize_limbs: bool = True, ) -> BundleBuildResult: + """Build and export the standardized EKF3D reconstruction bundle.""" + triangulation_method = canonical_triangulation_method(triangulation_method) coherence_method = canonical_coherence_method(coherence_method, triangulation_method) effective_triangulation_method = triangulation_method_from_coherence_method(coherence_method, triangulation_method) @@ -1873,6 +2046,7 @@ def build_ekf_3d_bundle( initial_rotation_correction=initial_rotation_correction, lengths_mode="full_triangulation", model_variant=model_variant, + symmetrize_limbs=symmetrize_limbs, ) ) shutil.copy2(biomod_cache_path, output_biomod_path) @@ -1890,12 +2064,18 @@ def build_ekf_3d_bundle( noise_factor=biorbd_kalman_noise_factor, error_factor=biorbd_kalman_error_factor, unwrap_root=unwrap_root, + root_unwrap_mode=root_unwrap_mode, initial_state_method=biorbd_kalman_init_method, ) ekf3d_s = time.perf_counter() - ekf3d_start result["q_names"] = q_names_from_model(model) model_points_3d = compute_model_marker_points_3d(model, result["q"]) - q_root = extract_root_from_q(result["q_names"], result["q"], unwrap_rotations=unwrap_root) + q_root = extract_root_from_q( + result["q_names"], + result["q"], + unwrap_rotations=unwrap_root, + unwrap_mode=root_unwrap_mode, + ) qdot_root = extract_root_from_q( result["q_names"], result["qdot"], unwrap_rotations=False, renormalize_rotations=False ) @@ -1910,6 +2090,7 @@ def build_ekf_3d_bundle( model_points_3d, reconstruction.frames, calibrations, pose_data_used ) reprojection_stats = summarize_reprojection_errors(reprojection_errors, pose_data_used.camera_names) + view_usage_stats = summarize_view_usage(reconstruction.excluded_views, pose_data_used.camera_names) save_legacy_ekf_3d(output_dir, result, reprojection_stats, model_points_3d) print_step(5, 5, "Export bundle EKF 3D") @@ -1988,6 +2169,7 @@ def build_ekf_3d_bundle( "pose_data_mode": pose_data_mode, "flip_left_right": bool(flip_left_right), "triangulation_method": effective_triangulation_method, + "reprojection_threshold_px": reprojection_threshold_px, "coherence_method": coherence_method, "cache_paths": { "triangulation": str(reconstruction_cache_path), @@ -1997,6 +2179,7 @@ def build_ekf_3d_bundle( }, "selected_model": None if selected_biomod_path is None else str(selected_biomod_path), "model_variant": str(model_variant), + "symmetrize_limbs": bool(symmetrize_limbs), "filter_parameters": { "noise_factor": float(biorbd_kalman_noise_factor), "error_factor": float(biorbd_kalman_error_factor), @@ -2010,6 +2193,7 @@ def build_ekf_3d_bundle( "per_keypoint": reprojection_stats["per_keypoint"], "per_camera": reprojection_stats["per_camera"], }, + "view_usage": view_usage_stats, "stage_timings_s": { "triangulation_s": triangulation_s, "epipolar_coherence_s": float(reconstruction.epipolar_coherence_compute_time_s), @@ -2048,6 +2232,7 @@ def build_ekf_3d_bundle( reprojection_errors=reprojection_errors, summary=summary, support_points_3d=reconstruction.points_3d, + excluded_views=reconstruction.excluded_views, ) write_bundle(output_dir, payload, summary) return BundleBuildResult(payload=payload, summary=summary) @@ -2063,8 +2248,9 @@ def build_ekf_2d_bundle( fps: float, initial_rotation_correction: bool, unwrap_root: bool, + root_unwrap_mode: str = "off", triangulation_method: str, - reprojection_threshold_px: float, + reprojection_threshold_px: float | None, min_cameras_for_triangulation: int, epipolar_threshold_px: float, coherence_method: str, @@ -2098,9 +2284,16 @@ def build_ekf_2d_bundle( skip_low_coherence_updates: bool, flight_height_threshold_m: float, flight_min_consecutive_frames: int, + upper_back_sagittal_gain: float = DEFAULT_UPPER_BACK_SAGITTAL_GAIN, + upper_back_pseudo_std_deg: float = np.rad2deg(DEFAULT_UPPER_BACK_PSEUDO_STD_RAD), + ankle_bed_pseudo_obs: bool = False, + ankle_bed_pseudo_std_m: float = DEFAULT_ANKLE_BED_PSEUDO_STD_M, biomod_path: Path | None = None, model_variant: str = "single_trunk", + symmetrize_limbs: bool = True, ) -> BundleBuildResult: + """Build and export the standardized EKF2D reconstruction bundle.""" + triangulation_method = canonical_triangulation_method(triangulation_method) coherence_method = canonical_coherence_method(coherence_method, triangulation_method) support_coherence_method = support_coherence_method_for_runtime(coherence_method) @@ -2250,6 +2443,7 @@ def build_ekf_2d_bundle( initial_rotation_correction=initial_rotation_correction, lengths_mode=ekf2d_3d_source, model_variant=model_variant, + symmetrize_limbs=symmetrize_limbs, ) ) shutil.copy2(biomod_cache_path, output_biomod_path) @@ -2279,6 +2473,10 @@ def build_ekf_2d_bundle( flip_min_gain_px=flip_min_gain_px, flip_error_threshold_px=epipolar_threshold_px, flip_error_delta_threshold_px=flip_min_gain_px, + upper_back_sagittal_gain=upper_back_sagittal_gain, + upper_back_pseudo_std_rad=np.deg2rad(float(upper_back_pseudo_std_deg)), + ankle_bed_pseudo_obs=ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=ankle_bed_pseudo_std_m, ) initial_state_s = time.perf_counter() - initial_state_start print_step(4, 5, f"EKF 2D {predictor.upper()}") @@ -2296,10 +2494,12 @@ def build_ekf_2d_bundle( coherence_confidence_floor=coherence_confidence_floor, epipolar_threshold_px=epipolar_threshold_px, enable_dof_locking=enable_dof_locking, - root_flight_dynamics=(predictor == "dyn"), + root_flight_dynamics=(predictor in {"dyn", "dyn_history3"}), + predictor_mode=predictor, flight_height_threshold_m=flight_height_threshold_m, flight_min_consecutive_frames=flight_min_consecutive_frames, unwrap_root=unwrap_root, + root_unwrap_mode=root_unwrap_mode, model=model, initial_state=initial_state, flip_method=("ekf_prediction_gate" if use_runtime_flip_gate else None), @@ -2307,10 +2507,19 @@ def build_ekf_2d_bundle( flip_min_gain_px=flip_min_gain_px, flip_error_threshold_px=epipolar_threshold_px, flip_error_delta_threshold_px=flip_min_gain_px, + upper_back_sagittal_gain=upper_back_sagittal_gain, + upper_back_pseudo_std_rad=np.deg2rad(float(upper_back_pseudo_std_deg)), + ankle_bed_pseudo_obs=ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=ankle_bed_pseudo_std_m, ) ekf_s = time.perf_counter() - ekf_start model_points_3d = compute_model_marker_points_3d(model, result["q"]) - q_root = extract_root_from_q(result["q_names"], result["q"], unwrap_rotations=unwrap_root) + q_root = extract_root_from_q( + result["q_names"], + result["q"], + unwrap_rotations=unwrap_root, + unwrap_mode=root_unwrap_mode, + ) qdot_root = extract_root_from_q( result["q_names"], result["qdot"], unwrap_rotations=False, renormalize_rotations=False ) @@ -2325,6 +2534,7 @@ def build_ekf_2d_bundle( model_points_3d, reconstruction.frames, calibrations, pose_data_used ) reprojection_stats = summarize_reprojection_errors(reprojection_errors, pose_data_used.camera_names) + view_usage_stats = summarize_view_usage(reconstruction.excluded_views, pose_data_used.camera_names) save_legacy_ekf_2d(output_dir, result, predictor, flip_left_right, model_points_3d) print_step(5, 5, "Export bundle EKF 2D") @@ -2498,6 +2708,7 @@ def build_ekf_2d_bundle( "initial_rotation_correction_angle_rad": float(correction_angle), "pose_data_mode": pose_data_mode, "triangulation_method": effective_triangulation_method, + "reprojection_threshold_px": reprojection_threshold_px, "coherence_method": coherence_method, "ekf2d_3d_source": ekf2d_3d_source, "ekf2d_initial_state_method": ekf2d_initial_state_method, @@ -2514,10 +2725,15 @@ def build_ekf_2d_bundle( }, "selected_model": None if selected_biomod_path is None else str(selected_biomod_path), "model_variant": str(model_variant), + "symmetrize_limbs": bool(symmetrize_limbs), "filter_parameters": { "measurement_noise_scale": float(measurement_noise_scale), "process_noise_scale": float(process_noise_scale), "coherence_confidence_floor": float(coherence_confidence_floor), + "upper_back_sagittal_gain": float(upper_back_sagittal_gain), + "upper_back_pseudo_std_deg": float(upper_back_pseudo_std_deg), + "ankle_bed_pseudo_obs": bool(ankle_bed_pseudo_obs), + "ankle_bed_pseudo_std_m": float(ankle_bed_pseudo_std_m), "min_frame_coherence_for_update": float(min_frame_coherence_for_update), "skip_low_coherence_updates": bool(skip_low_coherence_updates), "flight_height_threshold_m": float(flight_height_threshold_m), @@ -2535,6 +2751,7 @@ def build_ekf_2d_bundle( "per_keypoint": reprojection_stats["per_keypoint"], "per_camera": reprojection_stats["per_camera"], }, + "view_usage": view_usage_stats, "stage_timings_s": { "triangulation_s": triangulation_s, "epipolar_coherence_s": float(reconstruction.epipolar_coherence_compute_time_s), @@ -2597,6 +2814,7 @@ def build_ekf_2d_bundle( reprojection_errors=reprojection_errors, summary=summary, support_points_3d=reconstruction.points_3d, + excluded_views=reconstruction.excluded_views, ) write_bundle(output_dir, payload, summary) return BundleBuildResult(payload=payload, summary=summary) diff --git a/reconstruction/reconstruction_dataset.py b/reconstruction/reconstruction_dataset.py index 4697ce8..d068296 100644 --- a/reconstruction/reconstruction_dataset.py +++ b/reconstruction/reconstruction_dataset.py @@ -70,6 +70,8 @@ def load_json_if_exists(path: Path) -> dict[str, object]: + """Load one JSON file when it exists, else return an empty mapping.""" + if not path.exists(): return {} try: @@ -79,6 +81,8 @@ def load_json_if_exists(path: Path) -> dict[str, object]: def resolve_dataset_dir(path: Path) -> Path: + """Normalize a dataset-or-bundle path to the enclosing dataset directory.""" + path = Path(path) if (path / "reconstruction_bundle.npz").exists(): return path.parent @@ -86,6 +90,8 @@ def resolve_dataset_dir(path: Path) -> Path: def reconstruction_dirs_for_path(path: Path) -> list[Path]: + """Return bundle directories reachable from one dataset or bundle path.""" + path = Path(path) if (path / "reconstruction_bundle.npz").exists(): return [path] @@ -97,10 +103,14 @@ def reconstruction_dirs_for_path(path: Path) -> list[Path]: def dataset_manifest(dataset_dir: Path) -> dict[str, object]: + """Load the dataset-level manifest when available.""" + return load_json_if_exists(Path(dataset_dir) / "manifest.json") def dataset_name_from_dir(dataset_dir: Path) -> str: + """Resolve the canonical dataset name for one dataset directory.""" + manifest = dataset_manifest(dataset_dir) return str(manifest.get("dataset_name", Path(dataset_dir).name)) @@ -112,6 +122,8 @@ def dataset_source_paths( keypoints: Path | None = None, pose2sim_trc: Path | None = None, ) -> dict[str, Path]: + """Infer the canonical source paths associated with one dataset directory.""" + dataset_dir = Path(dataset_dir) dataset_name = dataset_name_from_dir(dataset_dir) manifest = dataset_manifest(dataset_dir) @@ -128,12 +140,16 @@ def dataset_source_paths( def reconstruction_label(name: str) -> str: + """Return a user-facing label for one reconstruction family/name.""" + if name in KNOWN_RECONSTRUCTION_LABELS: return KNOWN_RECONSTRUCTION_LABELS[name] return name.replace("_", " ").strip() def reconstruction_color(name: str) -> str: + """Return the display color associated with one reconstruction name.""" + if name in KNOWN_RECONSTRUCTION_COLORS: return KNOWN_RECONSTRUCTION_COLORS[name] color_idx = sum(name.encode("utf-8")) % len(FALLBACK_COLORS) @@ -141,6 +157,8 @@ def reconstruction_color(name: str) -> str: def preferred_triangulation_name(available_names: list[str]) -> str | None: + """Return the preferred triangulation reconstruction among available names.""" + available = set(available_names) for candidate in PREFERRED_TRIANGULATION_NAMES: if candidate in available: @@ -149,6 +167,8 @@ def preferred_triangulation_name(available_names: list[str]) -> str | None: def preferred_master_name(available_names: list[str]) -> str | None: + """Return the preferred master reconstruction for multi-view comparisons.""" + available = set(available_names) for candidate in PREFERRED_MASTER_NAMES: if candidate in available: @@ -157,6 +177,8 @@ def preferred_master_name(available_names: list[str]) -> str | None: def default_show_names(available_names: list[str]) -> list[str]: + """Return the default subset to display when no explicit names are requested.""" + preferred = [] for candidate in ( preferred_triangulation_name(available_names), @@ -173,6 +195,8 @@ def default_show_names(available_names: list[str]) -> list[str]: def resolve_requested_names(requested: list[str] | tuple[str, ...] | None, available_names: list[str]) -> list[str]: + """Resolve aliases like ``triangulation`` or ``ekf_2d`` to concrete names.""" + if not requested: return default_show_names(available_names) @@ -194,6 +218,8 @@ def resolve_requested_names(requested: list[str] | tuple[str, ...] | None, avail def align_array_to_frames( array: np.ndarray, source_frames: np.ndarray, target_frames: np.ndarray, fill_value: float = np.nan ) -> np.ndarray: + """Align one frame-indexed array onto a new frame axis.""" + source_frames = np.asarray(source_frames, dtype=int) target_frames = np.asarray(target_frames, dtype=int) aligned_shape = (len(target_frames),) + tuple(array.shape[1:]) @@ -207,6 +233,8 @@ def align_array_to_frames( def load_bundle_entries(path: Path) -> list[dict[str, object]]: + """Load lightweight bundle entries for one dataset preview/comparison view.""" + entries: list[dict[str, object]] = [] for bundle_dir in reconstruction_dirs_for_path(path): bundle_path = bundle_dir / "reconstruction_bundle.npz" diff --git a/reconstruction/reconstruction_profiles.py b/reconstruction/reconstruction_profiles.py index 034b15d..4156065 100644 --- a/reconstruction/reconstruction_profiles.py +++ b/reconstruction/reconstruction_profiles.py @@ -11,11 +11,13 @@ from __future__ import annotations import json +import math import sys from dataclasses import asdict, dataclass from itertools import product from pathlib import Path +from kinematics.root_kinematics import normalize_root_unwrap_mode from reconstruction.reconstruction_registry import ( infer_dataset_name, reconstruction_output_dir, @@ -25,10 +27,16 @@ ) SUPPORTED_FAMILIES = ("pose2sim", "triangulation", "ekf_3d", "ekf_2d") -SUPPORTED_PREDICTORS = ("acc", "dyn") +SUPPORTED_PREDICTORS = ("acc", "dyn", "history3", "dyn_history3") SUPPORTED_EKF2D_3D_SOURCE_MODES = ("full_triangulation", "first_frame_only") -SUPPORTED_MODEL_VARIANTS = ("single_trunk", "back_flexion_1d", "back_3dof") -SUPPORTED_POSE_DATA_MODES = ("raw", "filtered", "cleaned") +SUPPORTED_MODEL_VARIANTS = ( + "single_trunk", + "back_flexion_1d", + "back_3dof", + "upper_root_back_flexion_1d", + "upper_root_back_3dof", +) +SUPPORTED_POSE_DATA_MODES = ("raw", "filtered", "cleaned", "annotated") SUPPORTED_TRIANGULATION_METHODS = ("once", "greedy", "exhaustive") SUPPORTED_COHERENCE_METHODS = ( "epipolar", @@ -59,15 +67,20 @@ ) SUPPORTED_FRAME_STRIDES = (1, 2, 3, 4) DEFAULT_POSE2SIM_TRC = Path("inputs/trc/1_partie_0429.trc") +DEFAULT_PROFILE_REPROJECTION_THRESHOLD_PX = 15.0 @dataclass class ReconstructionProfile: + """Serializable description of one reproducible reconstruction configuration.""" + name: str family: str camera_names: list[str] | None = None + use_all_cameras: bool = False ekf_model_path: str | None = None model_variant: str = "single_trunk" + symmetrize_limbs: bool = True frame_stride: int = 1 predictor: str | None = None ekf2d_3d_source: str = "full_triangulation" @@ -88,16 +101,22 @@ class ReconstructionProfile: initial_rotation_correction: bool = False pose_data_mode: str = "cleaned" triangulation_method: str = "exhaustive" + reprojection_threshold_px: float | None = DEFAULT_PROFILE_REPROJECTION_THRESHOLD_PX coherence_method: str = "epipolar" compare_biorbd_kalman: bool = True reuse_triangulation: bool = False - no_root_unwrap: bool = False + no_root_unwrap: bool = True + root_unwrap_mode: str = "off" biorbd_kalman_noise_factor: float = 1e-8 biorbd_kalman_error_factor: float = 1e-4 biorbd_kalman_init_method: str = "triangulation_ik_root_translation" measurement_noise_scale: float = 1.5 process_noise_scale: float = 1.0 coherence_confidence_floor: float = 0.35 + upper_back_sagittal_gain: float = 0.2 + upper_back_pseudo_std_deg: float = 10.0 + ankle_bed_pseudo_obs: bool = False + ankle_bed_pseudo_std_m: float = 0.02 pose_filter_window: int = 9 pose_outlier_threshold_ratio: float = 0.10 pose_amplitude_lower_percentile: float = 5.0 @@ -107,9 +126,13 @@ class ReconstructionProfile: def canonical_profile_name(profile: ReconstructionProfile) -> str: + """Build the canonical slug used to name outputs for one profile.""" + parts = [profile.family] if profile.model_variant != "single_trunk": parts.append(profile.model_variant) + if not bool(profile.symmetrize_limbs): + parts.append("asym") if profile.family in ("ekf_2d", "ekf_3d") and profile.ekf_model_path: parts.append(f"mdl_{Path(profile.ekf_model_path).stem}") if profile.family == "pose2sim": @@ -127,6 +150,15 @@ def canonical_profile_name(profile: ReconstructionProfile) -> str: parts.append(f"boot{int(profile.ekf2d_bootstrap_passes)}") if profile.coherence_method != "epipolar": parts.append(f"coh_{profile.coherence_method}") + if not math.isclose(float(profile.upper_back_sagittal_gain), 0.2, rel_tol=0.0, abs_tol=1e-9): + parts.append(f"ubg{slugify(f'{profile.upper_back_sagittal_gain:.2f}')}") + if bool(getattr(profile, "ankle_bed_pseudo_obs", False)): + parts.append("ankbed") + if not math.isclose( + float(getattr(profile, "ankle_bed_pseudo_std_m", 0.02)), 0.02, rel_tol=0.0, abs_tol=1e-9 + ): + ankle_std_m = float(getattr(profile, "ankle_bed_pseudo_std_m", 0.02)) + parts.append(f"abm{slugify(f'{ankle_std_m:.3f}')}") if profile.flip: if profile.flip_method != "epipolar": parts.append(f"flip_{profile.flip_method}") @@ -156,6 +188,12 @@ def canonical_profile_name(profile: ReconstructionProfile) -> str: parts.append("flip") if profile.initial_rotation_correction: parts.append("rotfix") + if profile.family in ("triangulation", "ekf_2d", "ekf_3d"): + if profile.reprojection_threshold_px is None: + parts.append("taunone") + elif abs(float(profile.reprojection_threshold_px) - DEFAULT_PROFILE_REPROJECTION_THRESHOLD_PX) > 1e-9: + tau_value = float(profile.reprojection_threshold_px) + parts.append(f"tau{int(tau_value) if float(tau_value).is_integer() else slugify(f'{tau_value:g}')}") if profile.flip: if abs(float(profile.flip_improvement_ratio) - 0.7) > 1e-9: parts.append(f"fr{str(float(profile.flip_improvement_ratio)).replace('.', 'p')}") @@ -177,19 +215,27 @@ def canonical_profile_name(profile: ReconstructionProfile) -> str: parts.append(profile.pose_data_mode) if int(profile.frame_stride) != 1: parts.append(f"stride{int(profile.frame_stride)}") - if profile.camera_names: + if profile.use_all_cameras: + parts.append("all_cameras") + elif profile.camera_names: parts.append("cams") parts.extend(str(name) for name in profile.camera_names) return slugify("_".join(parts)) def validate_profile(profile: ReconstructionProfile) -> ReconstructionProfile: + """Normalize and validate one reconstruction profile in place.""" + if profile.family not in SUPPORTED_FAMILIES: raise ValueError(f"Unsupported family: {profile.family}") if profile.pose_data_mode not in SUPPORTED_POSE_DATA_MODES: raise ValueError(f"Unsupported pose_data_mode: {profile.pose_data_mode}") if profile.triangulation_method not in SUPPORTED_TRIANGULATION_METHODS: raise ValueError(f"Unsupported triangulation_method: {profile.triangulation_method}") + if profile.reprojection_threshold_px is not None and float(profile.reprojection_threshold_px) <= 0.0: + raise ValueError("reprojection_threshold_px must be > 0 when provided.") + if profile.reprojection_threshold_px is None and profile.triangulation_method != "once": + raise ValueError("reprojection_threshold_px=None is only supported when triangulation_method='once'.") if profile.coherence_method not in SUPPORTED_COHERENCE_METHODS: raise ValueError(f"Unsupported coherence_method: {profile.coherence_method}") if profile.flip_method not in SUPPORTED_FLIP_METHODS: @@ -198,6 +244,17 @@ def validate_profile(profile: ReconstructionProfile) -> ReconstructionProfile: raise ValueError(f"Unsupported model_variant: {profile.model_variant}") if profile.biorbd_kalman_init_method not in SUPPORTED_BIORBD_KALMAN_INIT_METHODS: raise ValueError(f"Unsupported biorbd_kalman_init_method: {profile.biorbd_kalman_init_method}") + profile.root_unwrap_mode = normalize_root_unwrap_mode( + getattr(profile, "root_unwrap_mode", None), + legacy_unwrap=(not bool(getattr(profile, "no_root_unwrap", False))), + ) + profile.no_root_unwrap = profile.root_unwrap_mode == "off" + if float(profile.upper_back_sagittal_gain) < 0.0: + raise ValueError("upper_back_sagittal_gain must be >= 0.") + if float(profile.upper_back_pseudo_std_deg) <= 0.0: + raise ValueError("upper_back_pseudo_std_deg must be > 0.") + if float(getattr(profile, "ankle_bed_pseudo_std_m", 0.02)) <= 0.0: + raise ValueError("ankle_bed_pseudo_std_m must be > 0.") profile.frame_stride = int(profile.frame_stride) if profile.frame_stride not in SUPPORTED_FRAME_STRIDES: raise ValueError(f"Unsupported frame_stride: {profile.frame_stride}") @@ -211,6 +268,9 @@ def validate_profile(profile: ReconstructionProfile) -> ReconstructionProfile: seen_camera_names.add(name) normalized_camera_names.append(name) profile.camera_names = normalized_camera_names or None + profile.use_all_cameras = bool(profile.use_all_cameras) + if profile.use_all_cameras: + profile.camera_names = None if profile.ekf_model_path is not None: normalized_model_path = str(profile.ekf_model_path).strip() profile.ekf_model_path = normalized_model_path or None @@ -227,6 +287,7 @@ def validate_profile(profile: ReconstructionProfile) -> ReconstructionProfile: if profile.family == "pose2sim": profile.pose_data_mode = "cleaned" profile.triangulation_method = "exhaustive" + profile.reprojection_threshold_px = DEFAULT_PROFILE_REPROJECTION_THRESHOLD_PX profile.frame_stride = 1 if profile.family == "ekf_2d": profile.predictor = profile.predictor or "acc" @@ -252,11 +313,17 @@ def validate_profile(profile: ReconstructionProfile) -> ReconstructionProfile: profile.ekf2d_initial_state_method = "ekf_bootstrap" profile.ekf2d_bootstrap_passes = 5 profile.dof_locking = False + profile.ankle_bed_pseudo_obs = False if profile.family != "ekf_3d": profile.biorbd_kalman_init_method = "triangulation_ik_root_translation" if profile.family not in ("ekf_2d", "ekf_3d"): profile.ekf_model_path = None profile.model_variant = "single_trunk" + profile.symmetrize_limbs = True + profile.upper_back_sagittal_gain = 0.2 + profile.upper_back_pseudo_std_deg = 10.0 + profile.ankle_bed_pseudo_obs = False + profile.ankle_bed_pseudo_std_m = 0.02 profile.flip_min_other_cameras = max(1, int(profile.flip_min_other_cameras)) if not (0.0 < float(profile.flip_improvement_ratio) < 1.0): raise ValueError("flip_improvement_ratio must be in (0, 1).") @@ -285,15 +352,24 @@ def validate_profile(profile: ReconstructionProfile) -> ReconstructionProfile: def profile_from_dict(data: dict[str, object]) -> ReconstructionProfile: - profile = ReconstructionProfile(**data) + """Deserialize one JSON mapping into a validated reconstruction profile.""" + + payload = dict(data) + if "root_unwrap_mode" not in payload and "no_root_unwrap" in payload: + payload["root_unwrap_mode"] = "off" if bool(payload.get("no_root_unwrap")) else "single" + profile = ReconstructionProfile(**payload) return validate_profile(profile) def profile_to_dict(profile: ReconstructionProfile) -> dict[str, object]: + """Serialize one reconstruction profile into JSON-compatible values.""" + return asdict(validate_profile(profile)) def load_profiles_json(path: Path) -> list[ReconstructionProfile]: + """Load and validate all reconstruction profiles from one JSON file.""" + payload = json.loads(path.read_text()) if isinstance(payload, dict): entries = payload.get("profiles", []) @@ -303,12 +379,16 @@ def load_profiles_json(path: Path) -> list[ReconstructionProfile]: def save_profiles_json(path: Path, profiles: list[ReconstructionProfile]) -> None: + """Write one list of reconstruction profiles to disk as JSON.""" + path.parent.mkdir(parents=True, exist_ok=True) payload = {"profiles": [profile_to_dict(profile) for profile in profiles]} path.write_text(json.dumps(payload, indent=2), encoding="utf-8") def example_profiles() -> list[ReconstructionProfile]: + """Return a compact set of example profiles used by the GUI and tests.""" + examples = [ ReconstructionProfile(name="pose2sim", family="pose2sim", initial_rotation_correction=False), ReconstructionProfile(name="pose2sim_rotfix", family="pose2sim", initial_rotation_correction=True), @@ -360,6 +440,8 @@ def generate_supported_profiles( enable_dof_locking: bool = True, enable_initial_rotation_correction: bool = True, ) -> list[ReconstructionProfile]: + """Generate the supported family/predictor/profile combinations.""" + profiles: list[ReconstructionProfile] = [] for family in families: if family == "pose2sim": @@ -454,6 +536,8 @@ def variant_output_dir( keypoints_path: Path | None = None, pose2sim_trc: Path | None = None, ) -> Path: + """Return the output directory corresponding to one profile execution.""" + profile = validate_profile(profile) dataset_name = infer_dataset_name( keypoints_path=keypoints_path, pose2sim_trc=pose2sim_trc, dataset_name=dataset_name @@ -471,6 +555,8 @@ def build_pipeline_command( python_executable: str | None = None, camera_names_override: list[str] | None = None, ) -> list[str]: + """Build the CLI command that executes one reconstruction profile.""" + profile = validate_profile(profile) python_executable = python_executable or sys.executable out_dir = variant_output_dir( @@ -499,6 +585,8 @@ def build_pipeline_command( str(profile.frame_stride), "--triangulation-method", profile.triangulation_method, + "--reprojection-threshold-px", + ("none" if profile.reprojection_threshold_px is None else str(profile.reprojection_threshold_px)), "--coherence-method", profile.coherence_method, "--pose-filter-window", @@ -522,17 +610,23 @@ def build_pipeline_command( ] if pose2sim_trc is not None: cmd.extend(["--trc-file", str(pose2sim_trc)]) - camera_names = profile.camera_names or camera_names_override + camera_names = None if profile.use_all_cameras else (profile.camera_names or camera_names_override) if camera_names: cmd.extend(["--camera-names", ",".join(str(name) for name in camera_names)]) if profile.initial_rotation_correction: cmd.append("--initial-rotation-correction") - if profile.no_root_unwrap: - cmd.append("--no-root-unwrap") + cmd.extend(["--root-unwrap-mode", "off"]) if profile.family == "ekf_2d": if profile.ekf_model_path: cmd.extend(["--biomod", str(profile.ekf_model_path)]) cmd.extend(["--model-variant", profile.model_variant]) + if not profile.symmetrize_limbs: + cmd.append("--no-symmetrize-limbs") + cmd.extend(["--upper-back-sagittal-gain", str(profile.upper_back_sagittal_gain)]) + cmd.extend(["--upper-back-pseudo-std-deg", str(profile.upper_back_pseudo_std_deg)]) + if bool(getattr(profile, "ankle_bed_pseudo_obs", False)): + cmd.append("--ankle-bed-pseudo-obs") + cmd.extend(["--ankle-bed-pseudo-std-m", str(float(getattr(profile, "ankle_bed_pseudo_std_m", 0.02)))]) cmd.extend(["--predictor", str(profile.predictor or "acc")]) cmd.extend(["--ekf2d-3d-source", profile.ekf2d_3d_source]) cmd.extend(["--ekf2d-initial-state-method", profile.ekf2d_initial_state_method]) @@ -550,6 +644,8 @@ def build_pipeline_command( if profile.ekf_model_path: cmd.extend(["--biomod", str(profile.ekf_model_path)]) cmd.extend(["--model-variant", profile.model_variant]) + if not profile.symmetrize_limbs: + cmd.append("--no-symmetrize-limbs") cmd.extend(["--biorbd-kalman-init-method", profile.biorbd_kalman_init_method]) if profile.flip: cmd.append("--flip-left-right") @@ -571,6 +667,8 @@ def build_pipeline_command( def scan_variant_output_dirs(output_root: Path) -> list[Path]: + """Scan all dataset directories for profile-generated reconstruction outputs.""" + candidates: list[Path] = [] for dataset_dir in scan_dataset_dirs(output_root): candidates.extend(scan_reconstruction_dirs(dataset_dir)) diff --git a/reconstruction/reconstruction_registry.py b/reconstruction/reconstruction_registry.py index 3d8c270..3a22b7d 100644 --- a/reconstruction/reconstruction_registry.py +++ b/reconstruction/reconstruction_registry.py @@ -18,6 +18,7 @@ DEFAULT_MODEL_SUBJECT_MASS_KG = 55.0 DEFAULT_MODEL_VARIANT = "single_trunk" +DEFAULT_MODEL_SYMMETRIZE_LIMBS = True DEFAULT_POSE_FILTER_WINDOW = 9 DEFAULT_POSE_OUTLIER_THRESHOLD_RATIO = 0.10 DEFAULT_POSE_AMPLITUDE_LOWER_PERCENTILE = 5.0 @@ -25,6 +26,8 @@ def normalize_output_root(output_root: Path | str) -> Path: + """Normalize legacy ``outputs/`` roots to the canonical ``output/`` path.""" + path = Path(output_root) parts = list(path.parts) if parts and parts[-1] == "outputs": @@ -34,6 +37,8 @@ def normalize_output_root(output_root: Path | str) -> Path: def slugify(value: str) -> str: + """Convert arbitrary text into a filesystem-safe lowercase slug.""" + value = value.strip().lower() value = re.sub(r"[^a-z0-9]+", "_", value) value = re.sub(r"_+", "_", value).strip("_") @@ -41,6 +46,8 @@ def slugify(value: str) -> str: def canonical_dataset_name(path_or_name: str | Path) -> str: + """Return the canonical dataset slug derived from a file path or name.""" + name = Path(path_or_name).stem if isinstance(path_or_name, Path) else str(path_or_name) for suffix in ("_keypoints", "_points", "_detections", "_2d"): if name.endswith(suffix): @@ -52,6 +59,8 @@ def canonical_dataset_name(path_or_name: str | Path) -> str: def infer_dataset_name( keypoints_path: Path | None = None, pose2sim_trc: Path | None = None, dataset_name: str | None = None ) -> str: + """Infer the canonical dataset name from explicit or source-file inputs.""" + if dataset_name: return canonical_dataset_name(dataset_name) if keypoints_path is not None: @@ -62,22 +71,32 @@ def infer_dataset_name( def dataset_output_dir(output_root: Path, dataset_name: str) -> Path: + """Return the root output directory for one dataset.""" + return normalize_output_root(output_root) / canonical_dataset_name(dataset_name) def dataset_models_dir(output_root: Path, dataset_name: str) -> Path: + """Return the model-output directory for one dataset.""" + return dataset_output_dir(output_root, dataset_name) / "models" def dataset_reconstructions_dir(output_root: Path, dataset_name: str) -> Path: + """Return the reconstruction-output directory for one dataset.""" + return dataset_output_dir(output_root, dataset_name) / "reconstructions" def dataset_figures_dir(output_root: Path, dataset_name: str) -> Path: + """Return the figures-output directory for one dataset.""" + return dataset_output_dir(output_root, dataset_name) / "figures" def reconstruction_output_dir(output_root: Path, dataset_name: str, reconstruction_name: str) -> Path: + """Return the bundle directory for one named reconstruction.""" + return dataset_reconstructions_dir(output_root, dataset_name) / slugify(reconstruction_name) @@ -94,6 +113,7 @@ def default_model_stem( triangulation_method: str, *, model_variant: str = DEFAULT_MODEL_VARIANT, + symmetrize_limbs: bool = DEFAULT_MODEL_SYMMETRIZE_LIMBS, pose_correction_mode: str = "none", initial_rotation_correction: bool = False, max_frames: int | None = None, @@ -105,9 +125,13 @@ def default_model_stem( pose_amplitude_lower_percentile: float = DEFAULT_POSE_AMPLITUDE_LOWER_PERCENTILE, pose_amplitude_upper_percentile: float = DEFAULT_POSE_AMPLITUDE_UPPER_PERCENTILE, ) -> str: + """Build the canonical directory stem for one generated biomechanical model.""" + tokens = ["model", "2d", slugify(pose_data_mode), slugify(triangulation_method)] if str(model_variant).strip() and str(model_variant).strip() != DEFAULT_MODEL_VARIANT: tokens.append(slugify(str(model_variant))) + if not bool(symmetrize_limbs): + tokens.append("asym") if str(pose_correction_mode).strip() not in ("", "none"): tokens.append(slugify(str(pose_correction_mode))) if initial_rotation_correction: @@ -143,6 +167,7 @@ def model_output_dir( pose_data_mode: str, triangulation_method: str, model_variant: str = DEFAULT_MODEL_VARIANT, + symmetrize_limbs: bool = DEFAULT_MODEL_SYMMETRIZE_LIMBS, pose_correction_mode: str = "none", initial_rotation_correction: bool = False, max_frames: int | None = None, @@ -154,10 +179,13 @@ def model_output_dir( pose_amplitude_lower_percentile: float = DEFAULT_POSE_AMPLITUDE_LOWER_PERCENTILE, pose_amplitude_upper_percentile: float = DEFAULT_POSE_AMPLITUDE_UPPER_PERCENTILE, ) -> Path: + """Return the model directory corresponding to one model-generation setup.""" + stem = default_model_stem( pose_data_mode, triangulation_method, model_variant=model_variant, + symmetrize_limbs=symmetrize_limbs, pose_correction_mode=pose_correction_mode, initial_rotation_correction=initial_rotation_correction, max_frames=max_frames, @@ -179,6 +207,7 @@ def model_biomod_path( pose_data_mode: str, triangulation_method: str, model_variant: str = DEFAULT_MODEL_VARIANT, + symmetrize_limbs: bool = DEFAULT_MODEL_SYMMETRIZE_LIMBS, pose_correction_mode: str = "none", initial_rotation_correction: bool = False, max_frames: int | None = None, @@ -190,12 +219,15 @@ def model_biomod_path( pose_amplitude_lower_percentile: float = DEFAULT_POSE_AMPLITUDE_LOWER_PERCENTILE, pose_amplitude_upper_percentile: float = DEFAULT_POSE_AMPLITUDE_UPPER_PERCENTILE, ) -> Path: + """Return the expected `.bioMod` path for one generated model directory.""" + model_dir = model_output_dir( output_root, dataset_name, pose_data_mode=pose_data_mode, triangulation_method=triangulation_method, model_variant=model_variant, + symmetrize_limbs=symmetrize_limbs, pose_correction_mode=pose_correction_mode, initial_rotation_correction=initial_rotation_correction, max_frames=max_frames, @@ -211,6 +243,8 @@ def model_biomod_path( def scan_dataset_dirs(output_root: Path) -> list[Path]: + """List dataset directories that contain manifests or bundle outputs.""" + if not output_root.exists(): return [] dataset_dirs = [] @@ -230,6 +264,8 @@ def scan_dataset_dirs(output_root: Path) -> list[Path]: def scan_reconstruction_dirs(dataset_dir: Path) -> list[Path]: + """List reconstruction bundle directories inside one dataset directory.""" + candidates: list[Path] = [] if (dataset_dir / "bundle_summary.json").exists(): candidates.append(dataset_dir) @@ -248,6 +284,8 @@ def scan_reconstruction_dirs(dataset_dir: Path) -> list[Path]: def scan_model_dirs(dataset_dir: Path) -> list[Path]: + """List model directories that contain a generated `.bioMod` file.""" + candidates: list[Path] = [] models_dir = dataset_dir / "models" if models_dir.exists(): @@ -264,4 +302,6 @@ def scan_model_dirs(dataset_dir: Path) -> list[Path]: def latest_version_for_family(family: str) -> int | None: + """Return the latest tracked algorithm version for one reconstruction family.""" + return ALGORITHM_VERSIONS.get(family) diff --git a/reconstruction/reconstruction_timings.py b/reconstruction/reconstruction_timings.py index 8163460..748ffe4 100644 --- a/reconstruction/reconstruction_timings.py +++ b/reconstruction/reconstruction_timings.py @@ -4,8 +4,11 @@ from __future__ import annotations import math +from pathlib import Path from typing import Iterable +import numpy as np + def make_timing_stage( stage_id: str, @@ -39,14 +42,82 @@ def stage_compute_time(stage: dict[str, object]) -> float | None: return _coerce_float(stage.get("compute_time_s")) +def _load_model_stage_compute_time(model_stage_path: Path) -> float | None: + """Read the cached model generation time from one `model_stage.npz`.""" + + if not model_stage_path.exists(): + return None + try: + with np.load(model_stage_path, allow_pickle=True) as data: + if "compute_time_s" not in data: + return None + return _coerce_float(np.asarray(data["compute_time_s"]).item()) + except Exception: + return None + + +def _candidate_model_stage_paths(summary: dict[str, object]) -> list[Path]: + """Enumerate candidate `model_stage.npz` paths associated with this summary.""" + + candidates: list[Path] = [] + selected_model = summary.get("selected_model") + if isinstance(selected_model, str) and selected_model.strip(): + biomod_path = Path(selected_model) + candidates.append(biomod_path.parent / "model_stage.npz") + + cache_paths = summary.get("cache_paths") + if isinstance(cache_paths, dict): + raw_model_cache = cache_paths.get("model") + if isinstance(raw_model_cache, str) and raw_model_cache.strip(): + model_cache_path = Path(raw_model_cache) + if model_cache_path.name == "model_stage.npz": + candidates.append(model_cache_path) + elif model_cache_path.suffix == ".bioMod": + candidates.append(model_cache_path.parent / "model_stage.npz") + + seen: set[Path] = set() + result: list[Path] = [] + for candidate in candidates: + try: + resolved = candidate.resolve() + except Exception: + resolved = candidate + if resolved in seen: + continue + seen.add(resolved) + result.append(candidate) + return result + + +def fallback_model_objective_seconds(summary: dict[str, object]) -> float | None: + """Recover the model-generation cost when the bundle reuses an existing bioMod.""" + + for candidate in _candidate_model_stage_paths(summary): + value = _load_model_stage_compute_time(candidate) + if value is not None: + return value + return None + + +def stage_objective_seconds(stage: dict[str, object], summary: dict[str, object]) -> float | None: + """Return the objective cost of one stage, replacing cache-read times when possible.""" + + value = stage_compute_time(stage) + if str(stage.get("id", "")) != "model_creation": + return value + if value is not None and value > 0.0: + return value + fallback = fallback_model_objective_seconds(summary) + if fallback is not None: + return fallback + return value + + def objective_total_seconds(summary: dict[str, object]) -> float | None: """Return the total objective compute time, including reused cached stages.""" pipeline = summary.get("pipeline_timing") if isinstance(pipeline, dict): - explicit_total = _coerce_float(pipeline.get("objective_total_s")) - if explicit_total is not None: - return explicit_total stages = pipeline.get("stages") if isinstance(stages, list): total = 0.0 @@ -54,16 +125,59 @@ def objective_total_seconds(summary: dict[str, object]) -> float | None: for stage in stages: if not isinstance(stage, dict) or not bool(stage.get("include_in_total", True)): continue - value = stage_compute_time(stage) + value = stage_objective_seconds(stage, summary) if value is None: continue total += value used = True if used: return total + explicit_total = _coerce_float(pipeline.get("objective_total_s")) + if explicit_total is not None: + return explicit_total return compute_time_seconds(summary) +def model_compute_seconds(summary: dict[str, object]) -> float | None: + """Return the objective time attributable to the model-creation stage.""" + + pipeline = summary.get("pipeline_timing") + if isinstance(pipeline, dict): + stages = pipeline.get("stages") + if isinstance(stages, list): + total = 0.0 + used = False + for stage in stages: + if not isinstance(stage, dict): + continue + if str(stage.get("id", "")) != "model_creation": + continue + value = stage_objective_seconds(stage, summary) + if value is None: + continue + total += value + used = True + if used and total > 0.0: + return total + stage_timings = dict(parse_stage_timings(summary)) + stage_time = _coerce_float(stage_timings.get("model_creation_s")) + if stage_time is not None: + return stage_time + return fallback_model_objective_seconds(summary) + + +def reconstruction_run_seconds(summary: dict[str, object]) -> float | None: + """Return the objective reconstruction time excluding model creation.""" + + total = objective_total_seconds(summary) + if total is None: + return None + model_s = model_compute_seconds(summary) + if model_s is None: + return total + return max(0.0, total - model_s) + + def current_run_seconds(summary: dict[str, object]) -> float | None: """Return the wall time spent by the current run, excluding prior cached work.""" @@ -176,6 +290,8 @@ def format_reconstruction_timing_details(summary: dict[str, object]) -> str: pipeline_stages = parse_timing_trace(summary) objective_time = objective_total_seconds(summary) + model_time = model_compute_seconds(summary) + reconstruction_time = reconstruction_run_seconds(summary) current_time = current_run_seconds(summary) lines = [ f"Name: {summary.get('name', '-')}", @@ -185,6 +301,8 @@ def format_reconstruction_timing_details(summary: dict[str, object]) -> str: f"Effective FPS: {summary.get('fps', '-')}", f"Sequence duration: {format_seconds_brief(_coerce_float(summary.get('duration_s')))}", f"Objective compute time: {format_seconds_brief(objective_time)}", + f"Reconstruction time (excl. model): {format_seconds_brief(reconstruction_time)}", + f"Model time: {format_seconds_brief(model_time)}", ] source_path = summary.get("source") if isinstance(source_path, str) and source_path.strip(): diff --git a/reconstruction_profiles.json b/reconstruction_profiles.json index 4483e34..9ba6ea3 100644 --- a/reconstruction_profiles.json +++ b/reconstruction_profiles.json @@ -1,15 +1,139 @@ { "profiles": [ { - "name": "ekf_2d_acc_bootstrap1_flip_rotfix", + "name": "ekf_2d_mdl_model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50_acc_bootstrap1_rotfix_raw_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", "family": "ekf_2d", - "camera_names": null, - "ekf_model_path": null, + "camera_names": [ + "M11139", + "M11140", + "M11141", + "M11458", + "M11459", + "M11461", + "M11462", + "M11463" + ], + "use_all_cameras": false, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, "frame_stride": 1, "predictor": "acc", "ekf2d_3d_source": "first_frame_only", "ekf2d_initial_state_method": "ekf_bootstrap", "ekf2d_bootstrap_passes": 5, + "flip": false, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "raw", + "triangulation_method": "greedy", + "coherence_method": "epipolar", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "ekf_2d_mdl_model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50_dyn_bootstrap1_coh_epipolar_fast_framewise_flip_ekf_prediction_gate_flip_rotfix_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", + "family": "ekf_2d", + "camera_names": [ + "M11139", + "M11140", + "M11141", + "M11458", + "M11459", + "M11461", + "M11462", + "M11463" + ], + "use_all_cameras": false, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": "dyn", + "ekf2d_3d_source": "first_frame_only", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": true, + "flip_method": "ekf_prediction_gate", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "cleaned", + "triangulation_method": "greedy", + "coherence_method": "epipolar_fast_framewise", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "ekf_2d_mdl_model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50_dyn_bootstrap1_flip_rotfix_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", + "family": "ekf_2d", + "camera_names": [ + "M11139", + "M11140", + "M11141", + "M11458", + "M11459", + "M11461", + "M11462", + "M11463" + ], + "use_all_cameras": false, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": "dyn", + "ekf2d_3d_source": "first_frame_only", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, "flip": true, "flip_method": "epipolar", "flip_improvement_ratio": 0.7, @@ -35,6 +159,8 @@ "measurement_noise_scale": 1.5, "process_noise_scale": 1.0, "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, "pose_filter_window": 9, "pose_outlier_threshold_ratio": 0.1, "pose_amplitude_lower_percentile": 5.0, @@ -43,7 +169,7 @@ "extra_args": null }, { - "name": "ekf_2d_mdl_model_2d_cleaned_exhaustive_flip_epipolar_fast_rotfix_r0_1745_f100_dyn_bootstrap1_coh_epipolar_fast_flip_epipolar_fast_flip_rotfix_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", + "name": "ekf_2d_mdl_model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50_dyn_bootstrap1_rotfix_raw_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", "family": "ekf_2d", "camera_names": [ "M11139", @@ -55,14 +181,73 @@ "M11462", "M11463" ], - "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_epipolar_fast_rotfix_r0_1745_f100/model_2d_cleaned_exhaustive_flip_epipolar_fast_rotfix_r0_1745_f100.bioMod", + "use_all_cameras": false, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, "frame_stride": 1, "predictor": "dyn", "ekf2d_3d_source": "first_frame_only", "ekf2d_initial_state_method": "ekf_bootstrap", "ekf2d_bootstrap_passes": 5, + "flip": false, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "raw", + "triangulation_method": "greedy", + "coherence_method": "epipolar", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "ekf_3d_mdl_model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50_flip_rotfix_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", + "family": "ekf_3d", + "camera_names": [ + "M11139", + "M11140", + "M11141", + "M11458", + "M11459", + "M11461", + "M11462", + "M11463" + ], + "use_all_cameras": false, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, "flip": true, - "flip_method": "epipolar_fast", + "flip_method": "epipolar", "flip_improvement_ratio": 0.7, "flip_min_gain_px": 3.0, "flip_min_other_cameras": 2, @@ -76,7 +261,7 @@ "initial_rotation_correction": true, "pose_data_mode": "cleaned", "triangulation_method": "exhaustive", - "coherence_method": "epipolar_fast", + "coherence_method": "epipolar", "compare_biorbd_kalman": true, "reuse_triangulation": false, "no_root_unwrap": false, @@ -86,6 +271,8 @@ "measurement_noise_scale": 1.5, "process_noise_scale": 1.0, "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, "pose_filter_window": 9, "pose_outlier_threshold_ratio": 0.1, "pose_amplitude_lower_percentile": 5.0, @@ -94,10 +281,181 @@ "extra_args": null }, { - "name": "pose2sim_rotfix", - "family": "pose2sim", + "name": "ekf_3d_mdl_model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50_flip_rotfix_raw_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", + "family": "ekf_3d", + "camera_names": [ + "M11139", + "M11140", + "M11141", + "M11458", + "M11459", + "M11461", + "M11462", + "M11463" + ], + "use_all_cameras": false, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": true, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "raw", + "triangulation_method": "exhaustive", + "coherence_method": "epipolar", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "ekf_3d_mdl_model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50_flip_triangulation_greedy_flip_rotfix_raw_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", + "family": "ekf_3d", + "camera_names": [ + "M11139", + "M11140", + "M11141", + "M11458", + "M11459", + "M11461", + "M11462", + "M11463" + ], + "use_all_cameras": false, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": true, + "flip_method": "triangulation_greedy", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "raw", + "triangulation_method": "greedy", + "coherence_method": "epipolar", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "ekf_3d_mdl_model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50_rotfix_raw_all_cameras", + "family": "ekf_3d", "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50/model_2d_cleaned_exhaustive_flip_epipolar_rotfix_r0_500_f50.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": false, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "raw", + "triangulation_method": "exhaustive", + "coherence_method": "epipolar", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "pose2sim_rotfix_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", + "family": "pose2sim", + "camera_names": [ + "M11139", + "M11140", + "M11141", + "M11458", + "M11459", + "M11461", + "M11462", + "M11463" + ], + "use_all_cameras": false, "ekf_model_path": null, + "model_variant": "single_trunk", + "symmetrize_limbs": true, "frame_stride": 1, "predictor": null, "ekf2d_3d_source": "full_triangulation", @@ -128,6 +486,8 @@ "measurement_noise_scale": 1.5, "process_noise_scale": 1.0, "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, "pose_filter_window": 9, "pose_outlier_threshold_ratio": 0.1, "pose_amplitude_lower_percentile": 5.0, @@ -136,10 +496,22 @@ "extra_args": null }, { - "name": "triangulation_greedy_flip_rotfix", + "name": "triangulation_exhaustive_flip_rotfix_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", "family": "triangulation", - "camera_names": null, + "camera_names": [ + "M11139", + "M11140", + "M11141", + "M11458", + "M11459", + "M11461", + "M11462", + "M11463" + ], + "use_all_cameras": false, "ekf_model_path": null, + "model_variant": "single_trunk", + "symmetrize_limbs": true, "frame_stride": 1, "predictor": null, "ekf2d_3d_source": "full_triangulation", @@ -159,6 +531,118 @@ "dof_locking": false, "initial_rotation_correction": true, "pose_data_mode": "cleaned", + "triangulation_method": "exhaustive", + "coherence_method": "epipolar", + "compare_biorbd_kalman": false, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "triangulation_exhaustive_rotfix_raw_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", + "family": "triangulation", + "camera_names": [ + "M11139", + "M11140", + "M11141", + "M11458", + "M11459", + "M11461", + "M11462", + "M11463" + ], + "use_all_cameras": false, + "ekf_model_path": null, + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": false, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "raw", + "triangulation_method": "exhaustive", + "coherence_method": "epipolar", + "compare_biorbd_kalman": false, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "triangulation_greedy_flip_triangulation_greedy_flip_rotfix_cams_m11139_m11140_m11141_m11458_m11459_m11461_m11462_m11463", + "family": "triangulation", + "camera_names": [ + "M11139", + "M11140", + "M11141", + "M11458", + "M11459", + "M11461", + "M11462", + "M11463" + ], + "use_all_cameras": false, + "ekf_model_path": null, + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": true, + "flip_method": "triangulation_greedy", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "cleaned", "triangulation_method": "greedy", "coherence_method": "epipolar", "compare_biorbd_kalman": false, @@ -170,6 +654,8 @@ "measurement_noise_scale": 1.5, "process_noise_scale": 1.0, "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, "pose_filter_window": 9, "pose_outlier_threshold_ratio": 0.1, "pose_amplitude_lower_percentile": 5.0, diff --git a/reconstruction_profiles_Back.json b/reconstruction_profiles_Back.json new file mode 100644 index 0000000..1e1ea59 --- /dev/null +++ b/reconstruction_profiles_Back.json @@ -0,0 +1,145 @@ +{ + "profiles": [ + { + "name": "ekf_2d_upper_root_back_3dof_mdl_model_2d_cleaned_exhaustive_upper_root_back_3dof_flip_triangulation_rotfix_r0_1745_f100_dyn_bootstrap1_coh_epipolar_framewise_flip_ekf_prediction_gate_flip_rotfix_all_cameras", + "family": "ekf_2d", + "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_upper_root_back_3dof_flip_triangulation_rotfix_r0_1745_f100/model_2d_cleaned_exhaustive_upper_root_back_3dof_flip_triangulation_rotfix_r0_1745_f100.bioMod", + "model_variant": "upper_root_back_3dof", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": "dyn", + "ekf2d_3d_source": "first_frame_only", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": true, + "flip_method": "ekf_prediction_gate", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "cleaned", + "triangulation_method": "exhaustive", + "coherence_method": "epipolar_framewise", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "ekf_2d_upper_root_back_3dof_mdl_model_2d_cleaned_exhaustive_upper_root_back_3dof_flip_triangulation_rotfix_r0_1745_f100_dyn_bootstrap1_coh_epipolar_framewise_flip_ekf_prediction_gate_flip_rotfix_all_cameras_proc10", + "family": "ekf_2d", + "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_upper_root_back_3dof_flip_triangulation_rotfix_r0_1745_f100/model_2d_cleaned_exhaustive_upper_root_back_3dof_flip_triangulation_rotfix_r0_1745_f100.bioMod", + "model_variant": "upper_root_back_3dof", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": "dyn", + "ekf2d_3d_source": "first_frame_only", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": true, + "flip_method": "ekf_prediction_gate", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "cleaned", + "triangulation_method": "exhaustive", + "coherence_method": "epipolar_framewise", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 10.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "ekf_3d_upper_root_back_3dof_mdl_model_2d_cleaned_exhaustive_upper_root_back_3dof_flip_triangulation_rotfix_r0_1745_f100_flip_rotfix_all_cameras", + "family": "ekf_3d", + "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_upper_root_back_3dof_flip_triangulation_rotfix_r0_1745_f100/model_2d_cleaned_exhaustive_upper_root_back_3dof_flip_triangulation_rotfix_r0_1745_f100.bioMod", + "model_variant": "upper_root_back_3dof", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": true, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "cleaned", + "triangulation_method": "exhaustive", + "coherence_method": "epipolar", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + } + ] +} \ No newline at end of file diff --git a/reconstruction_profiles_SB.json b/reconstruction_profiles_SB.json new file mode 100644 index 0000000..92b132c --- /dev/null +++ b/reconstruction_profiles_SB.json @@ -0,0 +1,347 @@ +{ + "profiles": [ + { + "name": "ekf_2d_mdl_model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100_dyn_bootstrap1_flip_ekf_prediction_gate_flip_rotfix_all_cameras", + "family": "ekf_2d", + "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100/model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": "dyn", + "ekf2d_3d_source": "first_frame_only", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": true, + "flip_method": "ekf_prediction_gate", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "cleaned", + "triangulation_method": "exhaustive", + "reprojection_threshold_px": 15.0, + "coherence_method": "epipolar", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "root_unwrap_mode": "single", + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "ekf_2d_mdl_model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100_dyn_bootstrap1_rotfix_raw_all_cameras", + "family": "ekf_2d", + "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100/model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": "dyn", + "ekf2d_3d_source": "first_frame_only", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": false, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "raw", + "triangulation_method": "exhaustive", + "reprojection_threshold_px": 15.0, + "coherence_method": "epipolar", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "root_unwrap_mode": "single", + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "ekf_3d_mdl_model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100_flip_rotfix_all_cameras", + "family": "ekf_3d", + "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100/model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": true, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "cleaned", + "triangulation_method": "exhaustive", + "reprojection_threshold_px": 15.0, + "coherence_method": "epipolar", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "root_unwrap_mode": "single", + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "ekf_3d_mdl_model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100_rotfix_raw_all_cameras", + "family": "ekf_3d", + "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": "output/1_partie_0429/models/model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100/model_2d_cleaned_exhaustive_flip_triangulation_rotfix_r0_1745_f100.bioMod", + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": false, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "raw", + "triangulation_method": "exhaustive", + "reprojection_threshold_px": 15.0, + "coherence_method": "epipolar", + "compare_biorbd_kalman": true, + "reuse_triangulation": false, + "no_root_unwrap": false, + "root_unwrap_mode": "single", + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "triangulation_exhaustive_flip_rotfix_all_cameras", + "family": "triangulation", + "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": null, + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": true, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "cleaned", + "triangulation_method": "exhaustive", + "reprojection_threshold_px": 15.0, + "coherence_method": "epipolar", + "compare_biorbd_kalman": false, + "reuse_triangulation": false, + "no_root_unwrap": false, + "root_unwrap_mode": "single", + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "triangulation_exhaustive_rotfix_raw_all_cameras", + "family": "triangulation", + "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": null, + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": false, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "raw", + "triangulation_method": "exhaustive", + "reprojection_threshold_px": 15.0, + "coherence_method": "epipolar", + "compare_biorbd_kalman": false, + "reuse_triangulation": false, + "no_root_unwrap": false, + "root_unwrap_mode": "single", + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + }, + { + "name": "triangulation_once_rotfix_taunone_raw_all_cameras", + "family": "triangulation", + "camera_names": null, + "use_all_cameras": true, + "ekf_model_path": null, + "model_variant": "single_trunk", + "symmetrize_limbs": true, + "frame_stride": 1, + "predictor": null, + "ekf2d_3d_source": "full_triangulation", + "ekf2d_initial_state_method": "ekf_bootstrap", + "ekf2d_bootstrap_passes": 5, + "flip": false, + "flip_method": "epipolar", + "flip_improvement_ratio": 0.7, + "flip_min_gain_px": 3.0, + "flip_min_other_cameras": 2, + "flip_restrict_to_outliers": true, + "flip_outlier_percentile": 85.0, + "flip_outlier_floor_px": 5.0, + "flip_temporal_weight": 0.35, + "flip_temporal_tau_px": 20.0, + "flip_temporal_min_valid_keypoints": 4, + "dof_locking": false, + "initial_rotation_correction": true, + "pose_data_mode": "raw", + "triangulation_method": "once", + "reprojection_threshold_px": null, + "coherence_method": "epipolar", + "compare_biorbd_kalman": false, + "reuse_triangulation": false, + "no_root_unwrap": true, + "root_unwrap_mode": "off", + "biorbd_kalman_noise_factor": 1e-08, + "biorbd_kalman_error_factor": 0.0001, + "biorbd_kalman_init_method": "triangulation_ik_root_translation", + "measurement_noise_scale": 1.5, + "process_noise_scale": 1.0, + "coherence_confidence_floor": 0.35, + "upper_back_sagittal_gain": 0.2, + "upper_back_pseudo_std_deg": 10.0, + "pose_filter_window": 9, + "pose_outlier_threshold_ratio": 0.1, + "pose_amplitude_lower_percentile": 5.0, + "pose_amplitude_upper_percentile": 95.0, + "enabled": true, + "extra_args": null + } + ] +} \ No newline at end of file diff --git a/run_reconstruction_profiles.py b/run_reconstruction_profiles.py index 6245e77..31d2758 100644 --- a/run_reconstruction_profiles.py +++ b/run_reconstruction_profiles.py @@ -39,6 +39,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT, help="Dossier racine des sorties.") parser.add_argument("--calib", type=Path, default=DEFAULT_CALIB) parser.add_argument("--keypoints", type=Path, default=DEFAULT_KEYPOINTS) + parser.add_argument("--annotations-path", type=Path, default=None) parser.add_argument("--trc-file", "--pose2sim-trc", dest="pose2sim_trc", type=Path, default=None) parser.add_argument("--fps", type=float, default=120.0) parser.add_argument("--triangulation-workers", type=int, default=6) @@ -94,6 +95,8 @@ def main() -> None: python_executable=sys.executable, camera_names_override=[name.strip() for name in args.camera_names.split(",") if name.strip()] or None, ) + if args.annotations_path is not None: + cmd.extend(["--annotations-path", str(args.annotations_path)]) cmd.extend(["--fps", str(args.fps), "--triangulation-workers", str(args.triangulation_workers)]) print(f"\n[PROFILE {profile_idx}/{len(profiles)}] {profile.name}", flush=True) print(shlex.join(cmd), flush=True) @@ -122,6 +125,7 @@ def main() -> None: "dataset_name": dataset_name, "calib": str(args.calib), "keypoints": str(args.keypoints), + "annotations_path": None if args.annotations_path is None else str(args.annotations_path), "pose2sim_trc": None if args.pose2sim_trc is None else str(args.pose2sim_trc), "fps": float(args.fps), "triangulation_workers": int(args.triangulation_workers), diff --git a/tests/test_annotation_frame_navigation.py b/tests/test_annotation_frame_navigation.py new file mode 100644 index 0000000..b7e7034 --- /dev/null +++ b/tests/test_annotation_frame_navigation.py @@ -0,0 +1,63 @@ +from pathlib import Path + +import numpy as np + +from annotation import frame_navigation + + +def test_resolve_annotation_frame_filter_mode_accepts_label_or_key(): + options = {"all": "All frames", "worst_reproj": "Worst reproj 5%"} + + assert frame_navigation.resolve_annotation_frame_filter_mode("All frames", options) == "all" + assert frame_navigation.resolve_annotation_frame_filter_mode("worst_reproj", options) == "worst_reproj" + assert frame_navigation.resolve_annotation_frame_filter_mode("unknown", options) == "all" + + +def test_fallback_annotation_filtered_indices_uses_all_frames_when_empty(): + assert frame_navigation.fallback_annotation_filtered_indices(4, []) == [0, 1, 2, 3] + assert frame_navigation.fallback_annotation_filtered_indices(4, [1, 3]) == [1, 3] + + +def test_step_frame_index_within_subset_wraps_and_skips_gaps(): + candidates = [2, 7, 9] + + assert frame_navigation.step_frame_index_within_subset(7, 1, candidates) == 9 + assert frame_navigation.step_frame_index_within_subset(9, 1, candidates) == 2 + assert frame_navigation.step_frame_index_within_subset(7, -1, candidates) == 2 + assert frame_navigation.step_frame_index_within_subset(2, -1, candidates) == 9 + assert frame_navigation.step_frame_index_within_subset(5, 1, candidates) == 7 + assert frame_navigation.step_frame_index_within_subset(5, -1, candidates) == 2 + + +def test_clamp_index_to_subset_returns_current_or_first(): + assert frame_navigation.clamp_index_to_subset(3, [1, 3, 5]) == 3 + assert frame_navigation.clamp_index_to_subset(2, [1, 3, 5]) == 1 + assert frame_navigation.clamp_index_to_subset(2, []) is None + + +def test_navigable_annotation_frame_local_indices_filters_with_available_images(monkeypatch, tmp_path): + frames = np.array([10, 11, 12, 13], dtype=int) + images_root = tmp_path / "images" + images_root.mkdir() + + monkeypatch.setattr( + frame_navigation, + "available_execution_image_frames", + lambda _images_root, _camera_names: {"cam0": {10, 12}, "cam1": {13}}, + ) + + navigable = frame_navigation.navigable_annotation_frame_local_indices( + frames, + filtered_indices=[0, 1, 2, 3], + camera_names=["cam0"], + images_root=images_root, + ) + assert navigable == [0, 2] + + fallback = frame_navigation.navigable_annotation_frame_local_indices( + frames, + filtered_indices=[1, 3], + camera_names=["cam2"], + images_root=images_root, + ) + assert fallback == [1, 3] diff --git a/tests/test_annotation_kinematic_assist.py b/tests/test_annotation_kinematic_assist.py new file mode 100644 index 0000000..e664244 --- /dev/null +++ b/tests/test_annotation_kinematic_assist.py @@ -0,0 +1,145 @@ +import numpy as np + +import annotation.kinematic_assist as kinematic_assist +from annotation.kinematic_assist import ( + annotation_relevant_q_mask, + constrain_annotation_state_to_mask, + refine_annotation_window_states, + resolve_annotation_kinematic_state_info, + store_annotation_kinematic_state, +) +from vitpose_ekf_pipeline import PoseData + + +class _FakeModel: + def __init__(self, nq: int): + self._nq = int(nq) + + def nbQ(self): + return self._nq + + +def test_resolve_annotation_kinematic_state_info_prefers_exact_match(): + model = _FakeModel(2) + frame_states = { + ("demo", 10): np.array([1.0, 2.0], dtype=float), + ("demo", 12): np.array([9.0, 9.0], dtype=float), + } + + info = resolve_annotation_kinematic_state_info( + frame_states, + model_label="demo", + frame_number=10, + model=model, + ) + + assert info.is_exact is True + assert info.source_frame == 10 + np.testing.assert_allclose(info.state, np.array([1.0, 2.0, 0.0, 0.0, 0.0, 0.0])) + + +def test_resolve_annotation_kinematic_state_info_returns_nearest_valid_state(): + model = _FakeModel(2) + frame_states = { + ("demo", 4): np.array([4.0, 5.0], dtype=float), + ("demo", 14): np.array([14.0, 15.0], dtype=float), + ("other", 9): np.array([99.0, 99.0], dtype=float), + ("demo", 11): np.array([1.0, 2.0, 3.0], dtype=float), + } + + info = resolve_annotation_kinematic_state_info( + frame_states, + model_label="demo", + frame_number=12, + model=model, + ) + + assert info.is_exact is False + assert info.source_frame == 14 + np.testing.assert_allclose(info.state, np.array([14.0, 15.0, 0.0, 0.0, 0.0, 0.0])) + + +def test_store_annotation_kinematic_state_normalizes_before_storing(): + model = _FakeModel(2) + frame_states = {} + + stored = store_annotation_kinematic_state( + frame_states, + model_label="demo", + frame_number=8, + model=model, + state=np.array([1.0, 2.0], dtype=float), + ) + + np.testing.assert_allclose(stored, np.array([1.0, 2.0, 0.0, 0.0, 0.0, 0.0])) + np.testing.assert_allclose(frame_states[("demo", 8)], stored) + + +def test_annotation_relevant_q_mask_targets_left_arm_and_trunk(): + mask = annotation_relevant_q_mask( + [ + "TRUNK:RotX", + "LEFT_UPPER_ARM:RotY", + "LEFT_LOWER_ARM:RotZ", + "RIGHT_THIGH:RotX", + ], + "left_wrist", + ) + + np.testing.assert_array_equal(mask, np.array([True, True, True, False])) + + +def test_constrain_annotation_state_to_mask_freezes_inactive_dofs(): + state = np.array([10.0, 20.0, 1.0, 2.0, 3.0, 4.0], dtype=float) + reference = np.array([5.0, 6.0, 0.1, 0.2, 0.3, 0.4], dtype=float) + + constrained = constrain_annotation_state_to_mask(state, reference, np.array([True, False])) + + np.testing.assert_allclose(constrained, np.array([10.0, 6.0, 1.0, 0.2, 3.0, 0.4])) + + +def test_refine_annotation_window_states_propagates_and_refines_neighbor_frames(monkeypatch): + model = _FakeModel(2) + seed_state = np.array([1.0, 2.0, 0.0, 0.0, 0.0, 0.0], dtype=float) + pose_data = PoseData( + camera_names=["cam0"], + frames=np.array([0], dtype=int), + keypoints=np.zeros((1, 1, 17, 2), dtype=float), + scores=np.ones((1, 1, 17), dtype=float), + ) + propagated_deltas = [] + refined_frames = [] + + def _fake_propagate(_model, state, *, dt, frame_delta): + propagated_deltas.append((dt, frame_delta)) + propagated = np.array(state, copy=True) + propagated[:2] += float(frame_delta) + return propagated + + def _fake_refine(**kwargs): + refined_frames.append(int(kwargs["frame_number"])) + refined = np.array(kwargs["seed_state"], copy=True) + refined[:2] += 10.0 + return refined, {"used_fallback": False} + + monkeypatch.setattr(kinematic_assist, "propagate_annotation_kinematic_state", _fake_propagate) + monkeypatch.setattr(kinematic_assist, "refine_annotation_q_with_local_ekf", _fake_refine) + + refined_states, diagnostics = refine_annotation_window_states( + model=model, + calibrations={"cam0": object()}, + pose_data_by_frame={9: pose_data, 10: pose_data, 11: pose_data}, + center_frame_number=10, + seed_state=seed_state, + fps=120.0, + passes=2, + epipolar_threshold_px=15.0, + q_names=["TRUNK:RotX", "TRUNK:RotY"], + ) + + assert sorted(refined_states) == [9, 10, 11] + np.testing.assert_allclose(refined_states[10], seed_state) + assert refined_frames == [11, 9] + assert propagated_deltas == [(1.0 / 120.0, 1), (1.0 / 120.0, -1)] + assert diagnostics["completed_frames"] == 2 + assert diagnostics["frame_statuses"] == {11: "corrected", 9: "corrected"} diff --git a/tests/test_annotation_preview_render.py b/tests/test_annotation_preview_render.py new file mode 100644 index 0000000..c2477d7 --- /dev/null +++ b/tests/test_annotation_preview_render.py @@ -0,0 +1,79 @@ +import numpy as np +from matplotlib.figure import Figure + +from annotation.preview_render import annotation_frame_label_text, render_annotation_camera_view + + +def test_annotation_frame_label_text_formats_filtered_mode(): + text = annotation_frame_label_text( + frame_idx=7, + frame_number=42, + mode="worst_reproj", + filtered_indices=[1, 7, 9], + mode_labels={"all": "All frames", "worst_reproj": "Worst reproj 5%"}, + ) + + assert text == "frame 7 | raw 42 | Worst reproj 5% 2/3" + + +def test_render_annotation_camera_view_collects_hover_entries_and_draws_overlays(): + figure = Figure(figsize=(4, 4)) + ax = figure.subplots() + calls = {"background": 0, "limits": 0, "hide": 0, "skeleton": 0, "upper_back": 0} + annotated_points = { + ("cam0", 10, "nose"): np.array([10.0, 20.0], dtype=float), + ("cam0", 10, "left_wrist"): np.array([30.0, 40.0], dtype=float), + ("cam0", 9, "nose"): np.array([8.0, 18.0], dtype=float), + ("cam0", 8, "nose"): np.array([6.0, 16.0], dtype=float), + } + + hover_entries = render_annotation_camera_view( + ax, + ax_idx=0, + camera_name="cam0", + frame_idx=0, + frame_number=10, + width=640, + height=480, + crop_mode="full", + crop_limits={}, + background_image=np.zeros((10, 10, 3), dtype=float), + current_marker="nose", + current_color="red", + keypoint_names=("nose", "left_wrist"), + kp_index={"nose": 0, "left_wrist": 1}, + annotation_xy_getter=lambda camera_name, frame_number, keypoint_name: annotated_points.get( + (camera_name, frame_number, keypoint_name) + ), + pending_reprojection_points={("cam0", 10, "left_wrist"): np.array([50.0, 60.0], dtype=float)}, + marker_color_getter=lambda keypoint_name: {"nose": "red", "left_wrist": "blue"}[keypoint_name], + marker_shape_getter=lambda keypoint_name: "+" if keypoint_name == "nose" else "x", + draw_background_fn=lambda *_args, **_kwargs: calls.__setitem__("background", calls["background"] + 1), + apply_axis_limits_fn=lambda *_args, **_kwargs: calls.__setitem__("limits", calls["limits"] + 1), + hide_axes_fn=lambda *_args, **_kwargs: calls.__setitem__("hide", calls["hide"] + 1), + draw_skeleton_fn=lambda *_args, **_kwargs: calls.__setitem__("skeleton", calls["skeleton"] + 1), + draw_upper_back_fn=lambda *_args, **_kwargs: calls.__setitem__("upper_back", calls["upper_back"] + 1), + kinematic_projected_points=np.zeros((1, 1, 2, 2), dtype=float), + kinematic_segmented_back_projected={ + "hip_triangle": np.zeros((1, 1, 4, 2), dtype=float), + "shoulder_triangle": np.zeros((1, 1, 4, 2), dtype=float), + "mid_back": np.zeros((1, 1, 1, 2), dtype=float), + }, + reference_projected_points=np.zeros((2, 2), dtype=float), + reference_projected_label="Recon", + reference_projected_color="#8844ff", + motion_prior_enabled=True, + motion_prior_diameter=15.0, + motion_prior_center_fn=lambda pt1, pt2: np.array([12.0, 22.0], dtype=float), + ) + + assert calls == {"background": 1, "limits": 1, "hide": 1, "skeleton": 2, "upper_back": 1} + assert [entry["source"] for entry in hover_entries] == [ + "annotated", + "annotated", + "model reproj", + "model reproj", + "reconstruction reproj", + "reconstruction reproj", + "pending reproj", + ] diff --git a/tests/test_annotation_store.py b/tests/test_annotation_store.py new file mode 100644 index 0000000..d6154f7 --- /dev/null +++ b/tests/test_annotation_store.py @@ -0,0 +1,100 @@ +from pathlib import Path + +import numpy as np + +from annotation.annotation_store import ( + apply_annotations_to_pose_arrays, + clear_annotation_point, + default_annotation_path, + empty_annotation_payload, + get_annotation_point, + load_annotation_payload, + save_annotation_payload, + set_annotation_point, +) +from vitpose_ekf_pipeline import COCO17 + + +def test_default_annotation_path_uses_inputs_annotations_folder(): + keypoints_path = Path("inputs/keypoints/1_partie_0429_keypoints.json") + + assert default_annotation_path(keypoints_path) == Path("inputs/annotations/1_partie_0429_annotations.json") + + +def test_sparse_annotation_payload_round_trip_and_clear(tmp_path): + annotation_path = tmp_path / "trial_annotations.json" + payload = load_annotation_payload(annotation_path, keypoints_path=Path("inputs/keypoints/trial_keypoints.json")) + + set_annotation_point( + payload, + camera_name="M11139", + frame_number=12, + keypoint_name="left_wrist", + xy=[123.0, 456.0], + score=0.95, + ) + saved_path = save_annotation_payload(annotation_path, payload) + reloaded = load_annotation_payload(saved_path) + + xy, score = get_annotation_point( + reloaded, + camera_name="M11139", + frame_number=12, + keypoint_name="left_wrist", + ) + + np.testing.assert_allclose(xy, np.array([123.0, 456.0], dtype=float)) + assert score == 0.95 + + clear_annotation_point( + reloaded, + camera_name="M11139", + frame_number=12, + keypoint_name="left_wrist", + ) + xy, score = get_annotation_point( + reloaded, + camera_name="M11139", + frame_number=12, + keypoint_name="left_wrist", + ) + assert xy is None + assert score is None + + +def test_apply_annotations_to_pose_arrays_overlays_sparse_points(): + keypoints = np.zeros((1, 2, 17, 2), dtype=float) + scores = np.ones((1, 2, 17), dtype=float) + payload = {"annotations": {"M11139": {"5": {"left_wrist": {"xy": [321.0, 654.0], "score": 0.8}}}}} + + annotated_keypoints, annotated_scores = apply_annotations_to_pose_arrays( + keypoints=keypoints, + scores=scores, + camera_names=["M11139"], + frames=np.array([4, 5], dtype=int), + keypoint_names=COCO17, + payload=payload, + ) + + np.testing.assert_allclose(annotated_keypoints[0, 1, 9], np.array([321.0, 654.0], dtype=float)) + assert annotated_scores[0, 1, 9] == 0.8 + np.testing.assert_allclose(annotated_keypoints[0, 0, 9], np.array([0.0, 0.0], dtype=float)) + + +def test_apply_annotations_to_pose_arrays_on_empty_base_produces_sparse_only_arrays(): + payload = empty_annotation_payload() + set_annotation_point(payload, camera_name="cam0", frame_number=0, keypoint_name="nose", xy=[5.0, 7.0], score=1.0) + + annotated_keypoints, annotated_scores = apply_annotations_to_pose_arrays( + keypoints=np.full((2, 2, 17, 2), np.nan, dtype=float), + scores=np.zeros((2, 2, 17), dtype=float), + camera_names=["cam0", "cam1"], + frames=np.array([0, 1], dtype=int), + keypoint_names=COCO17, + payload=payload, + ) + + np.testing.assert_allclose(annotated_keypoints[0, 0, 0], np.array([5.0, 7.0], dtype=float)) + assert annotated_scores[0, 0, 0] == 1.0 + assert np.isnan(annotated_keypoints[1]).all() + assert np.all(annotated_scores[1] == 0.0) diff --git a/tests/test_batch_run.py b/tests/test_batch_run.py new file mode 100644 index 0000000..cf8a43f --- /dev/null +++ b/tests/test_batch_run.py @@ -0,0 +1,180 @@ +import json +from pathlib import Path + +import pytest + +import batch_run + +openpyxl = pytest.importorskip("openpyxl") +load_workbook = openpyxl.load_workbook + + +def test_discover_keypoints_files_resolves_patterns_and_deduplicates(tmp_path): + root = tmp_path / "workspace" + keypoints_dir = root / "inputs" / "keypoints" + keypoints_dir.mkdir(parents=True) + trial_a = keypoints_dir / "trial_a_keypoints.json" + trial_b = keypoints_dir / "trial_b_keypoints.json" + trial_a.write_text("{}", encoding="utf-8") + trial_b.write_text("{}", encoding="utf-8") + + discovered = batch_run.discover_keypoints_files( + ["inputs/keypoints/*.json", "inputs/keypoints/trial_a_keypoints.json"], + root=root, + ) + + assert discovered == [trial_a, trial_b] + + +def test_infer_optional_inputs_from_keypoints(tmp_path): + keypoints_path = tmp_path / "inputs" / "keypoints" / "trial_keypoints.json" + trc_path = tmp_path / "inputs" / "trc" / "trial.trc" + annotations_path = tmp_path / "inputs" / "annotations" / "trial_annotations.json" + keypoints_path.parent.mkdir(parents=True) + trc_path.parent.mkdir(parents=True) + annotations_path.parent.mkdir(parents=True) + keypoints_path.write_text("{}", encoding="utf-8") + trc_path.write_text("dummy", encoding="utf-8") + annotations_path.write_text("{}", encoding="utf-8") + + assert batch_run.infer_pose2sim_trc_for_keypoints(keypoints_path) == trc_path + assert batch_run.infer_annotations_for_keypoints(keypoints_path) == annotations_path + + +def test_collect_manifest_rows_flattens_summary_and_profile(tmp_path): + output_root = tmp_path / "output" + recon_dir = output_root / "trial" / "reconstructions" / "demo" + recon_dir.mkdir(parents=True) + (recon_dir / "profile.json").write_text( + json.dumps( + { + "name": "demo", + "family": "ekf_2d", + "pose_data_mode": "cleaned", + "predictor": "dyn", + "triangulation_method": "exhaustive", + "coherence_method": "epipolar", + "frame_stride": 1, + } + ), + encoding="utf-8", + ) + batch_manifest = { + "output_root": str(output_root), + "calib": "inputs/calibration/Calib.toml", + "fps": 120.0, + "triangulation_workers": 6, + "datasets": [ + { + "dataset_name": "trial", + "keypoints": "inputs/keypoints/trial_keypoints.json", + "annotations_path": "inputs/annotations/trial_annotations.json", + "pose2sim_trc": "inputs/trc/trial.trc", + "returncode": 0, + "stdout": "", + "stderr": "", + "command": "python run_reconstruction_profiles.py ...", + "manifest": { + "dataset_name": "trial", + "keypoints": "inputs/keypoints/trial_keypoints.json", + "annotations_path": "inputs/annotations/trial_annotations.json", + "pose2sim_trc": "inputs/trc/trial.trc", + "runs": [ + { + "name": "demo", + "family": "ekf_2d", + "returncode": 0, + "output_dir": str(recon_dir), + "latest_family_version": 8, + "bundle_summary_path": str(recon_dir / "bundle_summary.json"), + "bundle_summary": { + "n_frames": 100, + "pose_data_mode": "cleaned", + "triangulation_method": "exhaustive", + "coherence_method": "epipolar", + "reprojection_px": { + "mean": 12.5, + "std": 3.4, + "per_camera": {"cam0": {"mean_px": 10.0, "std_px": 2.0}}, + "per_keypoint": {"left_wrist": {"mean_px": 15.0, "std_px": 5.0, "n_samples": 80}}, + }, + "stage_timings_s": {"triangulation_s": 5.2, "ekf_2d_s": 1.4}, + }, + } + ], + }, + } + ], + } + + run_rows, stage_rows, camera_rows, keypoint_rows, recognition_or_failures = batch_run.collect_manifest_rows( + batch_manifest + ) + + assert len(run_rows) == 1 + assert run_rows[0]["dataset_name"] == "trial" + assert run_rows[0]["profile_family"] == "ekf_2d" + assert run_rows[0]["reprojection_mean_px"] == 12.5 + assert len(stage_rows) == 2 + assert stage_rows[0]["dataset_name"] == "trial" + assert len(camera_rows) == 1 + assert camera_rows[0]["camera_name"] == "cam0" + assert len(keypoint_rows) == 1 + assert keypoint_rows[0]["keypoint_name"] == "left_wrist" + assert recognition_or_failures == [] + + +def test_write_batch_workbook_creates_expected_sheets(tmp_path): + workbook_path = tmp_path / "batch.xlsx" + batch_manifest = { + "output_root": str(tmp_path / "output"), + "calib": "inputs/calibration/Calib.toml", + "fps": 120.0, + "triangulation_workers": 6, + "datasets": [], + } + + written = batch_run.write_batch_workbook(batch_manifest, workbook_path) + + assert written == workbook_path + assert workbook_path.exists() + workbook = load_workbook(workbook_path) + assert workbook.sheetnames == [ + "Runs", + "StageTimings", + "ReprojectionPerCamera", + "ReprojectionPerKeypoint", + "Recognition", + "Failures", + ] + + +def test_run_batch_export_only_reads_existing_manifest(tmp_path): + output_root = tmp_path / "output" + dataset_dir = output_root / "trial" + dataset_dir.mkdir(parents=True) + manifest_path = dataset_dir / "manifest.json" + manifest_path.write_text( + json.dumps({"dataset_name": "trial", "keypoints": "inputs/keypoints/trial_keypoints.json", "runs": []}), + encoding="utf-8", + ) + keypoints_path = tmp_path / "inputs" / "keypoints" / "trial_keypoints.json" + keypoints_path.parent.mkdir(parents=True) + keypoints_path.write_text("{}", encoding="utf-8") + + manifest = batch_run.run_batch( + keypoints_files=[keypoints_path], + config_path=tmp_path / "profiles.json", + output_root=output_root, + calib_path=tmp_path / "Calib.toml", + fps=120.0, + triangulation_workers=6, + selected_profiles=None, + continue_on_error=False, + export_only=True, + python_executable="python", + ) + + assert len(manifest["datasets"]) == 1 + assert manifest["datasets"][0]["dataset_name"] == "trial" + assert manifest["datasets"][0]["manifest"]["dataset_name"] == "trial" diff --git a/tests/test_calibration_qc.py b/tests/test_calibration_qc.py new file mode 100644 index 0000000..f5f37af --- /dev/null +++ b/tests/test_calibration_qc.py @@ -0,0 +1,124 @@ +import numpy as np +import warnings + +import calibration_qc +from vitpose_ekf_pipeline import CameraCalibration, PoseData + + +def _make_camera(name: str, tx: float) -> CameraCalibration: + K = np.eye(3, dtype=float) + R = np.eye(3, dtype=float) + tvec = np.array([[tx], [0.0], [0.0]], dtype=float) + P = np.hstack((R, tvec)) + return CameraCalibration( + name=name, + image_size=(1920, 1080), + K=K, + dist=np.zeros(5, dtype=float), + rvec=np.zeros(3, dtype=float), + tvec=tvec, + R=R, + P=P, + ) + + +def test_compute_2d_calibration_qc_trims_worst_fraction(monkeypatch): + keypoints = np.full((2, 10, 17, 2), np.nan, dtype=float) + scores = np.zeros((2, 10, 17), dtype=float) + keypoints[0, :, 0] = np.column_stack((np.arange(10, dtype=float), np.zeros(10, dtype=float))) + keypoints[1, :, 0] = np.column_stack((np.arange(10, dtype=float), np.ones(10, dtype=float))) + scores[:, :, 0] = 1.0 + pose_data = PoseData(camera_names=["cam0", "cam1"], frames=np.arange(10), keypoints=keypoints, scores=scores) + calibrations = {"cam0": _make_camera("cam0", 0.0), "cam1": _make_camera("cam1", 1.0)} + + monkeypatch.setattr( + calibration_qc, + "sampson_error_pixels_vectorized", + lambda _pts_a, _pts_b, _f: np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 100.0]], dtype=float), + ) + + qc = calibration_qc.compute_2d_calibration_qc(pose_data, calibrations, trim_fraction=0.15) + + assert qc.trim_threshold_px is not None + assert qc.kept_ratio == 0.9 + assert qc.pairwise_sample_count[0, 1] == 9 + assert qc.pairwise_mean_px[0, 1] == 5.0 + assert np.isnan(qc.per_frame_mean_px[-1]) + + +def test_compute_3d_calibration_qc_reports_spatial_non_uniformity(): + points_3d = np.full((6, 17, 3), np.nan, dtype=float) + reproj = np.full((6, 17, 2), np.nan, dtype=float) + excluded = np.zeros((6, 17, 2), dtype=bool) + low_points = np.array([[-1.0, 0.0, 1.0], [-0.8, 0.0, 1.1], [-0.6, 0.0, 1.2]], dtype=float) + high_points = np.array([[0.6, 0.0, 1.3], [0.8, 0.0, 1.4], [1.0, 0.0, 1.5]], dtype=float) + for kp_idx in range(17): + points_3d[:3, kp_idx] = low_points + points_3d[3:, kp_idx] = high_points + reproj[:3, kp_idx, :] = 1.0 + reproj[3:, kp_idx, :] = 10.0 + + qc = calibration_qc.compute_3d_calibration_qc( + { + "points_3d": points_3d, + "reprojection_error_per_view": reproj, + "excluded_views": excluded, + }, + camera_names=["cam0", "cam1"], + spatial_bins=2, + ) + + assert qc is not None + assert qc.spatial_uniformity_cv is not None + assert qc.spatial_uniformity_cv > 0.0 + assert qc.spatial_uniformity_range_px == 9.0 + np.testing.assert_allclose(qc.spatial_axis_means_px["x"], np.array([1.0, 10.0])) + finite_spatial = np.sort(qc.spatial_xz_mean_px[np.isfinite(qc.spatial_xz_mean_px)]) + np.testing.assert_allclose(finite_spatial, np.array([1.0, 10.0])) + np.testing.assert_array_equal(np.sort(qc.spatial_xz_count[qc.spatial_xz_count > 0]), np.array([51, 51])) + assert qc.view_usage_summary["per_camera"]["cam0"]["excluded_ratio"] == 0.0 + + +def test_compute_3d_calibration_qc_ignores_empty_slices_without_warning(): + points_3d = np.full((3, 17, 3), np.nan, dtype=float) + reproj = np.full((3, 17, 2), np.nan, dtype=float) + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + qc = calibration_qc.compute_3d_calibration_qc( + { + "points_3d": points_3d, + "reprojection_error_per_view": reproj, + }, + camera_names=["cam0", "cam1"], + spatial_bins=2, + ) + + assert qc is not None + assert np.all(np.isnan(qc.per_frame_mean_px)) + + +def test_frame_camera_epipolar_errors_averages_other_views(monkeypatch): + keypoints = np.full((3, 1, 17, 2), np.nan, dtype=float) + scores = np.zeros((3, 1, 17), dtype=float) + for cam_idx in range(3): + keypoints[cam_idx, 0, 0] = [10.0 + cam_idx, 20.0] + scores[cam_idx, 0, 0] = 1.0 + pose_data = PoseData( + camera_names=["cam0", "cam1", "cam2"], frames=np.array([0]), keypoints=keypoints, scores=scores + ) + calibrations = { + "cam0": _make_camera("cam0", 0.0), + "cam1": _make_camera("cam1", 1.0), + "cam2": _make_camera("cam2", 2.0), + } + monkeypatch.setattr( + calibration_qc, + "sampson_error_pixels_vectorized", + lambda _pts_a, _pts_b, _f: np.array([[2.0] + [np.nan] * 16, [4.0] + [np.nan] * 16], dtype=float), + ) + + values = calibration_qc.frame_camera_epipolar_errors(pose_data, calibrations, frame_idx=0, camera_idx=0) + + assert values[0] == 3.0 + assert np.isnan(values[1]) diff --git a/tests/test_dd_analysis.py b/tests/test_dd_analysis.py index 7166c2c..9b0ec11 100644 --- a/tests/test_dd_analysis.py +++ b/tests/test_dd_analysis.py @@ -12,6 +12,28 @@ ) +def _body_shape_points() -> np.ndarray: + points = np.full((3, 17, 3), np.nan, dtype=float) + for frame_idx in range(3): + points[frame_idx, 5] = np.array([0.0, 0.2, 1.0], dtype=float) + points[frame_idx, 6] = np.array([0.0, -0.2, 1.0], dtype=float) + points[frame_idx, 11] = np.array([0.0, 0.2, 0.0], dtype=float) + points[frame_idx, 12] = np.array([0.0, -0.2, 0.0], dtype=float) + points[0, 13] = np.array([0.0, 0.2, -1.0], dtype=float) + points[0, 14] = np.array([0.0, -0.2, -1.0], dtype=float) + points[0, 15] = np.array([0.0, 0.2, -2.0], dtype=float) + points[0, 16] = np.array([0.0, -0.2, -2.0], dtype=float) + points[1, 13] = np.array([1.0, 0.2, 0.0], dtype=float) + points[1, 14] = np.array([1.0, -0.2, 0.0], dtype=float) + points[1, 15] = np.array([1.0, 0.2, -1.0], dtype=float) + points[1, 16] = np.array([1.0, -0.2, -1.0], dtype=float) + points[2, 13] = np.array([1.0, 0.2, 0.0], dtype=float) + points[2, 14] = np.array([1.0, -0.2, 0.0], dtype=float) + points[2, 15] = np.array([2.0, 0.2, 0.0], dtype=float) + points[2, 16] = np.array([2.0, -0.2, 0.0], dtype=float) + return points + + def test_default_body_shape_indices_use_rot_y_flexion_axes(): q_names = [ "LEFT_THIGH:RotY", @@ -64,6 +86,28 @@ def fake_compute_angles_over_jump(_root_q, _start, _end, rotation_sequence="yxz" assert jump.code == "821" +def test_analyze_single_jump_uses_geometric_hip_and_knee_angles_when_points_are_available(monkeypatch): + som_curve = np.array([0.0, -0.25, -0.5], dtype=float) * (2.0 * np.pi) + tw_curve = np.zeros_like(som_curve) + tilt_curve = np.zeros_like(som_curve) + + def fake_compute_angles_over_jump(_root_q, _start, _end, rotation_sequence="yxz", angle_mode="euler"): + return som_curve, tw_curve, tilt_curve + + monkeypatch.setattr("judging.dd_analysis.compute_angles_over_jump", fake_compute_angles_over_jump) + jump = analyze_single_jump( + np.zeros((3, 6), dtype=float), + JumpSegment(start=0, end=2, peak_index=1), + points_3d=_body_shape_points(), + ) + + assert jump.body_shape == "grouped" + np.testing.assert_allclose(np.rad2deg(jump.hip_flex_curve_rad), np.array([11.30993247, 90.0, 90.0]), atol=1e-6) + np.testing.assert_allclose(np.rad2deg(jump.knee_flex_curve_rad), np.array([0.0, 90.0, 0.0]), atol=1e-6) + assert jump.grouped_mask.tolist() == [False, True, False] + assert jump.piked_mask.tolist() == [False, False, True] + + def test_analyze_dd_session_ignores_first_frames_and_keeps_only_complete_jumps(): fps = 10.0 root_q = np.zeros((60, 6), dtype=float) @@ -93,6 +137,39 @@ def test_analyze_dd_session_ignores_first_frames_and_keeps_only_complete_jumps() assert analysis.jump_segments[0].end < len(height) - 1 +def test_analyze_dd_session_threshold_ignores_null_prefix_before_analysis_window(): + fps = 10.0 + root_q = np.zeros((80, 6), dtype=float) + height = np.ones(80, dtype=float) * 1.2 + # Null prefix would collapse the relative threshold if we looked at the full sequence. + height[:10] = 0.0 + # Two contact valleys and one final incomplete jump. + height[12:20] = 2.8 + height[20:24] = 1.15 + height[24:34] = 2.7 + height[34:38] = 1.18 + height[38:48] = 2.9 + height[48:52] = 1.16 + height[52:79] = 2.6 + root_q[:, 2] = height + + analysis = analyze_dd_session( + root_q, + fps, + height_values=height, + smoothing_window_s=0.0, + min_airtime_s=0.2, + min_gap_s=0.0, + min_peak_prominence_m=0.2, + contact_window_s=0.2, + analysis_start_frame=10, + require_complete_jumps=True, + ) + + assert analysis.height_threshold >= 1.5 + assert len(analysis.jump_segments) == 2 + + def test_body_shape_phase_masks_separate_grouped_and_piked_frames(): hip_curve = np.deg2rad(np.array([30.0, 80.0, 85.0, 90.0])) knee_curve = np.deg2rad(np.array([10.0, 85.0, 15.0, 5.0])) diff --git a/tests/test_dd_presenter.py b/tests/test_dd_presenter.py index 311611c..dd6e98d 100644 --- a/tests/test_dd_presenter.py +++ b/tests/test_dd_presenter.py @@ -48,7 +48,7 @@ def test_jump_list_label_formats_expected_text(): def test_jump_list_label_with_reference_appends_reference_code(): label = jump_list_label_with_reference(3, make_jump(), "821o") - assert label == "S3 | som 2.25 | tw 1.50 | ref 821o" + assert label == "S3 | som 2.25 | tw 1.50 | det 82+33 | exp 821o" def test_format_dd_summary_handles_missing_body_shape_and_code(): diff --git a/tests/test_execution.py b/tests/test_execution.py index 56d54a5..ae848a8 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -3,6 +3,7 @@ from judging.dd_analysis import DDSessionAnalysis, JumpSegment from judging.execution import ( analyze_execution_session, + available_execution_image_frames, build_execution_overlay_frame, compute_time_of_flight_robust, detect_contacts_velocity, @@ -162,7 +163,7 @@ def test_resolve_execution_image_path_matches_camera_folder_and_frame(tmp_path): images_root = tmp_path / "images" camera_dir = images_root / "camA" camera_dir.mkdir(parents=True) - image_path = camera_dir / "frame_000123.png" + image_path = camera_dir / "frame_000123.jpeg" image_path.write_bytes(b"fake") resolved = resolve_execution_image_path(images_root, "camA", 123) @@ -170,6 +171,32 @@ def test_resolve_execution_image_path_matches_camera_folder_and_frame(tmp_path): assert resolved == image_path +def test_resolve_execution_image_path_matches_flat_camera_prefixed_frame_pattern(tmp_path): + images_root = tmp_path / "images" + images_root.mkdir(parents=True) + wrong_image = images_root / "Camera1_M11139_frame_001623.png" + wrong_image.write_bytes(b"fake") + image_path = images_root / "Camera1_M11139_frame_000123.JPG" + image_path.write_bytes(b"fake") + + resolved = resolve_execution_image_path(images_root, "M11139", 123) + + assert resolved == image_path + + +def test_available_execution_image_frames_indexes_folder_and_flat_layouts(tmp_path): + images_root = tmp_path / "images" + (images_root / "camA").mkdir(parents=True) + (images_root / "camA" / "frame_000123.jpeg").write_bytes(b"fake") + (images_root / "Camera1_M11139_frame_000124.JPG").write_bytes(b"fake") + + available = available_execution_image_frames(images_root, ["camA", "M11139", "camB"]) + + assert available["camA"] == {123} + assert available["M11139"] == {124} + assert available["camB"] == set() + + def test_build_execution_overlay_frame_collects_raw_projected_points_and_image(tmp_path): class _Calibration: def project_point(self, point): diff --git a/tests/test_jump_cache.py b/tests/test_jump_cache.py new file mode 100644 index 0000000..b5f7f18 --- /dev/null +++ b/tests/test_jump_cache.py @@ -0,0 +1,69 @@ +import numpy as np + +from judging.jump_cache import get_cached_jump_analysis, jump_segmentation_height_series + + +def test_jump_segmentation_height_series_prefers_ankles_when_available(): + root_q = np.zeros((3, 6), dtype=float) + points = np.full((3, 17, 3), np.nan, dtype=float) + points[:, 15, 2] = np.array([1.2, 1.3, 1.4], dtype=float) + points[:, 16, 2] = np.array([0.8, 0.7, 0.6], dtype=float) + + series = jump_segmentation_height_series(points, root_q) + + np.testing.assert_allclose(series, np.array([0.8, 0.7, 0.6], dtype=float)) + + +def test_get_cached_jump_analysis_reuses_same_cached_object(): + cache = {} + root_q = np.zeros((20, 6), dtype=float) + points = np.full((20, 17, 3), np.nan, dtype=float) + points[:, 5] = np.array([0.0, 0.2, 1.0], dtype=float) + points[:, 6] = np.array([0.0, -0.2, 1.0], dtype=float) + points[:, 11] = np.array([0.0, 0.2, 0.0], dtype=float) + points[:, 12] = np.array([0.0, -0.2, 0.0], dtype=float) + points[:, 13] = np.array([0.0, 0.2, -1.0], dtype=float) + points[:, 14] = np.array([0.0, -0.2, -1.0], dtype=float) + points[:, 15] = np.array([0.0, 0.2, -2.0], dtype=float) + points[:, 16] = np.array([0.0, -0.2, -2.0], dtype=float) + points[5:12, 15, 2] = 1.0 + points[5:12, 16, 2] = 1.0 + + first = get_cached_jump_analysis( + cache, + reconstruction_name="demo", + root_q=root_q, + points_3d=points, + fps=10.0, + height_threshold=0.5, + height_threshold_range_ratio=0.2, + smoothing_window_s=0.0, + min_airtime_s=0.2, + min_gap_s=0.0, + min_peak_prominence_m=0.2, + contact_window_s=0.2, + q_names=["TRUNK:TransZ"], + angle_mode="euler", + analysis_start_frame=0, + require_complete_jumps=True, + ) + second = get_cached_jump_analysis( + cache, + reconstruction_name="demo", + root_q=root_q, + points_3d=points, + fps=10.0, + height_threshold=0.5, + height_threshold_range_ratio=0.2, + smoothing_window_s=0.0, + min_airtime_s=0.2, + min_gap_s=0.0, + min_peak_prominence_m=0.2, + contact_window_s=0.2, + q_names=["TRUNK:TransZ"], + angle_mode="euler", + analysis_start_frame=0, + require_complete_jumps=True, + ) + + assert first is second diff --git a/tests/test_model_variants.py b/tests/test_model_variants.py index 5cd5bd4..f3e9910 100644 --- a/tests/test_model_variants.py +++ b/tests/test_model_variants.py @@ -1,17 +1,21 @@ from pathlib import Path from reconstruction.reconstruction_registry import default_model_stem -from vitpose_ekf_pipeline import SegmentLengths, build_biomod +from vitpose_ekf_pipeline import SegmentLengths, build_biomod, segment_length_for_side def test_default_model_stem_distinguishes_back_variant(): stem_default = default_model_stem("cleaned", "exhaustive") stem_back_flex = default_model_stem("cleaned", "exhaustive", model_variant="back_flexion_1d") stem_back = default_model_stem("cleaned", "exhaustive", model_variant="back_3dof") + stem_upper_root_back_flex = default_model_stem("cleaned", "exhaustive", model_variant="upper_root_back_flexion_1d") + stem_upper_root_back = default_model_stem("cleaned", "exhaustive", model_variant="upper_root_back_3dof") assert stem_default == "model_2d_cleaned_exhaustive" assert stem_back_flex == "model_2d_cleaned_exhaustive_back_flexion_1d" assert stem_back == "model_2d_cleaned_exhaustive_back_3dof" + assert stem_upper_root_back_flex == "model_2d_cleaned_exhaustive_upper_root_back_flexion_1d" + assert stem_upper_root_back == "model_2d_cleaned_exhaustive_upper_root_back_3dof" def _lengths() -> SegmentLengths: @@ -27,9 +31,31 @@ def _lengths() -> SegmentLengths: eye_offset_x=0.03, eye_offset_y=0.025, ear_offset_y=0.06, + left_upper_arm_length=0.31, + right_upper_arm_length=0.27, + left_forearm_length=0.26, + right_forearm_length=0.24, + left_thigh_length=0.47, + right_thigh_length=0.43, + left_shank_length=0.41, + right_shank_length=0.39, ) +def test_segment_length_for_side_symmetrizes_by_default(): + lengths = _lengths() + + assert segment_length_for_side(lengths, side="left", base_name="upper_arm_length") == lengths.upper_arm_length + assert segment_length_for_side(lengths, side="right", base_name="upper_arm_length") == lengths.upper_arm_length + + +def test_segment_length_for_side_preserves_side_specific_lengths_when_requested(): + lengths = _lengths() + + assert segment_length_for_side(lengths, side="left", base_name="upper_arm_length", symmetrize_limbs=False) == 0.31 + assert segment_length_for_side(lengths, side="right", base_name="upper_arm_length", symmetrize_limbs=False) == 0.27 + + def test_build_biomod_back_flexion_1d_creates_upper_back_segment(tmp_path: Path): output_path = tmp_path / "back_flexion_1d.bioMod" @@ -37,13 +63,25 @@ def test_build_biomod_back_flexion_1d_creates_upper_back_segment(tmp_path: Path) text = output_path.read_text() assert "segment\tUPPER_BACK" in text + trunk_start = text.index("segment\tTRUNK") + trunk_end = text.index("endsegment", trunk_start) + trunk_block = text[trunk_start:trunk_end] upper_back_start = text.index("segment\tUPPER_BACK") upper_back_end = text.index("endsegment", upper_back_start) upper_back_block = text[upper_back_start:upper_back_end] - assert "rotations\tx" in upper_back_block.lower() + assert "rotations\ty" in upper_back_block.lower() assert "parent\tTRUNK" in text + assert "marker\tmid_back" in text + assert "parent\tUPPER_BACK" in text assert "marker\tleft_shoulder" in text assert "parent\tUPPER_BACK" in text + assert "mesh\t0.000000\t-0.120000\t0.000000" in trunk_block + assert "mesh\t0.000000\t0.000000\t0.000000" in trunk_block + assert "mesh\t0.000000\t0.120000\t0.000000" in trunk_block + assert "mesh\t0.000000\t0.300000\t0.300000" not in upper_back_block + assert "mesh\t0.000000\t0.180000\t0.300000" in upper_back_block + assert "mesh\t0.000000\t-0.180000\t0.300000" in upper_back_block + assert upper_back_block.count("mesh\t0.000000\t0.000000\t0.300000") >= 2 def test_build_biomod_back_3dof_creates_upper_back_segment(tmp_path: Path): @@ -53,6 +91,37 @@ def test_build_biomod_back_3dof_creates_upper_back_segment(tmp_path: Path): text = output_path.read_text() assert "segment\tUPPER_BACK" in text + upper_back_start = text.index("segment\tUPPER_BACK") + upper_back_end = text.index("endsegment", upper_back_start) + upper_back_block = text[upper_back_start:upper_back_end] assert "parent\tTRUNK" in text assert "marker\tleft_shoulder" in text assert "parent\tUPPER_BACK" in text + assert "mesh\t0.000000\t0.180000\t0.300000" in upper_back_block + assert "mesh\t0.000000\t-0.180000\t0.300000" in upper_back_block + + +def test_build_biomod_upper_root_back_flexion_1d_creates_lower_trunk_segment(tmp_path: Path): + output_path = tmp_path / "upper_root_back_flexion_1d.bioMod" + + build_biomod(_lengths(), output_path, model_variant="upper_root_back_flexion_1d") + + text = output_path.read_text() + assert "segment\tLOWER_TRUNK" in text + trunk_start = text.index("segment\tTRUNK") + trunk_end = text.index("endsegment", trunk_start) + trunk_block = text[trunk_start:trunk_end] + lower_trunk_start = text.index("segment\tLOWER_TRUNK") + lower_trunk_end = text.index("endsegment", lower_trunk_start) + lower_trunk_block = text[lower_trunk_start:lower_trunk_end] + assert "marker\tleft_shoulder" in text + assert "parent\tTRUNK" in text + assert "marker\tmid_back" in text + assert "parent\tLOWER_TRUNK" in text + assert "marker\tleft_hip" in text + assert "parent\tLOWER_TRUNK" in text + assert "rotations\ty" in lower_trunk_block.lower() + assert "mesh\t0.000000\t0.180000\t0.000000" in trunk_block + assert "mesh\t0.000000\t-0.180000\t0.000000" in trunk_block + assert "mesh\t0.000000\t0.120000\t-0.300000" in lower_trunk_block + assert "mesh\t0.000000\t-0.120000\t-0.300000" in lower_trunk_block diff --git a/tests/test_pipeline_gui_file_dialog.py b/tests/test_pipeline_gui_file_dialog.py index 47ad77a..51b57ec 100644 --- a/tests/test_pipeline_gui_file_dialog.py +++ b/tests/test_pipeline_gui_file_dialog.py @@ -1,17 +1,26 @@ +import json from pathlib import Path from types import SimpleNamespace import numpy as np +import pytest +from matplotlib.figure import Figure import pipeline_gui +from annotation import frame_navigation +from preview import two_d_view from preview.dataset_preview_state import DatasetPreviewState from preview.shared_reconstruction_panel import SharedReconstructionPanel +from vitpose_ekf_pipeline import CameraCalibration class _FakeTree: def __init__(self): self.rows = {} self._selection = () + self._focus = None + self._identified_row = None + self._seen = None def get_children(self, _root=""): return tuple(self.rows.keys()) @@ -22,15 +31,46 @@ def delete(self, item): def insert(self, _parent, _where, iid, values): self.rows[iid] = tuple(values) + def item(self, item, option=None): + values = self.rows[item] + if option == "values": + return values + return {"values": values} + def selection_set(self, selection): self._selection = tuple(selection) + def selection_clear(self): + self._selection = () + + def selection_add(self, selection): + if isinstance(selection, str): + values = [selection] + else: + values = list(selection) + updated = list(self._selection) + for value in values: + if value not in updated: + updated.append(value) + self._selection = tuple(updated) + def selection(self): return self._selection def exists(self, name): return name in self.rows + def identify_row(self, _y): + return self._identified_row or "" + + def focus(self, item=None): + if item is not None: + self._focus = item + return self._focus + + def see(self, item): + self._seen = item + def test_normalize_pose_correction_mode_accepts_epipolar_fast(): assert pipeline_gui.normalize_pose_correction_mode("flip_epipolar_fast") == "flip_epipolar_fast" @@ -45,6 +85,103 @@ def test_normalize_pose_correction_mode_falls_back_to_none(): assert pipeline_gui.normalize_pose_correction_mode("unexpected_mode") == "none" +def test_schedule_after_idle_once_coalesces_multiple_requests(): + calls = [] + + class _FakeWidget: + def __init__(self): + self._queued = [] + self._token = 0 + + def after_idle(self, callback): + self._token += 1 + self._queued.append((self._token, callback)) + return self._token + + def after_cancel(self, _token): + return + + widget = _FakeWidget() + + pipeline_gui.schedule_after_idle_once(widget, "_scheduled_demo", lambda: calls.append("run")) + pipeline_gui.schedule_after_idle_once(widget, "_scheduled_demo", lambda: calls.append("run")) + + assert len(widget._queued) == 1 + _token, callback = widget._queued.pop() + callback() + assert calls == ["run"] + + +def test_annotation_path_change_is_ignored_while_syncing_defaults(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab._syncing_annotation_defaults = True + tab.request_load_resources = lambda: (_ for _ in ()).throw( + AssertionError("request_load_resources should not be called") + ) + + pipeline_gui.AnnotationTab._on_annotation_path_changed(tab) + + +def test_annotation_sync_dataset_defaults_requests_one_load(monkeypatch, tmp_path): + keypoints_path = tmp_path / "inputs" / "keypoints" / "trial_keypoints.json" + keypoints_path.parent.mkdir(parents=True) + keypoints_path.write_text("{}", encoding="utf-8") + monkeypatch.setattr(pipeline_gui, "ROOT", tmp_path) + monkeypatch.setattr(pipeline_gui, "default_annotation_path", lambda _path: tmp_path / "inputs" / "annotations.json") + monkeypatch.setattr(pipeline_gui, "infer_execution_images_root", lambda _path: tmp_path / "inputs" / "images") + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.state = SimpleNamespace( + keypoints_var=SimpleNamespace(get=lambda: "inputs/keypoints/trial_keypoints.json"), + annotation_path_var=SimpleNamespace( + get=lambda: "", set=lambda value: setattr(tab, "_annotation_path_set", value) + ), + ) + tab.images_root_entry = SimpleNamespace( + var=SimpleNamespace(get=lambda: "", set=lambda value: setattr(tab, "_images_root_set", value)) + ) + tab.refresh_kinematic_model_choices = lambda: setattr(tab, "_models_refreshed", True) + calls = [] + tab.request_load_resources = lambda: calls.append("load") + + pipeline_gui.AnnotationTab.sync_dataset_defaults(tab) + + assert calls == ["load"] + assert tab._annotation_path_set.endswith("inputs/annotations.json") + assert tab._images_root_set.endswith("inputs/images") + + +def test_2d_analysis_on_keypoints_changed_sets_shared_images_root(monkeypatch, tmp_path): + keypoints_path = tmp_path / "inputs" / "keypoints" / "trial_keypoints.json" + keypoints_path.parent.mkdir(parents=True) + keypoints_path.write_text("{}", encoding="utf-8") + inferred_images_root = tmp_path / "inputs" / "images" / "trial" + inferred_images_root.mkdir(parents=True) + shared_images_root = {"value": ""} + + monkeypatch.setattr(pipeline_gui, "ROOT", tmp_path) + monkeypatch.setattr(pipeline_gui, "display_path", lambda path: str(path)) + monkeypatch.setattr(pipeline_gui, "infer_execution_images_root", lambda _path: inferred_images_root) + monkeypatch.setattr(pipeline_gui, "infer_pose2sim_trc_from_keypoints", lambda _path: None) + + tab = pipeline_gui.DataExplorer2DTab.__new__(pipeline_gui.DataExplorer2DTab) + tab.keypoints = SimpleNamespace(get=lambda: "inputs/keypoints/trial_keypoints.json") + tab.state = SimpleNamespace( + pose2sim_trc_var=SimpleNamespace(get=lambda: "", set=lambda _value: None), + shared_images_root_var=SimpleNamespace( + get=lambda: shared_images_root["value"], + set=lambda value: shared_images_root.__setitem__("value", value), + ), + ) + tab.trc_status_var = SimpleNamespace(set=lambda _value: None) + tab.update_dataset_summary = lambda: None + tab.update_flip_status_text = lambda: None + + pipeline_gui.DataExplorer2DTab.on_keypoints_changed(tab) + + assert shared_images_root["value"] == str(inferred_images_root) + + def test_append_default_pose2sim_profile_adds_pose2sim_when_trc_exists(): triangulation = pipeline_gui.ReconstructionProfile(name="tri", family="triangulation") pose2sim = pipeline_gui.ReconstructionProfile(name="p2s", family="pose2sim") @@ -74,6 +211,66 @@ def test_keypoint_preset_names_body_only_removes_face_side_keypoints(): assert "right_wrist" in body_only +def test_annotation_keypoint_order_groups_left_then_right_then_head(): + assert list(pipeline_gui.ANNOTATION_KEYPOINT_ORDER[:6]) == [ + "left_shoulder", + "left_elbow", + "left_wrist", + "left_hip", + "left_knee", + "left_ankle", + ] + assert list(pipeline_gui.ANNOTATION_KEYPOINT_ORDER[6:12]) == [ + "right_shoulder", + "right_elbow", + "right_wrist", + "right_hip", + "right_knee", + "right_ankle", + ] + assert list(pipeline_gui.ANNOTATION_KEYPOINT_ORDER[12:]) == [ + "nose", + "left_eye", + "right_eye", + "left_ear", + "right_ear", + ] + + +def test_annotation_keypoint_names_for_biomod_adds_mid_back_only_for_segmented_back(monkeypatch): + monkeypatch.setattr(pipeline_gui, "biomod_supports_upper_back_options", lambda path: bool(path)) + + with_mid_back = pipeline_gui.annotation_keypoint_names_for_biomod("demo.bioMod") + without_mid_back = pipeline_gui.annotation_keypoint_names_for_biomod(None) + + assert "mid_back" in with_mid_back + assert with_mid_back.index("mid_back") == 12 + assert "mid_back" not in without_mid_back + + +def test_refresh_annotation_keypoint_choices_preserves_selection_when_possible(monkeypatch): + monkeypatch.setattr( + pipeline_gui, + "annotation_keypoint_names_for_biomod", + lambda _path: ("left_shoulder", "mid_back", "nose"), + ) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.annotation_keypoints_list = _FakeListbox() + tab.current_marker_var = SimpleNamespace(set=lambda value: setattr(tab, "_marker_text", value)) + tab.kinematic_model_choices = {"demo": Path("demo.bioMod")} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + for keypoint_name in ("left_shoulder", "nose"): + tab.annotation_keypoints_list.insert("end", keypoint_name) + tab.annotation_keypoints_list.selection_set(1) + + pipeline_gui.AnnotationTab.refresh_annotation_keypoint_choices(tab) + + assert tab.annotation_keypoints_list.items == ["left_shoulder", "mid_back", "nose"] + assert tab.annotation_keypoints_list.curselection() == (2,) + assert tab._marker_text == "Current marker: nose" + + def test_load_shared_reconstruction_preview_state_returns_bundle_and_preview_state(monkeypatch): state = SimpleNamespace() bundle = {"recon_q": {"demo": object()}} @@ -100,6 +297,436 @@ def test_load_shared_reconstruction_preview_state_returns_bundle_and_preview_sta assert loaded_preview_state is preview_state +def test_calibration_tab_refresh_analysis_uses_selected_reconstruction_payload(monkeypatch): + tab = pipeline_gui.CalibrationTab.__new__(pipeline_gui.CalibrationTab) + tab.pose_data = SimpleNamespace(frames=np.array([0], dtype=int)) + tab.calibrations = {"cam0": object()} + tab.state = SimpleNamespace( + shared_reconstruction_selection=("triangulation_exhaustive",), + output_root_var=SimpleNamespace(get=lambda: "output"), + keypoints_var=SimpleNamespace(get=lambda: "inputs/keypoints/demo_keypoints.json"), + ) + tab.trim_fraction_var = SimpleNamespace(get=lambda: "15") + tab.pose_data_mode = SimpleNamespace(get=lambda: "cleaned") + tab.status_var = SimpleNamespace(set=lambda value: setattr(tab, "_status_text", value)) + tab.worst_frame_list = _FakeListbox() + tab.render_summary = lambda: setattr(tab, "_summary_rendered", True) + tab.refresh_plot = lambda: setattr(tab, "_plot_rendered", True) + + captured = {} + + monkeypatch.setattr(pipeline_gui, "current_dataset_dir", lambda _state: Path("output/demo")) + monkeypatch.setattr( + pipeline_gui, + "reconstruction_dir_by_name", + lambda _dataset_dir, _name: Path("output/demo/reconstructions/triangulation_exhaustive"), + ) + monkeypatch.setattr( + pipeline_gui, + "load_bundle_payload", + lambda _path: {"points_3d": np.zeros((1, 17, 3)), "reprojection_error_per_view": np.zeros((1, 17, 1))}, + ) + monkeypatch.setattr(pipeline_gui, "load_bundle_summary", lambda _path: {"pose_data_mode": "cleaned"}) + + def _fake_qc(pose_data, calibrations, *, reconstruction_payload, trim_fraction, spatial_bins): + captured["pose_data"] = pose_data + captured["calibrations"] = calibrations + captured["payload"] = reconstruction_payload + captured["trim_fraction"] = trim_fraction + captured["spatial_bins"] = spatial_bins + return SimpleNamespace( + two_d=SimpleNamespace(trim_fraction=trim_fraction, per_frame_mean_px=np.array([1.0], dtype=float)), + three_d=None, + ) + + monkeypatch.setattr(pipeline_gui, "compute_calibration_qc", _fake_qc) + + pipeline_gui.CalibrationTab.refresh_analysis(tab) + + assert captured["pose_data"] is tab.pose_data + assert captured["calibrations"] is tab.calibrations + assert "points_3d" in captured["payload"] + assert captured["trim_fraction"] == 0.15 + assert captured["spatial_bins"] == 3 + assert tab._summary_rendered is True + assert tab._plot_rendered is True + + +def test_annotation_jump_context_uses_shared_jump_analysis(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([100], dtype=int)) + tab.state = SimpleNamespace(shared_reconstruction_selection=("demo_recon",)) + tab.annotation_jump_analysis = None + tab.current_frame_number = lambda: 120 + + analysis = SimpleNamespace( + jumps=[ + SimpleNamespace( + classification="straight", + segment=SimpleNamespace(start=110, end=130), + ) + ] + ) + monkeypatch.setattr(pipeline_gui, "shared_jump_analysis_for_reconstruction", lambda _state, _name: analysis) + + text = pipeline_gui.AnnotationTab._annotation_jump_context(tab) + + assert text == "Jump context: S1 | straight | frames 110-130" + assert tab.annotation_jump_analysis is analysis + + +def test_contact_segments_from_airborne_regions_returns_complementary_intervals(): + segments = pipeline_gui.contact_segments_from_airborne_regions( + [(3, 5), (8, 9)], + n_frames=12, + analysis_start_frame=2, + ) + + assert segments == [(2, 2), (6, 7), (10, 11)] + + +def test_draw_jump_phase_spans_marks_contact_and_airborne(): + spans = [] + + class _FakeAxis: + def axvspan(self, start, end, **kwargs): + spans.append((round(float(start), 3), round(float(end), 3), kwargs.get("label"))) + + time_s = np.arange(0, 6, dtype=float) * 0.1 + analysis = SimpleNamespace(airborne_regions=[(1, 2), (4, 4)], analysis_start_frame=0) + + pipeline_gui.draw_jump_phase_spans(_FakeAxis(), time_s=time_s, analysis=analysis) + + assert (0.0, 0.0, "contact") in spans + assert (0.1, 0.2, "airborne") in spans + assert (0.3, 0.3, None) in spans + assert (0.4, 0.4, None) in spans + assert (0.5, 0.5, None) in spans + + +def test_gui_busy_popup_does_not_show_before_delay(monkeypatch): + created = [] + + class _FakeBusyWindow: + def __init__(self, _parent, title, message): + created.append((title, message)) + + def set_status(self, _message): + return + + def close(self): + return + + def update(self): + return + + times = iter([0.0, 0.2]) + monkeypatch.setattr(pipeline_gui, "BusyStatusWindow", _FakeBusyWindow) + monkeypatch.setattr(pipeline_gui.time, "monotonic", lambda: next(times)) + + with pipeline_gui.gui_busy_popup( + SimpleNamespace(update_idletasks=lambda: None), title="Test", message="Short" + ) as popup: + popup.set_status("Still short") + + assert created == [] + + +def test_gui_busy_popup_shows_after_delay(monkeypatch): + created = [] + + class _FakeBusyWindow: + def __init__(self, _parent, title, message): + self.title = title + self.message = message + created.append((title, message)) + + def set_status(self, message): + self.message = message + + def close(self): + return + + def update(self): + return + + times = iter([0.0, 0.8]) + monkeypatch.setattr(pipeline_gui, "BusyStatusWindow", _FakeBusyWindow) + monkeypatch.setattr(pipeline_gui.time, "monotonic", lambda: next(times)) + + with pipeline_gui.gui_busy_popup( + SimpleNamespace(update_idletasks=lambda: None), title="DD", message="Analyse..." + ) as popup: + popup.set_status("Long") + + assert created == [("DD", "Long")] + + +def test_annotation_only_pose_data_keeps_only_manual_annotations(tmp_path): + keypoints_path = tmp_path / "inputs" / "keypoints" / "trial_keypoints.json" + annotations_path = tmp_path / "inputs" / "annotations" / "trial_annotations.json" + keypoints_path.parent.mkdir(parents=True) + annotations_path.parent.mkdir(parents=True) + keypoints_path.write_text("{}", encoding="utf-8") + annotations_path.write_text( + json.dumps( + { + "schema_version": 1, + "annotations": {"cam0": {"0": {"nose": {"xy": [11.0, 22.0], "score": 1.0}}}}, + } + ), + encoding="utf-8", + ) + pose_data = pipeline_gui.PoseData( + camera_names=["cam0", "cam1"], + frames=np.array([0], dtype=int), + keypoints=np.full((2, 1, len(pipeline_gui.COCO17), 2), 99.0, dtype=float), + scores=np.ones((2, 1, len(pipeline_gui.COCO17)), dtype=float), + ) + + sparse_pose = pipeline_gui.annotation_only_pose_data( + pose_data, + keypoints_path=keypoints_path, + annotations_path=annotations_path, + ) + + np.testing.assert_allclose(sparse_pose.keypoints[0, 0, 0], np.array([11.0, 22.0])) + assert sparse_pose.scores[0, 0, 0] == 1.0 + assert np.isnan(sparse_pose.keypoints[1]).all() + assert np.all(sparse_pose.scores[1] == 0.0) + + +def test_calibration_tab_refresh_analysis_hides_3d_when_reconstruction_pose_mode_mismatches(monkeypatch): + tab = pipeline_gui.CalibrationTab.__new__(pipeline_gui.CalibrationTab) + tab.pose_data = SimpleNamespace(frames=np.array([0], dtype=int)) + tab.calibrations = {"cam0": object()} + tab.state = SimpleNamespace(shared_reconstruction_selection=("demo",)) + tab.trim_fraction_var = SimpleNamespace(get=lambda: "15") + tab.pose_data_mode = SimpleNamespace(get=lambda: "annotated") + tab.status_var = SimpleNamespace(set=lambda value: setattr(tab, "_status_text", value)) + tab.worst_frame_list = _FakeListbox() + tab.render_summary = lambda: setattr(tab, "_summary_rendered", True) + tab.refresh_plot = lambda: setattr(tab, "_plot_rendered", True) + tab.refresh_worst_frame_list = lambda: setattr(tab, "_worst_rendered", True) + + captured = {} + + monkeypatch.setattr(pipeline_gui, "current_dataset_dir", lambda _state: Path("output/demo")) + monkeypatch.setattr( + pipeline_gui, + "reconstruction_dir_by_name", + lambda _dataset_dir, _name: Path("output/demo/reconstructions/demo"), + ) + monkeypatch.setattr( + pipeline_gui, + "load_bundle_payload", + lambda _path: {"points_3d": np.zeros((1, 17, 3)), "reprojection_error_per_view": np.zeros((1, 17, 1))}, + ) + monkeypatch.setattr(pipeline_gui, "load_bundle_summary", lambda _path: {"pose_data_mode": "cleaned"}) + + def _fake_qc(pose_data, calibrations, *, reconstruction_payload, trim_fraction, spatial_bins): + captured["reconstruction_payload"] = reconstruction_payload + return SimpleNamespace( + two_d=SimpleNamespace(trim_fraction=trim_fraction, per_frame_mean_px=np.array([1.0], dtype=float)), + three_d=None, + ) + + monkeypatch.setattr(pipeline_gui, "compute_calibration_qc", _fake_qc) + + pipeline_gui.CalibrationTab.refresh_analysis(tab) + + assert captured["reconstruction_payload"] is None + assert "3D hidden" in tab._status_text + assert tab._summary_rendered is True + assert tab._plot_rendered is True + + +def test_calibration_tab_refresh_pose_mode_choices_falls_back_when_annotated_missing(monkeypatch, tmp_path): + root = tmp_path + + class _Var: + def __init__(self, value): + self.value = value + + def get(self): + return self.value + + def set(self, value): + self.value = value + + tab = pipeline_gui.CalibrationTab.__new__(pipeline_gui.CalibrationTab) + tab.state = SimpleNamespace( + keypoints_var=SimpleNamespace(get=lambda: "inputs/keypoints/trial_keypoints.json"), + annotation_path_var=SimpleNamespace(get=lambda: "inputs/annotations/trial_annotations.json"), + ) + tab.pose_mode_box = _FakeCombobox() + tab.pose_data_mode = _Var("annotated") + + monkeypatch.setattr(pipeline_gui, "ROOT", root) + + pipeline_gui.CalibrationTab.refresh_pose_mode_choices(tab) + + assert tab.pose_mode_box.values == ["raw", "cleaned"] + assert tab.pose_data_mode.get() == "cleaned" + + +def test_calibration_tab_load_resources_uses_local_pose_mode(monkeypatch): + tab = pipeline_gui.CalibrationTab.__new__(pipeline_gui.CalibrationTab) + tab.state = SimpleNamespace( + keypoints_var=SimpleNamespace(get=lambda: "inputs/keypoints/trial_keypoints.json"), + calib_var=SimpleNamespace(get=lambda: "inputs/calibration/Calib.toml"), + pose_filter_window_var=SimpleNamespace(get=lambda: "9"), + pose_outlier_ratio_var=SimpleNamespace(get=lambda: "0.1"), + pose_p_low_var=SimpleNamespace(get=lambda: "5"), + pose_p_high_var=SimpleNamespace(get=lambda: "95"), + pose_data_mode_var=SimpleNamespace(get=lambda: "cleaned"), + annotation_path_var=SimpleNamespace(get=lambda: "inputs/annotations/trial_annotations.json"), + ) + tab.pose_mode_box = _FakeCombobox() + tab.pose_data_mode = SimpleNamespace(get=lambda: "raw", set=lambda _value: None) + tab.refresh_analysis = lambda: setattr(tab, "_analysis_refreshed", True) + tab.refresh_pose_mode_choices = lambda: None + + captured = {} + + def _fake_get_cached_pose_data(_state, **kwargs): + captured.update(kwargs) + return {"cam0": object()}, object() + + monkeypatch.setattr(pipeline_gui, "get_cached_pose_data", _fake_get_cached_pose_data) + + pipeline_gui.CalibrationTab.load_resources(tab) + + assert captured["data_mode"] == "raw" + assert tab._analysis_refreshed is True + + +def test_calibration_tab_refresh_worst_frame_list_populates_both_2d_and_3d(): + tab = pipeline_gui.CalibrationTab.__new__(pipeline_gui.CalibrationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 11, 12, 13], dtype=int)) + tab.qc = SimpleNamespace( + two_d=SimpleNamespace(per_frame_mean_px=np.array([1.0, 4.0, np.nan, 3.0], dtype=float)), + three_d=SimpleNamespace(per_frame_mean_px=np.array([2.0, np.nan, 7.0, 5.0], dtype=float)), + ) + tab.worst_frame_list = _FakeListbox() + + pipeline_gui.CalibrationTab.refresh_worst_frame_list(tab) + + assert tab.worst_frame_list.items[0].startswith("2D | frame 11") + assert any(item.startswith("3D | frame 12") for item in tab.worst_frame_list.items) + + +def test_camera_tools_tab_qa_overlay_data_reads_reprojection_and_excluded_payload(monkeypatch): + tab = pipeline_gui.CameraToolsTab.__new__(pipeline_gui.CameraToolsTab) + tab.pose_data = SimpleNamespace(camera_names=["cam0", "cam1"]) + tab.calibrations = {"cam0": object(), "cam1": object()} + tab.qa_overlay_var = SimpleNamespace(get=lambda: "3D reproj") + tab._reference_payload = lambda: { + "reprojection_error_per_view": np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=float), + "excluded_views": np.array([[[False, True], [True, False]]], dtype=bool), + } + + label, values, mask, cmap = pipeline_gui.CameraToolsTab._qa_overlay_data(tab, "cam1", 0) + + assert label == "3D reproj" + np.testing.assert_array_equal(values, np.array([2.0, 4.0])) + assert mask is None + assert cmap == "turbo" + + tab.qa_overlay_var = SimpleNamespace(get=lambda: "3D excluded") + label, values, mask, cmap = pipeline_gui.CameraToolsTab._qa_overlay_data(tab, "cam0", 0) + assert label == "3D excluded" + assert values is None + np.testing.assert_array_equal(mask, np.array([False, True])) + assert cmap is None + + +def test_multiview_tab_qa_overlay_data_reads_reprojection_and_excluded_payload(monkeypatch): + tab = pipeline_gui.MultiViewTab.__new__(pipeline_gui.MultiViewTab) + tab.pose_data = SimpleNamespace(camera_names=["cam0", "cam1"]) + tab.calibrations = {"cam0": object(), "cam1": object()} + tab.qa_overlay_var = SimpleNamespace(get=lambda: "3D reproj") + tab._selected_reconstruction = lambda: "demo" + tab.reconstruction_payloads = { + "demo": { + "reprojection_error_per_view": np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=float), + "excluded_views": np.array([[[False, True], [True, False]]], dtype=bool), + } + } + + label, values, mask, cmap = pipeline_gui.MultiViewTab._qa_overlay_data(tab, "cam1", 0) + + assert label == "3D reproj" + np.testing.assert_array_equal(values, np.array([2.0, 4.0])) + assert mask is None + assert cmap == "turbo" + + tab.qa_overlay_var = SimpleNamespace(get=lambda: "3D excluded") + label, values, mask, cmap = pipeline_gui.MultiViewTab._qa_overlay_data(tab, "cam0", 0) + assert label == "3D excluded" + assert values is None + np.testing.assert_array_equal(mask, np.array([False, True])) + assert cmap is None + + +def test_multiview_tab_selected_reconstruction_uses_shared_selection(): + tab = pipeline_gui.MultiViewTab.__new__(pipeline_gui.MultiViewTab) + tab.state = SimpleNamespace(shared_reconstruction_selection=["raw", "demo"]) + + assert pipeline_gui.MultiViewTab._selected_reconstruction(tab) == "demo" + + +def test_get_cached_calibrations_reports_startup_status(monkeypatch, tmp_path): + messages = [] + state = SimpleNamespace(calibration_cache={}, startup_status_callback=messages.append) + calib_path = tmp_path / "Calib.toml" + calib_path.write_text("") + + monkeypatch.setattr(pipeline_gui, "load_calibrations", lambda _path: {"cam0": object()}) + + calibrations = pipeline_gui.get_cached_calibrations(state, calib_path) + + assert "Loading calibrations: Calib.toml" in messages + assert calibrations.keys() == {"cam0"} + + +def test_get_cached_pose_data_reports_cached_status(monkeypatch, tmp_path): + messages = [] + keypoints_path = tmp_path / "trial_keypoints.json" + keypoints_path.write_text("{}") + calib_path = tmp_path / "Calib.toml" + calib_path.write_text("") + cache_key = pipeline_gui.pose_data_cache_key( + keypoints_path=keypoints_path, + calib_path=calib_path, + max_frames=None, + frame_start=None, + frame_end=None, + data_mode="cleaned", + smoothing_window=9, + outlier_threshold_ratio=0.1, + lower_percentile=5.0, + upper_percentile=95.0, + ) + cached_pose_data = object() + state = SimpleNamespace( + calibration_cache={pipeline_gui.calibration_cache_key(calib_path): {"cam0": object()}}, + pose_data_cache={cache_key: cached_pose_data}, + startup_status_callback=messages.append, + ) + + calibrations, pose_data = pipeline_gui.get_cached_pose_data( + state, + keypoints_path=keypoints_path, + calib_path=calib_path, + ) + + assert "Using cached 2D poses: trial_keypoints.json" in messages + assert "Using cached calibrations: Calib.toml" in messages + assert calibrations.keys() == {"cam0"} + assert pose_data is cached_pose_data + + def test_reconstruction_legend_label_uses_shared_panel_order(): tree = SimpleNamespace(get_children=lambda _root="": ("recon_b", "recon_a", "__placeholder__")) panel = SimpleNamespace(tree=tree) @@ -148,52 +775,2126 @@ def test_shared_reconstruction_panel_prepends_numeric_index_column(): default_names=["recon_b"], ) - assert panel.tree.rows["recon_b"][0] == "1" - assert panel.tree.rows["recon_a"][0] == "2" + assert panel.tree.rows["recon_b"][0] == "1" + assert panel.tree.rows["recon_a"][0] == "2" + + +def test_shared_reconstruction_panel_does_not_number_raw_2d_rows(): + panel = SharedReconstructionPanel.__new__(SharedReconstructionPanel) + panel.tree = _FakeTree() + panel._default_names = [] + panel._selection_callback = None + panel._refresh_callback = None + panel._suspend_selection_callback = False + panel.state = SimpleNamespace(set_shared_reconstruction_selection=lambda _names: None) + + SharedReconstructionPanel.set_rows( + panel, + rows=[ + {"name": "raw", "label": "Raw 2D", "family": "2d", "frames": "-", "reproj_mean": None, "path": "-"}, + { + "name": "pose2sim", + "label": "TRC file", + "family": "pose2sim", + "frames": 8, + "reproj_mean": 2.3, + "path": "/a", + }, + ], + default_names=["raw"], + ) + + assert panel.tree.rows["raw"][0] == "" + assert panel.tree.rows["pose2sim"][0] == "2" + + +def test_extend_listbox_selection_adds_next_item(): + listbox = _FakeListbox() + for value in ("a", "b", "c"): + listbox.insert("end", value) + listbox.selection_set(0) + + result = pipeline_gui.extend_listbox_selection(listbox, 1) + + assert result == "break" + assert listbox.curselection() == (0, 1) + assert listbox._active == 1 + assert listbox._seen == 1 + + +def test_select_all_listbox_selects_every_item(): + listbox = _FakeListbox() + for value in ("a", "b", "c"): + listbox.insert("end", value) + + result = pipeline_gui.select_all_listbox(listbox) + + assert result == "break" + assert listbox.curselection() == (0, 1, 2) + + +def test_shared_reconstruction_tree_shortcuts_extend_and_select_all(): + panel = SharedReconstructionPanel.__new__(SharedReconstructionPanel) + panel.tree = _FakeTree() + panel._default_names = [] + panel._selection_callback = None + panel._refresh_callback = None + panel._suspend_selection_callback = False + panel._allow_empty_selection = False + panel._explicitly_cleared = False + panel.state = SimpleNamespace(set_shared_reconstruction_selection=lambda _names: None) + + SharedReconstructionPanel.set_rows( + panel, + rows=[ + {"name": "recon_a", "label": "A", "family": "ekf_2d", "frames": 12, "reproj_mean": 1.2, "path": "/a"}, + {"name": "recon_b", "label": "B", "family": "ekf_2d", "frames": 10, "reproj_mean": 1.5, "path": "/b"}, + {"name": "recon_c", "label": "C", "family": "ekf_2d", "frames": 8, "reproj_mean": 2.0, "path": "/c"}, + ], + default_names=["recon_a"], + ) + panel.tree.selection_set(("recon_a",)) + panel.tree.focus("recon_a") + + from preview.shared_reconstruction_panel import _extend_treeview_selection, _select_all_treeview + + assert _extend_treeview_selection(panel.tree, 1) == "break" + assert panel.tree.selection() == ("recon_a", "recon_b") + assert panel.tree.focus() == "recon_b" + assert _select_all_treeview(panel.tree) == "break" + assert panel.tree.selection() == ("recon_a", "recon_b", "recon_c") + + +def test_shared_reconstruction_panel_can_clear_browse_selection_on_same_row_click(): + published = [] + callbacks = [] + panel = SharedReconstructionPanel.__new__(SharedReconstructionPanel) + panel.tree = _FakeTree() + panel._default_names = ["recon_a"] + panel._selection_callback = lambda: callbacks.append("called") + panel._refresh_callback = None + panel._suspend_selection_callback = False + panel._allow_empty_selection = True + panel._selectmode = "browse" + panel._explicitly_cleared = False + panel.state = SimpleNamespace(set_shared_reconstruction_selection=lambda names: published.append(list(names))) + + SharedReconstructionPanel.set_rows( + panel, + rows=[ + {"name": "recon_a", "label": "A", "family": "ekf_2d", "frames": 12, "reproj_mean": 1.2, "path": "/a"}, + {"name": "recon_b", "label": "B", "family": "ekf_2d", "frames": 10, "reproj_mean": 1.5, "path": "/b"}, + ], + default_names=["recon_a"], + ) + panel.tree.selection_set(("recon_a",)) + panel.tree._identified_row = "recon_a" + + result = SharedReconstructionPanel._on_button_press(panel, SimpleNamespace(y=12)) + + assert result == "break" + assert panel.tree.selection() == () + assert panel.selected_names() == [] + assert published[-1] == [] + assert callbacks == ["called"] + + +def test_shared_reconstruction_panel_can_clear_browse_selection_on_blank_click(): + published = [] + callbacks = [] + panel = SharedReconstructionPanel.__new__(SharedReconstructionPanel) + panel.tree = _FakeTree() + panel._default_names = ["recon_a"] + panel._selection_callback = lambda: callbacks.append("called") + panel._refresh_callback = None + panel._suspend_selection_callback = False + panel._allow_empty_selection = True + panel._selectmode = "browse" + panel._explicitly_cleared = False + panel.state = SimpleNamespace(set_shared_reconstruction_selection=lambda names: published.append(list(names))) + + SharedReconstructionPanel.set_rows( + panel, + rows=[ + {"name": "recon_a", "label": "A", "family": "ekf_2d", "frames": 12, "reproj_mean": 1.2, "path": "/a"}, + {"name": "recon_b", "label": "B", "family": "ekf_2d", "frames": 10, "reproj_mean": 1.5, "path": "/b"}, + ], + default_names=["recon_a"], + ) + panel.tree.selection_set(("recon_a",)) + panel.tree._identified_row = "" + + result = SharedReconstructionPanel._on_button_press(panel, SimpleNamespace(y=999)) + + assert result == "break" + assert panel.tree.selection() == () + assert panel.selected_names() == [] + assert published[-1] == [] + assert callbacks == ["called"] + + +def test_compose_multiview_crop_points_includes_selected_reprojections(): + base = np.zeros((1, 2, 3, 2), dtype=float) + reproj = np.ones((1, 2, 3, 2), dtype=float) + + crop_points = pipeline_gui.compose_multiview_crop_points( + base, + {"ekf_demo": reproj}, + ["raw", "ekf_demo"], + ) + + assert crop_points.shape == (1, 2, 6, 2) + np.testing.assert_allclose(crop_points[:, :, :3], base) + np.testing.assert_allclose(crop_points[:, :, 3:], reproj) + + +def test_square_crop_bounds_returns_square_window(): + x0, x1, y1, y0 = pipeline_gui.square_crop_bounds( + xmin=1200.0, + xmax=1380.0, + ymin=550.0, + ymax=800.0, + width=1920.0, + height=1080.0, + margin=0.1, + ) + + assert np.isclose(x1 - x0, y1 - y0) + + +def test_draw_2d_background_image_uses_pixel_extent(): + ax = _FakeAxis() + image = np.zeros((1080, 1920, 3), dtype=float) + + pipeline_gui.draw_2d_background_image(ax, image, 1920.0, 1080.0) + + assert len(ax.images) == 1 + _image, kwargs = ax.images[0] + assert kwargs["extent"] == (0.0, 1920.0, 1080.0, 0.0) + assert kwargs["origin"] == "upper" + + +def test_hide_2d_axes_removes_pixel_ticks(): + ax = _FakeAxis() + + pipeline_gui.hide_2d_axes(ax) + + assert ax.xticks == [] + assert ax.yticks == [] + + +def test_camera_layout_is_adaptive_for_small_camera_counts(): + assert pipeline_gui.camera_layout(1) == (1, 1) + assert pipeline_gui.camera_layout(2) == (1, 2) + assert pipeline_gui.camera_layout(3) == (2, 2) + assert pipeline_gui.camera_layout(4) == (2, 2) + assert pipeline_gui.camera_layout(5) == (2, 3) + + +def test_annotation_motion_prior_center_extrapolates_velocity(): + center = pipeline_gui.annotation_motion_prior_center( + np.array([100.0, 80.0], dtype=float), + np.array([92.0, 74.0], dtype=float), + ) + + np.testing.assert_allclose(center, np.array([108.0, 86.0], dtype=float)) + + +def test_interpolate_short_nan_runs_fills_only_short_interior_gaps(): + values = np.array( + [ + [0.0, 10.0], + [np.nan, np.nan], + [2.0, 14.0], + [np.nan, np.nan], + [np.nan, np.nan], + [5.0, 20.0], + ], + dtype=float, + ) + + interpolated = pipeline_gui.interpolate_short_nan_runs(values, max_gap_frames=1) + + np.testing.assert_allclose(interpolated[1], np.array([1.0, 12.0])) + assert np.all(np.isnan(interpolated[3:5])) + + +def test_interpolate_short_nan_runs_keeps_edge_nans(): + values = np.array([np.nan, 1.0, np.nan, 3.0, np.nan], dtype=float) + + interpolated = pipeline_gui.interpolate_short_nan_runs(values, max_gap_frames=2) + + assert np.isnan(interpolated[0]) + np.testing.assert_allclose(interpolated[2], 2.0) + assert np.isnan(interpolated[4]) + + +def test_fill_short_edge_nan_runs_fills_only_short_edges(): + values = np.array([np.nan, np.nan, 2.0, 4.0, np.nan, np.nan, np.nan], dtype=float) + + filled = pipeline_gui.fill_short_edge_nan_runs(values, max_gap_frames=2) + + np.testing.assert_allclose(filled[:2], np.array([2.0, 2.0])) + np.testing.assert_allclose(filled[2:4], np.array([2.0, 4.0])) + assert np.all(np.isnan(filled[4:])) + + +def test_annotation_adjust_image_changes_brightness_and_contrast(): + image = np.array([[[0.25, 0.5, 0.75], [0.4, 0.5, 0.6]]], dtype=float) + + adjusted = pipeline_gui.annotation_adjust_image(image, brightness=1.2, contrast=0.5) + + assert adjusted.shape == image.shape + assert np.all(np.isfinite(adjusted)) + assert not np.allclose(adjusted, image) + + +def test_find_annotation_frame_with_images_skips_missing_frames(tmp_path): + images_root = tmp_path / "images" + images_root.mkdir(parents=True) + target_image = images_root / "Camera1_M11139_frame_00005.jpg" + target_image.write_bytes(b"test") + + frame_idx = pipeline_gui.find_annotation_frame_with_images( + frames=np.array([3, 4, 5, 6], dtype=int), + current_index=0, + direction=1, + camera_names=["M11139"], + images_root=images_root, + ) + + assert frame_idx == 2 + + +def test_annotation_tab_set_frame_index_saves_before_frame_change(): + saved = [] + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 11, 12], dtype=int)) + tab._current_frame_idx = 0 + tab.frame_var = SimpleNamespace(set=lambda value: setattr(tab, "_frame_value", value)) + tab.save_annotations = lambda: saved.append("saved") + + pipeline_gui.AnnotationTab._set_frame_index(tab, 2) + + assert saved == ["saved"] + assert tab._current_frame_idx == 2 + assert tab._frame_value == 2 + + +def test_annotation_click_advances_marker_without_monoview(): + saved = [] + refreshed = [] + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 11, 12], dtype=int)) + tab.calibrations = {"cam0": object()} + tab.annotation_payload = {} + tab._axis_to_camera = {"axis0": "cam0"} + tab.advance_marker_var = SimpleNamespace(get=lambda: True) + tab._pending_reprojection_points = {} + tab.kinematic_assist_var = SimpleNamespace(get=lambda: False) + tab.save_annotations = lambda: saved.append("saved") + tab.refresh_preview = lambda: refreshed.append("refreshed") + tab._advance_to_next_keypoint = lambda: setattr(tab, "_advanced", True) + tab.selected_keypoint_name = lambda: "left_shoulder" + tab.current_frame_number = lambda: 10 + tab._clear_kinematic_assist_preview = lambda: None + tab._snap_annotation_xy = lambda **kwargs: np.array([kwargs["pointer_xy"][0], kwargs["pointer_xy"][1]], dtype=float) + + pipeline_gui.AnnotationTab.on_preview_click( + tab, + SimpleNamespace(inaxes="axis0", xdata=120.0, ydata=240.0, button=1), + ) + pipeline_gui.AnnotationTab.on_preview_release( + tab, + SimpleNamespace(inaxes="axis0", xdata=120.0, ydata=240.0, button=1), + ) + + assert getattr(tab, "_advanced", False) is True + assert saved == ["saved"] + assert refreshed == ["refreshed"] + + +def test_annotation_refresh_preview_preserves_saved_view_limits(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace( + camera_names=["cam0"], + frames=np.array([10], dtype=int), + ) + tab.calibrations = {"cam0": SimpleNamespace(image_size=(640, 480))} + tab.annotation_payload = {} + tab.preview_figure = Figure(figsize=(4, 4)) + tab.preview_canvas = SimpleNamespace(draw_idle=lambda: None) + tab.frame_var = SimpleNamespace(get=lambda: 0, set=lambda _value: None) + tab.frame_label = SimpleNamespace(configure=lambda **_kwargs: None) + tab.crop_var = SimpleNamespace(get=lambda: False) + tab.show_images_var = SimpleNamespace(get=lambda: False) + tab.image_brightness_var = SimpleNamespace(get=lambda: 1.0) + tab.image_contrast_var = SimpleNamespace(get=lambda: 1.0) + tab.show_reference_reprojection_var = SimpleNamespace(get=lambda: False) + tab.show_motion_prior_var = SimpleNamespace(get=lambda: False) + tab.show_triangulated_hint_var = SimpleNamespace(get=lambda: False) + tab.motion_prior_diameter = SimpleNamespace(get=lambda: "15") + tab.show_epipolar_var = SimpleNamespace(get=lambda: False) + tab.kinematic_assist_var = SimpleNamespace(get=lambda: False) + tab.kinematic_projected_points = None + tab.kinematic_segmented_back_projected = {} + tab._annotation_hover_entries = {} + tab._cursor_artists = {} + tab._axis_to_camera = {} + tab._annotation_view_limits = {"cam0": ((10.0, 20.0), (40.0, 30.0))} + tab._pending_reprojection_points = {} + tab.current_frame_number = lambda: 10 + tab.selected_annotation_camera_names = lambda: ["cam0"] + tab._current_images_root = lambda: None + tab._frame_filter_mode = lambda: "all" + tab._filtered_annotation_frame_local_indices = lambda: [0] + tab.selected_keypoint_name = lambda: "nose" + tab._reference_projected_points = lambda *_args, **_kwargs: (None, None, "#6c5ce7") + tab._annotation_xy = lambda *_args, **_kwargs: None + + monkeypatch.setattr( + pipeline_gui, + "render_annotation_camera_view", + lambda ax, **_kwargs: (ax.scatter([], []), [])[1], + ) + + pipeline_gui.AnnotationTab.refresh_preview(tab) + + ax = tab.preview_figure.axes[0] + np.testing.assert_allclose(ax.get_xlim(), np.array([10.0, 20.0])) + np.testing.assert_allclose(ax.get_ylim(), np.array([40.0, 30.0])) + + +def test_annotation_set_frame_index_resets_user_view_limits_on_frame_change(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 11], dtype=int)) + tab._current_frame_idx = 0 + tab._annotation_view_limits = {"cam0": ((10.0, 20.0), (40.0, 30.0))} + tab._reset_annotation_view_limits_on_next_refresh = False + tab.save_annotations = lambda: setattr(tab, "_saved", True) + tab._clear_pending_reprojection = lambda: setattr(tab, "_cleared_pending", True) + tab._clear_kinematic_assist_preview = lambda: setattr(tab, "_cleared_kinematic", True) + tab.frame_var = SimpleNamespace(set=lambda value: setattr(tab, "_frame_var_value", value)) + + pipeline_gui.AnnotationTab._set_frame_index(tab, 1) + + assert tab._annotation_view_limits == {} + assert tab._reset_annotation_view_limits_on_next_refresh is True + assert tab._frame_var_value == 1 + assert tab._saved is True + assert tab._cleared_pending is True + assert tab._cleared_kinematic is True + + +def test_annotation_set_frame_index_keeps_user_view_limits_when_frame_unchanged(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 11], dtype=int)) + tab._current_frame_idx = 1 + original_limits = {"cam0": ((10.0, 20.0), (40.0, 30.0))} + tab._annotation_view_limits = dict(original_limits) + tab._reset_annotation_view_limits_on_next_refresh = False + tab.save_annotations = lambda: (_ for _ in ()).throw(AssertionError("save_annotations should not be called")) + tab._clear_pending_reprojection = lambda: (_ for _ in ()).throw( + AssertionError("_clear_pending_reprojection should not be called") + ) + tab._clear_kinematic_assist_preview = lambda: (_ for _ in ()).throw( + AssertionError("_clear_kinematic_assist_preview should not be called") + ) + tab.frame_var = SimpleNamespace(set=lambda value: setattr(tab, "_frame_var_value", value)) + + pipeline_gui.AnnotationTab._set_frame_index(tab, 1) + + assert tab._annotation_view_limits == original_limits + assert tab._reset_annotation_view_limits_on_next_refresh is False + assert tab._frame_var_value == 1 + + +def test_annotation_refresh_preview_skips_storing_old_view_limits_after_frame_change(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace( + camera_names=["cam0"], + frames=np.array([10, 11], dtype=int), + ) + tab.calibrations = {"cam0": SimpleNamespace(image_size=(640, 480))} + tab.annotation_payload = {} + tab.preview_figure = Figure(figsize=(4, 4)) + tab.preview_canvas = SimpleNamespace(draw_idle=lambda: None) + tab.frame_var = SimpleNamespace(get=lambda: 1, set=lambda _value: None) + tab.frame_label = SimpleNamespace(configure=lambda **_kwargs: None) + tab.crop_var = SimpleNamespace(get=lambda: False) + tab.show_images_var = SimpleNamespace(get=lambda: False) + tab.image_brightness_var = SimpleNamespace(get=lambda: 1.0) + tab.image_contrast_var = SimpleNamespace(get=lambda: 1.0) + tab.show_reference_reprojection_var = SimpleNamespace(get=lambda: False) + tab.show_motion_prior_var = SimpleNamespace(get=lambda: False) + tab.show_triangulated_hint_var = SimpleNamespace(get=lambda: False) + tab.motion_prior_diameter = SimpleNamespace(get=lambda: "15") + tab.show_epipolar_var = SimpleNamespace(get=lambda: False) + tab.kinematic_assist_var = SimpleNamespace(get=lambda: False) + tab.kinematic_projected_points = None + tab.kinematic_segmented_back_projected = {} + tab._annotation_hover_entries = {} + tab._cursor_artists = {} + tab._axis_to_camera = {} + tab._annotation_view_limits = {"cam0": ((10.0, 20.0), (40.0, 30.0))} + tab._reset_annotation_view_limits_on_next_refresh = True + tab._pending_reprojection_points = {} + tab.current_frame_number = lambda: 11 + tab.selected_annotation_camera_names = lambda: ["cam0"] + tab._current_images_root = lambda: None + tab._frame_filter_mode = lambda: "all" + tab._filtered_annotation_frame_local_indices = lambda: [0, 1] + tab.selected_keypoint_name = lambda: "nose" + tab._reference_projected_points = lambda *_args, **_kwargs: (None, None, "#6c5ce7") + tab._annotation_xy = lambda *_args, **_kwargs: None + + monkeypatch.setattr( + pipeline_gui, + "render_annotation_camera_view", + lambda ax, **_kwargs: (ax.scatter([], []), [])[1], + ) + monkeypatch.setattr( + pipeline_gui.AnnotationTab, + "_store_current_annotation_view_limits", + lambda _self: (_ for _ in ()).throw(AssertionError("old view limits should not be stored")), + ) + + pipeline_gui.AnnotationTab.refresh_preview(tab) + + assert tab._annotation_view_limits == {} + assert tab._reset_annotation_view_limits_on_next_refresh is False + + +def test_annotation_delete_nearest_annotation_removes_closest_marker(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.annotation_payload = pipeline_gui.empty_annotation_payload() + pipeline_gui.set_annotation_point( + tab.annotation_payload, + camera_name="cam0", + frame_number=10, + keypoint_name="left_shoulder", + xy=[100.0, 200.0], + ) + pipeline_gui.set_annotation_point( + tab.annotation_payload, + camera_name="cam0", + frame_number=10, + keypoint_name="right_shoulder", + xy=[300.0, 400.0], + ) + tab._annotation_xy = lambda camera_name, frame_number, keypoint_name: ( + np.asarray( + pipeline_gui.get_annotation_point( + tab.annotation_payload, + camera_name=camera_name, + frame_number=frame_number, + keypoint_name=keypoint_name, + )[0] + ) + if pipeline_gui.get_annotation_point( + tab.annotation_payload, + camera_name=camera_name, + frame_number=frame_number, + keypoint_name=keypoint_name, + )[0] + is not None + else None + ) + + deleted = pipeline_gui.AnnotationTab._delete_nearest_annotation( + tab, "cam0", 10, np.array([108.0, 206.0], dtype=float) + ) + + assert deleted is True + assert ( + pipeline_gui.get_annotation_point( + tab.annotation_payload, + camera_name="cam0", + frame_number=10, + keypoint_name="left_shoulder", + )[0] + is None + ) + assert ( + pipeline_gui.get_annotation_point( + tab.annotation_payload, + camera_name="cam0", + frame_number=10, + keypoint_name="right_shoulder", + )[0] + is not None + ) + + +def test_annotation_ctrl_click_deletes_nearest_marker(): + saved = [] + refreshed = [] + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 11, 12], dtype=int)) + tab.calibrations = {"cam0": object()} + tab.annotation_payload = {} + tab._axis_to_camera = {"axis0": "cam0"} + tab.advance_marker_var = SimpleNamespace(get=lambda: True) + tab._pending_reprojection_points = {} + tab.kinematic_assist_var = SimpleNamespace(get=lambda: False) + tab.save_annotations = lambda: saved.append("saved") + tab.refresh_preview = lambda: refreshed.append("refreshed") + tab.selected_keypoint_name = lambda: "left_shoulder" + tab.current_frame_number = lambda: 10 + tab._delete_nearest_annotation = ( + lambda camera_name, frame_number, xy: setattr(tab, "_deleted", (camera_name, frame_number, tuple(xy))) or True + ) + + pipeline_gui.AnnotationTab.on_preview_click( + tab, + SimpleNamespace(inaxes="axis0", xdata=120.0, ydata=240.0, button=1, key="control"), + ) + + assert tab._deleted == ("cam0", 10, (120.0, 240.0)) + assert saved == ["saved"] + assert refreshed == ["refreshed"] + + +def test_annotation_tab_frame_scale_click_uses_slider_position(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.frame_scale = _FakeScale(from_value=0, to_value=20, width=300) + tab.frame_var = SimpleNamespace(set=lambda value: setattr(tab, "_frame_value", value)) + tab._current_frame_idx = 0 + tab._dragging_frame_scale = False + tab._set_frame_index = lambda value: setattr(tab, "_frame_index", value) + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + + monkeypatch.setattr(pipeline_gui, "frame_from_slider_click", lambda **_kwargs: 7) + + result = pipeline_gui.AnnotationTab._on_frame_scale_click(tab, SimpleNamespace(x=40)) + + assert result == "break" + assert tab._dragging_frame_scale is True + assert tab.frame_scale.focused is True + assert tab._frame_index == 7 + assert tab._refreshed is True + + +def test_annotation_marker_shape_uses_side_specific_symbols(): + assert pipeline_gui.annotation_marker_shape("left_wrist") == "+" + assert pipeline_gui.annotation_marker_shape("right_wrist") == "x" + assert pipeline_gui.annotation_marker_shape("nose") == "+" + + +def test_annotation_blend_q_by_relevance_updates_only_selected_subtree(): + q_names = ["TRUNK:RotY", "LEFT_UPPER_ARM:RotY", "RIGHT_UPPER_ARM:RotY", "LEFT_THIGH:RotY"] + previous_q = np.array([0.0, 1.0, 2.0, 3.0], dtype=float) + estimated_q = np.array([10.0, 11.0, 12.0, 13.0], dtype=float) + + blended = pipeline_gui.annotation_blend_q_by_relevance(q_names, previous_q, estimated_q, "left_wrist") + + np.testing.assert_allclose(blended, np.array([10.0, 11.0, 2.0, 3.0], dtype=float)) + + +def test_annotation_tab_frame_scale_release_stops_dragging(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.frame_scale = _FakeScale(from_value=0, to_value=20, width=300) + tab._dragging_frame_scale = True + tab._set_frame_index = lambda value: setattr(tab, "_frame_index", value) + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + + monkeypatch.setattr(pipeline_gui, "frame_from_slider_click", lambda **_kwargs: 9) + + result = pipeline_gui.AnnotationTab._on_frame_scale_release(tab, SimpleNamespace(x=75)) + + assert result == "break" + assert tab._dragging_frame_scale is False + assert tab._frame_index == 9 + assert tab._refreshed is True + + +def test_annotation_step_frame_uses_filtered_candidates(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 11, 12, 13, 14], dtype=int)) + tab.frame_var = SimpleNamespace(get=lambda: 1) + tab._set_frame_index = lambda value: setattr(tab, "_frame_index", value) + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + tab._navigable_annotation_frame_local_indices = lambda: [1, 4] + + pipeline_gui.AnnotationTab.step_frame(tab, 1) + + assert tab._frame_index == 4 + assert tab._refreshed is True + + +def test_annotation_step_frame_wraps_within_filtered_candidates(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 11, 12, 13, 14], dtype=int)) + tab.frame_var = SimpleNamespace(get=lambda: 4) + tab._set_frame_index = lambda value: setattr(tab, "_frame_index", value) + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + tab._navigable_annotation_frame_local_indices = lambda: [1, 4] + + pipeline_gui.AnnotationTab.step_frame(tab, 1) + + assert tab._frame_index == 1 + assert tab._refreshed is True + + +def test_annotation_step_frame_wraps_within_worst_reprojection_set(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.arange(20, dtype=int)) + tab.frame_var = SimpleNamespace(get=lambda: 12) + tab._set_frame_index = lambda value: setattr(tab, "_frame_index", value) + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + tab._navigable_annotation_frame_local_indices = lambda: [3, 7, 12] + + pipeline_gui.AnnotationTab.step_frame(tab, 1) + + assert tab._frame_index == 3 + assert tab._refreshed is True + + +def test_annotation_flip_frame_filter_uses_selected_cameras(monkeypatch, tmp_path): + pose_data = SimpleNamespace(frames=np.array([10, 11, 12], dtype=int), camera_names=["cam0", "cam1"]) + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = pose_data + tab.calibrations = {"cam0": object(), "cam1": object()} + tab.state = SimpleNamespace( + keypoints_var=SimpleNamespace(get=lambda: "inputs/keypoints/trial_keypoints.json"), + calib_var=SimpleNamespace(get=lambda: "inputs/calibration/Calib.toml"), + pose_data_mode_var=SimpleNamespace(get=lambda: "cleaned"), + pose_filter_window_var=SimpleNamespace(get=lambda: "9"), + pose_outlier_ratio_var=SimpleNamespace(get=lambda: "0.1"), + pose_p_low_var=SimpleNamespace(get=lambda: "5"), + pose_p_high_var=SimpleNamespace(get=lambda: "95"), + calibration_correction_var=SimpleNamespace(get=lambda: "flip_epipolar"), + flip_improvement_ratio_var=SimpleNamespace(get=lambda: "0.7"), + flip_min_gain_px_var=SimpleNamespace(get=lambda: "3.0"), + flip_min_other_cameras_var=SimpleNamespace(get=lambda: "2"), + flip_restrict_to_outliers_var=SimpleNamespace(get=lambda: True), + flip_outlier_percentile_var=SimpleNamespace(get=lambda: "85"), + flip_outlier_floor_px_var=SimpleNamespace(get=lambda: "5"), + flip_temporal_weight_var=SimpleNamespace(get=lambda: "0.35"), + flip_temporal_tau_px_var=SimpleNamespace(get=lambda: "20"), + ) + tab.selected_annotation_camera_names = lambda: ["cam1"] + + monkeypatch.setattr(pipeline_gui, "ROOT", tmp_path) + monkeypatch.setattr( + pipeline_gui, + "get_cached_pose_data", + lambda *_args, **_kwargs: (tab.calibrations, pose_data), + ) + monkeypatch.setattr(pipeline_gui, "current_dataset_dir", lambda _state: tmp_path / "output" / "trial") + monkeypatch.setattr( + pipeline_gui, + "load_or_compute_left_right_flip_cache", + lambda **_kwargs: ( + np.array([[True, False, False], [False, False, True]], dtype=bool), + {}, + 0.0, + tmp_path / "flip_cache.npz", + "cache", + ), + ) + + local_indices = pipeline_gui.AnnotationTab._annotation_flip_frame_local_indices(tab) + + assert local_indices == [2] + + +def test_annotation_worst_reprojection_filter_uses_selected_reconstruction_and_cameras(monkeypatch, tmp_path): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.arange(20, dtype=int), camera_names=["cam0", "cam1"]) + tab.state = SimpleNamespace(shared_reconstruction_selection=["demo"]) + tab.selected_annotation_camera_names = lambda: ["cam1"] + + errors = np.ones((20, 17, 2), dtype=float) + errors[3, :, 0] = 50.0 + errors[7, :, 1] = 90.0 + payload = { + "reprojection_error_per_view": errors, + "frames": np.arange(20, dtype=int), + "camera_names": np.array(["cam0", "cam1"], dtype=object), + } + + monkeypatch.setattr(pipeline_gui, "current_dataset_dir", lambda _state: tmp_path / "output" / "trial") + monkeypatch.setattr(pipeline_gui, "reconstruction_dir_by_name", lambda *_args, **_kwargs: tmp_path / "recon") + monkeypatch.setattr(pipeline_gui, "load_bundle_payload", lambda _path: payload) + + local_indices = pipeline_gui.AnnotationTab._annotation_worst_reprojection_frame_local_indices(tab) + + assert local_indices == [7] + + +def test_annotation_configure_shared_reconstruction_panel_uses_shared_selection(): + configured = {} + published = {} + + class _FakePanel: + def configure_for_consumer(self, **kwargs): + configured.update(kwargs) + + def set_rows(self, rows, defaults): + published["rows"] = rows + published["defaults"] = defaults + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.uses_shared_reconstruction_panel = True + tab.shared_reconstruction_selectmode = "browse" + tab.state = SimpleNamespace( + shared_reconstruction_selection=["demo"], + shared_reconstruction_panel=_FakePanel(), + active_reconstruction_consumer=tab, + ) + tab.on_frame_filter_changed = lambda: published.setdefault("selection_changed", True) + tab.refresh_available_reconstructions = lambda: published.setdefault("refreshed", True) + + pipeline_gui.AnnotationTab.configure_shared_reconstruction_panel(tab, tab.state.shared_reconstruction_panel) + configured["selection_callback"]() + + assert configured["title"] == "Reconstructions | Annotation" + assert configured["selectmode"] == "browse" + assert published["refreshed"] is True + assert published["selection_changed"] is True + assert tab.uses_shared_reconstruction_panel is True + + +def test_annotation_on_frame_filter_changed_does_not_scan_images(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 11, 12], dtype=int)) + tab.frame_var = SimpleNamespace(get=lambda: 2) + tab._set_frame_index = lambda value: setattr(tab, "_frame_index", value) + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + tab._filtered_annotation_frame_local_indices = lambda: [0, 1] + tab._navigable_annotation_frame_local_indices = lambda: (_ for _ in ()).throw( + AssertionError("image scan should not run during filter change") + ) + + pipeline_gui.AnnotationTab.on_frame_filter_changed(tab) + + assert tab._frame_index == 0 + assert tab._refreshed is True + + +def test_annotation_navigable_frames_use_indexed_image_availability(monkeypatch, tmp_path): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 11, 12, 13], dtype=int)) + tab._filtered_annotation_frame_local_indices = lambda: [0, 1, 2, 3] + tab.selected_annotation_camera_names = lambda: ["cam0", "cam1"] + tab._current_images_root = lambda: tmp_path + tab._navigable_frame_cache = {} + + calls = [] + monkeypatch.setattr( + frame_navigation, + "available_execution_image_frames", + lambda root, camera_names: calls.append((root, tuple(camera_names))) or {"cam0": {11}, "cam1": {13}}, + ) + monkeypatch.setattr( + pipeline_gui, + "resolve_execution_image_path", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not resolve image paths here")), + ) + + local_indices = pipeline_gui.AnnotationTab._navigable_annotation_frame_local_indices(tab) + + assert local_indices == [1, 3] + assert calls == [(tmp_path, ("cam0", "cam1"))] + + +def test_annotation_crop_limits_use_selected_camera_indices(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace( + camera_names=["cam0", "cam1", "cam2"], + raw_keypoints=np.arange(3 * 2 * 17 * 2, dtype=float).reshape(3, 2, 17, 2), + keypoints=np.zeros((3, 2, 17, 2), dtype=float), + ) + tab.calibrations = { + "cam0": SimpleNamespace(image_size=(100, 100)), + "cam1": SimpleNamespace(image_size=(100, 100)), + "cam2": SimpleNamespace(image_size=(100, 100)), + } + tab.crop_limits_cache = {} + tab.crop_limits_key = None + + captured = {} + + def fake_compute_pose_crop_limits_2d(raw_2d, calibrations, camera_names, margin): + captured["raw_2d"] = np.asarray(raw_2d) + captured["camera_names"] = list(camera_names) + captured["margin"] = margin + return {name: np.zeros((2, 4), dtype=float) for name in camera_names} + + monkeypatch.setattr(pipeline_gui, "compute_pose_crop_limits_2d", fake_compute_pose_crop_limits_2d) + + result = pipeline_gui.AnnotationTab._ensure_crop_limits(tab, ["cam2"]) + + assert list(result.keys()) == ["cam2"] + assert captured["camera_names"] == ["cam2"] + assert captured["margin"] == 0.2 + np.testing.assert_array_equal(captured["raw_2d"], tab.pose_data.raw_keypoints[[2]]) + + +def test_annotation_triangulated_reprojection_projects_hint_into_target_view(): + calibrations = { + "cam0": CameraCalibration( + name="cam0", + image_size=(1920, 1080), + K=np.eye(3, dtype=float), + dist=np.zeros(5, dtype=float), + rvec=np.zeros(3, dtype=float), + tvec=np.array([[0.0], [0.0], [0.0]], dtype=float), + R=np.eye(3, dtype=float), + P=np.hstack((np.eye(3, dtype=float), np.array([[0.0], [0.0], [0.0]], dtype=float))), + ), + "cam1": CameraCalibration( + name="cam1", + image_size=(1920, 1080), + K=np.eye(3, dtype=float), + dist=np.zeros(5, dtype=float), + rvec=np.zeros(3, dtype=float), + tvec=np.array([[1.0], [0.0], [0.0]], dtype=float), + R=np.eye(3, dtype=float), + P=np.hstack((np.eye(3, dtype=float), np.array([[1.0], [0.0], [0.0]], dtype=float))), + ), + "cam2": CameraCalibration( + name="cam2", + image_size=(1920, 1080), + K=np.eye(3, dtype=float), + dist=np.zeros(5, dtype=float), + rvec=np.zeros(3, dtype=float), + tvec=np.array([[-0.5], [0.0], [0.0]], dtype=float), + R=np.eye(3, dtype=float), + P=np.hstack((np.eye(3, dtype=float), np.array([[-0.5], [0.0], [0.0]], dtype=float))), + ), + } + point_3d = np.array([0.2, -0.1, 2.0], dtype=float) + source_points = [calibrations["cam0"].project_point(point_3d), calibrations["cam1"].project_point(point_3d)] + + hint = pipeline_gui.annotation_triangulated_reprojection( + calibrations, + target_camera_name="cam2", + source_camera_names=["cam0", "cam1"], + source_points_2d=source_points, + ) + + np.testing.assert_allclose(hint, calibrations["cam2"].project_point(point_3d), atol=1e-8) + + +def test_annotation_snap_annotation_xy_prefers_nearest_reprojection_candidate(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.snap_reprojection_var = SimpleNamespace(get=lambda: True) + tab.snap_epipolar_var = SimpleNamespace(get=lambda: False) + tab._triangulated_hint_for_keypoint = lambda *_args, **_kwargs: np.array([10.0, 10.0], dtype=float) + tab._reference_projected_keypoint = lambda *_args, **_kwargs: np.array([30.0, 30.0], dtype=float) + tab._epipolar_lines_for_keypoint = lambda *_args, **_kwargs: [] + + snapped = pipeline_gui.AnnotationTab._snap_annotation_xy( + tab, + camera_name="cam0", + frame_number=10, + keypoint_name="left_wrist", + pointer_xy=np.array([12.0, 11.0], dtype=float), + ) + + np.testing.assert_allclose(snapped, np.array([10.0, 10.0], dtype=float)) + + +def test_annotation_snap_annotation_xy_projects_to_epipolar_line(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.snap_reprojection_var = SimpleNamespace(get=lambda: False) + tab.snap_epipolar_var = SimpleNamespace(get=lambda: True) + tab._triangulated_hint_for_keypoint = lambda *_args, **_kwargs: None + tab._reference_projected_keypoint = lambda *_args, **_kwargs: None + tab._epipolar_lines_for_keypoint = lambda *_args, **_kwargs: [np.array([1.0, 0.0, -20.0], dtype=float)] + + snapped = pipeline_gui.AnnotationTab._snap_annotation_xy( + tab, + camera_name="cam0", + frame_number=10, + keypoint_name="left_wrist", + pointer_xy=np.array([12.0, 15.0], dtype=float), + ) + + np.testing.assert_allclose(snapped, np.array([20.0, 15.0], dtype=float)) + + +def test_triangulate_annotation_frame_points_uses_existing_annotations_only(): + calibrations = { + "cam0": CameraCalibration( + name="cam0", + image_size=(1920, 1080), + K=np.eye(3, dtype=float), + dist=np.zeros(5, dtype=float), + rvec=np.zeros(3, dtype=float), + tvec=np.array([[0.0], [0.0], [0.0]], dtype=float), + R=np.eye(3, dtype=float), + P=np.hstack((np.eye(3, dtype=float), np.array([[0.0], [0.0], [0.0]], dtype=float))), + ), + "cam1": CameraCalibration( + name="cam1", + image_size=(1920, 1080), + K=np.eye(3, dtype=float), + dist=np.zeros(5, dtype=float), + rvec=np.zeros(3, dtype=float), + tvec=np.array([[1.0], [0.0], [0.0]], dtype=float), + R=np.eye(3, dtype=float), + P=np.hstack((np.eye(3, dtype=float), np.array([[1.0], [0.0], [0.0]], dtype=float))), + ), + } + payload = pipeline_gui.empty_annotation_payload() + point_3d = np.array([0.2, -0.1, 2.0], dtype=float) + for camera_name in ("cam0", "cam1"): + pipeline_gui.set_annotation_point( + payload, + camera_name=camera_name, + frame_number=10, + keypoint_name="left_shoulder", + xy=calibrations[camera_name].project_point(point_3d), + ) + + points_3d = pipeline_gui.triangulate_annotation_frame_points( + calibrations, + camera_names=["cam0", "cam1"], + frame_number=10, + annotation_payload=payload, + ) + + np.testing.assert_allclose(points_3d[pipeline_gui.KP_INDEX["left_shoulder"]], point_3d, atol=1e-8) + assert np.all(np.isnan(points_3d[pipeline_gui.KP_INDEX["nose"]])) + + +def test_annotation_step_keypoint_down_updates_selection(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.annotation_keypoints_list = _FakeListbox() + for keypoint_name in pipeline_gui.ANNOTATION_KEYPOINT_ORDER[:3]: + tab.annotation_keypoints_list.insert("end", keypoint_name) + tab.annotation_keypoints_list.selection_set(0) + tab.on_keypoint_selection_changed = lambda: setattr(tab, "_selection_changed", True) + + result = pipeline_gui.AnnotationTab._step_annotation_keypoint(tab, 1) + + assert result == "break" + assert tab.annotation_keypoints_list.curselection() == (1,) + assert tab.annotation_keypoints_list._active == 1 + assert tab._selection_changed is True + + +def test_annotation_step_keypoint_up_clamps_at_first_item(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.annotation_keypoints_list = _FakeListbox() + for keypoint_name in pipeline_gui.ANNOTATION_KEYPOINT_ORDER[:3]: + tab.annotation_keypoints_list.insert("end", keypoint_name) + tab.annotation_keypoints_list.selection_set(0) + tab.on_keypoint_selection_changed = lambda: setattr(tab, "_selection_changed", True) + + result = pipeline_gui.AnnotationTab._step_annotation_keypoint(tab, -1) + + assert result == "break" + assert tab.annotation_keypoints_list.curselection() == (2,) + assert tab.annotation_keypoints_list._active == 2 + assert tab._selection_changed is True + + +def test_annotation_step_camera_wraps_selection(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.annotation_cameras_list = _FakeListbox() + for camera_name in ["cam0", "cam1", "cam2"]: + tab.annotation_cameras_list.insert("end", camera_name) + tab.annotation_cameras_list.selection_set(2) + tab.on_camera_selection_changed = lambda: setattr(tab, "_camera_selection_changed", True) + + result = pipeline_gui.AnnotationTab._step_annotation_camera(tab, 1) + + assert result == "break" + assert tab.annotation_cameras_list.curselection() == (0,) + assert tab.annotation_cameras_list._active == 0 + assert tab._camera_selection_changed is True + + +def test_annotation_step_camera_backwards_wraps_selection(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.annotation_cameras_list = _FakeListbox() + for camera_name in ["cam0", "cam1", "cam2"]: + tab.annotation_cameras_list.insert("end", camera_name) + tab.annotation_cameras_list.selection_set(0) + tab.on_camera_selection_changed = lambda: setattr(tab, "_camera_selection_changed", True) + + result = pipeline_gui.AnnotationTab._step_annotation_camera(tab, -1) + + assert result == "break" + assert tab.annotation_cameras_list.curselection() == (2,) + assert tab.annotation_cameras_list._active == 2 + assert tab._camera_selection_changed is True + + +def test_annotation_advance_to_next_keypoint_wraps_to_start(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.annotation_keypoints_list = _FakeListbox() + for keypoint_name in pipeline_gui.ANNOTATION_KEYPOINT_ORDER[:3]: + tab.annotation_keypoints_list.insert("end", keypoint_name) + tab.annotation_keypoints_list.selection_set(2) + tab.advance_marker_var = SimpleNamespace(get=lambda: True) + tab.on_keypoint_selection_changed = lambda: setattr(tab, "_selection_changed", True) + + pipeline_gui.AnnotationTab._advance_to_next_keypoint(tab) + + assert tab.annotation_keypoints_list.curselection() == (0,) + assert tab.annotation_keypoints_list._active == 0 + assert tab._selection_changed is True + + +def test_annotation_estimate_kinematic_assist_state_sets_projected_overlay(monkeypatch, tmp_path): + biomod_path = tmp_path / "demo.bioMod" + biomod_path.write_text("demo", encoding="utf-8") + + class _FakeBiorbdModel: + def __init__(self, _path): + pass + + def nbQ(self): + return 2 + + import sys + + monkeypatch.setitem(sys.modules, "biorbd", SimpleNamespace(Model=_FakeBiorbdModel)) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10], dtype=int)) + tab.calibrations = {"cam0": object(), "cam1": object()} + tab.annotation_payload = {} + tab.kinematic_model_choices = {"demo": biomod_path} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + tab.kinematic_frame_states = {} + tab.selected_annotation_camera_names = lambda: ["cam0", "cam1"] + tab.current_frame_number = lambda: 10 + tab.kinematic_status_var = SimpleNamespace(set=lambda value: setattr(tab, "_kin_status", value)) + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + + monkeypatch.setattr( + pipeline_gui, + "triangulate_annotation_frame_points", + lambda *_args, **_kwargs: np.pad(np.ones((2, 3), dtype=float), ((0, 15), (0, 0)), constant_values=np.nan), + ) + monkeypatch.setattr( + pipeline_gui, + "annotation_pose_data_for_frame", + lambda *_args, **_kwargs: pipeline_gui.PoseData( + camera_names=["cam0", "cam1"], + frames=np.array([10], dtype=int), + keypoints=np.zeros((2, 1, len(pipeline_gui.COCO17), 2), dtype=float), + scores=np.pad( + np.ones((2, 1, 2), dtype=float), + ((0, 0), (0, 0), (0, len(pipeline_gui.COCO17) - 2)), + constant_values=0.0, + ), + ), + ) + monkeypatch.setattr(pipeline_gui, "canonicalize_model_q_rotation_branches", lambda _model, q: np.asarray(q)) + monkeypatch.setattr(pipeline_gui, "biorbd_q_names", lambda _model: ["q0", "q1"]) + monkeypatch.setattr( + pipeline_gui, + "refine_annotation_q_with_marker_lm", + lambda **_kwargs: ( + np.array([0.1, -0.2, 0.0, 0.0, 0.0, 0.0], dtype=float), + {"completed_passes": 7}, + ), + ) + monkeypatch.setattr( + pipeline_gui, + "biorbd_markers_from_q", + lambda _biomod_path, q_series: np.zeros((q_series.shape[0], len(pipeline_gui.COCO17), 3), dtype=float), + ) + monkeypatch.setattr( + pipeline_gui, + "project_points_all_cameras", + lambda points_3d, calibrations, camera_names: np.zeros( + (len(camera_names), points_3d.shape[0], len(pipeline_gui.COCO17), 2), dtype=float + ), + ) + monkeypatch.setattr(pipeline_gui, "segmented_back_overlay_from_q", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + pipeline_gui.messagebox, + "showerror", + lambda *_args, **_kwargs: pytest.fail("estimate_kinematic_assist_state unexpectedly called showerror"), + ) + + pipeline_gui.AnnotationTab.estimate_kinematic_assist_state(tab) + + np.testing.assert_allclose(tab.kinematic_q_current, np.array([0.1, -0.2], dtype=float)) + assert tab.kinematic_projected_points.shape == (2, 1, len(pipeline_gui.COCO17), 2) + assert tab._kin_status == "Estimated q from triangulation LM (2 markers, 4 2D points, 7 iter)." + assert tab._refreshed is True + + +def test_annotation_estimate_kinematic_assist_state_reports_when_not_enough_2d_markers(monkeypatch, tmp_path): + biomod_path = tmp_path / "demo.bioMod" + biomod_path.write_text("demo", encoding="utf-8") + + class _FakeBiorbdModel: + def __init__(self, _path): + pass + + def nbQ(self): + return 2 + + import sys + + monkeypatch.setitem(sys.modules, "biorbd", SimpleNamespace(Model=_FakeBiorbdModel)) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10], dtype=int)) + tab.calibrations = {"cam0": object(), "cam1": object()} + tab.annotation_payload = {} + tab.kinematic_model_choices = {"demo": biomod_path} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + tab.kinematic_frame_states = {} + tab.selected_annotation_camera_names = lambda: ["cam0", "cam1"] + tab.current_frame_number = lambda: 10 + tab.kinematic_status_var = SimpleNamespace(set=lambda value: setattr(tab, "_kin_status", value)) + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + tab._clear_kinematic_assist_preview = lambda: setattr(tab, "_cleared_preview", True) + + monkeypatch.setattr( + pipeline_gui, + "annotation_pose_data_for_frame", + lambda *_args, **_kwargs: pipeline_gui.PoseData( + camera_names=["cam0", "cam1"], + frames=np.array([10], dtype=int), + keypoints=np.zeros((2, 1, len(pipeline_gui.COCO17), 2), dtype=float), + scores=np.pad( + np.ones((2, 1, 1), dtype=float), + ((0, 0), (0, 0), (0, len(pipeline_gui.COCO17) - 1)), + constant_values=0.0, + ), + ), + ) + monkeypatch.setattr(pipeline_gui, "biorbd_q_names", lambda _model: ["q0", "q1"]) + seen = {} + monkeypatch.setattr( + pipeline_gui.messagebox, + "showerror", + lambda title, message: seen.update({"title": title, "message": message}), + ) + + pipeline_gui.AnnotationTab.estimate_kinematic_assist_state(tab) + + assert tab._cleared_preview is True + assert tab._kin_status == "" + assert seen["title"] == "Annotation" + assert "Not enough annotated 2D markers to estimate q." in seen["message"] + + +def test_annotation_pose_data_for_frame_uses_only_existing_annotations(): + base_pose_data = pipeline_gui.PoseData( + camera_names=["cam0", "cam1"], + frames=np.array([10, 11], dtype=int), + keypoints=np.full((2, 2, len(pipeline_gui.COCO17), 2), np.nan, dtype=float), + scores=np.zeros((2, 2, len(pipeline_gui.COCO17)), dtype=float), + ) + payload = pipeline_gui.empty_annotation_payload() + pipeline_gui.set_annotation_point( + payload, camera_name="cam0", frame_number=11, keypoint_name="nose", xy=[10.0, 20.0] + ) + pipeline_gui.set_annotation_point( + payload, camera_name="cam1", frame_number=11, keypoint_name="left_shoulder", xy=[30.0, 40.0] + ) + + pose_data = pipeline_gui.annotation_pose_data_for_frame( + base_pose_data, + camera_names=["cam0", "cam1"], + frame_number=11, + annotation_payload=payload, + ) + + assert pose_data.frames.tolist() == [11] + np.testing.assert_allclose(pose_data.keypoints[0, 0, pipeline_gui.KP_INDEX["nose"]], np.array([10.0, 20.0])) + np.testing.assert_allclose( + pose_data.keypoints[1, 0, pipeline_gui.KP_INDEX["left_shoulder"]], np.array([30.0, 40.0]) + ) + assert pose_data.scores[0, 0, pipeline_gui.KP_INDEX["nose"]] == 1.0 + assert np.isnan(pose_data.keypoints[0, 0, pipeline_gui.KP_INDEX["left_shoulder"]]).all() + + +def test_annotation_reconstruction_from_points_uses_framewise_coherence(): + points_3d = np.full((len(pipeline_gui.COCO17), 3), np.nan, dtype=float) + points_3d[pipeline_gui.KP_INDEX["nose"]] = np.array([1.0, 2.0, 3.0], dtype=float) + + reconstruction = pipeline_gui.annotation_reconstruction_from_points(points_3d, frame_number=10, n_cameras=2) + + assert reconstruction.coherence_method == "epipolar_fast_framewise" + assert reconstruction.frames.tolist() == [10] + assert reconstruction.points_3d.shape == (1, len(pipeline_gui.COCO17), 3) + + +def test_annotation_hover_label_includes_source_and_current_marker(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.selected_keypoint_name = lambda: "left_wrist" + + label = pipeline_gui.AnnotationTab._annotation_hover_label( + tab, + {"keypoint_name": "left_wrist", "source": "annotated"}, + ) + + assert label == "left_wrist | annotated | current" + + +def test_annotation_nearest_hover_entry_returns_closest_point(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + ax = object() + tab._annotation_hover_entries = { + ax: [ + {"xy": np.array([10.0, 20.0]), "keypoint_name": "nose", "source": "annotated"}, + {"xy": np.array([30.0, 40.0]), "keypoint_name": "left_wrist", "source": "model reproj"}, + ] + } + + nearest = pipeline_gui.AnnotationTab._nearest_annotation_hover_entry(tab, ax, 31.0, 39.0) + + assert nearest is not None + assert nearest["keypoint_name"] == "left_wrist" + + +def test_annotation_nearest_hover_entry_uses_small_hover_radius(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + ax = object() + tab._annotation_hover_entries = { + ax: [ + {"xy": np.array([10.0, 20.0]), "keypoint_name": "nose", "source": "annotated"}, + ] + } + + nearest = pipeline_gui.AnnotationTab._nearest_annotation_hover_entry(tab, ax, 25.0, 20.0) + + assert nearest is None + + +def test_annotation_estimate_kinematic_q_with_keypoint_reports_local_update(monkeypatch, tmp_path): + biomod_path = tmp_path / "demo.bioMod" + biomod_path.write_text("demo", encoding="utf-8") + + class _FakeBiorbdModel: + def __init__(self, _path): + pass + + def nbQ(self): + return 2 + + import sys + + monkeypatch.setitem(sys.modules, "biorbd", SimpleNamespace(Model=_FakeBiorbdModel)) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10], dtype=int)) + tab.calibrations = {"cam0": object(), "cam1": object()} + tab.annotation_payload = {} + tab.kinematic_model_choices = {"demo": biomod_path} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + tab.kinematic_frame_states = {("demo", 10): np.array([1.0, 2.0], dtype=float)} + tab.selected_annotation_camera_names = lambda: ["cam0", "cam1"] + tab.current_frame_number = lambda: 10 + tab.kinematic_status_var = SimpleNamespace(set=lambda value: setattr(tab, "_kin_status", value)) + + monkeypatch.setattr( + pipeline_gui, + "triangulate_annotation_frame_points", + lambda *_args, **_kwargs: pytest.fail("triangulation should not be used when a current state exists"), + ) + monkeypatch.setattr(pipeline_gui, "canonicalize_model_q_rotation_branches", lambda _model, q: np.asarray(q)) + monkeypatch.setattr(pipeline_gui, "biorbd_q_names", lambda _model: ["TRUNK:RotY", "LEFT_LOWER_ARM:RotY"]) + monkeypatch.setattr( + pipeline_gui, + "biorbd_markers_from_q", + lambda _biomod_path, q_series: np.zeros((q_series.shape[0], len(pipeline_gui.COCO17), 3), dtype=float), + ) + monkeypatch.setattr( + pipeline_gui, + "project_points_all_cameras", + lambda points_3d, calibrations, camera_names: np.zeros( + (len(camera_names), points_3d.shape[0], len(pipeline_gui.COCO17), 2), dtype=float + ), + ) + monkeypatch.setattr(pipeline_gui, "segmented_back_overlay_from_q", lambda *_args, **_kwargs: None) + + estimated_q = pipeline_gui.AnnotationTab._estimate_kinematic_q(tab, keypoint_name="left_wrist") + + np.testing.assert_allclose(estimated_q, np.array([1.0, 2.0], dtype=float)) + assert ( + tab._kin_status == "Updated model after left_wrist using current state from current frame | local ekf fallback." + ) + + +def test_annotation_estimate_kinematic_q_runs_bootstrap_when_measurements_are_available(monkeypatch, tmp_path): + biomod_path = tmp_path / "demo.bioMod" + biomod_path.write_text("demo", encoding="utf-8") + + class _FakeBiorbdModel: + def __init__(self, _path): + pass + + def nbQ(self): + return 2 + + import sys + + monkeypatch.setitem(sys.modules, "biorbd", SimpleNamespace(Model=_FakeBiorbdModel)) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = pipeline_gui.PoseData( + camera_names=["cam0", "cam1"], + frames=np.array([10], dtype=int), + keypoints=np.full((2, 1, len(pipeline_gui.COCO17), 2), np.nan, dtype=float), + scores=np.zeros((2, 1, len(pipeline_gui.COCO17)), dtype=float), + ) + tab.calibrations = {"cam0": object(), "cam1": object()} + tab.annotation_payload = pipeline_gui.empty_annotation_payload() + pipeline_gui.set_annotation_point( + tab.annotation_payload, camera_name="cam0", frame_number=10, keypoint_name="nose", xy=[1.0, 2.0] + ) + pipeline_gui.set_annotation_point( + tab.annotation_payload, camera_name="cam1", frame_number=10, keypoint_name="nose", xy=[2.0, 3.0] + ) + pipeline_gui.set_annotation_point( + tab.annotation_payload, camera_name="cam0", frame_number=10, keypoint_name="left_shoulder", xy=[4.0, 5.0] + ) + pipeline_gui.set_annotation_point( + tab.annotation_payload, camera_name="cam1", frame_number=10, keypoint_name="left_shoulder", xy=[5.0, 6.0] + ) + tab.kinematic_model_choices = {"demo": biomod_path} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + tab.kinematic_frame_states = {} + tab.selected_annotation_camera_names = lambda: ["cam0", "cam1"] + tab.current_frame_number = lambda: 10 + tab.kinematic_status_var = SimpleNamespace(set=lambda value: setattr(tab, "_kin_status", value)) + tab.state = SimpleNamespace(fps_var=SimpleNamespace(get=lambda: "120")) + + monkeypatch.setattr( + pipeline_gui, + "triangulate_annotation_frame_points", + lambda *_args, **_kwargs: np.pad(np.ones((2, 3), dtype=float), ((0, 15), (0, 0)), constant_values=np.nan), + ) + monkeypatch.setattr( + pipeline_gui, + "initial_state_from_triangulation", + lambda model, reconstruction: np.array([0.1, -0.2, 0.0, 0.0, 0.0, 0.0], dtype=float), + ) + monkeypatch.setattr(pipeline_gui, "canonicalize_model_q_rotation_branches", lambda _model, q: np.asarray(q)) + monkeypatch.setattr(pipeline_gui, "biorbd_q_names", lambda _model: ["q0", "q1"]) + monkeypatch.setattr( + pipeline_gui, + "biorbd_markers_from_q", + lambda _biomod_path, q_series: np.zeros((q_series.shape[0], len(pipeline_gui.COCO17), 3), dtype=float), + ) + monkeypatch.setattr( + pipeline_gui, + "project_points_all_cameras", + lambda points_3d, calibrations, camera_names: np.zeros( + (len(camera_names), points_3d.shape[0], len(pipeline_gui.COCO17), 2), dtype=float + ), + ) + monkeypatch.setattr(pipeline_gui, "segmented_back_overlay_from_q", lambda *_args, **_kwargs: None) + ekf_calls = {} + + def fake_refine_local_ekf(**kwargs): + ekf_calls.update(kwargs) + return np.array([0.4, -0.5], dtype=float), {"completed_passes": 3, "used_fallback": False} + + monkeypatch.setattr(pipeline_gui, "refine_annotation_q_with_local_ekf", fake_refine_local_ekf) + + estimated_q = pipeline_gui.AnnotationTab._estimate_kinematic_q(tab) + + np.testing.assert_allclose(estimated_q, np.array([0.4, -0.5], dtype=float)) + assert ekf_calls["passes"] == ( + pipeline_gui.ANNOTATION_KINEMATIC_BOOTSTRAP_PASSES + * pipeline_gui.ANNOTATION_KINEMATIC_INITIAL_BOOTSTRAP_MULTIPLIER + ) + assert ekf_calls["pose_data"].frames.tolist() == [10] + assert "local ekf 3/" in tab._kin_status + + +def test_annotation_estimate_kinematic_q_click_zeroes_derivatives_and_uses_short_passes(monkeypatch, tmp_path): + biomod_path = tmp_path / "demo.bioMod" + biomod_path.write_text("demo", encoding="utf-8") + + class _FakeBiorbdModel: + def __init__(self, _path): + pass + + def nbQ(self): + return 2 + + import sys + + monkeypatch.setitem(sys.modules, "biorbd", SimpleNamespace(Model=_FakeBiorbdModel)) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = pipeline_gui.PoseData( + camera_names=["cam0", "cam1"], + frames=np.array([10], dtype=int), + keypoints=np.full((2, 1, len(pipeline_gui.COCO17), 2), np.nan, dtype=float), + scores=np.zeros((2, 1, len(pipeline_gui.COCO17)), dtype=float), + ) + tab.calibrations = {"cam0": object(), "cam1": object()} + tab.annotation_payload = pipeline_gui.empty_annotation_payload() + pipeline_gui.set_annotation_point( + tab.annotation_payload, camera_name="cam0", frame_number=10, keypoint_name="left_wrist", xy=[1.0, 2.0] + ) + pipeline_gui.set_annotation_point( + tab.annotation_payload, camera_name="cam1", frame_number=10, keypoint_name="left_wrist", xy=[2.0, 3.0] + ) + tab.kinematic_model_choices = {"demo": biomod_path} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + tab.kinematic_frame_states = {("demo", 10): np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], dtype=float)} + tab.selected_annotation_camera_names = lambda: ["cam0", "cam1"] + tab.current_frame_number = lambda: 10 + tab.kinematic_status_var = SimpleNamespace(set=lambda value: setattr(tab, "_kin_status", value)) + tab.state = SimpleNamespace(fps_var=SimpleNamespace(get=lambda: "120")) + + monkeypatch.setattr(pipeline_gui, "canonicalize_model_q_rotation_branches", lambda _model, q: np.asarray(q)) + monkeypatch.setattr(pipeline_gui, "biorbd_q_names", lambda _model: ["TRUNK:RotY", "LEFT_LOWER_ARM:RotY"]) + monkeypatch.setattr( + pipeline_gui, + "biorbd_markers_from_q", + lambda _biomod_path, q_series: np.zeros((q_series.shape[0], len(pipeline_gui.COCO17), 3), dtype=float), + ) + monkeypatch.setattr( + pipeline_gui, + "project_points_all_cameras", + lambda points_3d, calibrations, camera_names: np.zeros( + (len(camera_names), points_3d.shape[0], len(pipeline_gui.COCO17), 2), dtype=float + ), + ) + monkeypatch.setattr(pipeline_gui, "segmented_back_overlay_from_q", lambda *_args, **_kwargs: None) + + ekf_calls = {} + + def fake_refine_local_ekf(**kwargs): + ekf_calls.update(kwargs) + return np.array([7.0, 8.0, 0.1, 0.2, 0.3, 0.4], dtype=float), {"completed_passes": 2, "used_fallback": False} + + monkeypatch.setattr(pipeline_gui, "refine_annotation_q_with_local_ekf", fake_refine_local_ekf) + + pipeline_gui.AnnotationTab._estimate_kinematic_q(tab, keypoint_name="left_wrist") + + np.testing.assert_allclose(ekf_calls["seed_state"][2:], np.zeros(4, dtype=float)) + assert ekf_calls["passes"] == pipeline_gui.ANNOTATION_KINEMATIC_CLICK_PASSES + assert ekf_calls["keypoint_name"] is None + + +def test_annotation_click_release_places_selected_marker_without_drag(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10], dtype=int)) + tab.annotation_payload = pipeline_gui.empty_annotation_payload() + tab._pending_reprojection_points = {} + tab._axis_to_camera = {} + tab._drag_annotation_state = None + tab._clear_kinematic_assist_preview = lambda: None + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + tab.save_annotations = lambda: setattr(tab, "_saved", True) + tab.current_frame_number = lambda: 10 + tab.selected_keypoint_name = lambda: "nose" + tab.advance_marker_var = SimpleNamespace(get=lambda: False) + tab.kinematic_assist_var = SimpleNamespace(get=lambda: False) + tab._snap_annotation_xy = lambda **kwargs: np.array([kwargs["pointer_xy"][0], kwargs["pointer_xy"][1]], dtype=float) + ax = object() + tab._axis_to_camera[ax] = "cam0" + tab._nearest_annotated_drag_entry = lambda *_args, **_kwargs: { + "xy": np.array([100.0, 100.0]), + "keypoint_name": "left_eye", + "source": "annotated", + } + + press_event = SimpleNamespace(inaxes=ax, xdata=120.0, ydata=80.0, button=1, key="") + release_event = SimpleNamespace(inaxes=ax, xdata=120.0, ydata=80.0, button=1, key="") + + pipeline_gui.AnnotationTab.on_preview_click(tab, press_event) + pipeline_gui.AnnotationTab.on_preview_release(tab, release_event) + + point_xy, _score = pipeline_gui.get_annotation_point( + tab.annotation_payload, + camera_name="cam0", + frame_number=10, + keypoint_name="nose", + ) + np.testing.assert_allclose(point_xy, np.array([120.0, 80.0], dtype=float)) + left_eye_xy, _score = pipeline_gui.get_annotation_point( + tab.annotation_payload, + camera_name="cam0", + frame_number=10, + keypoint_name="left_eye", + ) + assert left_eye_xy is None + + +def test_annotation_drag_moves_existing_marker_only_after_motion(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10], dtype=int)) + tab.annotation_payload = pipeline_gui.empty_annotation_payload() + pipeline_gui.set_annotation_point( + tab.annotation_payload, camera_name="cam0", frame_number=10, keypoint_name="left_eye", xy=[100.0, 100.0] + ) + tab._pending_reprojection_points = {} + tab._axis_to_camera = {} + tab._drag_annotation_state = None + tab._clear_kinematic_assist_preview = lambda: None + tab.refresh_preview = lambda: None + tab.save_annotations = lambda: None + tab.current_frame_number = lambda: 10 + tab.selected_keypoint_name = lambda: "nose" + tab.advance_marker_var = SimpleNamespace(get=lambda: False) + tab.kinematic_assist_var = SimpleNamespace(get=lambda: False) + tab._snap_annotation_xy = lambda **kwargs: np.array([kwargs["pointer_xy"][0], kwargs["pointer_xy"][1]], dtype=float) + tab._update_preview_cursor = lambda _event: None + ax = object() + tab._axis_to_camera[ax] = "cam0" + tab._nearest_annotated_drag_entry = lambda *_args, **_kwargs: { + "xy": np.array([100.0, 100.0]), + "keypoint_name": "left_eye", + "source": "annotated", + } + + press_event = SimpleNamespace(inaxes=ax, xdata=100.0, ydata=100.0, button=1, key="") + move_event = SimpleNamespace(inaxes=ax, xdata=108.0, ydata=103.0, button=1, key="") + release_event = SimpleNamespace(inaxes=ax, xdata=108.0, ydata=103.0, button=1, key="") + + pipeline_gui.AnnotationTab.on_preview_click(tab, press_event) + pipeline_gui.AnnotationTab.on_preview_motion(tab, move_event) + pipeline_gui.AnnotationTab.on_preview_release(tab, release_event) + + left_eye_xy, _score = pipeline_gui.get_annotation_point( + tab.annotation_payload, + camera_name="cam0", + frame_number=10, + keypoint_name="left_eye", + ) + np.testing.assert_allclose(left_eye_xy, np.array([108.0, 103.0], dtype=float)) + nose_xy, _score = pipeline_gui.get_annotation_point( + tab.annotation_payload, + camera_name="cam0", + frame_number=10, + keypoint_name="nose", + ) + assert nose_xy is None + + +def test_annotation_estimate_kinematic_q_click_uses_single_measurement_when_state_exists(monkeypatch, tmp_path): + biomod_path = tmp_path / "demo.bioMod" + biomod_path.write_text("demo", encoding="utf-8") + + class _FakeBiorbdModel: + def __init__(self, _path): + pass + + def nbQ(self): + return 2 + + import sys + + monkeypatch.setitem(sys.modules, "biorbd", SimpleNamespace(Model=_FakeBiorbdModel)) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = pipeline_gui.PoseData( + camera_names=["cam0"], + frames=np.array([10], dtype=int), + keypoints=np.full((1, 1, len(pipeline_gui.COCO17), 2), np.nan, dtype=float), + scores=np.zeros((1, 1, len(pipeline_gui.COCO17)), dtype=float), + ) + tab.calibrations = {"cam0": object()} + tab.annotation_payload = pipeline_gui.empty_annotation_payload() + pipeline_gui.set_annotation_point( + tab.annotation_payload, camera_name="cam0", frame_number=10, keypoint_name="left_wrist", xy=[1.0, 2.0] + ) + tab.kinematic_model_choices = {"demo": biomod_path} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + tab.kinematic_frame_states = {("demo", 10): np.array([1.0, 2.0, 0.0, 0.0, 0.0, 0.0], dtype=float)} + tab.selected_annotation_camera_names = lambda: ["cam0"] + tab.current_frame_number = lambda: 10 + tab.kinematic_status_var = SimpleNamespace(set=lambda value: setattr(tab, "_kin_status", value)) + tab.state = SimpleNamespace(fps_var=SimpleNamespace(get=lambda: "120")) + + monkeypatch.setattr(pipeline_gui, "canonicalize_model_q_rotation_branches", lambda _model, q: np.asarray(q)) + monkeypatch.setattr(pipeline_gui, "biorbd_q_names", lambda _model: ["TRUNK:RotY", "LEFT_LOWER_ARM:RotY"]) + monkeypatch.setattr( + pipeline_gui, + "biorbd_markers_from_q", + lambda _biomod_path, q_series: np.zeros((q_series.shape[0], len(pipeline_gui.COCO17), 3), dtype=float), + ) + monkeypatch.setattr( + pipeline_gui, + "project_points_all_cameras", + lambda points_3d, calibrations, camera_names: np.zeros( + (len(camera_names), points_3d.shape[0], len(pipeline_gui.COCO17), 2), dtype=float + ), + ) + monkeypatch.setattr(pipeline_gui, "segmented_back_overlay_from_q", lambda *_args, **_kwargs: None) + + ekf_calls = {} + + def fake_refine_local_ekf(**kwargs): + ekf_calls.update(kwargs) + return np.array([1.5, 2.5, 0.1, 0.2, 0.3, 0.4], dtype=float), {"completed_passes": 1, "used_fallback": False} + + monkeypatch.setattr(pipeline_gui, "refine_annotation_q_with_local_ekf", fake_refine_local_ekf) + + estimated_q = pipeline_gui.AnnotationTab._estimate_kinematic_q(tab, keypoint_name="left_wrist") + + np.testing.assert_allclose(estimated_q, np.array([1.5, 2.5], dtype=float)) + assert ekf_calls["pose_data"].scores.sum() == 1.0 + assert "local ekf 1/" in tab._kin_status + + +def test_annotation_estimate_kinematic_q_click_uses_direct_fit_when_local_ekf_is_insufficient(monkeypatch, tmp_path): + biomod_path = tmp_path / "demo.bioMod" + biomod_path.write_text("demo", encoding="utf-8") + + class _FakeBiorbdModel: + def __init__(self, _path): + pass + + def nbQ(self): + return 2 + + import sys + + monkeypatch.setitem(sys.modules, "biorbd", SimpleNamespace(Model=_FakeBiorbdModel)) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = pipeline_gui.PoseData( + camera_names=["cam0"], + frames=np.array([10], dtype=int), + keypoints=np.full((1, 1, len(pipeline_gui.COCO17), 2), np.nan, dtype=float), + scores=np.zeros((1, 1, len(pipeline_gui.COCO17)), dtype=float), + ) + tab.calibrations = {"cam0": object()} + tab.annotation_payload = pipeline_gui.empty_annotation_payload() + pipeline_gui.set_annotation_point( + tab.annotation_payload, camera_name="cam0", frame_number=10, keypoint_name="left_wrist", xy=[1.0, 2.0] + ) + tab.kinematic_model_choices = {"demo": biomod_path} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + tab.kinematic_frame_states = {("demo", 10): np.array([1.0, 2.0, 0.0, 0.0, 0.0, 0.0], dtype=float)} + tab.selected_annotation_camera_names = lambda: ["cam0"] + tab.current_frame_number = lambda: 10 + tab.kinematic_status_var = SimpleNamespace(set=lambda value: setattr(tab, "_kin_status", value)) + tab.state = SimpleNamespace(fps_var=SimpleNamespace(get=lambda: "120")) + + monkeypatch.setattr(pipeline_gui, "canonicalize_model_q_rotation_branches", lambda _model, q: np.asarray(q)) + monkeypatch.setattr(pipeline_gui, "biorbd_q_names", lambda _model: ["TRUNK:RotY", "LEFT_LOWER_ARM:RotY"]) + monkeypatch.setattr( + pipeline_gui, + "biorbd_markers_from_q", + lambda _biomod_path, q_series: np.zeros((q_series.shape[0], len(pipeline_gui.COCO17), 3), dtype=float), + ) + monkeypatch.setattr( + pipeline_gui, + "project_points_all_cameras", + lambda points_3d, calibrations, camera_names: np.zeros( + (len(camera_names), points_3d.shape[0], len(pipeline_gui.COCO17), 2), dtype=float + ), + ) + monkeypatch.setattr(pipeline_gui, "segmented_back_overlay_from_q", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + pipeline_gui, + "refine_annotation_q_with_local_ekf", + lambda **kwargs: ( + np.array([1.0, 2.0, 0.0, 0.0, 0.0, 0.0], dtype=float), + {"completed_passes": 3, "used_fallback": True}, + ), + ) + + direct_calls = {} + + def fake_direct_fit(**kwargs): + direct_calls.update(kwargs) + return np.array([3.0, 4.0, 0.5, 0.6, 0.7, 0.8], dtype=float), {"completed_passes": 1, "used_fallback": False} + + monkeypatch.setattr(pipeline_gui, "refine_annotation_q_with_direct_measurements", fake_direct_fit) + + estimated_q = pipeline_gui.AnnotationTab._estimate_kinematic_q(tab, keypoint_name="left_wrist") + + np.testing.assert_allclose(estimated_q, np.array([3.0, 4.0], dtype=float)) + assert direct_calls["passes"] == pipeline_gui.ANNOTATION_KINEMATIC_CLICK_DIRECT_PASSES + assert "direct fit 1/" in tab._kin_status + + +def test_annotation_step_frame_propagates_state_with_frame_delta(monkeypatch, tmp_path): + biomod_path = tmp_path / "demo.bioMod" + biomod_path.write_text("demo", encoding="utf-8") + + class _FakeBiorbdModel: + def __init__(self, _path): + pass + + def nbQ(self): + return 1 + + import sys + + monkeypatch.setitem(sys.modules, "biorbd", SimpleNamespace(Model=_FakeBiorbdModel)) + monkeypatch.setattr(pipeline_gui, "canonicalize_model_q_rotation_branches", lambda _model, q: np.asarray(q)) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 12, 15], dtype=int)) + tab.frame_var = SimpleNamespace(get=lambda: 0, set=lambda value: setattr(tab, "_frame_index", value)) + tab._current_frame_idx = 0 + tab.kinematic_assist_var = SimpleNamespace(get=lambda: True) + tab.kinematic_model_choices = {"demo": biomod_path} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + tab.kinematic_frame_states = {("demo", 10): np.array([1.0, 2.0, 3.0], dtype=float)} + tab.state = SimpleNamespace(fps_var=SimpleNamespace(get=lambda: "10")) + tab._selected_kinematic_biomod_path = lambda: biomod_path + tab._navigable_annotation_frame_local_indices = lambda: [2] + tab.selected_annotation_camera_names = lambda: [] + tab._current_images_root = lambda: None + tab.save_annotations = lambda: None + tab._clear_pending_reprojection = lambda: None + tab._clear_kinematic_assist_preview = lambda: None + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + + pipeline_gui.AnnotationTab.step_frame(tab, 1) + + assert tab._frame_index == 2 + np.testing.assert_allclose(tab.kinematic_frame_states[("demo", 15)], np.array([2.375, 3.5, 3.0])) + assert tab._refreshed is True + + +def test_annotation_step_frame_runs_local_estimate_when_target_frame_has_annotations(monkeypatch, tmp_path): + biomod_path = tmp_path / "demo.bioMod" + biomod_path.write_text("demo", encoding="utf-8") + + class _FakeBiorbdModel: + def __init__(self, _path): + pass + + def nbQ(self): + return 1 + + import sys + + monkeypatch.setitem(sys.modules, "biorbd", SimpleNamespace(Model=_FakeBiorbdModel)) + monkeypatch.setattr(pipeline_gui, "canonicalize_model_q_rotation_branches", lambda _model, q: np.asarray(q)) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10, 12, 15], dtype=int)) + tab.frame_var = SimpleNamespace(get=lambda: 0, set=lambda value: setattr(tab, "_frame_index", value)) + tab._current_frame_idx = 0 + tab.kinematic_assist_var = SimpleNamespace(get=lambda: True) + tab.kinematic_model_choices = {"demo": biomod_path} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + tab.kinematic_frame_states = {("demo", 10): np.array([1.0, 2.0, 3.0], dtype=float)} + tab.state = SimpleNamespace(fps_var=SimpleNamespace(get=lambda: "10")) + tab._selected_kinematic_biomod_path = lambda: biomod_path + tab._navigable_annotation_frame_local_indices = lambda: [2] + tab.selected_annotation_camera_names = lambda: ["cam0"] + tab._current_images_root = lambda: None + tab._frame_annotation_measurement_count = lambda frame_number, camera_names: 4 if int(frame_number) == 15 else 0 + tab._estimate_kinematic_q = lambda: setattr(tab, "_estimate_called", True) or np.array([0.5], dtype=float) + tab.save_annotations = lambda: None + tab._clear_pending_reprojection = lambda: None + tab._clear_kinematic_assist_preview = lambda: None + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) + + pipeline_gui.AnnotationTab.step_frame(tab, 1) + + assert tab._frame_index == 2 + assert tab._estimate_called is True + assert tab._refreshed is True + + +def test_annotation_refresh_preview_restores_nearest_saved_kinematic_state(monkeypatch, tmp_path): + biomod_path = tmp_path / "demo.bioMod" + biomod_path.write_text("demo", encoding="utf-8") + + class _FakeBiorbdModel: + def __init__(self, _path): + pass + + def nbQ(self): + return 2 + + import sys + + monkeypatch.setitem(sys.modules, "biorbd", SimpleNamespace(Model=_FakeBiorbdModel)) + monkeypatch.setattr(pipeline_gui, "canonicalize_model_q_rotation_branches", lambda _model, q: np.asarray(q)) + + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace( + frames=np.array([10, 11, 12], dtype=int), + camera_names=["cam0"], + raw_keypoints=np.zeros((1, 3, len(pipeline_gui.COCO17), 2), dtype=float), + ) + tab.calibrations = {"cam0": SimpleNamespace(image_size=(640, 480))} + tab.kinematic_model_choices = {"demo": biomod_path} + tab.kinematic_model_var = SimpleNamespace(get=lambda: "demo") + tab.kinematic_frame_states = {("demo", 10): np.array([1.0, 2.0], dtype=float)} + tab.kinematic_status_var = SimpleNamespace(set=lambda value: setattr(tab, "_kin_status", value)) + tab.kinematic_assist_var = SimpleNamespace(get=lambda: True) + tab.kinematic_projected_points = None + tab.kinematic_segmented_back_projected = {} + tab._current_frame_idx = 2 + tab.frame_var = SimpleNamespace(get=lambda: 2, set=lambda value: setattr(tab, "_frame_index", value)) + tab.frame_label = SimpleNamespace(configure=lambda **kwargs: setattr(tab, "_frame_label_text", kwargs["text"])) + tab.frame_filter_var = SimpleNamespace(get=lambda: pipeline_gui.ANNOTATION_FRAME_FILTER_OPTIONS["all"]) + tab.crop_var = SimpleNamespace(get=lambda: False) + tab.selected_keypoint_name = lambda: "nose" + tab.selected_annotation_camera_names = lambda: ["cam0"] + tab.show_images_var = SimpleNamespace(get=lambda: False) + tab._current_images_root = lambda: None + tab.preview_figure = pipeline_gui.Figure(figsize=(4, 4)) + tab.preview_canvas = SimpleNamespace(draw_idle=lambda: None) + tab.motion_prior_diameter = SimpleNamespace(get=lambda: "15") + tab.show_motion_prior_var = SimpleNamespace(get=lambda: False) + tab.show_epipolar_var = SimpleNamespace(get=lambda: False) + tab.show_triangulated_hint_var = SimpleNamespace(get=lambda: False) + tab._pending_reprojection_points = {} + tab.annotation_payload = {} + tab._annotation_xy = lambda *_args, **_kwargs: None + tab._axis_to_camera = {} + tab._cursor_artists = {} + tab.image_brightness_var = SimpleNamespace(get=lambda: 1.0) + tab.image_contrast_var = SimpleNamespace(get=lambda: 1.0) + tab.preview_canvas_widget = SimpleNamespace() + + monkeypatch.setattr(pipeline_gui, "camera_layout", lambda _n: (1, 1)) + monkeypatch.setattr(pipeline_gui, "apply_2d_axis_limits", lambda *args, **kwargs: None) + monkeypatch.setattr(pipeline_gui, "hide_2d_axes", lambda ax: None) + monkeypatch.setattr(pipeline_gui, "draw_skeleton_2d", lambda *args, **kwargs: None) + monkeypatch.setattr(pipeline_gui, "draw_upper_back_overlay_2d", lambda *args, **kwargs: None) + monkeypatch.setattr(pipeline_gui, "resolve_execution_image_path", lambda *_args, **_kwargs: None) + + def fake_set_preview(_biomod_path, _camera_names, q_values): + tab.kinematic_q_current = np.asarray(q_values, dtype=float) + tab.kinematic_projected_points = np.zeros((1, 1, len(pipeline_gui.COCO17), 2), dtype=float) + tab.kinematic_segmented_back_projected = {} + + tab._set_kinematic_preview_from_q = fake_set_preview + tab._ensure_crop_limits = lambda _camera_names: {} + + pipeline_gui.AnnotationTab.refresh_preview(tab) + + np.testing.assert_allclose(tab.kinematic_q_current, np.array([1.0, 2.0], dtype=float)) + assert tab._kin_status == "Using nearest saved q from frame 10 for frame 12." + + +def test_annotation_refresh_preview_draws_model_overlay_with_light_style(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace( + frames=np.array([10], dtype=int), + camera_names=["cam0"], + raw_keypoints=np.zeros((1, 1, len(pipeline_gui.COCO17), 2), dtype=float), + ) + tab.calibrations = {"cam0": SimpleNamespace(image_size=(640, 480))} + tab.kinematic_assist_var = SimpleNamespace(get=lambda: True) + tab.kinematic_projected_points = np.zeros((1, 1, len(pipeline_gui.COCO17), 2), dtype=float) + tab.kinematic_segmented_back_projected = { + "hip_triangle": np.zeros((1, 1, 4, 2), dtype=float), + "shoulder_triangle": np.zeros((1, 1, 4, 2), dtype=float), + "mid_back": np.zeros((1, 1, 1, 2), dtype=float), + } + tab.frame_var = SimpleNamespace(get=lambda: 0, set=lambda value: setattr(tab, "_frame_index", value)) + tab._current_frame_idx = 0 + tab.frame_label = SimpleNamespace(configure=lambda **kwargs: setattr(tab, "_frame_label_text", kwargs["text"])) + tab.frame_filter_var = SimpleNamespace(get=lambda: pipeline_gui.ANNOTATION_FRAME_FILTER_OPTIONS["all"]) + tab.crop_var = SimpleNamespace(get=lambda: False) + tab.selected_keypoint_name = lambda: "nose" + tab.selected_annotation_camera_names = lambda: ["cam0"] + tab.show_images_var = SimpleNamespace(get=lambda: False) + tab._current_images_root = lambda: None + tab.preview_figure = pipeline_gui.Figure(figsize=(4, 4)) + tab.preview_canvas = SimpleNamespace(draw_idle=lambda: None) + tab.motion_prior_diameter = SimpleNamespace(get=lambda: "15") + tab.show_motion_prior_var = SimpleNamespace(get=lambda: False) + tab.show_epipolar_var = SimpleNamespace(get=lambda: False) + tab.show_triangulated_hint_var = SimpleNamespace(get=lambda: False) + tab._pending_reprojection_points = {} + tab.annotation_payload = {} + tab._annotation_xy = lambda *_args, **_kwargs: None + tab._axis_to_camera = {} + tab._cursor_artists = {} + tab.image_brightness_var = SimpleNamespace(get=lambda: 1.0) + tab.image_contrast_var = SimpleNamespace(get=lambda: 1.0) + tab.preview_canvas_widget = SimpleNamespace() + tab._ensure_crop_limits = lambda _camera_names: {} + + captured: dict[str, dict[str, object]] = {} + + monkeypatch.setattr(pipeline_gui, "camera_layout", lambda _n: (1, 1)) + monkeypatch.setattr(pipeline_gui, "apply_2d_axis_limits", lambda *args, **kwargs: None) + monkeypatch.setattr(pipeline_gui, "hide_2d_axes", lambda ax: None) + monkeypatch.setattr(pipeline_gui, "resolve_execution_image_path", lambda *_args, **_kwargs: None) + + def capture_skeleton(*args, **kwargs): + captured["skeleton"] = kwargs + + def capture_back_overlay(*args, **kwargs): + captured["back"] = kwargs + + monkeypatch.setattr(pipeline_gui, "draw_skeleton_2d", capture_skeleton) + monkeypatch.setattr(pipeline_gui, "draw_upper_back_overlay_2d", capture_back_overlay) + + pipeline_gui.AnnotationTab.refresh_preview(tab) + + assert captured["skeleton"]["marker_size"] == pytest.approx(3.2) + assert captured["skeleton"]["marker_fill"] is False + assert captured["skeleton"]["line_alpha"] == pytest.approx(0.32) + assert captured["skeleton"]["line_style"] == "--" + assert captured["skeleton"]["line_width_scale"] == pytest.approx(0.62) + assert captured["back"]["line_width"] == pytest.approx(1.0) + assert captured["back"]["line_alpha"] == pytest.approx(0.32) + assert captured["back"]["line_style"] == "--" + assert captured["back"]["marker_size"] == pytest.approx(18.0) + assert captured["back"]["marker_alpha"] == pytest.approx(0.38) + + +def test_annotation_refresh_preview_keeps_epipolar_guides_from_all_cameras_when_single_view_selected(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace( + frames=np.array([10], dtype=int), + camera_names=["cam0", "cam1", "cam2"], + raw_keypoints=np.zeros((3, 1, len(pipeline_gui.COCO17), 2), dtype=float), + ) + tab.calibrations = { + "cam0": SimpleNamespace(image_size=(640, 480)), + "cam1": SimpleNamespace(image_size=(640, 480)), + "cam2": SimpleNamespace(image_size=(640, 480)), + } + tab.kinematic_assist_var = SimpleNamespace(get=lambda: False) + tab.kinematic_projected_points = None + tab.kinematic_segmented_back_projected = {} + tab.frame_var = SimpleNamespace(get=lambda: 0, set=lambda value: setattr(tab, "_frame_index", value)) + tab._current_frame_idx = 0 + tab.frame_label = SimpleNamespace(configure=lambda **kwargs: setattr(tab, "_frame_label_text", kwargs["text"])) + tab.frame_filter_var = SimpleNamespace(get=lambda: pipeline_gui.ANNOTATION_FRAME_FILTER_OPTIONS["all"]) + tab.crop_var = SimpleNamespace(get=lambda: False) + tab.selected_keypoint_name = lambda: "nose" + tab.selected_annotation_camera_names = lambda: ["cam0"] + tab.show_images_var = SimpleNamespace(get=lambda: False) + tab._current_images_root = lambda: None + tab.preview_figure = pipeline_gui.Figure(figsize=(4, 4)) + tab.preview_canvas = SimpleNamespace(draw_idle=lambda: None) + tab.motion_prior_diameter = SimpleNamespace(get=lambda: "15") + tab.show_motion_prior_var = SimpleNamespace(get=lambda: False) + tab.show_epipolar_var = SimpleNamespace(get=lambda: True) + tab.show_triangulated_hint_var = SimpleNamespace(get=lambda: False) + tab._pending_reprojection_points = {} + tab.annotation_payload = {} + tab._axis_to_camera = {} + tab._cursor_artists = {} + tab.image_brightness_var = SimpleNamespace(get=lambda: 1.0) + tab.image_contrast_var = SimpleNamespace(get=lambda: 1.0) + tab.preview_canvas_widget = SimpleNamespace() + tab._ensure_crop_limits = lambda _camera_names: {} + tab._annotation_xy = lambda camera_name, _frame_number, _keypoint_name: ( + np.array([100.0, 200.0], dtype=float) if camera_name in {"cam1", "cam2"} else None + ) + + seen_sources = [] + + monkeypatch.setattr(pipeline_gui, "camera_layout", lambda _n: (1, 1)) + monkeypatch.setattr(pipeline_gui, "apply_2d_axis_limits", lambda *args, **kwargs: None) + monkeypatch.setattr(pipeline_gui, "hide_2d_axes", lambda ax: None) + monkeypatch.setattr(pipeline_gui, "draw_skeleton_2d", lambda *args, **kwargs: None) + monkeypatch.setattr(pipeline_gui, "draw_upper_back_overlay_2d", lambda *args, **kwargs: None) + monkeypatch.setattr(pipeline_gui, "render_annotation_camera_view", lambda *args, **kwargs: []) + monkeypatch.setattr( + pipeline_gui, + "annotation_epipolar_guides", + lambda _calibs, source_camera_name, target_camera_name, _xy: ( + seen_sources.append((source_camera_name, target_camera_name)) or None + ), + ) + + pipeline_gui.AnnotationTab.refresh_preview(tab) + + assert ("cam1", "cam0") in seen_sources + assert ("cam2", "cam0") in seen_sources + + +def test_annotation_compute_pending_reprojection_points_updates_only_existing_annotations(monkeypatch): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.pose_data = SimpleNamespace(frames=np.array([10], dtype=int), camera_names=["cam0", "cam1", "cam2"]) + tab.calibrations = {"cam0": object(), "cam1": object(), "cam2": object()} + tab.current_frame_number = lambda: 10 + tab.selected_annotation_camera_names = lambda: ["cam0", "cam1", "cam2"] + points = { + ("cam0", 10, "left_shoulder"): np.array([100.0, 200.0], dtype=float), + ("cam1", 10, "left_shoulder"): np.array([105.0, 205.0], dtype=float), + ("cam2", 10, "left_shoulder"): np.array([110.0, 210.0], dtype=float), + ("cam0", 10, "nose"): np.array([120.0, 220.0], dtype=float), + ("cam1", 10, "nose"): np.array([125.0, 225.0], dtype=float), + } + tab._annotation_xy = lambda camera_name, frame_number, keypoint_name: points.get( + (camera_name, frame_number, keypoint_name) + ) + def fake_reproject(_calibrations, *, target_camera_name, source_camera_names, source_points_2d): + return np.array([len(source_camera_names), float(np.sum(np.asarray(source_points_2d)))], dtype=float) -def test_shared_reconstruction_panel_does_not_number_raw_2d_rows(): - panel = SharedReconstructionPanel.__new__(SharedReconstructionPanel) - panel.tree = _FakeTree() - panel._default_names = [] - panel._selection_callback = None - panel._refresh_callback = None - panel._suspend_selection_callback = False - panel.state = SimpleNamespace(set_shared_reconstruction_selection=lambda _names: None) + monkeypatch.setattr(pipeline_gui, "annotation_triangulated_reprojection", fake_reproject) - SharedReconstructionPanel.set_rows( - panel, - rows=[ - {"name": "raw", "label": "Raw 2D", "family": "2d", "frames": "-", "reproj_mean": None, "path": "-"}, - { - "name": "pose2sim", - "label": "TRC file", - "family": "pose2sim", - "frames": 8, - "reproj_mean": 2.3, - "path": "/a", - }, - ], - default_names=["raw"], + pending = pipeline_gui.AnnotationTab._compute_pending_reprojection_points(tab) + + assert ("cam2", 10, "nose") not in pending + assert ("cam0", 10, "left_shoulder") in pending + assert ("cam2", 10, "left_shoulder") in pending + assert ("cam0", 10, "nose") in pending + + +def test_annotation_reproject_button_toggles_to_confirm_and_applies_updates(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab._pending_reprojection_points = {} + tab.reproject_button_var = SimpleNamespace(set=lambda value: setattr(tab, "_button_text", value)) + tab.refresh_preview = lambda: setattr(tab, "_refreshed", getattr(tab, "_refreshed", 0) + 1) + tab.save_annotations = lambda: setattr(tab, "_saved", getattr(tab, "_saved", 0) + 1) + tab.annotation_payload = {} + tab._compute_pending_reprojection_points = lambda: { + ("cam0", 10, "left_shoulder"): np.array([111.0, 222.0], dtype=float) + } + + pipeline_gui.AnnotationTab.on_reproject_button(tab) + + assert tab._button_text == "Confirm" + assert len(tab._pending_reprojection_points) == 1 + + pipeline_gui.AnnotationTab.on_reproject_button(tab) + + xy, _score = pipeline_gui.get_annotation_point( + tab.annotation_payload, camera_name="cam0", frame_number=10, keypoint_name="left_shoulder" + ) + np.testing.assert_allclose(np.asarray(xy, dtype=float), np.array([111.0, 222.0])) + assert tab._saved == 1 + assert tab._button_text == "Reproject" + assert tab._pending_reprojection_points == {} + + +def test_annotation_click_outside_cancels_pending_reprojection(): + refreshed = [] + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab._pending_reprojection_points = {("cam0", 10, "left_shoulder"): np.array([1.0, 2.0], dtype=float)} + tab.reproject_button_var = SimpleNamespace(set=lambda value: setattr(tab, "_button_text", value)) + tab.refresh_preview = lambda: refreshed.append("refreshed") + tab.pose_data = SimpleNamespace(frames=np.array([10], dtype=int)) + + pipeline_gui.AnnotationTab.on_preview_click( + tab, + SimpleNamespace(inaxes=None, xdata=None, ydata=None, button=1), ) - assert panel.tree.rows["raw"][0] == "" - assert panel.tree.rows["pose2sim"][0] == "2" + assert tab._pending_reprojection_points == {} + assert tab._button_text == "Reproject" + assert refreshed == ["refreshed"] -def test_compose_multiview_crop_points_includes_selected_reprojections(): - base = np.zeros((1, 2, 3, 2), dtype=float) - reproj = np.ones((1, 2, 3, 2), dtype=float) +def test_annotation_cancel_pending_reprojection_from_tk_event_ignores_reproject_button(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab._pending_reprojection_points = {("cam0", 10, "left_shoulder"): np.array([1.0, 2.0], dtype=float)} + tab.reproject_button = object() + tab.reproject_button_var = SimpleNamespace(set=lambda value: setattr(tab, "_button_text", value)) + tab.refresh_preview = lambda: setattr(tab, "_refreshed", True) - crop_points = pipeline_gui.compose_multiview_crop_points( - base, - {"ekf_demo": reproj}, - ["raw", "ekf_demo"], + pipeline_gui.AnnotationTab._cancel_pending_reprojection_from_tk_event( + tab, SimpleNamespace(widget=tab.reproject_button) ) - assert crop_points.shape == (1, 2, 6, 2) - np.testing.assert_allclose(crop_points[:, :, :3], base) - np.testing.assert_allclose(crop_points[:, :, 3:], reproj) + assert tab._pending_reprojection_points + assert not hasattr(tab, "_refreshed") def test_preview_pose_frame_indices_aligns_sparse_bundle_frames(): @@ -243,6 +2944,14 @@ def test_dd_sync_dataset_dir_no_longer_depends_on_local_dataset_widget(): assert calls == ["dd", "refresh"] +def test_jump_list_label_with_reference_shows_detected_and_expected_codes(): + jump = SimpleNamespace(somersault_turns=-0.99, twist_turns=-0.02, code="40/") + + label = pipeline_gui.jump_list_label_with_reference(4, jump, "42/") + + assert label == "S4 | som -0.99 | tw -0.02 | det 40/ | exp 42/" + + def test_sanitize_filetypes_keeps_regular_patterns(monkeypatch): filetypes = ( ("JSON files", "*.json"), @@ -272,6 +2981,30 @@ def test_sanitize_filetypes_simplifies_macos_basename_wildcards(monkeypatch): ) +def test_labeled_entry_browse_invokes_callback_after_setting_relative_path(monkeypatch, tmp_path): + root = tmp_path / "workspace" + root.mkdir(parents=True) + selected = root / "profiles.json" + selected.write_text("[]", encoding="utf-8") + values = {"current": "", "callback": None} + + entry = pipeline_gui.LabeledEntry.__new__(pipeline_gui.LabeledEntry) + entry.directory = False + entry.filetypes = None + entry.browse_initialdir = None + entry.on_browse_selected = lambda value: values.__setitem__("callback", value) + entry.var = SimpleNamespace(set=lambda value: values.__setitem__("current", value)) + entry.get = lambda: values["current"] + + monkeypatch.setattr(pipeline_gui, "ROOT", root) + monkeypatch.setattr(pipeline_gui.filedialog, "askopenfilename", lambda **_kwargs: str(selected)) + + pipeline_gui.LabeledEntry._browse(entry) + + assert values["current"] == "profiles.json" + assert values["callback"] == "profiles.json" + + def test_flip_method_display_name_uses_user_friendly_labels(): assert pipeline_gui.flip_method_display_name("none") == "None" assert pipeline_gui.flip_method_display_name("epipolar_fast_viterbi") == "Epipolar fast (Viterbi)" @@ -294,6 +3027,257 @@ def test_profiles_tab_selected_profile_flip_method_disables_flip_for_none(): assert pipeline_gui.ProfilesTab.selected_profile_flip_method(tab) is None +def test_profiles_tab_refresh_profile_tree_shows_flip_mode_column(): + tab = pipeline_gui.ProfilesTab.__new__(pipeline_gui.ProfilesTab) + tab.profile_tree = _FakeTree() + tab.state = SimpleNamespace( + profiles=[ + pipeline_gui.ReconstructionProfile( + name="demo", + family="ekf_2d", + pose_data_mode="cleaned", + triangulation_method="exhaustive", + flip=True, + flip_method="epipolar_fast", + ) + ] + ) + + pipeline_gui.ProfilesTab.refresh_profile_tree(tab) + + assert tab.profile_tree.rows["0"][5] == "Epipolar fast (local)" + + +def test_profiles_tab_load_profile_into_form_restores_profile_options(): + tab = pipeline_gui.ProfilesTab.__new__(pipeline_gui.ProfilesTab) + tab.state = SimpleNamespace( + initial_rotation_correction_var=SimpleNamespace( + get=lambda: False, set=lambda value: setattr(tab, "_rotfix", bool(value)) + ), + pose_filter_window_var=SimpleNamespace(set=lambda value: setattr(tab, "_pose_filter_window", value)), + pose_outlier_ratio_var=SimpleNamespace(set=lambda value: setattr(tab, "_pose_outlier_ratio", value)), + pose_p_low_var=SimpleNamespace(set=lambda value: setattr(tab, "_pose_p_low", value)), + pose_p_high_var=SimpleNamespace(set=lambda value: setattr(tab, "_pose_p_high", value)), + flip_improvement_ratio_var=SimpleNamespace(set=lambda value: setattr(tab, "_flip_ratio", value)), + flip_min_gain_px_var=SimpleNamespace(set=lambda value: setattr(tab, "_flip_gain", value)), + flip_min_other_cameras_var=SimpleNamespace(set=lambda value: setattr(tab, "_flip_min_cams", value)), + flip_restrict_to_outliers_var=SimpleNamespace(set=lambda value: setattr(tab, "_flip_restrict", bool(value))), + flip_outlier_percentile_var=SimpleNamespace(set=lambda value: setattr(tab, "_flip_pct", value)), + flip_outlier_floor_px_var=SimpleNamespace(set=lambda value: setattr(tab, "_flip_floor", value)), + flip_temporal_weight_var=SimpleNamespace(set=lambda value: setattr(tab, "_flip_temp_w", value)), + flip_temporal_tau_px_var=SimpleNamespace(set=lambda value: setattr(tab, "_flip_temp_tau", value)), + ) + tab._updating_profile_name = False + tab.profile_name = _FakeEntryField("") + tab.family = SimpleNamespace(get=lambda: tab._family, set=lambda value: setattr(tab, "_family", value)) + tab._family = "ekf_2d" + tab.pose_data_mode = SimpleNamespace( + get=lambda: tab._pose_mode, set=lambda value: setattr(tab, "_pose_mode", value) + ) + tab._pose_mode = "raw" + tab.frame_stride = SimpleNamespace(get=lambda: tab._stride, set=lambda value: setattr(tab, "_stride", value)) + tab._stride = "1" + tab.triang_method = SimpleNamespace(get=lambda: tab._triang, set=lambda value: setattr(tab, "_triang", value)) + tab._triang = "greedy" + tab.reprojection_threshold_var = SimpleNamespace( + get=lambda: tab._reproj_threshold, + set=lambda value: setattr(tab, "_reproj_threshold", value), + ) + tab._reproj_threshold = "15" + tab.predictor = SimpleNamespace(get=lambda: tab._predictor, set=lambda value: setattr(tab, "_predictor", value)) + tab._predictor = "acc" + tab.coherence_method = SimpleNamespace( + get=lambda: tab._coherence, + set=lambda value: setattr(tab, "_coherence", value), + ) + tab._coherence = pipeline_gui.coherence_method_display_name("epipolar") + tab.ekf2d_initial_state_method = SimpleNamespace( + get=lambda: tab._q0, + set=lambda value: setattr(tab, "_q0", value), + ) + tab._q0 = "ekf_bootstrap" + tab.ekf2d_bootstrap_passes = _FakeEntryField("5") + tab.upper_back_sagittal_gain = _FakeEntryField("0.2") + tab.upper_back_pseudo_std_deg = _FakeEntryField("10") + tab.ankle_bed_pseudo_obs_var = SimpleNamespace( + get=lambda: tab._ankle_bed_enabled, + set=lambda value: setattr(tab, "_ankle_bed_enabled", bool(value)), + ) + tab._ankle_bed_enabled = False + tab.ankle_bed_pseudo_std_m = _FakeEntryField("0.02") + tab.flip_method = SimpleNamespace( + get=lambda: tab._flip_method, set=lambda value: setattr(tab, "_flip_method", value) + ) + tab._flip_method = "none" + tab.flip_method_label_var = SimpleNamespace(set=lambda value: setattr(tab, "_flip_label", value)) + tab._flip_label = "" + tab.lock_var = SimpleNamespace(get=lambda: tab._lock, set=lambda value: setattr(tab, "_lock", bool(value))) + tab._lock = False + tab.initial_rot_var = SimpleNamespace( + get=lambda: tab._rotfix, set=lambda value: setattr(tab, "_rotfix", bool(value)) + ) + tab._rotfix = False + tab.biorbd_noise = _FakeEntryField("1e-8") + tab.biorbd_error = _FakeEntryField("1e-4") + tab.biorbd_kalman_init_method = SimpleNamespace( + get=lambda: tab._ekf3d_init, + set=lambda value: setattr(tab, "_ekf3d_init", value), + ) + tab._ekf3d_init = "triangulation_ik_root_translation" + tab.measurement_noise = _FakeEntryField("1.5") + tab.process_noise = _FakeEntryField("1.0") + tab.coherence_floor = _FakeEntryField("0.35") + tab.profile_cameras_list = _FakeListbox() + for name in ("cam_a", "cam_b", "cam_c"): + tab.profile_cameras_list.insert("end", name) + tab.profile_cameras_summary = SimpleNamespace(set=lambda value: setattr(tab, "_camera_summary", value)) + tab._camera_summary = "" + tab.profile_models_list = _FakeListbox() + for label in ("model_a", "model_b"): + tab.profile_models_list.insert("end", label) + tab.profile_models_summary = SimpleNamespace(set=lambda value: setattr(tab, "_model_summary", value)) + tab.ekf_model_info_var = SimpleNamespace(set=lambda value: setattr(tab, "_model_info", value)) + tab._profile_model_choices = { + "model_a": "output/demo/models/model_a.bioMod", + "model_b": "output/demo/models/model_b.bioMod", + } + tab._model_summary = "" + tab._model_info = "" + tab._pose_filter_window = "" + tab._pose_outlier_ratio = "" + tab._pose_p_low = "" + tab._pose_p_high = "" + tab._flip_ratio = "" + tab._flip_gain = "" + tab._flip_min_cams = "" + tab._flip_restrict = False + tab._flip_pct = "" + tab._flip_floor = "" + tab._flip_temp_w = "" + tab._flip_temp_tau = "" + tab.add_profile_button = _FakeButton() + tab.refresh_profile_camera_choices = lambda: None + tab.refresh_profile_model_choices = lambda: None + tab.update_family_controls = lambda: None + + profile = pipeline_gui.ReconstructionProfile( + name="ekf_profile", + family="ekf_2d", + camera_names=["cam_b", "cam_c"], + ekf_model_path="output/demo/models/model_b.bioMod", + predictor="dyn", + ekf2d_initial_state_method="root_pose_bootstrap", + ekf2d_bootstrap_passes=7, + flip=True, + flip_method="ekf_prediction_gate", + dof_locking=True, + initial_rotation_correction=True, + pose_data_mode="cleaned", + frame_stride=3, + triangulation_method="exhaustive", + reprojection_threshold_px=None, + coherence_method="epipolar_fast_framewise", + no_root_unwrap=True, + measurement_noise_scale=2.0, + process_noise_scale=0.5, + coherence_confidence_floor=0.4, + upper_back_sagittal_gain=0.35, + upper_back_pseudo_std_deg=6.0, + ankle_bed_pseudo_obs=True, + ankle_bed_pseudo_std_m=0.015, + pose_filter_window=11, + pose_outlier_threshold_ratio=0.25, + pose_amplitude_lower_percentile=7.0, + pose_amplitude_upper_percentile=92.0, + flip_improvement_ratio=0.8, + flip_min_gain_px=4.0, + flip_min_other_cameras=3, + flip_restrict_to_outliers=False, + flip_outlier_percentile=90.0, + flip_outlier_floor_px=6.0, + flip_temporal_weight=0.5, + flip_temporal_tau_px=12.0, + ) + + pipeline_gui.ProfilesTab.load_profile_into_form(tab, profile) + + assert tab.profile_name.get() == "ekf_profile" + assert tab._family == "ekf_2d" + assert tab._predictor == "dyn" + assert tab._reproj_threshold == "none" + assert tab._flip_method == "ekf_prediction_gate" + assert tab._flip_label == "EKF prediction gate" + assert tab._coherence == pipeline_gui.coherence_method_display_name("epipolar_fast_framewise") + assert tab.ekf2d_bootstrap_passes.get() == "7" + assert tab.upper_back_sagittal_gain.get() == "0.35" + assert tab.upper_back_pseudo_std_deg.get() == "6" + assert tab._ankle_bed_enabled is True + assert tab.ankle_bed_pseudo_std_m.get() == "0.015" + assert tab.profile_cameras_list.curselection() == (1, 2) + assert tab.profile_models_list.curselection() == (1,) + assert tab._pose_filter_window == "11" + assert tab._pose_outlier_ratio == "0.25" + assert tab._pose_p_low == "7" + assert tab._pose_p_high == "92" + assert tab._flip_ratio == "0.8" + assert tab._flip_gain == "4" + assert tab._flip_min_cams == "3" + assert tab._flip_restrict is False + assert tab._flip_pct == "90" + assert tab._flip_floor == "6" + assert tab._flip_temp_w == "0.5" + assert tab._flip_temp_tau == "12" + assert tab.add_profile_button.state == "normal" + + +def test_infer_model_variant_from_biomod_detects_upper_back_layouts(tmp_path): + single_trunk = tmp_path / "single.bioMod" + single_trunk.write_text("segment\tTRUNK\nendsegment\n", encoding="utf-8") + one_dof = tmp_path / "back_1d.bioMod" + one_dof.write_text("segment\tUPPER_BACK\nrotations\ty\nendsegment\n", encoding="utf-8") + three_dof = tmp_path / "back_3dof.bioMod" + three_dof.write_text("segment\tUPPER_BACK\nrotations\tyxz\nendsegment\n", encoding="utf-8") + upper_root_one_dof = tmp_path / "upper_root_back_1d.bioMod" + upper_root_one_dof.write_text("segment\tLOWER_TRUNK\nrotations\ty\nendsegment\n", encoding="utf-8") + upper_root_three_dof = tmp_path / "upper_root_back_3dof.bioMod" + upper_root_three_dof.write_text("segment\tLOWER_TRUNK\nrotations\tyxz\nendsegment\n", encoding="utf-8") + + assert pipeline_gui.infer_model_variant_from_biomod(single_trunk) == pipeline_gui.DEFAULT_MODEL_VARIANT + assert pipeline_gui.infer_model_variant_from_biomod(one_dof) == "back_flexion_1d" + assert pipeline_gui.infer_model_variant_from_biomod(three_dof) == "back_3dof" + assert pipeline_gui.infer_model_variant_from_biomod(upper_root_one_dof) == "upper_root_back_flexion_1d" + assert pipeline_gui.infer_model_variant_from_biomod(upper_root_three_dof) == "upper_root_back_3dof" + + +def test_biomod_supports_upper_back_options(tmp_path): + one_dof = tmp_path / "back_1d.bioMod" + one_dof.write_text("segment\tUPPER_BACK\nrotations\ty\nendsegment\n", encoding="utf-8") + + assert not pipeline_gui.biomod_supports_upper_back_options(None) + assert pipeline_gui.biomod_supports_upper_back_options(one_dof) + + +def test_profiles_tab_load_selected_profile_from_tree_uses_double_clicked_row(): + tab = pipeline_gui.ProfilesTab.__new__(pipeline_gui.ProfilesTab) + loaded = [] + tab.state = SimpleNamespace( + profiles=[ + pipeline_gui.ReconstructionProfile(name="first", family="pose2sim"), + pipeline_gui.ReconstructionProfile(name="second", family="pose2sim"), + ] + ) + tab.profile_tree = _FakeTree() + tab.profile_tree.selection_set(("0",)) + tab.profile_tree._identified_row = "1" + tab.load_profile_into_form = lambda profile: loaded.append(profile.name) + + result = pipeline_gui.ProfilesTab.load_selected_profile_from_tree(tab, SimpleNamespace(y=12)) + + assert loaded == ["second"] + assert result == "break" + assert tab.profile_tree.selection() == ("1",) + + def test_profiles_tab_build_command_uses_runtime_profiles_cache(monkeypatch): tab = pipeline_gui.ProfilesTab.__new__(pipeline_gui.ProfilesTab) tab.state = SimpleNamespace( @@ -319,6 +3303,34 @@ def test_profiles_tab_build_command_uses_runtime_profiles_cache(monkeypatch): assert cmd[cmd.index("--config") + 1] == ".cache/runtime_profiles.json" +def test_profiles_tab_build_command_passes_annotations_path_for_annotated_profiles(monkeypatch): + tab = pipeline_gui.ProfilesTab.__new__(pipeline_gui.ProfilesTab) + annotated_profile = pipeline_gui.ReconstructionProfile( + name="annotated", family="ekf_2d", pose_data_mode="annotated" + ) + tab.state = SimpleNamespace( + output_root_var=SimpleNamespace(get=lambda: "output"), + calib_var=SimpleNamespace(get=lambda: "inputs/calibration/Calib.toml"), + keypoints_var=SimpleNamespace(get=lambda: "inputs/keypoints/trial_keypoints.json"), + annotation_path_var=SimpleNamespace(get=lambda: "inputs/annotations/trial_annotations.json"), + fps_var=SimpleNamespace(get=lambda: "120"), + workers_var=SimpleNamespace(get=lambda: "6"), + pose2sim_trc_var=SimpleNamespace(get=lambda: ""), + ) + tab.selected_profiles = lambda: [annotated_profile] + + monkeypatch.setattr( + pipeline_gui, "write_runtime_profiles_config", lambda _state: Path(".cache/runtime_profiles.json") + ) + monkeypatch.setattr(pipeline_gui, "display_path", lambda path: str(path)) + monkeypatch.setattr(pipeline_gui, "current_dataset_name", lambda _state: "trial") + monkeypatch.setattr(pipeline_gui, "current_selected_camera_names", lambda _state: []) + + cmd = pipeline_gui.ProfilesTab.build_command(tab) + + assert cmd[cmd.index("--annotations-path") + 1] == "inputs/annotations/trial_annotations.json" + + def test_reconstructions_tab_build_command_uses_runtime_profiles_cache(monkeypatch): tab = pipeline_gui.ReconstructionsTab.__new__(pipeline_gui.ReconstructionsTab) tab.state = SimpleNamespace( @@ -343,18 +3355,217 @@ def test_reconstructions_tab_build_command_uses_runtime_profiles_cache(monkeypat assert cmd[cmd.index("--config") + 1] == ".cache/runtime_profiles.json" +def test_batch_tab_scan_keypoints_files_populates_tree(monkeypatch, tmp_path): + keypoints_a = tmp_path / "inputs" / "keypoints" / "trial_a_keypoints.json" + keypoints_b = tmp_path / "inputs" / "keypoints" / "trial_b_keypoints.json" + keypoints_a.parent.mkdir(parents=True) + keypoints_a.write_text("{}", encoding="utf-8") + keypoints_b.write_text("{}", encoding="utf-8") + + tab = pipeline_gui.BatchTab.__new__(pipeline_gui.BatchTab) + tab.keypoints_glob_entry = _FakeEntryField("inputs/keypoints/*.json") + tab.keypoints_tree = _FakeTree() + + monkeypatch.setattr( + pipeline_gui, + "batch_discover_keypoints_files", + lambda patterns, root=None: [keypoints_a, keypoints_b], + ) + monkeypatch.setattr( + pipeline_gui, + "batch_infer_annotations_for_keypoints", + lambda path: path.with_name(f"{path.stem.replace('_keypoints', '')}_annotations.json"), + ) + monkeypatch.setattr( + pipeline_gui, + "batch_infer_pose2sim_trc_for_keypoints", + lambda path: path.parent.parent / "trc" / f"{path.stem.replace('_keypoints', '')}.trc", + ) + monkeypatch.setattr( + pipeline_gui, + "infer_dataset_name", + lambda **kwargs: Path(kwargs["keypoints_path"]).stem.replace("_keypoints", ""), + ) + monkeypatch.setattr(pipeline_gui, "display_path", lambda path: str(Path(path).name)) + + pipeline_gui.BatchTab.scan_keypoints_files(tab) + + assert set(tab.keypoints_tree.rows.keys()) == {str(keypoints_a), str(keypoints_b)} + assert tab.keypoints_tree.rows[str(keypoints_a)][0] == "trial_a" + assert tab.keypoints_tree.rows[str(keypoints_b)][2] == "trial_b_annotations.json" + + +def test_batch_tab_build_command_uses_selected_datasets_and_profiles(): + tab = pipeline_gui.BatchTab.__new__(pipeline_gui.BatchTab) + tab.state = SimpleNamespace( + output_root_var=SimpleNamespace(get=lambda: "output"), + calib_var=SimpleNamespace(get=lambda: "inputs/calibration/Calib.toml"), + fps_var=SimpleNamespace(get=lambda: "120"), + workers_var=SimpleNamespace(get=lambda: "6"), + notify_reconstructions_updated=lambda: None, + ) + tab.config_path = _FakeEntryField("reconstruction_profiles.json") + tab.excel_output_entry = _FakeEntryField("output/batch_summary.xlsx") + tab.batch_name_entry = _FakeEntryField("demo_batch") + tab.continue_on_error_var = SimpleNamespace(get=lambda: True) + tab.export_only_var = SimpleNamespace(get=lambda: False) + tab.keypoints_glob_entry = _FakeEntryField("inputs/keypoints/*.json") + tab.keypoints_tree = _FakeTree() + keypoints_a = "inputs/keypoints/trial_a_keypoints.json" + keypoints_b = "inputs/keypoints/trial_b_keypoints.json" + tab.keypoints_tree.insert("", "end", iid=keypoints_a, values=("trial_a", keypoints_a, "-", "-")) + tab.keypoints_tree.insert("", "end", iid=keypoints_b, values=("trial_b", keypoints_b, "-", "-")) + tab.keypoints_tree.selection_set((keypoints_b,)) + tab.batch_profiles = [ + pipeline_gui.ReconstructionProfile(name="tri", family="triangulation"), + pipeline_gui.ReconstructionProfile(name="ekf", family="ekf_2d"), + ] + tab.batch_profile_tree = _FakeTree() + tab.batch_profile_tree.insert("", "end", iid="0", values=("tri", "triangulation", "cleaned")) + tab.batch_profile_tree.insert("", "end", iid="1", values=("ekf", "ekf_2d", "cleaned")) + tab.batch_profile_tree.selection_set(("1",)) + + cmd = pipeline_gui.BatchTab.build_command(tab) + + assert cmd[:2] == [pipeline_gui.sys.executable, "batch_run.py"] + assert cmd[cmd.index("--config") + 1] == "reconstruction_profiles.json" + assert cmd[cmd.index("--excel-output") + 1] == "output/batch_summary.xlsx" + assert cmd[cmd.index("--batch-name") + 1] == "demo_batch" + assert cmd[cmd.index("--keypoints-glob") + 1] == keypoints_b + assert cmd[cmd.index("--profile") + 1] == "ekf" + assert "--continue-on-error" in cmd + + +def test_reconstructions_tab_export_selected_pseudo_root_from_points_writes_npz(monkeypatch, tmp_path): + recon_dir = tmp_path / "output" / "trial" / "reconstructions" / "demo" + recon_dir.mkdir(parents=True) + points_3d = np.zeros((3, 17, 3), dtype=float) + for frame_idx in range(points_3d.shape[0]): + z_offset = 1.0 + 0.1 * frame_idx + points_3d[frame_idx, pipeline_gui.KP_INDEX["left_hip"]] = (-0.2, 0.0, z_offset) + points_3d[frame_idx, pipeline_gui.KP_INDEX["right_hip"]] = (0.2, 0.0, z_offset) + points_3d[frame_idx, pipeline_gui.KP_INDEX["left_shoulder"]] = (-0.25, 0.0, z_offset + 0.5) + points_3d[frame_idx, pipeline_gui.KP_INDEX["right_shoulder"]] = (0.25, 0.0, z_offset + 0.5) + bundle_path = recon_dir / "reconstruction_bundle.npz" + np.savez( + bundle_path, + points_3d=points_3d, + frames=np.array([0, 1, 2], dtype=int), + time_s=np.array([0.0, 1.0 / 120.0, 2.0 / 120.0], dtype=float), + ) + + info_messages = [] + tab = pipeline_gui.ReconstructionsTab.__new__(pipeline_gui.ReconstructionsTab) + tab.state = SimpleNamespace( + fps_var=SimpleNamespace(get=lambda: "120"), + initial_rotation_correction_var=SimpleNamespace(get=lambda: True), + ) + tab.status_summaries = {"demo": {"initial_rotation_correction_applied": True}} + tab._selected_reconstruction_dir = lambda: recon_dir + monkeypatch.setattr( + pipeline_gui.messagebox, "showinfo", lambda title, message: info_messages.append((title, message)) + ) + + pipeline_gui.ReconstructionsTab.export_selected_pseudo_root_from_points(tab) + + output_path = recon_dir / "demo_pseudo_root_q.npz" + assert output_path.exists() + exported = np.load(output_path, allow_pickle=True) + assert exported["q"].shape == (3, 6) + assert exported["qdot"].shape == (3, 6) + assert exported["qddot"].shape == (3, 6) + assert exported["q_names"].tolist() == pipeline_gui.ROOT_Q_NAMES.tolist() + assert info_messages + + +def test_reconstructions_tab_export_selected_pseudo_root_interpolates_short_nan_gap(monkeypatch, tmp_path): + recon_dir = tmp_path / "output" / "trial" / "reconstructions" / "demo" + recon_dir.mkdir(parents=True) + points_3d = np.zeros((5, 17, 3), dtype=float) + for frame_idx in range(points_3d.shape[0]): + z_offset = 1.0 + 0.1 * frame_idx + points_3d[frame_idx, pipeline_gui.KP_INDEX["left_hip"]] = (-0.2, 0.0, z_offset) + points_3d[frame_idx, pipeline_gui.KP_INDEX["right_hip"]] = (0.2, 0.0, z_offset) + points_3d[frame_idx, pipeline_gui.KP_INDEX["left_shoulder"]] = (-0.25, 0.0, z_offset + 0.5) + points_3d[frame_idx, pipeline_gui.KP_INDEX["right_shoulder"]] = (0.25, 0.0, z_offset + 0.5) + points_3d[2, pipeline_gui.KP_INDEX["left_shoulder"]] = np.array([np.nan, np.nan, np.nan], dtype=float) + bundle_path = recon_dir / "reconstruction_bundle.npz" + np.savez( + bundle_path, + points_3d=points_3d, + frames=np.arange(5, dtype=int), + time_s=np.arange(5, dtype=float) / 120.0, + ) + + info_messages = [] + tab = pipeline_gui.ReconstructionsTab.__new__(pipeline_gui.ReconstructionsTab) + tab.state = SimpleNamespace( + fps_var=SimpleNamespace(get=lambda: "120"), + initial_rotation_correction_var=SimpleNamespace(get=lambda: True), + ) + tab.status_summaries = {"demo": {"initial_rotation_correction_applied": True}} + tab._selected_reconstruction_dir = lambda: recon_dir + monkeypatch.setattr( + pipeline_gui.messagebox, "showinfo", lambda title, message: info_messages.append((title, message)) + ) + + pipeline_gui.ReconstructionsTab.export_selected_pseudo_root_from_points(tab) + + exported = np.load(recon_dir / "demo_pseudo_root_q.npz", allow_pickle=True) + assert np.all(np.isfinite(exported["q"][2])) + assert np.all(np.isfinite(exported["qdot"][2])) + assert info_messages + + +def test_reconstructions_tab_export_selected_pseudo_root_fills_short_edge_gaps(monkeypatch, tmp_path): + recon_dir = tmp_path / "output" / "trial" / "reconstructions" / "demo" + recon_dir.mkdir(parents=True) + points_3d = np.zeros((5, 17, 3), dtype=float) + for frame_idx in range(points_3d.shape[0]): + z_offset = 1.0 + 0.1 * frame_idx + points_3d[frame_idx, pipeline_gui.KP_INDEX["left_hip"]] = (-0.2, 0.0, z_offset) + points_3d[frame_idx, pipeline_gui.KP_INDEX["right_hip"]] = (0.2, 0.0, z_offset) + points_3d[frame_idx, pipeline_gui.KP_INDEX["left_shoulder"]] = (-0.25, 0.0, z_offset + 0.5) + points_3d[frame_idx, pipeline_gui.KP_INDEX["right_shoulder"]] = (0.25, 0.0, z_offset + 0.5) + points_3d[0, pipeline_gui.KP_INDEX["left_shoulder"]] = np.array([np.nan, np.nan, np.nan], dtype=float) + points_3d[-1, pipeline_gui.KP_INDEX["right_shoulder"]] = np.array([np.nan, np.nan, np.nan], dtype=float) + np.savez( + recon_dir / "reconstruction_bundle.npz", + points_3d=points_3d, + frames=np.arange(5, dtype=int), + time_s=np.arange(5, dtype=float) / 120.0, + ) + + tab = pipeline_gui.ReconstructionsTab.__new__(pipeline_gui.ReconstructionsTab) + tab.state = SimpleNamespace( + fps_var=SimpleNamespace(get=lambda: "120"), + initial_rotation_correction_var=SimpleNamespace(get=lambda: True), + ) + tab.status_summaries = {"demo": {"initial_rotation_correction_applied": True}} + tab._selected_reconstruction_dir = lambda: recon_dir + monkeypatch.setattr(pipeline_gui.messagebox, "showinfo", lambda *_args, **_kwargs: None) + + pipeline_gui.ReconstructionsTab.export_selected_pseudo_root_from_points(tab) + + exported = np.load(recon_dir / "demo_pseudo_root_q.npz", allow_pickle=True) + assert np.all(np.isfinite(exported["q"][0])) + assert np.all(np.isfinite(exported["q"][-1])) + assert np.all(np.isfinite(exported["qdot"][0])) + assert np.all(np.isfinite(exported["qdot"][-1])) + + def test_multiview_tab_build_command_uses_selected_cameras_and_images_root(monkeypatch): tab = pipeline_gui.MultiViewTab.__new__(pipeline_gui.MultiViewTab) tab.state = SimpleNamespace( fps_var=SimpleNamespace(get=lambda: "120"), workers_var=SimpleNamespace(get=lambda: "6"), pose2sim_trc_var=SimpleNamespace(get=lambda: ""), + shared_images_root_var=SimpleNamespace(get=lambda: "inputs/images/trial"), ) tab.output_gif = _FakeEntryField("trial.gif") tab.gif_fps = _FakeEntryField("10") tab.stride = _FakeEntryField("5") tab.marker_size = _FakeEntryField("18") - tab.images_root_entry = _FakeEntryField("inputs/images/trial") tab.crop_var = SimpleNamespace(get=lambda: True) tab.show_images_var = SimpleNamespace(get=lambda: True) tab.extra = _FakeEntryField("") @@ -399,6 +3610,8 @@ class _FakeListbox: def __init__(self): self.items = [] self._selection = [] + self._active = 0 + self._seen = None def delete(self, _start, _end=None): self.items = [] @@ -407,46 +3620,180 @@ def delete(self, _start, _end=None): def insert(self, _index, value): self.items.append(str(value)) - def selection_clear(self, _start, _end=None): - self._selection = [] + def selection_clear(self, _start, _end=None): + self._selection = [] + + def selection_set(self, first, last=None): + if last in (None, ""): + values = [int(first)] + else: + end = len(self.items) - 1 if last == pipeline_gui.tk.END else int(last) + values = list(range(int(first), end + 1)) + for value in values: + if value not in self._selection: + self._selection.append(value) + self._selection.sort() + + def curselection(self): + return tuple(self._selection) + + def size(self): + return len(self.items) + + def get(self, index): + return self.items[int(index)] + + def see(self, _index): + self._seen = int(_index) + return None + + def activate(self, index): + self._active = int(index) + + def index(self, value): + if value == pipeline_gui.tk.ACTIVE: + return self._active + return int(value) + + +class _FakePackFrame: + def __init__(self): + self.pack_calls = [] + self.pack_forget_calls = 0 + + def pack(self, *args, **kwargs): + self.pack_calls.append((args, kwargs)) + + def pack_forget(self): + self.pack_forget_calls += 1 + + +class _FakeButton: + def __init__(self): + self.state = None + self.text = None + + def configure(self, **kwargs): + if "state" in kwargs: + self.state = kwargs["state"] + if "text" in kwargs: + self.text = kwargs["text"] + + +class _FakeCombobox: + def __init__(self): + self.values = None + + def configure(self, **kwargs): + if "values" in kwargs: + self.values = list(kwargs["values"]) + + +class _FakeScale: + def __init__(self, *, from_value=0, to_value=10, width=200): + self._from = from_value + self._to = to_value + self._width = width + self.focused = False + + def winfo_width(self): + return self._width + + def cget(self, key): + if key == "from": + return self._from + if key == "to": + return self._to + raise KeyError(key) + + def focus_set(self): + self.focused = True + + +class _FakeAxis: + def __init__(self): + self.images = [] + self.xticks = None + self.yticks = None + self.tick_kwargs = None + self.spines = { + name: SimpleNamespace(set_visible=lambda _visible: None) for name in ("left", "right", "top", "bottom") + } + + def imshow(self, image, **kwargs): + self.images.append((image, kwargs)) + + def set_xlim(self, *_args, **_kwargs): + return None + + def set_ylim(self, *_args, **_kwargs): + return None + + def set_aspect(self, *_args, **_kwargs): + return None + + def set_title(self, *_args, **_kwargs): + return None + + def grid(self, *_args, **_kwargs): + return None + + def set_xlabel(self, *_args, **_kwargs): + return None + + def set_ylabel(self, *_args, **_kwargs): + return None + + def set_xticks(self, ticks): + self.xticks = ticks - def selection_set(self, index): - value = int(index) - if value not in self._selection: - self._selection.append(value) + def set_yticks(self, ticks): + self.yticks = ticks - def curselection(self): - return tuple(self._selection) + def get_legend_handles_labels(self): + return [], [] - def size(self): - return len(self.items) + def legend(self, *_args, **_kwargs): + return None - def get(self, index): - return self.items[int(index)] + def text(self, *_args, **_kwargs): + return None - def see(self, _index): + def set_axis_off(self): return None + def tick_params(self, **kwargs): + self.tick_kwargs = kwargs -class _FakePackFrame: + +class _FakeFigure: def __init__(self): - self.pack_calls = [] - self.pack_forget_calls = 0 + self.axis = _FakeAxis() - def pack(self, *args, **kwargs): - self.pack_calls.append((args, kwargs)) + def clear(self): + return None - def pack_forget(self): - self.pack_forget_calls += 1 + def subplots(self, *_args, **_kwargs): + return self.axis + def suptitle(self, *_args, **_kwargs): + return None -class _FakeButton: - def __init__(self): - self.state = None + def tight_layout(self): + return None - def configure(self, **kwargs): - if "state" in kwargs: - self.state = kwargs["state"] + +class _FakeCanvas: + def draw_idle(self): + return None + + +class _FakeText: + def delete(self, *_args, **_kwargs): + return None + + def insert(self, *_args, **_kwargs): + return None def test_model_tab_sync_frame_range_defaults_uses_available_2d_bounds(monkeypatch): @@ -491,6 +3838,131 @@ def test_model_tab_on_command_success_refreshes_existing_models(): assert calls == ["refresh"] +def test_available_model_pose_modes_include_annotated_when_annotations_exist(monkeypatch, tmp_path): + root = tmp_path / "workspace" + keypoints_path = root / "inputs" / "keypoints" / "trial_keypoints.json" + annotations_path = root / "inputs" / "annotations" / "trial_annotations.json" + keypoints_path.parent.mkdir(parents=True) + annotations_path.parent.mkdir(parents=True) + keypoints_path.write_text("{}", encoding="utf-8") + annotations_path.write_text("{}", encoding="utf-8") + state = SimpleNamespace( + annotation_path_var=SimpleNamespace(get=lambda: "inputs/annotations/trial_annotations.json") + ) + + monkeypatch.setattr(pipeline_gui, "ROOT", root) + + modes = pipeline_gui.available_model_pose_modes(state, keypoints_path) + + assert modes == ["raw", "annotated", "cleaned"] + + +def test_model_tab_sync_available_pose_modes_drops_annotated_when_missing(monkeypatch, tmp_path): + root = tmp_path / "workspace" + keypoints_path = root / "inputs" / "keypoints" / "trial_keypoints.json" + keypoints_path.parent.mkdir(parents=True) + keypoints_path.write_text("{}", encoding="utf-8") + + tab = pipeline_gui.ModelTab.__new__(pipeline_gui.ModelTab) + + class _Var: + def __init__(self, value): + self.value = value + + def get(self): + return self.value + + def set(self, value): + self.value = value + + tab.state = SimpleNamespace( + keypoints_var=SimpleNamespace(get=lambda: "inputs/keypoints/trial_keypoints.json"), + annotation_path_var=SimpleNamespace(get=lambda: "inputs/annotations/trial_annotations.json"), + ) + tab.pose_mode_box = _FakeCombobox() + tab.pose_data_mode = _Var("annotated") + + monkeypatch.setattr(pipeline_gui, "ROOT", root) + + pipeline_gui.ModelTab._sync_available_pose_modes(tab) + + assert tab.pose_mode_box.values == ["raw", "cleaned"] + assert tab.pose_data_mode.get() == "cleaned" + + +def test_model_tab_build_command_uses_two_cameras_min_for_annotated(monkeypatch): + tab = pipeline_gui.ModelTab.__new__(pipeline_gui.ModelTab) + tab.state = SimpleNamespace( + keypoints_var=SimpleNamespace(get=lambda: "inputs/keypoints/trial_keypoints.json"), + calib_var=SimpleNamespace(get=lambda: "inputs/calibration/Calib.toml"), + fps_var=SimpleNamespace(get=lambda: "120"), + workers_var=SimpleNamespace(get=lambda: "6"), + output_root_var=SimpleNamespace(get=lambda: "output"), + pose_filter_window_var=SimpleNamespace(get=lambda: "9"), + pose_outlier_ratio_var=SimpleNamespace(get=lambda: "0.1"), + pose_p_low_var=SimpleNamespace(get=lambda: "5"), + pose_p_high_var=SimpleNamespace(get=lambda: "95"), + ) + tab.subject_mass = _FakeEntryField("55") + tab.triang_method = SimpleNamespace(get=lambda: "exhaustive") + tab.model_variant = SimpleNamespace(get=lambda: "single_trunk") + tab.pose_data_mode = SimpleNamespace(get=lambda: "annotated") + tab.pose_correction_mode = SimpleNamespace(get=lambda: "none") + tab.symmetrize_limbs_var = SimpleNamespace(get=lambda: True) + tab.frame_start = _FakeEntryField("") + tab.frame_end = _FakeEntryField("") + tab.max_frames = _FakeEntryField("") + tab.initial_rot_var = SimpleNamespace(get=lambda: False) + tab.extra = _FakeEntryField("") + tab.parse_extra_args = lambda value: [] + tab.derived_model_dir = lambda: Path("output/trial/models/demo") + tab.derived_biomod_path = lambda: "output/trial/models/demo/vitpose_chain.bioMod" + monkeypatch.setattr(pipeline_gui, "current_selected_camera_names", lambda _state: []) + + cmd = pipeline_gui.ModelTab.build_command(tab) + + assert "--min-cameras-for-triangulation" in cmd + idx = cmd.index("--min-cameras-for-triangulation") + assert cmd[idx + 1] == "2" + + +def test_model_tab_preview_uses_two_camera_min_for_annotated(monkeypatch): + tab = pipeline_gui.ModelTab.__new__(pipeline_gui.ModelTab) + tab.state = SimpleNamespace( + workers_var=SimpleNamespace(get=lambda: "6"), + pose_filter_window_var=SimpleNamespace(get=lambda: "9"), + pose_outlier_ratio_var=SimpleNamespace(get=lambda: "0.1"), + pose_p_low_var=SimpleNamespace(get=lambda: "5"), + pose_p_high_var=SimpleNamespace(get=lambda: "95"), + ) + tab.pose_data_mode = SimpleNamespace(get=lambda: "annotated") + tab.pose_correction_mode = SimpleNamespace(get=lambda: "none") + tab.triang_method = SimpleNamespace(get=lambda: "exhaustive") + + pose_data = SimpleNamespace(frames=np.array([12], dtype=int)) + recorded = {} + + monkeypatch.setattr(pipeline_gui, "slice_pose_data", lambda _pose_data, _indices: pose_data) + + def fake_load_or_compute_triangulation_cache(**kwargs): + recorded.update(kwargs) + reconstruction = SimpleNamespace(points_3d=np.ones((1, len(pipeline_gui.COCO17), 3), dtype=float)) + return reconstruction, None, None, "computed_now" + + monkeypatch.setattr(pipeline_gui, "load_or_compute_triangulation_cache", fake_load_or_compute_triangulation_cache) + + reconstruction, preview_idx = pipeline_gui.ModelTab._first_valid_preview_reconstruction( + tab, + calibrations={"cam0": object(), "cam1": object()}, + pose_data=pose_data, + model_dir=Path("output/trial/models/demo"), + ) + + assert reconstruction is not None + assert preview_idx == 0 + assert recorded["min_cameras_for_triangulation"] == 2 + + def test_profiles_tab_refresh_profile_model_choices_populates_existing_models(monkeypatch): model_dir = Path("output/1_partie_0429/models/model_demo") biomod_path = model_dir / "model_demo.bioMod" @@ -525,7 +3997,7 @@ def test_profiles_tab_refresh_profile_model_choices_populates_existing_models(mo assert tuple(tab.profile_models_list.items) == ("model_demo",) assert tab._profile_model_choices["model_demo"] == str(biomod_path) - assert tab._model_info == "reuse existing model (faster)" + assert tab._model_info == "reuse existing single_trunk model (faster)" assert tab.profile_models_list.curselection() == (0,) assert tab._model_summary == Path(str(biomod_path)).stem @@ -575,14 +4047,18 @@ def test_profiles_tab_current_profile_reuses_2d_explorer_clean_settings(): tab.predictor = SimpleNamespace(get=lambda: "acc") tab.ekf2d_initial_state_method = SimpleNamespace(get=lambda: "ekf_bootstrap") tab.ekf2d_bootstrap_passes = SimpleNamespace(get=lambda: "5") + tab.upper_back_sagittal_gain = SimpleNamespace(get=lambda: "0.2") + tab.upper_back_pseudo_std_deg = SimpleNamespace(get=lambda: "10") + tab.ankle_bed_pseudo_obs_var = SimpleNamespace(get=lambda: True) + tab.ankle_bed_pseudo_std_m = SimpleNamespace(get=lambda: "0.018") tab.selected_profile_flip_method = lambda: None tab.lock_var = SimpleNamespace(get=lambda: False) tab.initial_rot_var = SimpleNamespace(get=lambda: False) tab.pose_data_mode = SimpleNamespace(get=lambda: "cleaned") tab.frame_stride = SimpleNamespace(get=lambda: "1") - tab.triang_method = SimpleNamespace(get=lambda: "exhaustive") + tab.triang_method = SimpleNamespace(get=lambda: "once") + tab.reprojection_threshold_var = SimpleNamespace(get=lambda: "none") tab.coherence_method = SimpleNamespace(get=lambda: "Epipolar (precomputed)") - tab.unwrap_var = SimpleNamespace(get=lambda: False) tab.biorbd_noise = SimpleNamespace(get=lambda: "1e-8") tab.biorbd_error = SimpleNamespace(get=lambda: "1e-4") tab.biorbd_kalman_init_method = SimpleNamespace(get=lambda: "triangulation_ik_root_translation") @@ -597,6 +4073,11 @@ def test_profiles_tab_current_profile_reuses_2d_explorer_clean_settings(): assert profile.pose_amplitude_lower_percentile == 7.0 assert profile.pose_amplitude_upper_percentile == 93.0 assert profile.coherence_method == "epipolar" + assert profile.reprojection_threshold_px is None + assert profile.root_unwrap_mode == "off" + assert profile.no_root_unwrap is True + assert profile.ankle_bed_pseudo_obs is True + assert profile.ankle_bed_pseudo_std_m == 0.018 def test_profiles_tab_update_profile_model_info_marks_existing_biomod_as_faster(): @@ -608,7 +4089,7 @@ def test_profiles_tab_update_profile_model_info_marks_existing_biomod_as_faster( pipeline_gui.ProfilesTab.update_profile_model_info(tab) - assert tab._model_info == "reuse existing model (faster)" + assert tab._model_info == "reuse existing single_trunk model (faster)" def test_profiles_tab_update_profile_model_info_requires_existing_biomod_for_ekf2d(): @@ -620,7 +4101,7 @@ def test_profiles_tab_update_profile_model_info_requires_existing_biomod_for_ekf pipeline_gui.ProfilesTab.update_profile_model_info(tab) - assert tab._model_info == "auto-build model from current 2D data (slower)" + assert tab._model_info == "auto-build single_trunk model from current 2D data (slower)" def test_profiles_tab_update_add_profile_button_state_requires_two_cameras_and_model_for_ekf2d(): @@ -708,6 +4189,49 @@ def test_model_tab_refresh_existing_models_keeps_detected_models_with_match_stat assert row[2] == str(biomod_path) +def test_model_tab_update_preview_viewer_controls_switches_button_state(): + tab = pipeline_gui.ModelTab.__new__(pipeline_gui.ModelTab) + tab.preview_viewer = SimpleNamespace(get=lambda: "pyorerun") + tab.open_preview_viewer_button = _FakeButton() + + pipeline_gui.ModelTab.update_preview_viewer_controls(tab) + + assert tab.open_preview_viewer_button.state == "normal" + assert tab.open_preview_viewer_button.text == "Open pyorerun" + + +def test_model_tab_open_preview_in_viewer_launches_pyorerun(monkeypatch, tmp_path): + biomod_path = tmp_path / "output" / "trial" / "models" / "demo" / "model_demo.bioMod" + biomod_path.parent.mkdir(parents=True) + biomod_path.write_text("bioMod", encoding="utf-8") + launched = [] + + tab = pipeline_gui.ModelTab.__new__(pipeline_gui.ModelTab) + tab.preview_viewer = SimpleNamespace(get=lambda: "pyorerun") + tab.preview_q_current = np.array([0.1, -0.2], dtype=float) + tab.state = SimpleNamespace(fps_var=SimpleNamespace(get=lambda: "120")) + tab._preview_model_path = lambda use_selected_model=False: biomod_path + + monkeypatch.setattr(pipeline_gui.subprocess, "Popen", lambda cmd, cwd=None: launched.append((cmd, cwd))) + + pipeline_gui.ModelTab.open_preview_in_viewer(tab) + + assert launched + cmd, cwd = launched[0] + assert cmd[0] == pipeline_gui.sys.executable + assert cmd[1] == "tools/show_biomod_pyorerun.py" + assert "--biomod" in cmd + assert "--states" in cmd + assert "--mode" in cmd + assert "trajectory" in cmd + states_path = biomod_path.parent / "preview_pyorerun_states.npz" + assert states_path.exists() + q = np.load(states_path)["q"] + assert q.shape == (2, 2) + assert np.allclose(q[0], [0.1, -0.2]) + assert cwd == pipeline_gui.ROOT + + def test_clean_trial_outputs_aborts_when_confirmation_is_declined(monkeypatch, tmp_path): tab = pipeline_gui.DataExplorer2DTab.__new__(pipeline_gui.DataExplorer2DTab) tab.state = SimpleNamespace( @@ -773,3 +4297,425 @@ def test_clean_trial_outputs_deletes_dataset_after_confirmation(monkeypatch, tmp assert summaries == ["summary"] assert refreshes == ["refresh"] assert info_messages + + +def test_clean_trial_caches_deletes_only_cache_artifacts(monkeypatch, tmp_path): + notifications = [] + summaries = [] + refreshes = [] + tab = pipeline_gui.DataExplorer2DTab.__new__(pipeline_gui.DataExplorer2DTab) + tab.state = SimpleNamespace( + pose_data_cache={("demo",): object()}, + calibration_cache={"demo": object()}, + notify_reconstructions_updated=lambda: notifications.append("updated"), + shared_reconstruction_panel=SimpleNamespace(_refresh_callback=lambda: refreshes.append("refresh")), + ) + tab.update_dataset_summary = lambda: summaries.append("summary") + tab.after_idle = lambda callback: callback() + dataset_dir = tmp_path / "output" / "trial" + cache_root = dataset_dir / "_cache" / "pose2d" + cache_root.mkdir(parents=True) + (cache_root / "demo.npz").write_text("dummy", encoding="utf-8") + model_dir = dataset_dir / "models" / "demo" + model_dir.mkdir(parents=True) + preview_cache = model_dir / "preview_q0_cache.npz" + preview_cache.write_text("preview", encoding="utf-8") + kept_file = model_dir / "model.bioMod" + kept_file.write_text("bioMod", encoding="utf-8") + info_messages = [] + + monkeypatch.setattr(pipeline_gui, "current_dataset_dir", lambda _state: dataset_dir) + monkeypatch.setattr(pipeline_gui, "current_dataset_name", lambda _state: "trial") + monkeypatch.setattr(pipeline_gui, "display_path", lambda path: str(path)) + monkeypatch.setattr(pipeline_gui.messagebox, "askyesno", lambda *args, **kwargs: True) + monkeypatch.setattr( + pipeline_gui.messagebox, + "showinfo", + lambda title, message: info_messages.append((title, message)), + ) + + pipeline_gui.DataExplorer2DTab.clean_trial_caches(tab) + + assert not (dataset_dir / "_cache").exists() + assert not preview_cache.exists() + assert kept_file.exists() + assert dataset_dir.exists() + assert tab.state.pose_data_cache == {} + assert tab.state.calibration_cache == {} + assert notifications == ["updated"] + assert summaries == ["summary"] + assert refreshes == ["refresh"] + assert info_messages + + +def test_clear_models_deletes_only_selected_model_dirs(monkeypatch, tmp_path): + selected_dir = tmp_path / "output" / "trial" / "models" / "selected_model" + kept_dir = tmp_path / "output" / "trial" / "models" / "kept_model" + selected_dir.mkdir(parents=True) + kept_dir.mkdir(parents=True) + (selected_dir / "selected.bioMod").write_text("bioMod", encoding="utf-8") + (kept_dir / "kept.bioMod").write_text("bioMod", encoding="utf-8") + + tab = pipeline_gui.ModelTab.__new__(pipeline_gui.ModelTab) + tab.model_tree = _FakeTree() + tab.model_tree.insert( + "", "end", iid="selected", values=("selected_model", "yes", str(selected_dir / "selected.bioMod")) + ) + tab.model_tree.insert("", "end", iid="kept", values=("kept_model", "yes", str(kept_dir / "kept.bioMod"))) + tab.model_tree.selection_set(("selected",)) + sync_calls = [] + refresh_calls = [] + info_messages = [] + tab.sync_paths_from_state = lambda: sync_calls.append("sync") + tab.refresh_existing_models = lambda: refresh_calls.append("refresh") + + monkeypatch.setattr(pipeline_gui.messagebox, "askyesno", lambda *args, **kwargs: True) + monkeypatch.setattr( + pipeline_gui.messagebox, "showinfo", lambda title, message: info_messages.append((title, message)) + ) + + pipeline_gui.ModelTab.clear_models(tab) + + assert not selected_dir.exists() + assert kept_dir.exists() + assert sync_calls == ["sync"] + assert refresh_calls == ["refresh"] + assert info_messages + + +def test_clear_reconstructions_deletes_only_selected_rows(monkeypatch, tmp_path): + recon_root = tmp_path / "output" / "trial" / "reconstructions" + kept_dir = recon_root / "kept" + deleted_dir = recon_root / "selected" + kept_dir.mkdir(parents=True) + deleted_dir.mkdir(parents=True) + notifications = [] + refreshes = [] + info_messages = [] + tab = pipeline_gui.ReconstructionsTab.__new__(pipeline_gui.ReconstructionsTab) + tab.state = SimpleNamespace( + shared_reconstruction_selection=["selected"], + notify_reconstructions_updated=lambda: notifications.append("updated"), + ) + tab.refresh_status_rows = lambda: refreshes.append("refresh") + + monkeypatch.setattr(pipeline_gui, "current_dataset_dir", lambda _state: tmp_path / "output" / "trial") + monkeypatch.setattr(pipeline_gui, "reconstruction_dirs_for_path", lambda _dataset_dir: [deleted_dir, kept_dir]) + monkeypatch.setattr(pipeline_gui.messagebox, "askyesno", lambda *args, **kwargs: True) + monkeypatch.setattr( + pipeline_gui.messagebox, + "showinfo", + lambda title, message: info_messages.append((title, message)), + ) + + pipeline_gui.ReconstructionsTab.clear_reconstructions(tab) + + assert not deleted_dir.exists() + assert kept_dir.exists() + assert tab.state.shared_reconstruction_selection == [] + assert notifications == ["updated"] + assert refreshes == ["refresh"] + assert info_messages + + +def test_annotation_tab_reports_unsaved_changes_from_payload_signature(): + tab = pipeline_gui.AnnotationTab.__new__(pipeline_gui.AnnotationTab) + tab.annotation_payload = {"cameras": {"cam0": {"0": {"nose": [10.0, 20.0]}}}} + tab._last_saved_annotation_signature = pipeline_gui.annotation_payload_signature(tab.annotation_payload) + + assert pipeline_gui.AnnotationTab.has_unsaved_changes(tab) is False + + pipeline_gui.set_annotation_point( + tab.annotation_payload, + camera_name="cam0", + frame_number=0, + keypoint_name="left_eye", + xy=[11.0, 21.0], + ) + + assert pipeline_gui.AnnotationTab.has_unsaved_changes(tab) is True + + +def test_launcher_app_unsaved_change_sources_collect_profiles_and_tabs(): + app = pipeline_gui.LauncherApp.__new__(pipeline_gui.LauncherApp) + app.state = SimpleNamespace(profiles_dirty=True) + + annotation_tab = SimpleNamespace( + unsaved_changes_label="annotations", + has_unsaved_changes=lambda: True, + ) + clean_tab = SimpleNamespace( + unsaved_changes_label="clean tab", + has_unsaved_changes=lambda: False, + ) + app.notebook = SimpleNamespace(tabs=lambda: ("annotation", "clean")) + tab_lookup = {"annotation": annotation_tab, "clean": clean_tab} + app.nametowidget = lambda tab_id: tab_lookup[tab_id] + + assert pipeline_gui.LauncherApp._unsaved_change_sources(app) == [ + "reconstruction profiles", + "annotations", + ] + + +def test_launcher_app_close_is_cancelled_when_unsaved_changes_are_declined(monkeypatch): + app = pipeline_gui.LauncherApp.__new__(pipeline_gui.LauncherApp) + app.state = SimpleNamespace(profiles_dirty=True) + app.notebook = SimpleNamespace(tabs=lambda: ()) + destroyed = [] + prompts = [] + app.destroy = lambda: destroyed.append("destroyed") + + monkeypatch.setattr( + pipeline_gui.messagebox, + "askyesno", + lambda title, message, parent=None: prompts.append((title, message, parent)) or False, + ) + + pipeline_gui.LauncherApp._on_request_close(app) + + assert destroyed == [] + assert prompts + assert prompts[0][0] == "Quit GUI" + assert "reconstruction profiles" in prompts[0][1] + + +def test_launcher_app_close_allows_shutdown_after_confirmation(monkeypatch): + app = pipeline_gui.LauncherApp.__new__(pipeline_gui.LauncherApp) + app.state = SimpleNamespace(profiles_dirty=False) + app.notebook = SimpleNamespace(tabs=lambda: ("annotation",)) + app.nametowidget = lambda _tab_id: SimpleNamespace( + unsaved_changes_label="annotations", + has_unsaved_changes=lambda: True, + ) + destroyed = [] + app.destroy = lambda: destroyed.append("destroyed") + + monkeypatch.setattr(pipeline_gui.messagebox, "askyesno", lambda *args, **kwargs: True) + + pipeline_gui.LauncherApp._on_request_close(app) + + assert destroyed == ["destroyed"] + + +def test_load_preview_bundle_accepts_missing_pose2sim_trc(tmp_path): + output_dir = tmp_path / "empty_dataset" + output_dir.mkdir(parents=True) + + bundle = pipeline_gui.load_preview_bundle(output_dir, biomod_path=None, pose2sim_trc=None, align_root=False) + + np.testing.assert_array_equal(bundle["frames"], np.array([], dtype=int)) + np.testing.assert_array_equal(bundle["time_s"], np.array([], dtype=float)) + assert bundle["recon_3d"] == {} + + +def test_camera_tools_tab_sync_dataset_dir_infers_images_root(monkeypatch, tmp_path): + shared_images_root = {"value": ""} + tab = pipeline_gui.CameraToolsTab.__new__(pipeline_gui.CameraToolsTab) + tab.state = SimpleNamespace( + keypoints_var=SimpleNamespace(get=lambda: "inputs/keypoints/trial_keypoints.json"), + shared_images_root_var=SimpleNamespace( + get=lambda: shared_images_root["value"], + set=lambda value: shared_images_root.__setitem__("value", value), + ), + ) + tab.refresh_available_reconstructions = lambda: None + tab.update_camera_filter_status = lambda: None + tab.load_resources = lambda: None + inferred_root = tmp_path / "inputs" / "images" / "trial" + inferred_root.mkdir(parents=True) + + monkeypatch.setattr(pipeline_gui, "ROOT", tmp_path) + monkeypatch.setattr(pipeline_gui, "display_path", lambda path: str(path)) + monkeypatch.setattr(pipeline_gui, "infer_execution_images_root", lambda _path: inferred_root) + + pipeline_gui.CameraToolsTab.sync_dataset_dir(tab) + + assert tab.images_root == inferred_root + assert shared_images_root["value"] == str(inferred_root) + + +def test_camera_tools_render_flip_preview_uses_image_frame_number_before_overlay(monkeypatch, tmp_path): + tab = pipeline_gui.CameraToolsTab.__new__(pipeline_gui.CameraToolsTab) + tab.base_pose_data = SimpleNamespace( + camera_names=["M11139"], + frames=np.array([24], dtype=int), + keypoints=np.zeros((1, 1, 17, 2), dtype=float), + ) + tab.base_pose_data.keypoints[0, 0, 11] = np.array([100.0, 200.0], dtype=float) + tab.calibrations = {"M11139": SimpleNamespace(image_size=(1920, 1080))} + tab.flip_method_var = SimpleNamespace(get=lambda: "epipolar") + tab.flip_camera_var = SimpleNamespace(get=lambda: "M11139") + tab.flip_applied_var = SimpleNamespace(get=lambda: False) + tab.flip_figure = _FakeFigure() + tab.flip_canvas = _FakeCanvas() + tab.flip_details = _FakeText() + tab.flip_status_var = SimpleNamespace(set=lambda _value: None) + tab.state = SimpleNamespace(shared_images_root_var=SimpleNamespace(get=lambda: str(tmp_path))) + tab.images_root = tmp_path + tab.show_images_var = SimpleNamespace(get=lambda: True) + tab.flip_masks = {"epipolar": np.zeros((1, 1), dtype=bool)} + tab.flip_detail_arrays = {"epipolar": {}} + tab._selected_flip_frame_local_idx = lambda: 0 + tab._reference_projection = lambda *_args, **_kwargs: (None, "none", "#444444") + + requested = [] + monkeypatch.setattr( + two_d_view, + "resolve_execution_image_path", + lambda root, camera_name, frame_number: requested.append((root, camera_name, frame_number)) or None, + ) + drawn_colors = [] + monkeypatch.setattr( + pipeline_gui, + "draw_skeleton_2d", + lambda _ax, _points, color, *_args, **_kwargs: drawn_colors.append(color), + ) + + pipeline_gui.CameraToolsTab.render_flip_preview(tab) + + assert requested == [(tmp_path, "M11139", 24)] + assert drawn_colors[0] == "#000000" + + +def test_camera_tools_selected_flip_frame_local_idx_falls_back_to_slider(): + tab = pipeline_gui.CameraToolsTab.__new__(pipeline_gui.CameraToolsTab) + tab.flip_frame_list = _FakeListbox() + tab.flip_frame_local_indices = [] + tab.base_pose_data = SimpleNamespace(frames=np.array([10, 11, 12, 13], dtype=int)) + tab.flip_frame_var = SimpleNamespace(get=lambda: 2.4) + + assert pipeline_gui.CameraToolsTab._selected_flip_frame_local_idx(tab) == 2 + + +def test_camera_tools_selected_inspector_camera_prefers_metrics_tree_focus(): + tab = pipeline_gui.CameraToolsTab.__new__(pipeline_gui.CameraToolsTab) + tab.base_pose_data = SimpleNamespace(camera_names=["cam0", "cam1", "cam2"]) + tab.metrics_tree = _FakeTree() + tab.metrics_tree.selection_set(("cam0", "cam2")) + tab.metrics_tree.focus("cam2") + tab.flip_camera_var = SimpleNamespace(get=lambda: "") + + assert pipeline_gui.CameraToolsTab._selected_inspector_camera_name(tab) == "cam2" + + +def test_camera_tools_step_flip_frame_moves_by_one(): + tab = pipeline_gui.CameraToolsTab.__new__(pipeline_gui.CameraToolsTab) + current = {"value": 1.0} + tab.base_pose_data = SimpleNamespace(frames=np.array([10, 11, 12, 13], dtype=int)) + tab.flip_frame_var = SimpleNamespace( + get=lambda: current["value"], + set=lambda value: current.__setitem__("value", float(value)), + ) + tab.flip_frame_list = _FakeListbox() + tab.flip_frame_local_indices = [] + calls = [] + tab._on_flip_frame_scale_changed = lambda _value: calls.append(current["value"]) + + result = pipeline_gui.CameraToolsTab.step_flip_frame(tab, 1) + + assert result == "break" + assert current["value"] == 2.0 + assert calls == [2.0] + + +def test_camera_tools_show_specific_frame_updates_slider_and_preview(): + tab = pipeline_gui.CameraToolsTab.__new__(pipeline_gui.CameraToolsTab) + tab.base_pose_data = SimpleNamespace(camera_names=["cam0"], frames=np.array([100, 120], dtype=int)) + tab.metrics_tree = _FakeTree() + tab.metrics_tree.rows = {"cam0": ("cam0",)} + tab.flip_camera_var = SimpleNamespace(get=lambda: "cam0", set=lambda _value: None) + tab.flip_frame_local_indices = [] + tab.flip_frame_list = _FakeListbox() + slider_value = {"value": None} + tab.flip_frame_var = SimpleNamespace(set=lambda value: slider_value.__setitem__("value", value)) + tab._update_flip_frame_label = lambda: None + tab._render_flip_frame_markers = lambda: None + rendered = [] + tab.render_flip_preview = lambda: rendered.append(True) + + pipeline_gui.CameraToolsTab.show_specific_frame(tab, frame_number=120, camera_name="cam0") + + assert slider_value["value"] == 1.0 + assert tab.flip_frame_list.items == ["manual | frame 120 | cam0"] + assert rendered == [True] + + +def test_camera_tools_flip_frame_list_selection_updates_slider(): + current = {"value": 0.0} + labels = [] + rendered = [] + tab = pipeline_gui.CameraToolsTab.__new__(pipeline_gui.CameraToolsTab) + tab.flip_frame_list = _FakeListbox() + tab.flip_frame_list.insert("end", "flip | frame 120 | cam0") + tab.flip_frame_list.selection_set(0) + tab.flip_frame_local_indices = [7] + tab.flip_frame_var = SimpleNamespace( + get=lambda: current["value"], + set=lambda value: current.__setitem__("value", float(value)), + ) + tab._update_flip_frame_label = lambda: labels.append(current["value"]) + tab.render_flip_preview = lambda: rendered.append(True) + + pipeline_gui.CameraToolsTab._on_flip_frame_list_selection_changed(tab) + + assert current["value"] == 7.0 + assert labels == [7.0] + assert rendered == [True] + + +def test_camera_tools_slider_change_overrides_selected_list_frame(): + current = {"value": 3.0} + rendered = [] + tab = pipeline_gui.CameraToolsTab.__new__(pipeline_gui.CameraToolsTab) + tab.base_pose_data = SimpleNamespace(frames=np.array([100, 101, 102, 103, 104], dtype=int)) + tab.flip_frame_list = _FakeListbox() + tab.flip_frame_list.insert("end", "flip | frame 101 | cam0") + tab.flip_frame_list.selection_set(0) + tab.flip_frame_local_indices = [1] + tab.flip_frame_var = SimpleNamespace( + get=lambda: current["value"], + set=lambda value: current.__setitem__("value", float(value)), + ) + tab._update_flip_frame_label = lambda: None + tab.render_flip_preview = lambda: rendered.append(True) + + pipeline_gui.CameraToolsTab._on_flip_frame_scale_changed(tab, None) + + assert tab.flip_frame_list.curselection() == () + assert current["value"] == 3.0 + assert rendered == [True] + + +def test_camera_tools_render_flip_frame_markers_draws_suspects_and_candidates(): + lines = [] + + class _FakeMarks: + def delete(self, _tag): + return None + + def winfo_width(self): + return 101 + + def winfo_height(self): + return 10 + + def create_line(self, x0, y0, x1, y1, **kwargs): + lines.append((x0, y0, x1, y1, kwargs)) + + tab = pipeline_gui.CameraToolsTab.__new__(pipeline_gui.CameraToolsTab) + tab.flip_frame_marks = _FakeMarks() + tab.base_pose_data = SimpleNamespace(camera_names=["cam0"], frames=np.arange(5, dtype=int)) + tab.flip_camera_var = SimpleNamespace(get=lambda: "cam0") + tab.flip_method_var = SimpleNamespace(get=lambda: "epipolar") + tab.flip_masks = {"epipolar": np.array([[False, True, False, False, True]], dtype=bool)} + tab.flip_detail_arrays = { + "epipolar": {"candidate_mask": np.array([[True, False, False, True, False]], dtype=bool)} + } + + pipeline_gui.CameraToolsTab._render_flip_frame_markers(tab) + + candidate_lines = [entry for entry in lines if entry[4].get("fill") == "#9aa5b1"] + suspect_lines = [entry for entry in lines if entry[4].get("fill") == "#dd8452"] + assert len(candidate_lines) == 2 + assert len(suspect_lines) == 2 diff --git a/tests/test_pipeline_gui_joint_tab.py b/tests/test_pipeline_gui_joint_tab.py index a4652ba..bc8dc07 100644 --- a/tests/test_pipeline_gui_joint_tab.py +++ b/tests/test_pipeline_gui_joint_tab.py @@ -11,12 +11,17 @@ class _FakeAxis: def __init__(self): self.plots = [] + self.scatters = [] self.ylabel = None def plot(self, *args, **kwargs): self.plots.append({"args": args, "kwargs": kwargs}) return None + def scatter(self, *args, **kwargs): + self.scatters.append({"args": args, "kwargs": kwargs}) + return None + def set_title(self, *_args, **_kwargs): return None @@ -236,3 +241,131 @@ def test_joint_kinematics_rotation_unit_popup_converts_rotational_dofs(monkeypat np.testing.assert_allclose(axis.plots[0]["args"][1], expected_left) np.testing.assert_allclose(axis.plots[1]["args"][1], expected_right) assert axis.ylabel == "deg" + + +def test_pair_dof_names_includes_upper_back_singletons(): + q_names = np.asarray( + [ + "LEFT_KNEE:RotY", + "RIGHT_KNEE:RotY", + "UPPER_BACK:RotX", + "UPPER_BACK:RotY", + "UPPER_BACK:RotZ", + "LOWER_TRUNK:RotY", + ], + dtype=object, + ) + + pairs = pipeline_gui.pair_dof_names(q_names) + + assert ("KNEE:RotY", "LEFT_KNEE:RotY", "RIGHT_KNEE:RotY") in pairs + assert ("UPPER_BACK:RotX", "UPPER_BACK:RotX", None) in pairs + assert ("UPPER_BACK:RotY", "UPPER_BACK:RotY", None) in pairs + assert ("UPPER_BACK:RotZ", "UPPER_BACK:RotZ", None) in pairs + assert ("LOWER_TRUNK:RotY", "LOWER_TRUNK:RotY", None) in pairs + + +def test_upper_back_target_series_uses_hips_for_roty_and_zero_for_other_axes(): + series = np.array( + [ + [0.0, 1.0, 0.5, -0.2, 0.3], + [0.0, 0.5, 1.5, 0.1, -0.1], + ], + dtype=float, + ) + name_to_index = { + "UPPER_BACK:RotY": 0, + "LEFT_THIGH:RotY": 1, + "RIGHT_THIGH:RotY": 2, + "UPPER_BACK:RotX": 3, + "UPPER_BACK:RotZ": 4, + } + roty_target = pipeline_gui.JointKinematicsTab._upper_back_target_series(series, name_to_index, "UPPER_BACK:RotY") + rotx_target = pipeline_gui.JointKinematicsTab._upper_back_target_series(series, name_to_index, "UPPER_BACK:RotX") + rotz_target = pipeline_gui.JointKinematicsTab._upper_back_target_series(series, name_to_index, "UPPER_BACK:RotZ") + + np.testing.assert_allclose(roty_target, np.array([0.15, 0.2], dtype=float)) + np.testing.assert_allclose(rotx_target, np.zeros(2)) + np.testing.assert_allclose(rotz_target, np.zeros(2)) + + +def test_upper_back_target_series_supports_lower_trunk_variant(): + series = np.array([[1.0, 0.5], [0.2, 0.4]], dtype=float) + name_to_index = { + "LEFT_THIGH:RotY": 0, + "RIGHT_THIGH:RotY": 1, + } + target = pipeline_gui.JointKinematicsTab._upper_back_target_series(series, name_to_index, "LOWER_TRUNK:RotY") + + np.testing.assert_allclose(target, np.array([0.15, 0.06], dtype=float)) + + +def test_draw_upper_back_preview_draws_back_triangles(): + axis = _FakeAxis() + frame_points = np.full((len(pipeline_gui.COCO17), 3), np.nan, dtype=float) + frame_points[pipeline_gui.KP_INDEX["left_hip"]] = np.array([0.0, 0.2, 0.0]) + frame_points[pipeline_gui.KP_INDEX["right_hip"]] = np.array([0.0, -0.2, 0.0]) + frame_points[pipeline_gui.KP_INDEX["left_shoulder"]] = np.array([0.0, 0.3, 1.0]) + frame_points[pipeline_gui.KP_INDEX["right_shoulder"]] = np.array([0.0, -0.3, 1.0]) + segment_frames = [ + ("TRUNK", np.array([0.0, 0.0, 0.0]), np.eye(3)), + ("UPPER_BACK", np.array([0.0, 0.0, 0.5]), np.eye(3)), + ] + + pipeline_gui.draw_upper_back_preview(axis, frame_points, segment_frames) + + assert len(axis.plots) >= 2 + assert len(axis.scatters) == 1 + + +def test_draw_upper_back_preview_without_segment_frames_uses_mid_back_point(): + axis = _FakeAxis() + frame_points = np.full((len(pipeline_gui.COCO17), 3), np.nan, dtype=float) + frame_points[pipeline_gui.KP_INDEX["left_hip"]] = np.array([0.0, 0.2, 0.0]) + frame_points[pipeline_gui.KP_INDEX["right_hip"]] = np.array([0.0, -0.2, 0.0]) + frame_points[pipeline_gui.KP_INDEX["left_shoulder"]] = np.array([0.0, 0.3, 1.0]) + frame_points[pipeline_gui.KP_INDEX["right_shoulder"]] = np.array([0.0, -0.3, 1.0]) + + pipeline_gui.draw_upper_back_preview(axis, frame_points) + + assert len(axis.plots) >= 2 + assert len(axis.scatters) == 1 + + +def test_segmented_back_frame_geometry_returns_mid_back_and_triangles(): + frame_points = np.full((len(pipeline_gui.COCO17), 3), np.nan, dtype=float) + frame_points[pipeline_gui.KP_INDEX["left_hip"]] = np.array([0.0, 1.0, 0.0]) + frame_points[pipeline_gui.KP_INDEX["right_hip"]] = np.array([0.0, -1.0, 0.0]) + frame_points[pipeline_gui.KP_INDEX["left_shoulder"]] = np.array([0.0, 1.5, 2.0]) + frame_points[pipeline_gui.KP_INDEX["right_shoulder"]] = np.array([0.0, -1.5, 2.0]) + segment_frames = [("UPPER_BACK", np.array([0.3, 0.0, 1.0]), np.eye(3))] + + geometry = pipeline_gui.segmented_back_frame_geometry(frame_points, segment_frames) + + assert geometry is not None + mid_back, hip_triangle, shoulder_triangle = geometry + np.testing.assert_allclose(mid_back, np.array([0.3, 0.0, 1.0])) + np.testing.assert_allclose(hip_triangle[2], mid_back) + np.testing.assert_allclose(shoulder_triangle[2], mid_back) + + +def test_draw_upper_back_overlay_2d_draws_two_triangles_and_mid_back(): + axis = _FakeAxis() + + pipeline_gui.draw_upper_back_overlay_2d( + axis, + hip_triangle_2d=np.array([[10.0, 20.0], [20.0, 20.0], [15.0, 15.0], [10.0, 20.0]], dtype=float), + shoulder_triangle_2d=np.array([[11.0, 10.0], [19.0, 10.0], [15.0, 15.0], [11.0, 10.0]], dtype=float), + mid_back_2d=np.array([15.0, 15.0], dtype=float), + color="#123456", + ) + + assert len(axis.plots) == 2 + assert len(axis.scatters) == 1 + + +def test_has_segmented_back_visualization_detects_segment_frames_and_q_names(): + assert pipeline_gui.has_segmented_back_visualization(segment_frames=[("UPPER_BACK", np.zeros(3), np.eye(3))]) + assert pipeline_gui.has_segmented_back_visualization(q_names=["UPPER_BACK:RotY"]) + assert pipeline_gui.has_segmented_back_visualization(summary={"model_variant": "upper_root_back_3dof"}) + assert not pipeline_gui.has_segmented_back_visualization(q_names=["TRUNK:RotY"]) diff --git a/tests/test_pipeline_gui_launch.py b/tests/test_pipeline_gui_launch.py index b79e230..e43ee88 100644 --- a/tests/test_pipeline_gui_launch.py +++ b/tests/test_pipeline_gui_launch.py @@ -6,6 +6,35 @@ import pipeline_gui +def _find_tab_by_text(app, text: str): + for tab_id in app.notebook.tabs(): + if app.notebook.tab(tab_id, "text") == text: + return app.nametowidget(tab_id) + raise AssertionError(f"Notebook tab {text!r} not found") + + +def _find_descendant_by_text(widget, *, cls, text: str): + for child in widget.winfo_children(): + if isinstance(child, cls) and str(child.cget("text")) == text: + return child + nested = _find_descendant_by_text(child, cls=cls, text=text) + if nested is not None: + return nested + return None + + +def _assert_widget_inside_parent(widget, *, tolerance: int = 4): + widget.update_idletasks() + parent = widget.nametowidget(widget.winfo_parent()) + parent.update_idletasks() + assert widget.winfo_x() >= -tolerance + assert widget.winfo_y() >= -tolerance + assert widget.winfo_width() > 0 + assert widget.winfo_height() > 0 + assert widget.winfo_x() + widget.winfo_width() <= parent.winfo_width() + tolerance + assert widget.winfo_y() + widget.winfo_height() <= parent.winfo_height() + tolerance + + def test_small_keypoint_fixture_keeps_15_frames_per_camera(): fixture_path = pipeline_gui.ROOT / "inputs/keypoints/1_partie_0429_15f_keypoints.json" with fixture_path.open("r", encoding="utf-8") as handle: @@ -42,3 +71,54 @@ def test_launcher_app_starts_with_small_keypoint_fixture(monkeypatch): assert app.state.keypoints_var.get() == "inputs/keypoints/1_partie_0429_15f_keypoints.json" finally: app.destroy() + + +@pytest.mark.skipif( + os.environ.get("RUN_PIPELINE_GUI_SMOKE") != "1", + reason="Set RUN_PIPELINE_GUI_SMOKE=1 to enable the real Tk smoke test.", +) +def test_launcher_layout_keeps_annotation_camera_and_model_widgets_inside_window(monkeypatch): + pytest.importorskip("tkinter") + + monkeypatch.setattr( + pipeline_gui, + "DEFAULT_GUI_KEYPOINTS_PATH", + "inputs/keypoints/1_partie_0429_15f_keypoints.json", + ) + monkeypatch.setattr(pipeline_gui, "DEFAULT_GUI_CALIB_PATH", "inputs/calibration/Calib.toml") + monkeypatch.setattr(pipeline_gui, "DEFAULT_GUI_TRC_PATH", "inputs/trc/1_partie_0429.trc") + + app = pipeline_gui.LauncherApp() + try: + app.update_idletasks() + app.update() + + annotation_tab = _find_tab_by_text(app, "Annotation") + app.notebook.select(annotation_tab) + app.update_idletasks() + app.update() + _assert_widget_inside_parent(annotation_tab.annotation_cameras_list) + _assert_widget_inside_parent(annotation_tab.annotation_keypoints_list) + _assert_widget_inside_parent(annotation_tab.preview_canvas_widget) + _assert_widget_inside_parent(annotation_tab.frame_scale) + + cameras_tab = _find_tab_by_text(app, "Cameras") + app.notebook.select(cameras_tab) + app.update_idletasks() + app.update() + _assert_widget_inside_parent(cameras_tab.metrics_tree) + _assert_widget_inside_parent(cameras_tab.flip_canvas_widget) + + models_tab = _find_tab_by_text(app, "Models") + app.notebook.select(models_tab) + app.update_idletasks() + app.update() + symmetrize_check = _find_descendant_by_text( + models_tab, + cls=pipeline_gui.ttk.Checkbutton, + text="Symmetrize limbs", + ) + assert symmetrize_check is not None + _assert_widget_inside_parent(symmetrize_check) + finally: + app.destroy() diff --git a/tests/test_preview_bundle.py b/tests/test_preview_bundle.py index 9d892df..fde41a9 100644 --- a/tests/test_preview_bundle.py +++ b/tests/test_preview_bundle.py @@ -68,7 +68,9 @@ def test_assemble_dataset_preview_bundle_aligns_frames_and_keeps_q_root(): assert "pose2sim" in bundle["recon_3d"] assert "ekf_3d" in bundle["recon_q_root"] assert np.all(np.isnan(bundle["recon_3d"]["ekf_3d"][0])) - np.testing.assert_allclose(bundle["recon_q_root"]["ekf_3d"][1], np.array([0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), atol=1e-12) + np.testing.assert_allclose( + bundle["recon_q_root"]["ekf_3d"][1], np.array([0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), atol=1e-12 + ) def test_assemble_dataset_preview_bundle_rebuilds_markers_when_needed(): diff --git a/tests/test_preview_frame_2d_render.py b/tests/test_preview_frame_2d_render.py new file mode 100644 index 0000000..f076dfe --- /dev/null +++ b/tests/test_preview_frame_2d_render.py @@ -0,0 +1,90 @@ +import numpy as np + +from preview.frame_2d_render import PointValueOverlay2D, SkeletonLayer2D, draw_point_value_overlay, render_camera_frame_2d + + +class _FakeAxis: + def __init__(self) -> None: + self.title = None + self.aspect = None + self.xlim = None + self.ylim = None + self.autoscale = None + self.xlabel = None + self.ylabel = None + self.grid_alpha = None + self.scatter_calls = [] + + def set_xlim(self, *args): + self.xlim = args + + def set_ylim(self, *args): + self.ylim = args + + def set_autoscale_on(self, value): + self.autoscale = value + + def set_aspect(self, aspect, adjustable=None): + self.aspect = (aspect, adjustable) + + def set_title(self, title): + self.title = title + + def grid(self, alpha=None): + self.grid_alpha = alpha + + def set_xlabel(self, label): + self.xlabel = label + + def set_ylabel(self, label): + self.ylabel = label + + def scatter(self, *args, **kwargs): + self.scatter_calls.append((args, kwargs)) + return "scatter" + + +def test_render_camera_frame_2d_draws_background_layers_and_explicit_limits(): + ax = _FakeAxis() + calls = {"background": 0, "layers": 0, "hidden": 0} + + has_background = render_camera_frame_2d( + ax, + width=640, + height=480, + title="cam0", + layers=[SkeletonLayer2D(points=np.zeros((17, 2), dtype=float), color="#123456", label="Raw")], + draw_skeleton_fn=lambda *_args, **_kwargs: calls.__setitem__("layers", calls["layers"] + 1), + background_image=np.zeros((10, 10, 3), dtype=float), + draw_background_fn=lambda *_args, **_kwargs: calls.__setitem__("background", calls["background"] + 1), + x_limits=(10.0, 110.0), + y_limits=(210.0, 10.0), + hide_axes=True, + hide_axes_fn=lambda _ax: calls.__setitem__("hidden", calls["hidden"] + 1), + ) + + assert has_background is True + assert ax.title == "cam0" + assert ax.xlim == (10.0, 110.0) + assert ax.ylim == (210.0, 10.0) + assert ax.aspect == ("equal", "box") + assert calls == {"background": 1, "layers": 1, "hidden": 1} + + +def test_draw_point_value_overlay_draws_values_and_exclusions(): + ax = _FakeAxis() + + scatter = draw_point_value_overlay( + ax, + PointValueOverlay2D( + label="3D excluded", + points=np.array([[10.0, 20.0], [30.0, 40.0], [np.nan, 0.0]], dtype=float), + values=np.array([1.0, 2.0, 3.0], dtype=float), + mask=np.array([False, True, True]), + ), + ) + + assert scatter == "scatter" + assert len(ax.scatter_calls) == 2 + assert ax.scatter_calls[0][1]["cmap"] == "turbo" + assert ax.scatter_calls[1][1]["marker"] == "x" diff --git a/tests/test_preview_two_d_view.py b/tests/test_preview_two_d_view.py new file mode 100644 index 0000000..856a721 --- /dev/null +++ b/tests/test_preview_two_d_view.py @@ -0,0 +1,84 @@ +import numpy as np + +from preview import two_d_view +from preview.two_d_view import ( + adjust_image_levels, + camera_layout, + compute_pose_crop_limits_2d, + crop_limits_from_points, + load_camera_background_image, +) + + +class _DummyCalibration: + def __init__(self, image_size: tuple[int, int]) -> None: + self.image_size = image_size + + +def test_camera_layout_uses_more_screen_space_for_small_camera_sets(): + assert camera_layout(1) == (1, 1) + assert camera_layout(2) == (1, 2) + assert camera_layout(4) == (2, 2) + assert camera_layout(5) == (2, 3) + + +def test_compute_pose_crop_limits_2d_fills_missing_frames_from_neighbors(): + raw_2d = np.full((1, 3, 2, 2), np.nan, dtype=float) + raw_2d[0, 0] = np.array([[100.0, 50.0], [120.0, 90.0]]) + raw_2d[0, 2] = np.array([[140.0, 70.0], [180.0, 110.0]]) + calibrations = {"camA": _DummyCalibration((640, 480))} + + limits = compute_pose_crop_limits_2d(raw_2d, calibrations, ["camA"], margin=0.2) + + camera_limits = limits["camA"] + assert camera_limits.shape == (3, 4) + assert np.all(np.isfinite(camera_limits)) + np.testing.assert_allclose(camera_limits[1], camera_limits[0]) + + +def test_adjust_image_levels_keeps_dtype_and_changes_rgb_levels(): + image = np.array( + [ + [[10, 20, 30], [40, 50, 60]], + [[70, 80, 90], [100, 110, 120]], + ], + dtype=np.uint8, + ) + + adjusted = adjust_image_levels(image, brightness=1.1, contrast=1.2) + + assert adjusted.dtype == np.uint8 + assert adjusted.shape == image.shape + assert not np.array_equal(adjusted, image) + + +def test_crop_limits_from_points_falls_back_to_full_image_without_visible_points(): + x_limits, y_limits = crop_limits_from_points(np.full((2, 2), np.nan), width=640, height=480, margin=0.2) + + assert x_limits == (0.0, 640.0) + assert y_limits == (480.0, 0.0) + + +def test_load_camera_background_image_uses_resolved_path_and_adjustment(monkeypatch, tmp_path): + image_path = tmp_path / "cam_frame.png" + image_path.write_bytes(b"fake") + calls = [] + + monkeypatch.setattr( + two_d_view, + "resolve_execution_image_path", + lambda root, camera_name, frame_number: calls.append((root, camera_name, frame_number)) or image_path, + ) + + loaded = load_camera_background_image( + tmp_path, + "camA", + 12, + image_reader=lambda _path: np.ones((2, 2, 3), dtype=float) * 0.5, + brightness=1.1, + contrast=0.9, + ) + + assert loaded is not None + assert loaded.shape == (2, 2, 3) + assert calls == [(tmp_path, "camA", 12)] diff --git a/tests/test_reconstruction_bundle_pose_cache.py b/tests/test_reconstruction_bundle_pose_cache.py index cf8e3e4..bcfc82c 100644 --- a/tests/test_reconstruction_bundle_pose_cache.py +++ b/tests/test_reconstruction_bundle_pose_cache.py @@ -1,7 +1,24 @@ +import json +import math +from types import SimpleNamespace + import numpy as np -from reconstruction.reconstruction_bundle import epipolar_cache_metadata, load_or_compute_pose_data_variant_cache -from vitpose_ekf_pipeline import KP_INDEX, PoseData, apply_left_right_flip_corrections, reconstruction_cache_metadata +from reconstruction.reconstruction_bundle import ( + build_bundle_payload, + epipolar_cache_metadata, + load_or_build_model_cache, + load_or_compute_pose_data_variant_cache, + summarize_view_usage, +) +from vitpose_ekf_pipeline import ( + KP_INDEX, + PoseData, + SegmentLengths, + apply_left_right_flip_corrections, + metadata_cache_matches, + reconstruction_cache_metadata, +) def _make_pose_data() -> PoseData: @@ -177,3 +194,114 @@ def fake_flip_cache(**_kwargs): right_idx = KP_INDEX["right_shoulder"] np.testing.assert_allclose(corrected_a.keypoints[0, 0, left_idx], [20.0, 2.0]) np.testing.assert_allclose(corrected_b.keypoints[0, 0, right_idx], [10.0, 1.0]) + + +def test_load_or_build_model_cache_records_full_model_stage_time(tmp_path, monkeypatch): + reconstruction = SimpleNamespace(frames=np.array([0, 1, 2], dtype=int)) + lengths = SegmentLengths( + trunk_height=0.6, + head_length=0.2, + shoulder_half_width=0.18, + hip_half_width=0.12, + upper_arm_length=0.3, + forearm_length=0.25, + thigh_length=0.45, + shank_length=0.4, + eye_offset_x=0.03, + eye_offset_y=0.025, + ear_offset_y=0.06, + ) + + monkeypatch.setattr( + "reconstruction.reconstruction_bundle.estimate_segment_lengths", lambda *_args, **_kwargs: lengths + ) + monkeypatch.setattr( + "reconstruction.reconstruction_bundle.build_biomod", + lambda _lengths, output_path, **_kwargs: output_path.write_text("version 4", encoding="utf-8"), + ) + perf_counter_values = iter((10.0, 14.5)) + monkeypatch.setattr("reconstruction.reconstruction_bundle.time.perf_counter", lambda: next(perf_counter_values)) + + _cached_lengths, biomod_cache_path, cache_path, bootstrap_frame_idx, compute_time_s, source = ( + load_or_build_model_cache( + output_dir=tmp_path, + reconstruction=reconstruction, + reconstruction_cache_path=tmp_path / "triangulation_stage.npz", + fps=120.0, + subject_mass_kg=70.0, + initial_rotation_correction=True, + lengths_mode="full_triangulation", + model_variant="single_trunk", + symmetrize_limbs=True, + ) + ) + + assert source == "computed_now" + assert bootstrap_frame_idx == 0 + assert biomod_cache_path.exists() + assert cache_path.exists() + assert math.isclose(compute_time_s, 4.5) + + with np.load(cache_path, allow_pickle=True) as data: + assert math.isclose(float(np.asarray(data["compute_time_s"]).item()), 4.5) + + +def test_reconstruction_cache_metadata_and_match_support_none_threshold(tmp_path): + pose_data = _make_pose_data() + metadata = reconstruction_cache_metadata( + pose_data, + error_threshold_px=None, + min_cameras_for_triangulation=2, + epipolar_threshold_px=15.0, + triangulation_method="once", + pose_data_mode="raw", + pose_filter_window=9, + pose_outlier_threshold_ratio=0.1, + pose_amplitude_lower_percentile=5.0, + pose_amplitude_upper_percentile=95.0, + ) + np.savez(tmp_path / "cache.npz", metadata=np.asarray(json.dumps(metadata), dtype=object)) + + assert metadata["reprojection_threshold_px"] is None + assert metadata_cache_matches(tmp_path / "cache.npz", metadata) + + +def test_build_bundle_payload_includes_excluded_views(): + excluded_views = np.ones((1, 17, 1), dtype=bool) + excluded_views[0, 0, 0] = False + payload = build_bundle_payload( + name="demo", + family="triangulation", + frames=np.array([0], dtype=int), + time_s=np.array([0.0], dtype=float), + camera_names=["cam0"], + points_3d=np.full((1, 17, 3), np.nan, dtype=float), + q_names=np.array([], dtype=object), + q=None, + qdot=None, + qddot=None, + q_root=np.zeros((1, 6), dtype=float), + qdot_root=np.zeros((1, 6), dtype=float), + reprojection_errors=np.full((1, 17, 1), np.nan, dtype=float), + summary={}, + excluded_views=excluded_views, + ) + + np.testing.assert_array_equal(payload["excluded_views"], excluded_views) + + +def test_summarize_view_usage_reports_included_and_excluded_ratios(): + excluded_views = np.array( + [ + [[False, True], [True, True]], + [[False, False], [True, False]], + ], + dtype=bool, + ) + + stats = summarize_view_usage(excluded_views, ["cam0", "cam1"]) + + assert math.isclose(stats["included_ratio"], 0.5) + assert math.isclose(stats["excluded_ratio"], 0.5) + assert math.isclose(stats["per_camera"]["cam0"]["included_ratio"], 0.5) + assert math.isclose(stats["per_camera"]["cam1"]["excluded_ratio"], 0.5) diff --git a/tests/test_reconstruction_bundle_trc.py b/tests/test_reconstruction_bundle_trc.py index 4ccb36c..1dee3fa 100644 --- a/tests/test_reconstruction_bundle_trc.py +++ b/tests/test_reconstruction_bundle_trc.py @@ -1,8 +1,9 @@ from pathlib import Path +from types import SimpleNamespace import numpy as np -from reconstruction.reconstruction_bundle import parse_trc_points, root_kinematics_from_trc +from reconstruction.reconstruction_bundle import build_pose2sim_bundle, parse_trc_points, root_kinematics_from_trc from reconstruction.reconstruction_dataset import write_trc_root_kinematics_sidecar @@ -80,3 +81,68 @@ def test_root_kinematics_from_trc_prefers_exported_sidecar(tmp_path: Path): np.testing.assert_allclose(resolved_qdot_root, qdot_root) assert correction_applied is True assert source == "trc_root_kinematics_sidecar" + + +def test_build_pose2sim_bundle_initializes_excluded_views_without_reconstruction_object(tmp_path, monkeypatch): + trc_path = tmp_path / "demo.trc" + trc_path.write_text("demo", encoding="utf-8") + captured = {} + + monkeypatch.setattr( + "reconstruction.reconstruction_bundle.parse_trc_points", + lambda _path: ( + np.array([0, 1], dtype=int), + np.array([0.0, 1.0 / 120.0], dtype=float), + np.zeros((2, 17, 3), dtype=float), + 120.0, + ), + ) + monkeypatch.setattr( + "reconstruction.reconstruction_bundle.root_kinematics_from_trc", + lambda *_args, **_kwargs: ( + np.zeros((2, 6), dtype=float), + np.zeros((2, 6), dtype=float), + False, + "trc_points", + ), + ) + monkeypatch.setattr( + "reconstruction.reconstruction_bundle.compute_points_reprojection_error_per_view", + lambda *_args, **_kwargs: np.zeros((2, 17, 2), dtype=float), + ) + monkeypatch.setattr( + "reconstruction.reconstruction_bundle.summarize_reprojection_errors", + lambda _errors, camera_names: { + "mean_px": 0.0, + "std_px": 0.0, + "per_keypoint": {}, + "per_camera": {str(name): {"mean_px": 0.0, "std_px": 0.0} for name in camera_names}, + }, + ) + monkeypatch.setattr( + "reconstruction.reconstruction_bundle.root_z_correction_angle_from_points", + lambda *_args, **_kwargs: 0.0, + ) + + def fake_build_bundle_payload(**kwargs): + captured.update(kwargs) + return {"excluded_views": kwargs["excluded_views"]} + + monkeypatch.setattr("reconstruction.reconstruction_bundle.build_bundle_payload", fake_build_bundle_payload) + monkeypatch.setattr("reconstruction.reconstruction_bundle.write_bundle", lambda *_args, **_kwargs: None) + + pose_data = SimpleNamespace(camera_names=["cam0", "cam1"]) + + result = build_pose2sim_bundle( + name="pose2sim_demo", + output_dir=tmp_path / "output", + pose2sim_trc=trc_path, + calibrations={"cam0": object(), "cam1": object()}, + pose_data=pose_data, + fps=120.0, + initial_rotation_correction=False, + unwrap_root=False, + ) + + np.testing.assert_array_equal(result.payload["excluded_views"], np.zeros((2, 17, 2), dtype=bool)) + np.testing.assert_array_equal(captured["excluded_views"], np.zeros((2, 17, 2), dtype=bool)) diff --git a/tests/test_reconstruction_profiles.py b/tests/test_reconstruction_profiles.py index 57789a5..4c56651 100644 --- a/tests/test_reconstruction_profiles.py +++ b/tests/test_reconstruction_profiles.py @@ -22,6 +22,19 @@ def test_canonical_profile_name_includes_camera_names(): assert canonical_profile_name(profile) == "ekf_2d_acc_cams_m11139_m11140" +def test_canonical_profile_name_uses_all_cameras_token(): + profile = validate_profile( + ReconstructionProfile( + name="", + family="ekf_2d", + predictor="acc", + use_all_cameras=True, + camera_names=["M11139", "M11140"], + ) + ) + assert canonical_profile_name(profile) == "ekf_2d_acc_all_cameras" + + def test_build_pipeline_command_prefers_profile_camera_names_over_override(): profile = validate_profile( ReconstructionProfile( @@ -44,6 +57,48 @@ def test_build_pipeline_command_prefers_profile_camera_names_over_override(): assert camera_names_arg == "M11139,M11140" +def test_build_pipeline_command_omits_camera_names_for_all_cameras_profile(): + profile = validate_profile( + ReconstructionProfile( + name="ekf_all_cameras", + family="ekf_2d", + predictor="acc", + use_all_cameras=True, + ) + ) + cmd = build_pipeline_command( + profile=profile, + output_root=Path("outputs"), + calib=Path("inputs/calibration/Calib.toml"), + keypoints=Path("inputs/keypoints/1_partie_0429_keypoints.json"), + pose2sim_trc=Path("inputs/trc/1_partie_0429.trc"), + camera_names_override=["M11141", "M11458"], + python_executable="python", + ) + assert "--camera-names" not in cmd + + +def test_build_pipeline_command_forces_root_unwrap_off(): + profile = validate_profile( + ReconstructionProfile( + name="ekf_single_unwrap", + family="ekf_2d", + predictor="acc", + root_unwrap_mode="single", + ) + ) + cmd = build_pipeline_command( + profile=profile, + output_root=Path("outputs"), + calib=Path("inputs/calibration/Calib.toml"), + keypoints=Path("inputs/keypoints/1_partie_0429_keypoints.json"), + pose2sim_trc=Path("inputs/trc/1_partie_0429.trc"), + python_executable="python", + ) + assert "--root-unwrap-mode" in cmd + assert cmd[cmd.index("--root-unwrap-mode") + 1] == "off" + + def test_canonical_profile_name_includes_root_pose_bootstrap_flag(): profile = validate_profile( ReconstructionProfile( @@ -169,6 +224,81 @@ def test_build_pipeline_command_includes_model_variant_for_auto_built_ekf_profil assert cmd[cmd.index("--model-variant") + 1] == "back_3dof" +def test_build_pipeline_command_includes_upper_back_prior_parameters_for_ekf2d(): + profile = validate_profile( + ReconstructionProfile( + name="ekf2d_back", + family="ekf_2d", + predictor="acc", + ekf_model_path="output/1_partie_0429/models/model_demo/model_demo.bioMod", + upper_back_sagittal_gain=0.35, + upper_back_pseudo_std_deg=8.0, + ankle_bed_pseudo_obs=True, + ankle_bed_pseudo_std_m=0.015, + ) + ) + + cmd = build_pipeline_command( + profile=profile, + output_root=Path("outputs"), + calib=Path("inputs/calibration/Calib.toml"), + keypoints=Path("inputs/keypoints/1_partie_0429_keypoints.json"), + pose2sim_trc=Path("inputs/trc/1_partie_0429.trc"), + python_executable="python", + ) + + assert cmd[cmd.index("--upper-back-sagittal-gain") + 1] == "0.35" + assert cmd[cmd.index("--upper-back-pseudo-std-deg") + 1] == "8.0" + assert "--ankle-bed-pseudo-obs" in cmd + assert cmd[cmd.index("--ankle-bed-pseudo-std-m") + 1] == "0.015" + + +def test_validate_profile_accepts_history3_predictors(): + profile = validate_profile( + ReconstructionProfile( + name="ekf2d_history", + family="ekf_2d", + predictor="dyn_history3", + ekf_model_path="output/1_partie_0429/models/model_demo/model_demo.bioMod", + ) + ) + + assert profile.predictor == "dyn_history3" + + +def test_canonical_profile_name_marks_asymmetric_auto_built_models(): + profile = validate_profile( + ReconstructionProfile( + name="", + family="ekf_3d", + symmetrize_limbs=False, + ) + ) + + assert canonical_profile_name(profile).startswith("ekf_3d_asym") + + +def test_build_pipeline_command_disables_limb_symmetrization_when_requested(): + profile = validate_profile( + ReconstructionProfile( + name="ekf3d_asym", + family="ekf_3d", + symmetrize_limbs=False, + ) + ) + + cmd = build_pipeline_command( + profile=profile, + output_root=Path("outputs"), + calib=Path("inputs/calibration/Calib.toml"), + keypoints=Path("inputs/keypoints/1_partie_0429_keypoints.json"), + pose2sim_trc=Path("inputs/trc/1_partie_0429.trc"), + python_executable="python", + ) + + assert "--no-symmetrize-limbs" in cmd + + def test_build_pipeline_command_includes_frame_stride(): profile = validate_profile( ReconstructionProfile( @@ -210,6 +340,40 @@ def test_build_pipeline_command_includes_frame_stride_for_ekf2d(): assert cmd[cmd.index("--frame-stride") + 1] == "4" +def test_canonical_profile_name_includes_non_default_reprojection_threshold(): + profile = validate_profile( + ReconstructionProfile( + name="", + family="triangulation", + reprojection_threshold_px=10.0, + ) + ) + + assert canonical_profile_name(profile) == "triangulation_exhaustive_tau10" + + +def test_build_pipeline_command_supports_none_reprojection_threshold(): + profile = validate_profile( + ReconstructionProfile( + name="tri_once_none", + family="triangulation", + triangulation_method="once", + reprojection_threshold_px=None, + ) + ) + + cmd = build_pipeline_command( + profile=profile, + output_root=Path("outputs"), + calib=Path("inputs/calibration/Calib.toml"), + keypoints=Path("inputs/keypoints/1_partie_0429_keypoints.json"), + pose2sim_trc=Path("inputs/trc/1_partie_0429.trc"), + python_executable="python", + ) + + assert cmd[cmd.index("--reprojection-threshold-px") + 1] == "none" + + def test_validate_profile_pose2sim_forces_frame_stride_to_one(): profile = validate_profile( ReconstructionProfile( diff --git a/tests/test_reconstruction_registry_models.py b/tests/test_reconstruction_registry_models.py index 459fdb1..fe20ae8 100644 --- a/tests/test_reconstruction_registry_models.py +++ b/tests/test_reconstruction_registry_models.py @@ -13,6 +13,12 @@ def test_default_model_stem_includes_pose_correction_mode() -> None: assert flip_tri == "model_2d_cleaned_exhaustive_flip_triangulation" +def test_default_model_stem_includes_asym_suffix_when_limbs_are_not_symmetrized() -> None: + asym = default_model_stem("cleaned", "exhaustive", symmetrize_limbs=False) + + assert asym == "model_2d_cleaned_exhaustive_asym" + + def test_model_output_dir_changes_with_pose_correction_mode() -> None: output_root = Path("outputs") dataset_name = "demo" diff --git a/tests/test_reconstruction_timings.py b/tests/test_reconstruction_timings.py index a640836..ad40a92 100644 --- a/tests/test_reconstruction_timings.py +++ b/tests/test_reconstruction_timings.py @@ -1,3 +1,8 @@ +import math +from pathlib import Path + +import numpy as np + from reconstruction.reconstruction_timings import ( build_pipeline_diagram, compute_time_seconds, @@ -5,8 +10,10 @@ format_seconds_brief, humanize_stage_name, make_timing_stage, + model_compute_seconds, objective_total_seconds, parse_stage_timings, + reconstruction_run_seconds, ) @@ -71,6 +78,104 @@ def test_objective_total_seconds_prefers_pipeline_trace_sum(): assert objective_total_seconds(summary) == 1.5 +def test_model_and_reconstruction_seconds_split_pipeline_total(): + summary = { + "pipeline_timing": { + "stages": [ + make_timing_stage("pose_data", "2D cleaning", compute_time_s=0.4, source="cache"), + make_timing_stage("triangulation", "Triangulation", compute_time_s=1.1), + make_timing_stage("model_creation", "Model creation", compute_time_s=0.6), + make_timing_stage("ekf_2d", "EKF 2D", compute_time_s=0.2), + ] + } + } + assert math.isclose(objective_total_seconds(summary), 2.3) + assert math.isclose(model_compute_seconds(summary), 0.6) + assert math.isclose(reconstruction_run_seconds(summary), 1.7) + + +def test_model_and_reconstruction_seconds_prefer_stage_wall_times(): + summary = { + "stage_timings_s": { + "triangulation_s": 2.0, + "model_creation_s": 0.7, + "ekf_2d_s": 1.3, + "total_s": 4.0, + }, + "pipeline_timing": { + "stages": [ + make_timing_stage("triangulation", "Triangulation", compute_time_s=2.0), + make_timing_stage("model_creation", "Model creation", compute_time_s=0.0, source="provided"), + make_timing_stage("ekf_2d", "EKF 2D", compute_time_s=1.3), + ] + }, + } + assert math.isclose(objective_total_seconds(summary), 3.3) + assert math.isclose(model_compute_seconds(summary), 0.7) + assert math.isclose(reconstruction_run_seconds(summary), 2.6) + + +def test_objective_model_time_can_be_recovered_from_selected_biomod_cache(tmp_path: Path): + model_dir = tmp_path / "model_demo" + model_dir.mkdir() + biomod_path = model_dir / "model_demo.bioMod" + biomod_path.write_text("version 4", encoding="utf-8") + np.savez( + model_dir / "model_stage.npz", + lengths=np.asarray("{}", dtype=object), + biomod_path=np.asarray(str(biomod_path), dtype=object), + compute_time_s=np.asarray(1.25, dtype=float), + metadata=np.asarray("{}", dtype=object), + ) + + summary = { + "selected_model": str(biomod_path), + "pipeline_timing": { + "stages": [ + make_timing_stage("triangulation", "Triangulation", compute_time_s=2.0), + make_timing_stage("model_creation", "Model creation", compute_time_s=0.0, source="provided"), + make_timing_stage("ekf_3d", "EKF 3D", compute_time_s=0.5), + ] + }, + } + + assert math.isclose(model_compute_seconds(summary), 1.25) + assert math.isclose(objective_total_seconds(summary), 3.75) + assert math.isclose(reconstruction_run_seconds(summary), 2.5) + + +def test_objective_total_recomputes_stages_when_explicit_total_misses_model_cache(tmp_path: Path): + model_dir = tmp_path / "model_demo" + model_dir.mkdir() + biomod_path = model_dir / "model_demo.bioMod" + biomod_path.write_text("version 4", encoding="utf-8") + np.savez( + model_dir / "model_stage.npz", + lengths=np.asarray("{}", dtype=object), + biomod_path=np.asarray(str(biomod_path), dtype=object), + compute_time_s=np.asarray(11.3, dtype=float), + metadata=np.asarray("{}", dtype=object), + ) + + summary = { + "selected_model": str(biomod_path), + "pipeline_timing": { + "objective_total_s": 4.2, + "stages": [ + make_timing_stage("pose_data", "2D cleaning", compute_time_s=0.4), + make_timing_stage("triangulation", "Triangulation", compute_time_s=0.1), + make_timing_stage("model_creation", "Model creation", compute_time_s=0.0, source="provided"), + make_timing_stage("ekf_2d_initial_state", "EKF 2D initial state", compute_time_s=0.4), + make_timing_stage("ekf_2d", "EKF 2D", compute_time_s=3.3), + ], + }, + } + + assert math.isclose(objective_total_seconds(summary), 15.5) + assert math.isclose(model_compute_seconds(summary), 11.3) + assert math.isclose(reconstruction_run_seconds(summary), 4.2) + + def test_build_pipeline_diagram_marks_cached_stages(): diagram = build_pipeline_diagram( [ @@ -98,11 +203,33 @@ def test_format_reconstruction_timing_details_uses_pipeline_trace_when_present() } details = format_reconstruction_timing_details(summary) assert "Objective compute time: 1.80 s" in details + assert "Reconstruction time (excl. model): 1.80 s" in details + assert "Model time: -" in details assert "Current run wall time: 0.40 s" in details assert "2D cleaning [cache] -> Triangulation -> EKF 2D" in details assert "cache: /tmp/p" in details +def test_format_reconstruction_timing_details_shows_model_time_when_present(): + summary = { + "name": "ekf_2d_acc", + "family": "ekf_2d", + "n_frames": 100, + "duration_s": 0.83, + "pipeline_timing": { + "stages": [ + make_timing_stage("triangulation", "Triangulation", compute_time_s=1.0), + make_timing_stage("model_creation", "Model creation", compute_time_s=0.4), + make_timing_stage("ekf_2d", "EKF 2D", compute_time_s=0.6), + ], + }, + } + details = format_reconstruction_timing_details(summary) + assert "Objective compute time: 2.00 s" in details + assert "Reconstruction time (excl. model): 1.60 s" in details + assert "Model time: 0.40 s" in details + + def test_format_reconstruction_timing_details_shows_trc_source_details(): source_path = "output/1_partie_0429/reconstructions/pose2sim_rotfix/markers_from_q.trc" summary = { diff --git a/tests/test_root_kinematics.py b/tests/test_root_kinematics.py index 68b386b..55ed425 100644 --- a/tests/test_root_kinematics.py +++ b/tests/test_root_kinematics.py @@ -99,6 +99,68 @@ def test_extract_root_from_q_can_skip_unwrap_but_keep_rotation_reextraction(): assert root_q[1, 5] < -3.0 +def test_extract_root_from_q_applies_second_reextract_unwrap_pass_for_root_rotations(): + q_names = np.asarray( + [ + "TRUNK:TransX", + "TRUNK:TransY", + "TRUNK:TransZ", + "TRUNK:RotY", + "TRUNK:RotX", + "TRUNK:RotZ", + ], + dtype=object, + ) + q = np.array( + [ + [0.0, 0.0, 0.0, math.pi + 0.25, -0.15, 2.0 * math.pi + 0.30], + [0.0, 0.0, 0.0, math.pi + 0.27, -0.14, 4.0 * math.pi + 0.32], + ], + dtype=float, + ) + + root_q = extract_root_from_q(q_names, q, unwrap_rotations=True, renormalize_rotations=True) + + for frame_idx in range(q.shape[0]): + np.testing.assert_allclose( + Rotation.from_euler(TRUNK_ROOT_ROTATION_SCIPY_SEQUENCE, root_q[frame_idx, ROOT_ROTATION_SLICE]).as_matrix(), + Rotation.from_euler(TRUNK_ROOT_ROTATION_SCIPY_SEQUENCE, q[frame_idx, 3:6]).as_matrix(), + atol=1e-12, + ) + assert abs(root_q[0, 5]) < abs(q[0, 5]) + assert abs(root_q[1, 5] - root_q[0, 5]) < math.pi + + +def test_extract_root_from_q_accepts_single_unwrap_mode(): + q_names = np.asarray( + [ + "TRUNK:TransX", + "TRUNK:TransY", + "TRUNK:TransZ", + "TRUNK:RotY", + "TRUNK:RotX", + "TRUNK:RotZ", + ], + dtype=object, + ) + q = np.array( + [ + [0.0, 0.0, 0.0, math.pi + 0.20, -0.12, 2.0 * math.pi + 0.10], + [0.0, 0.0, 0.0, math.pi + 0.22, -0.11, 2.0 * math.pi + 0.12], + ], + dtype=float, + ) + + root_q = extract_root_from_q(q_names, q, unwrap_rotations=True, renormalize_rotations=True, unwrap_mode="single") + + for frame_idx in range(q.shape[0]): + np.testing.assert_allclose( + Rotation.from_euler(TRUNK_ROOT_ROTATION_SCIPY_SEQUENCE, root_q[frame_idx, ROOT_ROTATION_SLICE]).as_matrix(), + Rotation.from_euler(TRUNK_ROOT_ROTATION_SCIPY_SEQUENCE, q[frame_idx, 3:6]).as_matrix(), + atol=1e-12, + ) + + def test_centered_finite_difference_handles_edges_and_nans(): values = np.array( [ diff --git a/tests/test_root_series.py b/tests/test_root_series.py index cce0fe8..f3face1 100644 --- a/tests/test_root_series.py +++ b/tests/test_root_series.py @@ -3,11 +3,13 @@ from kinematics.root_kinematics import TRUNK_ROOT_ROTATION_SCIPY_SEQUENCE from kinematics.root_series import ( + interpolate_trunk_marker_gaps_for_root, quantity_unit_label, root_axis_labels, root_rotation_matrices_from_points, root_rotation_matrices_from_series, root_series_from_model_markers, + root_series_from_points, root_series_from_precomputed, root_series_from_q, scale_root_series_rotations, @@ -143,3 +145,42 @@ def marker_builder(_biomod_path, q_series): np.testing.assert_allclose(marker_points, points, atol=1e-12) np.testing.assert_allclose(series[:, :3], 0.5 * (points[:, 11] + points[:, 12]), atol=1e-12) + + +def test_interpolate_trunk_marker_gaps_for_root_fills_short_edge_gaps(): + points = np.full((5, 17, 3), np.nan, dtype=float) + for frame_idx in range(points.shape[0]): + z = 1.0 + 0.1 * frame_idx + points[frame_idx, 11] = [-0.2, 0.0, z] + points[frame_idx, 12] = [0.2, 0.0, z] + points[frame_idx, 5] = [-0.25, 0.0, z + 0.5] + points[frame_idx, 6] = [0.25, 0.0, z + 0.5] + points[0, 5] = [np.nan, np.nan, np.nan] + points[-1, 6] = [np.nan, np.nan, np.nan] + + filled = interpolate_trunk_marker_gaps_for_root(points, max_gap_frames=2) + + assert np.all(np.isfinite(filled[0, 5])) + assert np.all(np.isfinite(filled[-1, 6])) + + +def test_root_series_from_points_interpolates_before_root_extraction(): + points = np.full((5, 17, 3), np.nan, dtype=float) + for frame_idx in range(points.shape[0]): + z = 1.0 + 0.1 * frame_idx + points[frame_idx, 11] = [-0.2, 0.0, z] + points[frame_idx, 12] = [0.2, 0.0, z] + points[frame_idx, 5] = [-0.25, 0.0, z + 0.5] + points[frame_idx, 6] = [0.25, 0.0, z + 0.5] + points[0, 5] = [np.nan, np.nan, np.nan] + + series = root_series_from_points( + points, + quantity="q", + dt=1.0 / 120.0, + initial_rotation_correction=False, + unwrap_rotations=True, + interpolate_gap_frames=2, + ) + + assert np.all(np.isfinite(series[0])) diff --git a/tests/test_trampoline_displacement.py b/tests/test_trampoline_displacement.py index ad4dfac..7781a8c 100644 --- a/tests/test_trampoline_displacement.py +++ b/tests/test_trampoline_displacement.py @@ -2,6 +2,7 @@ from judging.dd_analysis import DDSessionAnalysis, JumpSegment from judging.trampoline_displacement import ( + TRAMPOLINE_BED_HEIGHT_M, TRAMPOLINE_GEOMETRY, X_INNER, X_MAX, @@ -66,6 +67,7 @@ def test_trampoline_geometry_is_centered_and_contains_expected_markers(): assert X_INNER < X_MAX assert Y_INNER < Y_MAX np.testing.assert_allclose(geometry.cross["left"], TRAMPOLINE_GEOMETRY.cross["left"]) + assert abs(TRAMPOLINE_BED_HEIGHT_M - 1.2441549663601654) < 1e-12 def test_analyze_trampoline_contacts_uses_contact_medians_and_sums_penalties(): diff --git a/tests/test_vitpose_ekf_hotspots.py b/tests/test_vitpose_ekf_hotspots.py index 946affc..1f39907 100644 --- a/tests/test_vitpose_ekf_hotspots.py +++ b/tests/test_vitpose_ekf_hotspots.py @@ -1,6 +1,7 @@ import json import math from pathlib import Path +from types import SimpleNamespace import numpy as np from scipy.spatial.transform import Rotation @@ -20,6 +21,7 @@ canonical_coherence_method, canonical_triangulation_method, canonicalize_model_q_rotation_branches, + canonicalize_state_q_rotation_branches, choose_ekf_prediction_gate_measurements, compute_biorbd_kalman_initial_state, compute_camera_epipolar_cost, @@ -31,11 +33,13 @@ compute_epipolar_frame_coherence, compute_framewise_epipolar_measurement_weights, load_pose_data, + once_triangulation_from_best_cameras, project_point_with_projection_matrices, q_names_from_model, sample_frames_uniformly, sampson_error_pixels_vectorized, smooth_camera_time_series, + stack_measurement_blocks, support_coherence_method_for_runtime, symmetric_epipolar_distance_vectorized, triangulation_method_from_coherence_method, @@ -95,6 +99,79 @@ def test_project_point_with_projection_matrices_matches_camera_projection(): np.testing.assert_allclose(projected, expected, atol=1e-8) +def test_once_triangulation_ignores_nan_observations_and_requires_enough_valid_views(): + point = np.array([0.2, -0.1, 2.0], dtype=float) + cameras = [_make_camera("cam0", 0.0), _make_camera("cam1", 1.0), _make_camera("cam2", -0.8)] + observations = np.asarray([camera.project_point(point) for camera in cameras], dtype=float) + observations[2] = np.array([np.nan, np.nan], dtype=float) + confidences = np.array([1.0, 0.8, 0.9], dtype=float) + + triangulated, mean_error, per_view_error, coherence_per_view, excluded_views = once_triangulation_from_best_cameras( + [camera.P for camera in cameras], + observations, + confidences, + cameras, + error_threshold_px=5.0, + min_cameras_for_triangulation=2, + ) + + np.testing.assert_allclose(triangulated, point, atol=1e-8) + assert np.isfinite(mean_error) + assert np.isnan(per_view_error[2]) + assert coherence_per_view[2] == 0.0 + assert bool(excluded_views[2]) is True + + triangulated, mean_error, per_view_error, coherence_per_view, excluded_views = once_triangulation_from_best_cameras( + [camera.P for camera in cameras], + observations, + confidences, + cameras, + error_threshold_px=5.0, + min_cameras_for_triangulation=3, + ) + + assert np.all(np.isnan(triangulated)) + assert np.isnan(mean_error) + assert np.all(np.isnan(per_view_error)) + assert np.all(coherence_per_view == 0.0) + assert np.array_equal(excluded_views, np.array([False, False, True])) + + +def test_once_triangulation_none_threshold_keeps_high_reprojection_solution(): + point = np.array([0.2, -0.1, 2.0], dtype=float) + cameras = [_make_camera("cam0", 0.0), _make_camera("cam1", 1.0), _make_camera("cam2", -0.8)] + observations = np.asarray([camera.project_point(point) for camera in cameras], dtype=float) + observations[2] += np.array([80.0, -60.0], dtype=float) + confidences = np.array([1.0, 0.9, 0.8], dtype=float) + + triangulated_limited, mean_error_limited, *_ = once_triangulation_from_best_cameras( + [camera.P for camera in cameras], + observations, + confidences, + cameras, + error_threshold_px=15.0, + min_cameras_for_triangulation=2, + ) + assert np.all(np.isnan(triangulated_limited)) + assert mean_error_limited > 15.0 + + triangulated_unbounded, mean_error_unbounded, per_view_error, coherence_per_view, excluded_views = ( + once_triangulation_from_best_cameras( + [camera.P for camera in cameras], + observations, + confidences, + cameras, + error_threshold_px=None, + min_cameras_for_triangulation=2, + ) + ) + assert np.all(np.isfinite(triangulated_unbounded)) + assert mean_error_unbounded > 15.0 + assert np.count_nonzero(np.isfinite(per_view_error)) == 3 + assert np.all(coherence_per_view >= 0.0) + assert np.array_equal(excluded_views, np.array([False, False, False])) + + def test_project_points_and_jacobians_matches_scalar_projection(): camera = _make_camera("cam0", 0.2) points = np.array([[0.15, 0.05, 2.5], [0.1, -0.2, 3.0]], dtype=float) @@ -190,6 +267,26 @@ def test_sequential_measurement_update_matches_batch_update(): np.testing.assert_allclose(sequential_covariance, batch_covariance, atol=1e-8, rtol=1e-8) +def test_stack_measurement_blocks_concatenates_valid_blocks(): + z1 = np.array([1.0, 2.0], dtype=float) + h1 = np.array([0.1, 0.2], dtype=float) + H1 = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=float) + R1 = np.array([0.5, 0.6], dtype=float) + z2 = np.array([3.0], dtype=float) + h2 = np.array([0.3], dtype=float) + H2 = np.array([[0.4, 0.5]], dtype=float) + R2 = np.array([0.7], dtype=float) + + stacked = stack_measurement_blocks([(z1, h1, H1, R1), (z2, h2, H2, R2)], nq=2) + + assert stacked is not None + z, h, H_q, R_diag_array = stacked + np.testing.assert_allclose(z, np.array([1.0, 2.0, 3.0], dtype=float)) + np.testing.assert_allclose(h, np.array([0.1, 0.2, 0.3], dtype=float)) + np.testing.assert_allclose(H_q, np.array([[1.0, 0.0], [0.0, 1.0], [0.4, 0.5]], dtype=float)) + np.testing.assert_allclose(R_diag_array, np.array([0.5, 0.6, 0.7], dtype=float)) + + def test_weighted_median_prefers_heavily_weighted_values(): values = np.array([1.0, 10.0, 100.0], dtype=float) weights = np.array([5.0, 1.0, 1.0], dtype=float) @@ -564,6 +661,52 @@ def test_load_pose_data_ignores_unselected_json_cameras(tmp_path: Path): assert pose_data.keypoints.shape[0] == 2 +def test_load_pose_data_annotated_overlays_sparse_annotations(tmp_path: Path): + keypoints_path = tmp_path / "keypoints.json" + annotations_path = tmp_path / "annotations.json" + payload = { + "Camera1_M11139": { + "frames": [0, 1], + "keypoints": np.zeros((2, 17, 2), dtype=float).tolist(), + "scores": np.ones((2, 17), dtype=float).tolist(), + } + } + keypoints_path.write_text(json.dumps(payload), encoding="utf-8") + annotations_path.write_text( + json.dumps( + { + "annotations": { + "M11139": { + "1": { + "left_wrist": { + "xy": [123.0, 456.0], + "score": 0.75, + } + } + } + } + } + ), + encoding="utf-8", + ) + calibrations = {"M11139": _make_camera("M11139", 0.0)} + + pose_data = load_pose_data( + keypoints_path, + calibrations, + data_mode="annotated", + annotations_path=annotations_path, + ) + + left_wrist_idx = KP_INDEX["left_wrist"] + np.testing.assert_allclose(pose_data.keypoints[0, 1, left_wrist_idx], np.array([123.0, 456.0], dtype=float)) + assert pose_data.scores[0, 1, left_wrist_idx] == 0.75 + np.testing.assert_allclose(pose_data.raw_keypoints[0, 1, left_wrist_idx], np.array([0.0, 0.0], dtype=float)) + np.testing.assert_allclose( + pose_data.annotated_keypoints[0, 1, left_wrist_idx], np.array([123.0, 456.0], dtype=float) + ) + + def test_vectorized_epipolar_cost_matches_scalar_versions(): cameras = [_make_camera("cam0", 0.0), _make_camera("cam1", 0.5), _make_camera("cam2", 2.0)] point = np.array([0.2, -0.1, 2.5], dtype=float) @@ -710,7 +853,9 @@ def test_compute_biorbd_kalman_initial_state_root_pose_zero_rest(monkeypatch): vitpose_ekf_pipeline, "initial_state_from_triangulation", lambda _model, _reconstruction: triangulation_state ) monkeypatch.setattr( - vitpose_ekf_pipeline, "first_valid_root_pose_from_triangulation", lambda _reconstruction: (12, root_pose) + vitpose_ekf_pipeline, + "first_valid_root_pose_from_triangulation", + lambda _reconstruction, **_kwargs: (12, root_pose), ) state, diagnostics = compute_biorbd_kalman_initial_state( @@ -727,9 +872,9 @@ def test_compute_biorbd_kalman_initial_state_root_pose_zero_rest(monkeypatch): assert state[q_names.index("TRUNK:TransX")] == 1.0 assert state[q_names.index("TRUNK:TransY")] == 2.0 assert state[q_names.index("TRUNK:TransZ")] == 3.0 - assert state[q_names.index("TRUNK:RotY")] == 0.4 - assert state[q_names.index("TRUNK:RotX")] == -0.2 - assert state[q_names.index("TRUNK:RotZ")] == 1.1 + assert math.isclose(state[q_names.index("TRUNK:RotY")], 0.4) + assert math.isclose(state[q_names.index("TRUNK:RotX")], -0.2) + assert math.isclose(state[q_names.index("TRUNK:RotZ")], 1.1) assert state[q_names.index("LEFT_UPPER_ARM:RotY")] == 0.0 assert state[q_names.index("LEFT_THIGH:RotY")] == 0.0 @@ -797,6 +942,27 @@ def test_canonicalize_model_q_rotation_branches_reextracts_multi_axis_blocks(): assert abs(canonical_q[q_names.index("TRUNK:RotZ")]) < abs(root_input[2]) +def test_canonicalize_state_q_rotation_branches_only_changes_q_block(): + model = _FakeModel() + q_names = q_names_from_model(model) + nq = model.nbQ() + state = np.zeros(3 * nq, dtype=float) + state[q_names.index("TRUNK:RotY")] = math.pi + 0.4 + state[q_names.index("TRUNK:RotX")] = -0.2 + state[q_names.index("TRUNK:RotZ")] = 2.0 * math.pi + 0.3 + state[nq:] = 7.0 + + canonical_state = canonicalize_state_q_rotation_branches(model, state) + + np.testing.assert_allclose(canonical_state[nq:], state[nq:], atol=1e-12) + np.testing.assert_allclose( + Rotation.from_euler("YXZ", canonical_state[[3, 4, 5]], degrees=False).as_matrix(), + Rotation.from_euler("YXZ", state[[3, 4, 5]], degrees=False).as_matrix(), + atol=1e-12, + ) + assert abs(canonical_state[q_names.index("TRUNK:RotZ")]) < abs(state[q_names.index("TRUNK:RotZ")]) + + def test_initial_state_from_ekf_bootstrap_canonicalizes_rotation_branches(monkeypatch): model = _FakeModel() q_names = q_names_from_model(model) @@ -853,6 +1019,28 @@ def update(self, predicted_state, predicted_covariance, frame_idx): ) +def test_compute_biorbd_kalman_initial_state_canonicalizes_triangulation_ik(monkeypatch): + model = _FakeModel() + q_names = q_names_from_model(model) + nq = model.nbQ() + state = np.zeros(3 * nq, dtype=float) + state[q_names.index("TRUNK:RotY")] = math.pi + 0.35 + state[q_names.index("TRUNK:RotX")] = -0.15 + state[q_names.index("TRUNK:RotZ")] = 2.0 * math.pi + 0.25 + + monkeypatch.setattr(vitpose_ekf_pipeline, "initial_state_from_triangulation", lambda *_args, **_kwargs: state) + + corrected_state, diagnostics = compute_biorbd_kalman_initial_state(model, object(), method="triangulation_ik") + + assert diagnostics["method"] == "triangulation_ik" + np.testing.assert_allclose( + Rotation.from_euler("YXZ", corrected_state[[3, 4, 5]], degrees=False).as_matrix(), + Rotation.from_euler("YXZ", state[[3, 4, 5]], degrees=False).as_matrix(), + atol=1e-12, + ) + assert abs(corrected_state[q_names.index("TRUNK:RotZ")]) < abs(state[q_names.index("TRUNK:RotZ")]) + + def test_choose_ekf_prediction_gate_measurements_prefers_swapped_when_prediction_matches(): frame_keypoints = np.full((17, 2), np.nan, dtype=float) frame_variances = np.full(17, np.inf, dtype=float) @@ -945,7 +1133,7 @@ def test_choose_ekf_prediction_gate_measurements_skips_when_error_delta_is_too_s def test_upper_back_pseudo_measurement_block_tracks_mean_hip_flexion(): ekf = vitpose_ekf_pipeline.MultiViewKinematicEKF.__new__(vitpose_ekf_pipeline.MultiViewKinematicEKF) ekf.nq = 6 - ekf.upper_back_rotx_idx = 1 + ekf.upper_back_sagittal_idx = 1 ekf.hip_flexion_indices = (3, 4) ekf.upper_back_sagittal_gain = 0.2 ekf.upper_back_pseudo_std_rad = np.deg2rad(10.0) @@ -960,3 +1148,192 @@ def test_upper_back_pseudo_measurement_block_tracks_mean_hip_flexion(): np.testing.assert_allclose(h, np.array([0.05], dtype=float)) np.testing.assert_allclose(h_q, np.array([[0.0, 1.0, 0.0, 0.0, 0.0, 0.0]], dtype=float)) np.testing.assert_allclose(variance, np.array([np.deg2rad(10.0) ** 2], dtype=float)) + + +def test_upper_back_zero_prior_blocks_pull_lateral_and_axial_dofs_to_zero(): + ekf = vitpose_ekf_pipeline.MultiViewKinematicEKF.__new__(vitpose_ekf_pipeline.MultiViewKinematicEKF) + ekf.nq = 6 + ekf.upper_back_zero_prior_indices = (2, 5) + ekf.upper_back_pseudo_std_rad = np.deg2rad(8.0) + + reference_q = np.array([0.0, 0.0, 0.2, 0.0, 0.0, -0.15], dtype=float) + + blocks = ekf._upper_back_zero_prior_blocks(reference_q) + + assert len(blocks) == 2 + first_z, first_h, first_h_q, first_variance = blocks[0] + second_z, second_h, second_h_q, second_variance = blocks[1] + np.testing.assert_allclose(first_z, np.array([0.0], dtype=float)) + np.testing.assert_allclose(first_h, np.array([0.2], dtype=float)) + np.testing.assert_allclose(first_h_q, np.array([[0.0, 0.0, 1.0, 0.0, 0.0, 0.0]], dtype=float)) + np.testing.assert_allclose(first_variance, np.array([np.deg2rad(8.0) ** 2], dtype=float)) + np.testing.assert_allclose(second_z, np.array([0.0], dtype=float)) + np.testing.assert_allclose(second_h, np.array([-0.15], dtype=float)) + np.testing.assert_allclose(second_h_q, np.array([[0.0, 0.0, 0.0, 0.0, 0.0, 1.0]], dtype=float)) + np.testing.assert_allclose(second_variance, np.array([np.deg2rad(8.0) ** 2], dtype=float)) + + +def test_ankle_bed_pseudo_measurement_blocks_keep_xz_targets_when_not_airborne(): + ekf = vitpose_ekf_pipeline.MultiViewKinematicEKF.__new__(vitpose_ekf_pipeline.MultiViewKinematicEKF) + ekf.ankle_bed_pseudo_obs = True + ekf.ankle_bed_pseudo_std_m = 0.02 + ekf.ankle_bed_pair_indices = ((0, vitpose_ekf_pipeline.KP_INDEX["left_ankle"]),) + ekf.flight_height_threshold_m = 1.5 + ekf.reconstruction = SimpleNamespace(points_3d=np.full((1, 17, 3), np.nan, dtype=float)) + ekf.reconstruction.points_3d[0, vitpose_ekf_pipeline.KP_INDEX["left_ankle"]] = np.array([0.4, -0.1, 1.2]) + + marker_points_array = np.array([[0.3, 0.0, 1.25]], dtype=float) + marker_jacobians_array = np.array( + [ + [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + ], + dtype=float, + ) + + blocks = ekf._ankle_bed_pseudo_measurement_blocks(0, marker_points_array, marker_jacobians_array) + + assert len(blocks) == 1 + z, h, h_q, variance = blocks[0] + np.testing.assert_allclose(z, np.array([0.4, 1.2], dtype=float)) + np.testing.assert_allclose(h, np.array([0.3, 1.25], dtype=float)) + np.testing.assert_allclose(h_q, np.array([[1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], dtype=float)) + np.testing.assert_allclose(variance, np.array([0.0004, 0.0004], dtype=float)) + + +def test_ankle_bed_pseudo_measurement_blocks_are_disabled_in_airborne_frames(): + ekf = vitpose_ekf_pipeline.MultiViewKinematicEKF.__new__(vitpose_ekf_pipeline.MultiViewKinematicEKF) + ekf.ankle_bed_pseudo_obs = True + ekf.ankle_bed_pseudo_std_m = 0.02 + ekf.ankle_bed_pair_indices = ((0, vitpose_ekf_pipeline.KP_INDEX["left_ankle"]),) + ekf.flight_height_threshold_m = 1.5 + ekf.reconstruction = SimpleNamespace(points_3d=np.full((1, 17, 3), np.nan, dtype=float)) + ekf.reconstruction.points_3d[0, :, 2] = 1.7 + + blocks = ekf._ankle_bed_pseudo_measurement_blocks( + 0, + np.array([[0.3, 0.0, 1.25]], dtype=float), + np.array([np.eye(3, dtype=float)], dtype=float), + ) + + assert blocks == [] + + +def test_apply_left_right_flip_corrections_halves_scores_for_swapped_views(): + keypoints = np.zeros((1, 2, 17, 2), dtype=float) + scores = np.ones((1, 2, 17), dtype=float) + pose_data = vitpose_ekf_pipeline.PoseData( + camera_names=["cam0"], + frames=np.array([0, 1], dtype=int), + keypoints=keypoints, + scores=scores, + ) + suspect_mask = np.array([[False, True]], dtype=bool) + + corrected = vitpose_ekf_pipeline.apply_left_right_flip_corrections(pose_data, suspect_mask) + + np.testing.assert_allclose(corrected.scores[0, 0], np.ones(17, dtype=float)) + np.testing.assert_allclose(corrected.scores[0, 1], 0.5 * np.ones(17, dtype=float)) + + +def test_history3_prediction_updates_joint_dofs_from_last_three_states(): + class _Model: + def nbSegment(self): + return 0 + + ekf = vitpose_ekf_pipeline.MultiViewKinematicEKF.__new__(vitpose_ekf_pipeline.MultiViewKinematicEKF) + ekf.model = _Model() + ekf.nq = 4 + ekf.dt = 0.1 + ekf.joint_indices = np.array([2, 3], dtype=int) + ekf.n_root = 2 + ekf.predictor_mode = "dyn_history3" + ekf.corrected_state_history = [ + np.array([0.0, 0.0, 1.0, 2.0, 10.0, 20.0, 12.0, 24.0, 0.0, 0.0, 10.0, 20.0], dtype=float), + np.array([0.0, 0.0, 2.0, 4.0, 15.0, 30.0, 16.0, 32.0, 0.0, 0.0, 30.0, 60.0], dtype=float), + np.array([0.0, 0.0, 4.0, 8.0, 20.0, 40.0, 20.0, 40.0, 0.0, 0.0, 50.0, 100.0], dtype=float), + ] + ekf.corrected_q_history = [state[:4] for state in ekf.corrected_state_history] + predicted_state = np.zeros(12, dtype=float) + predicted_state[:4] = np.array([10.0, 20.0, -1.0, -1.0], dtype=float) + + updated = ekf._apply_history3_prediction(predicted_state) + + np.testing.assert_allclose(updated[:4], np.array([10.0, 20.0, 6.225, 12.45], dtype=float)) + np.testing.assert_allclose(updated[4:8], np.array([0.0, 0.0, 24.25, 48.5], dtype=float)) + np.testing.assert_allclose(updated[8:12], np.array([0.0, 0.0, 35.0, 70.0], dtype=float)) + + +def test_history3_predicts_root_but_dyn_history3_keeps_root_from_base_predictor(): + class _Model: + def nbSegment(self): + return 0 + + history3_ekf = vitpose_ekf_pipeline.MultiViewKinematicEKF.__new__(vitpose_ekf_pipeline.MultiViewKinematicEKF) + history3_ekf.model = _Model() + history3_ekf.nq = 4 + history3_ekf.dt = 0.1 + history3_ekf.n_root = 2 + history3_ekf.joint_indices = np.array([2, 3], dtype=int) + history3_ekf.predictor_mode = "history3" + history3_ekf.corrected_state_history = [ + np.array([1.0, 10.0, 1.0, 2.0, 3.0, 30.0, 10.0, 20.0, 5.0, 50.0, 10.0, 20.0], dtype=float), + np.array([2.0, 20.0, 2.0, 4.0, 4.0, 40.0, 15.0, 30.0, 8.0, 80.0, 30.0, 60.0], dtype=float), + np.array([4.0, 40.0, 4.0, 8.0, 5.0, 50.0, 20.0, 40.0, 11.0, 110.0, 50.0, 100.0], dtype=float), + ] + history3_ekf.corrected_q_history = [state[:4] for state in history3_ekf.corrected_state_history] + dyn_history3_ekf = vitpose_ekf_pipeline.MultiViewKinematicEKF.__new__(vitpose_ekf_pipeline.MultiViewKinematicEKF) + dyn_history3_ekf.model = _Model() + dyn_history3_ekf.nq = 4 + dyn_history3_ekf.dt = 0.1 + dyn_history3_ekf.n_root = 2 + dyn_history3_ekf.joint_indices = np.array([2, 3], dtype=int) + dyn_history3_ekf.predictor_mode = "dyn_history3" + dyn_history3_ekf.corrected_state_history = history3_ekf.corrected_state_history + dyn_history3_ekf.corrected_q_history = history3_ekf.corrected_q_history + + predicted_state = np.zeros(12, dtype=float) + predicted_state[:4] = np.array([100.0, 200.0, -1.0, -1.0], dtype=float) + predicted_state[8:12] = np.array([7.0, 70.0, 0.0, 0.0], dtype=float) + + updated_history3 = history3_ekf._apply_history3_prediction(predicted_state) + updated_dyn_history3 = dyn_history3_ekf._apply_history3_prediction(predicted_state) + + np.testing.assert_allclose( + updated_history3[:4], + np.array([4.554166666666667, 45.541666666666664, 6.225, 12.45], dtype=float), + ) + np.testing.assert_allclose(updated_dyn_history3[:4], np.array([100.0, 200.0, 6.225, 12.45], dtype=float)) + + +def test_record_corrected_state_keeps_raw_q_without_absolute_canonicalization(monkeypatch): + class _Model: + def nbSegment(self): + return 1 + + ekf = vitpose_ekf_pipeline.MultiViewKinematicEKF.__new__(vitpose_ekf_pipeline.MultiViewKinematicEKF) + ekf.model = _Model() + ekf.nq = 3 + ekf.corrected_q_history = [] + ekf.corrected_state_history = [] + + monkeypatch.setattr( + vitpose_ekf_pipeline, + "canonicalize_model_q_rotation_branches", + lambda _model, _q: (_ for _ in ()).throw(AssertionError("Should not be called during sequential history3")), + ) + + state = np.array([3.4, -6.2, 9.1, 0.5, 0.6, 0.7, -0.1, -0.2, -0.3], dtype=float) + ekf.record_corrected_state(state) + + np.testing.assert_allclose(ekf.corrected_q_history[-1], state[:3]) + np.testing.assert_allclose(ekf.corrected_state_history[-1], state) + + +def test_back_pseudo_segment_name_for_q_names_prefers_lower_trunk_when_present(): + q_names = np.asarray(["TRUNK:RotY", "LOWER_TRUNK:RotY", "LEFT_THIGH:RotY", "RIGHT_THIGH:RotY"], dtype=object) + + assert vitpose_ekf_pipeline.back_pseudo_segment_name_for_q_names(q_names) == "LOWER_TRUNK" diff --git a/tools/biorbd_fd_to_c3d.py b/tools/biorbd_fd_to_c3d.py index 9872a57..516e7fc 100644 --- a/tools/biorbd_fd_to_c3d.py +++ b/tools/biorbd_fd_to_c3d.py @@ -13,18 +13,17 @@ from __future__ import annotations import argparse -from pathlib import Path import sys +from pathlib import Path from typing import Iterable, Optional ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -import numpy as np - import biorbd import ezc3d +import numpy as np def _try_loadmat(path: Path): @@ -36,9 +35,7 @@ def _try_loadmat(path: Path): try: import h5py except Exception as exc: - raise RuntimeError( - "Could not read .mat file with scipy.io.loadmat and h5py is unavailable." - ) from exc + raise RuntimeError("Could not read .mat file with scipy.io.loadmat and h5py is unavailable.") from exc data = {} with h5py.File(path, "r") as f: @@ -101,9 +98,7 @@ def _ensure_dof_by_frames(arr: np.ndarray, ndof: int, name: str) -> np.ndarray: if arr.shape[1] == ndof: return arr.T - raise ValueError( - f"{name} shape {arr.shape} does not match model DoF ({ndof}) in either axis." - ) + raise ValueError(f"{name} shape {arr.shape} does not match model DoF ({ndof}) in either axis.") def _to_np(v) -> np.ndarray: @@ -126,9 +121,7 @@ def _segment_origin(model: biorbd.Model, q: np.ndarray, iseg: int) -> np.ndarray raise ValueError(f"Unexpected RotoTrans shape for segment {iseg}: {rt_np.shape}") -def _collect_points( - model: biorbd.Model, q_traj: np.ndarray -) -> tuple[np.ndarray, list[str], int, int]: +def _collect_points(model: biorbd.Model, q_traj: np.ndarray) -> tuple[np.ndarray, list[str], int, int]: marker_names = [_str_name(n) for n in model.markerNames()] seg_names = [_str_name(model.segment(i).name()) for i in range(model.nbSegment())] jc_names = [f"JC_{name}" for name in seg_names] diff --git a/tools/show_biomod_pyorerun.py b/tools/show_biomod_pyorerun.py index fcdfb89..e621fa8 100644 --- a/tools/show_biomod_pyorerun.py +++ b/tools/show_biomod_pyorerun.py @@ -10,8 +10,8 @@ from __future__ import annotations import argparse -from pathlib import Path import sys +from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: diff --git a/vitpose_ekf_pipeline.py b/vitpose_ekf_pipeline.py index 94aef34..877f432 100644 --- a/vitpose_ekf_pipeline.py +++ b/vitpose_ekf_pipeline.py @@ -31,8 +31,21 @@ import numpy as np from scipy.spatial.transform import Rotation +from annotation.annotation_store import ( + apply_annotations_to_pose_arrays, + default_annotation_path, + load_annotation_payload, +) from camera_tools.camera_selection import parse_camera_names, subset_calibrations -from kinematics.root_kinematics import compute_trunk_dofs_from_points, root_z_correction_angle_from_points +from kinematics.root_kinematics import ( + ROOT_ROTATION_SLICE, + SUPPORTED_ROOT_UNWRAP_MODES, + TRUNK_ROOT_ROTATION_SEQUENCE, + compute_trunk_dofs_from_points, + normalize_root_unwrap_mode, + root_z_correction_angle_from_points, + stabilize_root_rotations, +) try: import tomllib @@ -47,8 +60,9 @@ DEFAULT_CALIB = Path("inputs/calibration/Calib.toml") DEFAULT_KEYPOINTS = Path("inputs/keypoints/1_partie_0429_keypoints.json") -LOCAL_BIOBUDDY = Path("/Users/mickaelbegon/Documents/GIT/biobuddy") -LOCAL_MPLCONFIG = Path("/Users/mickaelbegon/Documents/Playground/.cache/matplotlib") +ROOT = Path(__file__).resolve().parent +LOCAL_BIOBUDDY = Path(os.environ.get("BIOBUDDY_ROOT", ROOT.parent / "biobuddy")) +LOCAL_MPLCONFIG = ROOT / ".cache" / "matplotlib" LOCAL_MPLCONFIG.mkdir(parents=True, exist_ok=True) os.environ.setdefault("MPLCONFIGDIR", str(LOCAL_MPLCONFIG)) DEFAULT_CAMERA_FPS = 120.0 @@ -64,7 +78,13 @@ DEFAULT_TRIANGULATION_METHOD = "exhaustive" DEFAULT_TRIANGULATION_WORKERS = 6 DEFAULT_MODEL_VARIANT = "single_trunk" -SUPPORTED_MODEL_VARIANTS = ("single_trunk", "back_flexion_1d", "back_3dof") +SUPPORTED_MODEL_VARIANTS = ( + "single_trunk", + "back_flexion_1d", + "back_3dof", + "upper_root_back_flexion_1d", + "upper_root_back_3dof", +) SUPPORTED_TRIANGULATION_METHODS = ("once", "greedy", "exhaustive") SUPPORTED_COHERENCE_METHODS = ( "epipolar", @@ -94,6 +114,7 @@ DEFAULT_EKF2D_BOOTSTRAP_PASSES = 5 DEFAULT_UPPER_BACK_SAGITTAL_GAIN = 0.2 DEFAULT_UPPER_BACK_PSEUDO_STD_RAD = np.deg2rad(10.0) +DEFAULT_ANKLE_BED_PSEUDO_STD_M = 0.02 DEFAULT_FLIP_IMPROVEMENT_RATIO = 0.7 DEFAULT_FLIP_MIN_GAIN_PX = 3.0 DEFAULT_FLIP_MIN_OTHER_CAMERAS = 2 @@ -161,6 +182,31 @@ } +def model_variant_has_back_dofs(model_variant: str) -> bool: + return str(model_variant) in { + "back_flexion_1d", + "back_3dof", + "upper_root_back_flexion_1d", + "upper_root_back_3dof", + } + + +def model_variant_uses_upper_trunk_root(model_variant: str) -> bool: + return str(model_variant) in {"upper_root_back_flexion_1d", "upper_root_back_3dof"} + + +def back_dof_segment_name_for_variant(model_variant: str) -> str | None: + if str(model_variant) in {"back_flexion_1d", "back_3dof"}: + return "UPPER_BACK" + if str(model_variant) in {"upper_root_back_flexion_1d", "upper_root_back_3dof"}: + return "LOWER_TRUNK" + return None + + +def root_translation_origin_for_model_variant(model_variant: str) -> str: + return "upper_trunk" if model_variant_uses_upper_trunk_root(model_variant) else "pelvis" + + @dataclass class CameraCalibration: """Parametres intrinsèques/extrinseques d'une camera. @@ -248,6 +294,8 @@ class PoseData: frame_stride: int = 1 raw_keypoints: np.ndarray | None = None # (n_cam, n_frames, 17, 2) filtered_keypoints: np.ndarray | None = None # (n_cam, n_frames, 17, 2) + annotated_keypoints: np.ndarray | None = None # (n_cam, n_frames, 17, 2) + annotated_scores: np.ndarray | None = None # (n_cam, n_frames, 17) @dataclass @@ -265,6 +313,14 @@ class SegmentLengths: eye_offset_x: float eye_offset_y: float ear_offset_y: float + left_upper_arm_length: float | None = None + right_upper_arm_length: float | None = None + left_forearm_length: float | None = None + right_forearm_length: float | None = None + left_thigh_length: float | None = None + right_thigh_length: float | None = None + left_shank_length: float | None = None + right_shank_length: float | None = None @dataclass @@ -587,6 +643,7 @@ def load_pose_data( outlier_threshold_ratio: float = 0.10, lower_percentile: float = 5.0, upper_percentile: float = 95.0, + annotations_path: Path | None = None, ) -> PoseData: """Charge les keypoints 2D et les aligne camera par camera. @@ -664,6 +721,22 @@ def load_pose_data( raw_keypoints = np.array(keypoints, copy=True) raw_scores = np.array(scores, copy=True) + resolved_annotations_path = None if annotations_path is None else Path(annotations_path) + if data_mode == "annotated" and resolved_annotations_path is None: + resolved_annotations_path = default_annotation_path(keypoints_path) + annotation_payload = ( + load_annotation_payload(resolved_annotations_path, keypoints_path=keypoints_path) + if resolved_annotations_path is not None and resolved_annotations_path.exists() + else None + ) + annotated_keypoints, annotated_scores = apply_annotations_to_pose_arrays( + keypoints=raw_keypoints, + scores=raw_scores, + camera_names=[name for name, _ in ordered_items], + frames=frames, + keypoint_names=COCO17, + payload=annotation_payload, + ) cleaned_keypoints, cleaned_scores, filtered_keypoints = filter_pose_keypoints( keypoints, scores, @@ -685,6 +758,9 @@ def load_pose_data( elif data_mode == "cleaned": selected_keypoints = cleaned_keypoints selected_scores = cleaned_scores + elif data_mode == "annotated": + selected_keypoints = annotated_keypoints + selected_scores = annotated_scores else: raise ValueError(f"Unsupported pose data mode: {data_mode}") @@ -696,6 +772,8 @@ def load_pose_data( frame_stride=frame_stride, raw_keypoints=raw_keypoints, filtered_keypoints=filtered_keypoints, + annotated_keypoints=annotated_keypoints, + annotated_scores=annotated_scores, ) @@ -903,6 +981,43 @@ def apply_measurement_update_batch( ) +def stack_measurement_blocks( + measurement_blocks: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], + nq: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None: + """Concatenate per-camera/pseudo-observation blocks into one batch update.""" + + z_blocks = [] + h_blocks = [] + hq_blocks = [] + r_blocks = [] + for z, h, H_q, R_diag_array in measurement_blocks: + if z.size == 0 or h.size == 0 or H_q.size == 0 or R_diag_array.size == 0: + continue + z_array = np.asarray(z, dtype=float).reshape(-1) + h_array = np.asarray(h, dtype=float).reshape(-1) + hq_array = np.asarray(H_q, dtype=float).reshape((-1, nq)) + r_array = np.asarray(R_diag_array, dtype=float).reshape(-1) + if ( + z_array.shape[0] != h_array.shape[0] + or z_array.shape[0] != hq_array.shape[0] + or z_array.shape[0] != r_array.shape[0] + ): + continue + z_blocks.append(z_array) + h_blocks.append(h_array) + hq_blocks.append(hq_array) + r_blocks.append(r_array) + if not z_blocks: + return None + return ( + np.concatenate(z_blocks), + np.concatenate(h_blocks), + np.vstack(hq_blocks), + np.concatenate(r_blocks), + ) + + def apply_measurement_update_sequential( predicted_state: np.ndarray, predicted_covariance: np.ndarray, @@ -2059,9 +2174,13 @@ def apply_left_right_flip_corrections(pose_data: PoseData, suspect_mask: np.ndar La correction s'applique camera par camera et frame par frame, uniquement quand le diagnostic epipolaire juge le swap gauche/droite plus coherent. + Les scores 2D de ces vues sont aussi divises par deux afin de reduire leur + poids dans les etapes suivantes du filtre. """ corrected_keypoints = apply_left_right_flip_to_points(pose_data.keypoints, suspect_mask) corrected_scores = apply_left_right_flip_to_points(pose_data.scores[..., np.newaxis], suspect_mask)[..., 0] + corrected_scores = np.asarray(corrected_scores, dtype=float) + corrected_scores[suspect_mask] *= 0.5 return PoseData( camera_names=list(pose_data.camera_names), frames=np.array(pose_data.frames, copy=True), @@ -2262,7 +2381,7 @@ def once_triangulation_from_best_cameras( observations: np.ndarray, confidences: np.ndarray, calibrations: list[CameraCalibration], - error_threshold_px: float, + error_threshold_px: float | None, min_cameras_for_triangulation: int, ) -> tuple[np.ndarray, float, np.ndarray, np.ndarray, np.ndarray]: """Triangulate once from all valid cameras without view rejection.""" @@ -2292,9 +2411,14 @@ def once_triangulation_from_best_cameras( errors = np.linalg.norm(observations[included_indices] - projected, axis=1) mean_error = float(np.mean(errors)) if errors.size else np.nan reprojection_error_per_view[included_indices] = errors + coherence_threshold_px = ( + float(DEFAULT_REPROJECTION_THRESHOLD_PX) if error_threshold_px is None else float(error_threshold_px) + ) for i_cam in included_indices: - coherence_per_view[i_cam] = multiview_coherence_score(reprojection_error_per_view[i_cam], error_threshold_px) - if mean_error > error_threshold_px: + coherence_per_view[i_cam] = multiview_coherence_score( + reprojection_error_per_view[i_cam], coherence_threshold_px + ) + if error_threshold_px is not None and mean_error > float(error_threshold_px): point = np.full(3, np.nan) return point, mean_error, reprojection_error_per_view, coherence_per_view, excluded_views @@ -2464,7 +2588,7 @@ def compute_framewise_epipolar_measurement_weights( def triangulate_pose2sim_like( pose_data: PoseData, calibrations: dict[str, CameraCalibration], - error_threshold_px: float = DEFAULT_REPROJECTION_THRESHOLD_PX, + error_threshold_px: float | None = DEFAULT_REPROJECTION_THRESHOLD_PX, min_cameras_for_triangulation: int = DEFAULT_MIN_CAMERAS_FOR_TRIANGULATION, coherence_method: str = DEFAULT_COHERENCE_METHOD, epipolar_threshold_px: float = DEFAULT_EPIPOLAR_THRESHOLD_PX, @@ -2625,30 +2749,18 @@ def estimate_segment_lengths(reconstruction: ReconstructionResult, fps: float, w head_length = median_distance(pts[:, KP_INDEX["nose"], :], shoulder_center) shoulder_width = median_distance(l_shoulder, r_shoulder) hip_width = median_distance(l_hip, r_hip) - upper_arm_length = np.nanmedian( - [ - median_distance(pts[:, KP_INDEX["left_shoulder"], :], pts[:, KP_INDEX["left_elbow"], :]), - median_distance(pts[:, KP_INDEX["right_shoulder"], :], pts[:, KP_INDEX["right_elbow"], :]), - ] - ) - forearm_length = np.nanmedian( - [ - median_distance(pts[:, KP_INDEX["left_elbow"], :], pts[:, KP_INDEX["left_wrist"], :]), - median_distance(pts[:, KP_INDEX["right_elbow"], :], pts[:, KP_INDEX["right_wrist"], :]), - ] - ) - thigh_length = np.nanmedian( - [ - median_distance(pts[:, KP_INDEX["left_hip"], :], pts[:, KP_INDEX["left_knee"], :]), - median_distance(pts[:, KP_INDEX["right_hip"], :], pts[:, KP_INDEX["right_knee"], :]), - ] - ) - shank_length = np.nanmedian( - [ - median_distance(pts[:, KP_INDEX["left_knee"], :], pts[:, KP_INDEX["left_ankle"], :]), - median_distance(pts[:, KP_INDEX["right_knee"], :], pts[:, KP_INDEX["right_ankle"], :]), - ] - ) + left_upper_arm_length = median_distance(pts[:, KP_INDEX["left_shoulder"], :], pts[:, KP_INDEX["left_elbow"], :]) + right_upper_arm_length = median_distance(pts[:, KP_INDEX["right_shoulder"], :], pts[:, KP_INDEX["right_elbow"], :]) + upper_arm_length = np.nanmedian([left_upper_arm_length, right_upper_arm_length]) + left_forearm_length = median_distance(pts[:, KP_INDEX["left_elbow"], :], pts[:, KP_INDEX["left_wrist"], :]) + right_forearm_length = median_distance(pts[:, KP_INDEX["right_elbow"], :], pts[:, KP_INDEX["right_wrist"], :]) + forearm_length = np.nanmedian([left_forearm_length, right_forearm_length]) + left_thigh_length = median_distance(pts[:, KP_INDEX["left_hip"], :], pts[:, KP_INDEX["left_knee"], :]) + right_thigh_length = median_distance(pts[:, KP_INDEX["right_hip"], :], pts[:, KP_INDEX["right_knee"], :]) + thigh_length = np.nanmedian([left_thigh_length, right_thigh_length]) + left_shank_length = median_distance(pts[:, KP_INDEX["left_knee"], :], pts[:, KP_INDEX["left_ankle"], :]) + right_shank_length = median_distance(pts[:, KP_INDEX["right_knee"], :], pts[:, KP_INDEX["right_ankle"], :]) + shank_length = np.nanmedian([left_shank_length, right_shank_length]) left_eye = pts[:, KP_INDEX["left_eye"], :] right_eye = pts[:, KP_INDEX["right_eye"], :] @@ -2671,6 +2783,14 @@ def estimate_segment_lengths(reconstruction: ReconstructionResult, fps: float, w "eye_offset_x": 0.05, "eye_offset_y": 0.03, "ear_offset_y": 0.07, + "left_upper_arm_length": 0.29, + "right_upper_arm_length": 0.29, + "left_forearm_length": 0.27, + "right_forearm_length": 0.27, + "left_thigh_length": 0.42, + "right_thigh_length": 0.42, + "left_shank_length": 0.43, + "right_shank_length": 0.43, } values = { "trunk_height": trunk_height, @@ -2684,6 +2804,14 @@ def estimate_segment_lengths(reconstruction: ReconstructionResult, fps: float, w "eye_offset_x": eye_offset_x, "eye_offset_y": eye_offset_y, "ear_offset_y": ear_offset_y, + "left_upper_arm_length": left_upper_arm_length, + "right_upper_arm_length": right_upper_arm_length, + "left_forearm_length": left_forearm_length, + "right_forearm_length": right_forearm_length, + "left_thigh_length": left_thigh_length, + "right_thigh_length": right_thigh_length, + "left_shank_length": left_shank_length, + "right_shank_length": right_shank_length, } for key, default_value in fallback.items(): @@ -2788,6 +2916,24 @@ def aggregate_distal( } +def segment_length_for_side( + lengths: SegmentLengths, + *, + side: str, + base_name: str, + symmetrize_limbs: bool = True, +) -> float: + """Return one limb length for the requested side.""" + + shared_value = float(getattr(lengths, base_name)) + if symmetrize_limbs: + return shared_value + side_value = getattr(lengths, f"{side}_{base_name}", None) + if side_value is None or not np.isfinite(side_value) or float(side_value) <= 0.0: + return shared_value + return float(side_value) + + def build_biomod( lengths: SegmentLengths, output_path: Path, @@ -2795,6 +2941,7 @@ def build_biomod( reconstruction: ReconstructionResult | None = None, apply_initial_root_rotation_correction: bool = True, model_variant: str = DEFAULT_MODEL_VARIANT, + symmetrize_limbs: bool = True, ) -> Path: """Construit un modele `.bioMod` minimal compatible avec les keypoints COCO17. @@ -2858,19 +3005,58 @@ def scaled_inertia( upper_back_height = 0.0 trunk_inertia = inertia["TRUNK"] upper_back_inertia = None - if model_variant in {"back_flexion_1d", "back_3dof"}: + lower_back_inertia = None + uses_back_dofs = model_variant_has_back_dofs(model_variant) + uses_upper_root = model_variant_uses_upper_trunk_root(model_variant) + if uses_back_dofs: lower_back_height = 0.5 * lengths.trunk_height upper_back_height = lengths.trunk_height - lower_back_height - trunk_inertia = scaled_inertia( - inertia["TRUNK"], - mass_scale=0.55, - local_com=(0.0, 0.0, 0.5 * lower_back_height), - ) - upper_back_inertia = scaled_inertia( - inertia["TRUNK"], - mass_scale=0.45, - local_com=(0.0, 0.0, 0.5 * upper_back_height), - ) + if uses_upper_root: + trunk_inertia = scaled_inertia( + inertia["TRUNK"], + mass_scale=0.45, + local_com=(0.0, 0.0, -0.5 * upper_back_height), + ) + lower_back_inertia = scaled_inertia( + inertia["TRUNK"], + mass_scale=0.55, + local_com=(0.0, 0.0, -0.5 * lower_back_height), + ) + else: + trunk_inertia = scaled_inertia( + inertia["TRUNK"], + mass_scale=0.55, + local_com=(0.0, 0.0, 0.5 * lower_back_height), + ) + upper_back_inertia = scaled_inertia( + inertia["TRUNK"], + mass_scale=0.45, + local_com=(0.0, 0.0, 0.5 * upper_back_height), + ) + + if uses_upper_root: + trunk_mesh_points = [ + (0.0, -lengths.shoulder_half_width, 0.0), + (0.0, 0.0, 0.0), + (0.0, lengths.shoulder_half_width, 0.0), + (0.0, 0.0, 0.0), + (0.0, 0.0, -upper_back_height), + ] + else: + trunk_mesh_points = [ + (0.0, -lengths.hip_half_width, 0.0), + (0.0, lengths.hip_half_width, 0.0), + (0.0, 0.0, 0.0), + (0.0, 0.0, lower_back_height), + ] + if uses_back_dofs: + trunk_mesh_points = [ + (0.0, -lengths.hip_half_width, 0.0), + (0.0, 0.0, 0.0), + (0.0, lengths.hip_half_width, 0.0), + (0.0, 0.0, 0.0), + (0.0, 0.0, lower_back_height), + ] model.add_segment( SegmentReal( @@ -2879,56 +3065,110 @@ def scaled_inertia( rotations=Rotations.YXZ, inertia_parameters=trunk_inertia, mesh=mesh_with_axes( - [ - (0.0, -lengths.hip_half_width, 0.0), - (0.0, lengths.hip_half_width, 0.0), - (0.0, 0.0, 0.0), - (0.0, 0.0, lower_back_height), - ], - axis_scale=0.25 * max(lower_back_height, 1e-3), + trunk_mesh_points, + axis_scale=0.25 * max(lengths.trunk_height, 1e-3), ), ) ) trunk = model.segments["TRUNK"] - trunk.add_marker(MarkerReal(name="left_hip", parent_name="TRUNK", position=[0, lengths.hip_half_width, 0])) - trunk.add_marker(MarkerReal(name="right_hip", parent_name="TRUNK", position=[0, -lengths.hip_half_width, 0])) shoulder_parent_name = "TRUNK" - shoulder_parent_local_z = lower_back_height - if model_variant in {"back_flexion_1d", "back_3dof"}: + shoulder_parent_local_z = 0.0 if uses_upper_root else lower_back_height + leg_parent_name = "TRUNK" + leg_parent_local_z = 0.0 + + if uses_upper_root: + trunk.add_marker( + MarkerReal(name="left_shoulder", parent_name="TRUNK", position=[0, lengths.shoulder_half_width, 0]) + ) + trunk.add_marker( + MarkerReal(name="right_shoulder", parent_name="TRUNK", position=[0, -lengths.shoulder_half_width, 0]) + ) + lower_trunk_mesh_points = [ + (0.0, 0.0, 0.0), + (0.0, -lengths.hip_half_width, -lower_back_height), + (0.0, 0.0, -lower_back_height), + (0.0, lengths.hip_half_width, -lower_back_height), + ] model.add_segment( SegmentReal( - name="UPPER_BACK", + name="LOWER_TRUNK", parent_name="TRUNK", segment_coordinate_system=SegmentCoordinateSystemReal.from_euler_and_translation( - np.zeros(3), "xyz", np.array([0.0, 0.0, lower_back_height]), is_scs_local=True + np.zeros(3), "xyz", np.array([0.0, 0.0, -upper_back_height]), is_scs_local=True ), - rotations=(Rotations.X if model_variant == "back_flexion_1d" else Rotations.YXZ), - inertia_parameters=upper_back_inertia, + rotations=(Rotations.Y if model_variant == "upper_root_back_flexion_1d" else Rotations.YXZ), + inertia_parameters=lower_back_inertia, mesh=mesh_with_axes( - [(0.0, 0.0, 0.0), (0.0, 0.0, upper_back_height)], - axis_scale=0.2 * max(upper_back_height, 1e-3), + lower_trunk_mesh_points, + axis_scale=0.2 * max(lower_back_height, 1e-3), ), ) ) - shoulder_parent_name = "UPPER_BACK" - shoulder_parent_local_z = upper_back_height + lower_trunk = model.segments["LOWER_TRUNK"] + lower_trunk.add_marker(MarkerReal(name="mid_back", parent_name="LOWER_TRUNK", position=[0, 0, 0])) + lower_trunk.add_marker( + MarkerReal( + name="left_hip", parent_name="LOWER_TRUNK", position=[0, lengths.hip_half_width, -lower_back_height] + ) + ) + lower_trunk.add_marker( + MarkerReal( + name="right_hip", + parent_name="LOWER_TRUNK", + position=[0, -lengths.hip_half_width, -lower_back_height], + ) + ) + leg_parent_name = "LOWER_TRUNK" + leg_parent_local_z = -lower_back_height + else: + trunk.add_marker(MarkerReal(name="left_hip", parent_name="TRUNK", position=[0, lengths.hip_half_width, 0])) + trunk.add_marker(MarkerReal(name="right_hip", parent_name="TRUNK", position=[0, -lengths.hip_half_width, 0])) + if uses_back_dofs: + upper_back_mesh_points = [ + (0.0, 0.0, 0.0), + (0.0, 0.0, upper_back_height), + (0.0, lengths.shoulder_half_width, upper_back_height), + (0.0, 0.0, upper_back_height), + (0.0, -lengths.shoulder_half_width, upper_back_height), + ] + model.add_segment( + SegmentReal( + name="UPPER_BACK", + parent_name="TRUNK", + segment_coordinate_system=SegmentCoordinateSystemReal.from_euler_and_translation( + np.zeros(3), "xyz", np.array([0.0, 0.0, lower_back_height]), is_scs_local=True + ), + rotations=(Rotations.Y if model_variant == "back_flexion_1d" else Rotations.YXZ), + inertia_parameters=upper_back_inertia, + mesh=mesh_with_axes( + upper_back_mesh_points, + axis_scale=0.2 * max(upper_back_height, 1e-3), + ), + ) + ) + shoulder_parent_name = "UPPER_BACK" + shoulder_parent_local_z = upper_back_height + model.segments["UPPER_BACK"].add_marker( + MarkerReal(name="mid_back", parent_name="UPPER_BACK", position=[0, 0, 0]) + ) shoulder_parent = model.segments[shoulder_parent_name] - shoulder_parent.add_marker( - MarkerReal( - name="left_shoulder", - parent_name=shoulder_parent_name, - position=[0, lengths.shoulder_half_width, shoulder_parent_local_z], + if not uses_upper_root: + shoulder_parent.add_marker( + MarkerReal( + name="left_shoulder", + parent_name=shoulder_parent_name, + position=[0, lengths.shoulder_half_width, shoulder_parent_local_z], + ) ) - ) - shoulder_parent.add_marker( - MarkerReal( - name="right_shoulder", - parent_name=shoulder_parent_name, - position=[0, -lengths.shoulder_half_width, shoulder_parent_local_z], + shoulder_parent.add_marker( + MarkerReal( + name="right_shoulder", + parent_name=shoulder_parent_name, + position=[0, -lengths.shoulder_half_width, shoulder_parent_local_z], + ) ) - ) model.add_segment( SegmentReal( @@ -2969,8 +3209,20 @@ def scaled_inertia( ) for side, sign in (("left", 1.0), ("right", -1.0)): + upper_arm_length = segment_length_for_side( + lengths, side=side, base_name="upper_arm_length", symmetrize_limbs=symmetrize_limbs + ) + forearm_length = segment_length_for_side( + lengths, side=side, base_name="forearm_length", symmetrize_limbs=symmetrize_limbs + ) + thigh_length = segment_length_for_side( + lengths, side=side, base_name="thigh_length", symmetrize_limbs=symmetrize_limbs + ) + shank_length = segment_length_for_side( + lengths, side=side, base_name="shank_length", symmetrize_limbs=symmetrize_limbs + ) shoulder_offset = (0, sign * lengths.shoulder_half_width, shoulder_parent_local_z) - hip_offset = (0, sign * lengths.hip_half_width, 0) + hip_offset = (0, sign * lengths.hip_half_width, leg_parent_local_z) upper_name = f"{side.upper()}_UPPER_ARM" forearm_name = f"{side.upper()}_FOREARM" @@ -2987,14 +3239,14 @@ def scaled_inertia( rotations=Rotations.YX, inertia_parameters=inertia["UPPER_ARM"], mesh=mesh_with_axes( - [(0.0, 0.0, 0.0), (0.0, 0.0, -lengths.upper_arm_length)], - axis_scale=0.3 * lengths.upper_arm_length, + [(0.0, 0.0, 0.0), (0.0, 0.0, -upper_arm_length)], + axis_scale=0.3 * upper_arm_length, ), ) ) upper_arm = model.segments[upper_name] upper_arm.add_marker( - MarkerReal(name=f"{side}_elbow", parent_name=upper_name, position=[0, 0, -lengths.upper_arm_length]) + MarkerReal(name=f"{side}_elbow", parent_name=upper_name, position=[0, 0, -upper_arm_length]) ) model.add_segment( @@ -3002,60 +3254,54 @@ def scaled_inertia( name=forearm_name, parent_name=upper_name, segment_coordinate_system=SegmentCoordinateSystemReal.from_euler_and_translation( - np.zeros(3), "xyz", np.array([0, 0, -lengths.upper_arm_length]), is_scs_local=True + np.zeros(3), "xyz", np.array([0, 0, -upper_arm_length]), is_scs_local=True ), rotations=Rotations.ZY, inertia_parameters=inertia["FOREARM"], mesh=mesh_with_axes( - [(0.0, 0.0, 0.0), (0.0, 0.0, -lengths.forearm_length)], - axis_scale=0.3 * lengths.forearm_length, + [(0.0, 0.0, 0.0), (0.0, 0.0, -forearm_length)], + axis_scale=0.3 * forearm_length, ), ) ) forearm = model.segments[forearm_name] - forearm.add_marker( - MarkerReal(name=f"{side}_wrist", parent_name=forearm_name, position=[0, 0, -lengths.forearm_length]) - ) + forearm.add_marker(MarkerReal(name=f"{side}_wrist", parent_name=forearm_name, position=[0, 0, -forearm_length])) model.add_segment( SegmentReal( name=thigh_name, - parent_name="TRUNK", + parent_name=leg_parent_name, segment_coordinate_system=SegmentCoordinateSystemReal.from_euler_and_translation( np.zeros(3), "xyz", np.asarray(hip_offset, dtype=float), is_scs_local=True ), rotations=Rotations.YXZ, inertia_parameters=inertia["THIGH"], mesh=mesh_with_axes( - [(0.0, 0.0, 0.0), (0.0, 0.0, -lengths.thigh_length)], - axis_scale=0.25 * lengths.thigh_length, + [(0.0, 0.0, 0.0), (0.0, 0.0, -thigh_length)], + axis_scale=0.25 * thigh_length, ), ) ) thigh = model.segments[thigh_name] - thigh.add_marker( - MarkerReal(name=f"{side}_knee", parent_name=thigh_name, position=[0, 0, -lengths.thigh_length]) - ) + thigh.add_marker(MarkerReal(name=f"{side}_knee", parent_name=thigh_name, position=[0, 0, -thigh_length])) model.add_segment( SegmentReal( name=shank_name, parent_name=thigh_name, segment_coordinate_system=SegmentCoordinateSystemReal.from_euler_and_translation( - np.zeros(3), "xyz", np.array([0, 0, -lengths.thigh_length]), is_scs_local=True + np.zeros(3), "xyz", np.array([0, 0, -thigh_length]), is_scs_local=True ), rotations=Rotations.Y, inertia_parameters=inertia["SHANK"], mesh=mesh_with_axes( - [(0.0, 0.0, 0.0), (0.0, 0.0, -lengths.shank_length)], - axis_scale=0.25 * lengths.shank_length, + [(0.0, 0.0, 0.0), (0.0, 0.0, -shank_length)], + axis_scale=0.25 * shank_length, ), ) ) shank = model.segments[shank_name] - shank.add_marker( - MarkerReal(name=f"{side}_ankle", parent_name=shank_name, position=[0, 0, -lengths.shank_length]) - ) + shank.add_marker(MarkerReal(name=f"{side}_ankle", parent_name=shank_name, position=[0, 0, -shank_length])) output_path.parent.mkdir(parents=True, exist_ok=True) model.to_biomod(str(output_path), with_mesh=True) @@ -3169,7 +3415,11 @@ def unwrap_with_gaps(values: np.ndarray) -> np.ndarray: return unwrapped[:, 0] if squeeze else unwrapped -def unwrap_root_rotations(q: np.ndarray, q_names: np.ndarray | list[str]) -> np.ndarray: +def unwrap_root_rotations( + q: np.ndarray, + q_names: np.ndarray | list[str], + mode: str | None = "single", +) -> np.ndarray: """Applique un unwrap temporel aux trois rotations de la racine. Le but est d'eviter des sauts artificiels de type `+-2pi` sur les trois @@ -3178,12 +3428,24 @@ def unwrap_root_rotations(q: np.ndarray, q_names: np.ndarray | list[str]) -> np. q_unwrapped = np.array(q, copy=True) name_to_index = {str(name): i for i, name in enumerate(q_names)} root_rotation_names = ["TRUNK:RotX", "TRUNK:RotY", "TRUNK:RotZ"] - for dof_name in root_rotation_names: - if dof_name in name_to_index: - q_unwrapped[:, name_to_index[dof_name]] = unwrap_with_gaps(q_unwrapped[:, name_to_index[dof_name]]) + root_rotation_indices = [name_to_index[dof_name] for dof_name in root_rotation_names if dof_name in name_to_index] + if root_rotation_indices: + q_unwrapped[:, root_rotation_indices] = stabilize_root_rotations( + q_unwrapped[:, root_rotation_indices], + mode, + ) return q_unwrapped +def canonicalize_state_q_rotation_branches(model, state: np.ndarray) -> np.ndarray: + """Canonicalize only the generalized coordinates portion of one EKF state.""" + + state_array = np.asarray(state, dtype=float).reshape(-1) + canonical_state = np.array(state_array, copy=True) + canonical_state[: model.nbQ()] = canonicalize_model_q_rotation_branches(model, canonical_state[: model.nbQ()]) + return canonical_state + + def debug_state_summary(state: np.ndarray, q_names: np.ndarray | list[str], nq: int, prefix: str) -> str: """Construit un resume compact d'un etat EKF pour le debug console.""" q_names = [str(name) for name in q_names] @@ -3285,6 +3547,7 @@ def __init__( root_flight_dynamics: bool = False, flight_height_threshold_m: float = DEFAULT_FLIGHT_HEIGHT_THRESHOLD_M, flight_min_consecutive_frames: int = DEFAULT_FLIGHT_MIN_CONSECUTIVE_FRAMES, + predictor_mode: str = "acc", flip_method: str | None = None, flip_improvement_ratio: float = DEFAULT_FLIP_IMPROVEMENT_RATIO, flip_min_gain_px: float = DEFAULT_FLIP_MIN_GAIN_PX, @@ -3293,6 +3556,8 @@ def __init__( flip_error_delta_threshold_px: float = DEFAULT_EKF_PREDICTION_GATE_ERROR_DELTA_THRESHOLD_PX, upper_back_sagittal_gain: float = DEFAULT_UPPER_BACK_SAGITTAL_GAIN, upper_back_pseudo_std_rad: float = DEFAULT_UPPER_BACK_PSEUDO_STD_RAD, + ankle_bed_pseudo_obs: bool = False, + ankle_bed_pseudo_std_m: float = DEFAULT_ANKLE_BED_PSEUDO_STD_M, ): self.model = model self.calibrations = calibrations @@ -3307,6 +3572,7 @@ def __init__( self.epipolar_threshold_px = float(epipolar_threshold_px) self.enable_dof_locking = enable_dof_locking self.root_flight_dynamics = root_flight_dynamics + self.predictor_mode = str(predictor_mode or "acc") self.flight_height_threshold_m = flight_height_threshold_m self.flight_min_consecutive_frames = max(1, int(flight_min_consecutive_frames)) self.flip_method = None if flip_method is None else str(flip_method) @@ -3317,13 +3583,18 @@ def __init__( self.flip_error_delta_threshold_px = float(flip_error_delta_threshold_px) self.upper_back_sagittal_gain = float(upper_back_sagittal_gain) self.upper_back_pseudo_std_rad = float(upper_back_pseudo_std_rad) + self.ankle_bed_pseudo_obs = bool(ankle_bed_pseudo_obs) + self.ankle_bed_pseudo_std_m = float(ankle_bed_pseudo_std_m) self.use_prediction_flip_gate = self.flip_method == "ekf_prediction_gate" self.nq = model.nbQ() self.nx = 3 * self.nq self.n_root = int(model.nbRoot()) if hasattr(model, "nbRoot") else 0 self.root_indices = np.arange(self.n_root, dtype=int) self.joint_indices = np.arange(self.n_root, self.nq, dtype=int) + self.q_eye = np.eye(self.nq) + self.q_zero = np.zeros((self.nq, self.nq)) self.identity_x = np.eye(self.nx) + self._transition_matrix = self._build_transition_matrix() self.marker_names = marker_name_list(model) self.marker_pairs = [ (marker_idx, KP_INDEX[marker_name]) @@ -3331,6 +3602,9 @@ def __init__( if marker_name in KP_INDEX ] self.marker_pair_keypoint_indices = np.asarray([kp_idx for _, kp_idx in self.marker_pairs], dtype=int) + self.marker_pair_index_by_keypoint = { + kp_idx: pair_idx for pair_idx, (_marker_idx, kp_idx) in enumerate(self.marker_pairs) + } self.camera_order = pose_data.camera_names self.camera_calibrations = [self.calibrations[cam_name] for cam_name in self.camera_order] self.coherence_method = canonical_coherence_method(reconstruction.coherence_method) @@ -3350,7 +3624,17 @@ def __init__( ) self.q_names = self._make_q_names() self.lock_map = {name: i for i, name in enumerate(self.q_names)} - self.upper_back_rotx_idx, self.hip_flexion_indices = self._resolve_upper_back_pseudo_observation_indices() + self.upper_back_segment_name = back_pseudo_segment_name_for_q_names(self.q_names) + self.upper_back_sagittal_idx, self.hip_flexion_indices = self._resolve_upper_back_pseudo_observation_indices() + self.ankle_bed_pair_indices = self._resolve_ankle_bed_pair_indices() + self.upper_back_zero_prior_indices = tuple( + idx + for idx in ( + self.lock_map.get(f"{self.upper_back_segment_name}:RotX") if self.upper_back_segment_name else None, + self.lock_map.get(f"{self.upper_back_segment_name}:RotZ") if self.upper_back_segment_name else None, + ) + if idx is not None + ) self.locked_q_indices: set[int] = set() base_process_noise = np.concatenate((1e-4 * np.ones(self.nq), 5e-3 * np.ones(self.nq), 5e-2 * np.ones(self.nq))) self.process_noise = np.diag(base_process_noise * self.process_noise_scale) @@ -3377,6 +3661,8 @@ def __init__( "flip_gate_s": 0.0, } self.previous_prediction_gate_nominal_rms_px = np.full(len(self.camera_calibrations), np.nan, dtype=float) + self.corrected_q_history: list[np.ndarray] = [] + self.corrected_state_history: list[np.ndarray] = [] self.effective_confidences, self.measurement_variances = self._precompute_measurement_variances() def _make_q_names(self) -> list[str]: @@ -3430,7 +3716,9 @@ def _frame_measurement_inputs(self, frame_idx: int) -> tuple[np.ndarray | None, return frame_coherence, effective_confidences, measurement_variances def _resolve_upper_back_pseudo_observation_indices(self) -> tuple[int | None, tuple[int, ...]]: - upper_back_rotx_idx = self.lock_map.get("UPPER_BACK:RotX") + if not self.upper_back_segment_name: + return None, () + upper_back_sagittal_idx = self.lock_map.get(f"{self.upper_back_segment_name}:RotY") hip_candidates = ( "LEFT_THIGH:RotY", "RIGHT_THIGH:RotY", @@ -3438,39 +3726,267 @@ def _resolve_upper_back_pseudo_observation_indices(self) -> tuple[int | None, tu "RIGHT_THIGH:RotX", ) hip_indices = tuple(self.lock_map[name] for name in hip_candidates if name in self.lock_map) - if upper_back_rotx_idx is None or len(hip_indices) < 2: + if upper_back_sagittal_idx is None or len(hip_indices) < 2: return None, tuple() - return int(upper_back_rotx_idx), hip_indices + return int(upper_back_sagittal_idx), hip_indices + + def _resolve_ankle_bed_pair_indices(self) -> tuple[tuple[int, int], ...]: + """Map ankle keypoints to the corresponding model-marker pair indices. + + Returns: + Tuples ``(pair_index, keypoint_index)`` for each ankle marker that + exists both in the model markers and in the COCO keypoint set. + """ + + resolved: list[tuple[int, int]] = [] + for keypoint_name in ("left_ankle", "right_ankle"): + keypoint_idx = KP_INDEX[keypoint_name] + pair_idx = self.marker_pair_index_by_keypoint.get(keypoint_idx) + if pair_idx is not None: + resolved.append((int(pair_idx), int(keypoint_idx))) + return tuple(resolved) def _upper_back_pseudo_measurement_block( self, reference_q: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None: - if self.upper_back_rotx_idx is None or len(self.hip_flexion_indices) < 2: + if self.upper_back_sagittal_idx is None or len(self.hip_flexion_indices) < 2: return None hip_flexion = np.asarray(reference_q[list(self.hip_flexion_indices)], dtype=float) if not np.all(np.isfinite(hip_flexion)): return None target_value = self.upper_back_sagittal_gain * float(np.mean(hip_flexion)) H_q = np.zeros((1, self.nq), dtype=float) - H_q[0, self.upper_back_rotx_idx] = 1.0 - h = np.array([float(reference_q[self.upper_back_rotx_idx])], dtype=float) + H_q[0, self.upper_back_sagittal_idx] = 1.0 + h = np.array([float(reference_q[self.upper_back_sagittal_idx])], dtype=float) z = np.array([target_value], dtype=float) variance = np.array([self.upper_back_pseudo_std_rad**2], dtype=float) return z, h, H_q, variance - def transition_matrix(self) -> np.ndarray: - """Matrice d'etat du modele discret a acceleration constante.""" + def _upper_back_zero_prior_blocks( + self, + reference_q: np.ndarray, + ) -> list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]: + blocks: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = [] + for q_idx in self.upper_back_zero_prior_indices: + if q_idx is None: + continue + current_value = float(reference_q[int(q_idx)]) + if not np.isfinite(current_value): + continue + h_q = np.zeros((1, self.nq), dtype=float) + h_q[0, int(q_idx)] = 1.0 + blocks.append( + ( + np.array([0.0], dtype=float), + np.array([current_value], dtype=float), + h_q, + np.array([self.upper_back_pseudo_std_rad**2], dtype=float), + ) + ) + return blocks + + def _is_airborne_frame(self, frame_idx: int) -> bool: + """Return whether one frame belongs to the airborne phase. + + Args: + frame_idx: Frame index in the reconstruction support. + + Returns: + ``True`` when every finite 3D support point is above the configured + flight-height threshold, ``False`` otherwise. + """ + + if frame_idx < 0 or frame_idx >= self.reconstruction.points_3d.shape[0]: + return False + frame_points = np.asarray(self.reconstruction.points_3d[frame_idx], dtype=float) + frame_z = frame_points[:, 2] + valid = np.isfinite(frame_z) + if not np.any(valid): + return False + return bool(np.all(frame_z[valid] > self.flight_height_threshold_m)) + + def _ankle_bed_pseudo_measurement_blocks( + self, + frame_idx: int, + marker_points_array: np.ndarray, + marker_jacobians_array: np.ndarray, + ) -> list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]: + """Build pseudo-measurement blocks that keep ankle X/Z on the bed. + + Args: + frame_idx: Frame index being corrected. + marker_points_array: Current model-marker positions for the frame. + marker_jacobians_array: Marker Jacobians evaluated at the predicted + state. + + Returns: + A list of EKF measurement blocks constrained on ankle ``X`` and + ``Z`` coordinates during non-airborne phases. + """ + + if not self.ankle_bed_pseudo_obs or not self.ankle_bed_pair_indices or self._is_airborne_frame(frame_idx): + return [] + blocks: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = [] + for pair_idx, keypoint_idx in self.ankle_bed_pair_indices: + target_point = np.asarray(self.reconstruction.points_3d[frame_idx, keypoint_idx], dtype=float) + current_point = np.asarray(marker_points_array[pair_idx], dtype=float) + current_jacobian = np.asarray(marker_jacobians_array[pair_idx], dtype=float) + if not (np.all(np.isfinite(target_point[[0, 2]])) and np.all(np.isfinite(current_point[[0, 2]]))): + continue + if not np.all(np.isfinite(current_jacobian[[0, 2], :])): + continue + blocks.append( + ( + target_point[[0, 2]].astype(float, copy=False), + current_point[[0, 2]].astype(float, copy=False), + current_jacobian[[0, 2], :].astype(float, copy=False), + np.full(2, self.ankle_bed_pseudo_std_m**2, dtype=float), + ) + ) + return blocks + + def _build_transition_matrix(self) -> np.ndarray: + """Build and cache the constant-acceleration state transition matrix.""" + dt = self.dt - eye = np.eye(self.nq) return np.block( [ - [eye, dt * eye, 0.5 * dt * dt * eye], - [np.zeros_like(eye), eye, dt * eye], - [np.zeros_like(eye), np.zeros_like(eye), eye], + [self.q_eye, dt * self.q_eye, 0.5 * dt * dt * self.q_eye], + [self.q_zero, self.q_eye, dt * self.q_eye], + [self.q_zero, self.q_zero, self.q_eye], ] ) + def transition_matrix(self) -> np.ndarray: + """Return the cached constant-acceleration state transition matrix.""" + + return self._transition_matrix + + def _use_history3_prediction(self) -> bool: + return self.predictor_mode in {"history3", "dyn_history3"} + + def _history3_q_indices(self) -> np.ndarray: + """Return the DoF indices controlled by the history-based predictor.""" + + if self.predictor_mode == "history3": + return np.arange(self.nq, dtype=int) + if self.predictor_mode == "dyn_history3": + return np.asarray(self.joint_indices, dtype=int) + return np.empty(0, dtype=int) + + def _history3_state_components( + self, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray] | None: + """Return the state components needed by the history-based predictor. + + Returns: + ``(q_t, qdot_t, qddot_t, qddot_tm1, qddot_tm2)`` from the three + most recent corrected EKF states, or ``None`` when the history is + incomplete. + """ + + if len(self.corrected_state_history) < 3: + return None + state_tm2 = np.asarray(self.corrected_state_history[-3], dtype=float) + state_tm1 = np.asarray(self.corrected_state_history[-2], dtype=float) + state_t = np.asarray(self.corrected_state_history[-1], dtype=float) + return ( + np.asarray(state_t[: self.nq], dtype=float), + np.asarray(state_t[self.nq : 2 * self.nq], dtype=float), + np.asarray(state_t[2 * self.nq : 3 * self.nq], dtype=float), + np.asarray(state_tm1[2 * self.nq : 3 * self.nq], dtype=float), + np.asarray(state_tm2[2 * self.nq : 3 * self.nq], dtype=float), + ) + + @staticmethod + def _limited_acceleration_extrapolation( + accel_t: np.ndarray, + accel_tm1: np.ndarray, + accel_tm2: np.ndarray, + dt: float, + *, + anchor_accel: np.ndarray, + ) -> np.ndarray: + """Predict the next acceleration with a damped jerk extrapolation. + + The raw jerk estimate is computed from ``a(t)``, ``a(t-1)``, and + ``a(t-2)``. Its effect is then limited by the recent acceleration + increments and blended with the base predictor acceleration so the + result stays smooth and numerically stable. + """ + + jerk_t = (3.0 * accel_t - 4.0 * accel_tm1 + accel_tm2) / (2.0 * dt) + raw_next = accel_t + dt * jerk_t + delta_recent = accel_t - accel_tm1 + delta_previous = accel_tm1 - accel_tm2 + delta_limit = np.maximum(np.abs(delta_recent), np.abs(delta_previous)) + delta_limit = np.maximum(delta_limit, 1e-9) + limited_delta = np.clip(raw_next - accel_t, -delta_limit, delta_limit) + history_next = accel_t + limited_delta + return 0.5 * np.asarray(anchor_accel, dtype=float) + 0.5 * history_next + + def _apply_history3_prediction(self, predicted_state: np.ndarray) -> np.ndarray: + """Apply a smooth higher-order prediction from recent corrected states. + + Args: + predicted_state: State already predicted by the base ACC/DYN model. + + Returns: + Updated state where the selected DoFs are re-predicted from the + latest corrected ``q(t)``, ``qdot(t)``, ``qddot(t)``, + ``qddot(t-1)``, and ``qddot(t-2)`` values. + """ + + q_indices = self._history3_q_indices() + state_components = self._history3_state_components() + if state_components is None or q_indices.size == 0: + return predicted_state + q_t, qdot_t, qddot_t, qddot_tm1, qddot_tm2 = state_components + required = np.concatenate( + ( + q_t[q_indices], + qdot_t[q_indices], + qddot_t[q_indices], + qddot_tm1[q_indices], + qddot_tm2[q_indices], + ) + ) + if not np.all(np.isfinite(required)): + return predicted_state + dt = float(self.dt) + anchor_accel = np.asarray(predicted_state[2 * self.nq + q_indices], dtype=float) + qddot_next = self._limited_acceleration_extrapolation( + qddot_t[q_indices], + qddot_tm1[q_indices], + qddot_tm2[q_indices], + dt, + anchor_accel=anchor_accel, + ) + qdot_next = qdot_t[q_indices] + 0.5 * dt * (qddot_t[q_indices] + qddot_next) + q_next = q_t[q_indices] + dt * qdot_t[q_indices] + (dt * dt / 6.0) * (2.0 * qddot_t[q_indices] + qddot_next) + updated = np.array(predicted_state, copy=True) + updated[q_indices] = q_next + updated[self.nq + q_indices] = qdot_next + updated[2 * self.nq + q_indices] = qddot_next + return updated + + def record_corrected_state(self, state: np.ndarray) -> None: + """Store one corrected state so the next history-based prediction can use it. + + Args: + state: Corrected EKF state ``[q, qdot, qddot]`` for the current + frame. + """ + + state_array = np.asarray(state, dtype=float).copy() + self.corrected_q_history.append(np.asarray(state_array[: self.nq], dtype=float)) + self.corrected_state_history.append(state_array) + if len(self.corrected_q_history) > 3: + self.corrected_q_history = self.corrected_q_history[-3:] + if len(self.corrected_state_history) > 3: + self.corrected_state_history = self.corrected_state_history[-3:] + def _is_airborne_from_previous_frame(self, frame_idx: int) -> bool: """Retourne vrai si le critere de vol est satisfait sur assez de frames precedentes.""" if frame_idx <= 0: @@ -3574,6 +4090,8 @@ def predict(self, state: np.ndarray, covariance: np.ndarray, frame_idx: int) -> + self.dt * qdot_prev[self.root_indices] + 0.5 * self.dt * self.dt * qddot_root ) + if self._use_history3_prediction(): + predicted_state = self._apply_history3_prediction(predicted_state) self._update_locked_dofs(predicted_state) self._apply_lock_constraints(predicted_state, predicted_covariance) self.profiling["predict_s"] += time.perf_counter() - t_predict @@ -3726,6 +4244,8 @@ def update( self.update_status["flip_prediction_gate_swapped"] += 1 else: self.update_status["flip_prediction_gate_raw"] += 1 + if bool(gate_diagnostics.get("used_swapped")): + selected_variances = np.asarray(selected_variances, dtype=float) * 4.0 else: selected_mask = np.all(np.isfinite(frame_keypoints[keypoint_indices]), axis=1) & np.isfinite( frame_variances[keypoint_indices] @@ -3745,23 +4265,47 @@ def update( ) self.profiling["assembly_s"] += time.perf_counter() - t_assembly + pseudo_block = self._upper_back_pseudo_measurement_block(q) + zero_prior_blocks = self._upper_back_zero_prior_blocks(q) + ankle_bed_blocks = self._ankle_bed_pseudo_measurement_blocks( + frame_idx=frame_idx, + marker_points_array=marker_points_array, + marker_jacobians_array=marker_jacobians_array, + ) + has_pseudo_priors = pseudo_block is not None or bool(zero_prior_blocks) or bool(ankle_bed_blocks) + if pseudo_block is not None: + measurement_blocks.append(pseudo_block) + measurement_blocks.extend(zero_prior_blocks) + measurement_blocks.extend(ankle_bed_blocks) + if not measurement_blocks: self.update_status["pred_only_no_measurement"] += 1 self.profiling["update_s"] += time.perf_counter() - t_update return predicted_state, predicted_covariance, "pred_only_no_measurement" - pseudo_block = self._upper_back_pseudo_measurement_block(q) - if pseudo_block is not None: - measurement_blocks.append(pseudo_block) - t_solve = time.perf_counter() - update_result = apply_measurement_update_sequential( - predicted_state=predicted_state, - predicted_covariance=predicted_covariance, - measurement_blocks=measurement_blocks, - nq=self.nq, - identity_x=self.identity_x, - ) + stacked_measurements = stack_measurement_blocks(measurement_blocks, self.nq) if has_pseudo_priors else None + update_result = None + if stacked_measurements is not None: + z_batch, h_batch, hq_batch, r_batch = stacked_measurements + update_result = apply_measurement_update_batch( + predicted_state=predicted_state, + predicted_covariance=predicted_covariance, + z=z_batch, + h=h_batch, + H_q=hq_batch, + R_diag_array=r_batch, + nq=self.nq, + identity_x=self.identity_x, + ) + if update_result is None: + update_result = apply_measurement_update_sequential( + predicted_state=predicted_state, + predicted_covariance=predicted_covariance, + measurement_blocks=measurement_blocks, + nq=self.nq, + identity_x=self.identity_x, + ) if update_result is None: self.update_status["pred_only_no_measurement"] += 1 self.profiling["solve_s"] += time.perf_counter() - t_solve @@ -3786,7 +4330,8 @@ def initial_state_from_triangulation(model, reconstruction: ReconstructionResult q0 = np.asarray(biorbd.InverseKinematics(model, marker_positions).solve()).reshape(-1) except Exception: q0 = np.zeros(model.nbQ()) - return np.concatenate((q0, np.zeros(model.nbQ()), np.zeros(model.nbQ()))) + state = np.concatenate((q0, np.zeros(model.nbQ()), np.zeros(model.nbQ()))) + return canonicalize_state_q_rotation_branches(model, state) def q_names_from_model(model) -> list[str]: @@ -3798,6 +4343,24 @@ def q_names_from_model(model) -> list[str]: ] +def root_translation_origin_for_q_names(q_names: list[str] | np.ndarray) -> str: + q_name_set = {str(name) for name in q_names} + return "upper_trunk" if any(name.startswith("LOWER_TRUNK:") for name in q_name_set) else "pelvis" + + +def root_translation_origin_for_model(model) -> str: + return root_translation_origin_for_q_names(q_names_from_model(model)) + + +def back_pseudo_segment_name_for_q_names(q_names: list[str] | np.ndarray) -> str | None: + q_name_set = {str(name) for name in q_names} + if any(name.startswith("LOWER_TRUNK:") for name in q_name_set): + return "LOWER_TRUNK" + if any(name.startswith("UPPER_BACK:") for name in q_name_set): + return "UPPER_BACK" + return None + + def _segment_rotation_sequence_and_offsets(segment_dof_names: list[str]) -> tuple[str | None, np.ndarray]: """Infer a contiguous Euler rotation block from one segment DoF list.""" @@ -3867,21 +4430,29 @@ def canonicalize_model_q_rotation_branches(model, q_values: np.ndarray) -> np.nd def first_valid_root_translation_from_triangulation( reconstruction: ReconstructionResult, + *, + root_translation_origin: str = "pelvis", ) -> tuple[int | None, np.ndarray | None]: - """Estime `TransX/Y/Z` de la racine au milieu des hanches triangulees.""" - left_idx = KP_INDEX["left_hip"] - right_idx = KP_INDEX["right_hip"] + """Estime `TransX/Y/Z` de la racine depuis le tronc triangulé.""" + if str(root_translation_origin) == "upper_trunk": + left_idx = KP_INDEX["left_shoulder"] + right_idx = KP_INDEX["right_shoulder"] + else: + left_idx = KP_INDEX["left_hip"] + right_idx = KP_INDEX["right_hip"] for frame_idx in range(reconstruction.points_3d.shape[0]): - left_hip = reconstruction.points_3d[frame_idx, left_idx] - right_hip = reconstruction.points_3d[frame_idx, right_idx] - if np.all(np.isfinite(left_hip)) and np.all(np.isfinite(right_hip)): - return frame_idx, 0.5 * (left_hip + right_hip) + left_point = reconstruction.points_3d[frame_idx, left_idx] + right_point = reconstruction.points_3d[frame_idx, right_idx] + if np.all(np.isfinite(left_point)) and np.all(np.isfinite(right_point)): + return frame_idx, 0.5 * (left_point + right_point) return None, None def root_translation_from_triangulation_frame( reconstruction: ReconstructionResult, frame_idx: int, + *, + root_translation_origin: str = "pelvis", ) -> np.ndarray | None: """Estimate the root translation from one specific triangulated frame.""" @@ -3890,20 +4461,30 @@ def root_translation_from_triangulation_frame( frame_idx = int(frame_idx) if frame_idx < 0 or frame_idx >= reconstruction.points_3d.shape[0]: return None - left_idx = KP_INDEX["left_hip"] - right_idx = KP_INDEX["right_hip"] - left_hip = reconstruction.points_3d[frame_idx, left_idx] - right_hip = reconstruction.points_3d[frame_idx, right_idx] - if np.all(np.isfinite(left_hip)) and np.all(np.isfinite(right_hip)): - return 0.5 * (left_hip + right_hip) + if str(root_translation_origin) == "upper_trunk": + left_idx = KP_INDEX["left_shoulder"] + right_idx = KP_INDEX["right_shoulder"] + else: + left_idx = KP_INDEX["left_hip"] + right_idx = KP_INDEX["right_hip"] + left_point = reconstruction.points_3d[frame_idx, left_idx] + right_point = reconstruction.points_3d[frame_idx, right_idx] + if np.all(np.isfinite(left_point)) and np.all(np.isfinite(right_point)): + return 0.5 * (left_point + right_point) return None def first_valid_root_pose_from_triangulation( reconstruction: ReconstructionResult, + *, + root_translation_origin: str = "pelvis", ) -> tuple[int | None, np.ndarray | None]: """Estime les 6 DoF de la racine a partir du tronc triangule.""" - root_q = compute_trunk_dofs_from_points(reconstruction.points_3d, unwrap_rotations=False) + root_q = compute_trunk_dofs_from_points( + reconstruction.points_3d, + unwrap_rotations=False, + translation_origin=root_translation_origin, + ) for frame_idx in range(root_q.shape[0]): if np.all(np.isfinite(root_q[frame_idx])): return frame_idx, np.asarray(root_q[frame_idx], dtype=float) @@ -3915,9 +4496,10 @@ def apply_root_translation_guess_to_state(model, state: np.ndarray, translation_ if translation_xyz is None or not np.all(np.isfinite(translation_xyz)): return np.array(state, copy=True) q_names = q_names_from_model(model) + root_segment_name = "TRUNK" updated_state = np.array(state, copy=True) for axis_idx, axis_name in enumerate(("TransX", "TransY", "TransZ")): - target_name = f"TRUNK:{axis_name}" + target_name = f"{root_segment_name}:{axis_name}" if target_name in q_names: updated_state[q_names.index(target_name)] = float(translation_xyz[axis_idx]) return updated_state @@ -3931,9 +4513,18 @@ def apply_root_pose_guess_to_state(model, state: np.ndarray, root_pose: np.ndarr if root_pose.size < 6 or not np.all(np.isfinite(root_pose[:6])): return np.array(state, copy=True) q_names = q_names_from_model(model) + root_segment_name = "TRUNK" updated_state = np.array(state, copy=True) for value, target_name in zip( - root_pose[:6], ("TRUNK:TransX", "TRUNK:TransY", "TRUNK:TransZ", "TRUNK:RotY", "TRUNK:RotX", "TRUNK:RotZ") + root_pose[:6], + ( + f"{root_segment_name}:TransX", + f"{root_segment_name}:TransY", + f"{root_segment_name}:TransZ", + f"{root_segment_name}:RotY", + f"{root_segment_name}:RotX", + f"{root_segment_name}:RotZ", + ), ): if target_name in q_names: updated_state[q_names.index(target_name)] = float(value) @@ -3958,8 +4549,17 @@ def align_root_translation_guess_to_frame_zero( if source_frame_idx is None: return np.array(state, copy=True) - source_translation = root_translation_from_triangulation_frame(reconstruction, int(source_frame_idx)) - target_translation = root_translation_from_triangulation_frame(reconstruction, 0) + root_translation_origin = root_translation_origin_for_model(model) + source_translation = root_translation_from_triangulation_frame( + reconstruction, + int(source_frame_idx), + root_translation_origin=root_translation_origin, + ) + target_translation = root_translation_from_triangulation_frame( + reconstruction, + 0, + root_translation_origin=root_translation_origin, + ) if source_translation is None or target_translation is None: return np.array(state, copy=True) return apply_root_translation_guess_to_state(model, state, target_translation) @@ -3975,6 +4575,7 @@ def compute_biorbd_kalman_initial_state( return None, {"method": "none", "used_init_state": False} state = initial_state_from_triangulation(model, reconstruction) + root_translation_origin = root_translation_origin_for_model(model) diagnostics: dict[str, object] = { "method": str(method), "used_init_state": True, @@ -3991,9 +4592,12 @@ def compute_biorbd_kalman_initial_state( source_frame_idx=diagnostics["bootstrap_frame_idx"], ) diagnostics["aligned_root_translation_to_frame_zero"] = not np.allclose(aligned_state, state, equal_nan=True) - return aligned_state, diagnostics + return canonicalize_state_q_rotation_branches(model, aligned_state), diagnostics if method == "triangulation_ik_root_translation": - frame_idx, root_translation = first_valid_root_translation_from_triangulation(reconstruction) + frame_idx, root_translation = first_valid_root_translation_from_triangulation( + reconstruction, + root_translation_origin=root_translation_origin, + ) diagnostics["bootstrap_frame_idx"] = None if frame_idx is None else int(frame_idx) diagnostics["used_root_translation_mid_hips"] = bool(root_translation is not None) updated_state = apply_root_translation_guess_to_state(model, state, root_translation) @@ -4006,10 +4610,13 @@ def compute_biorbd_kalman_initial_state( diagnostics["aligned_root_translation_to_frame_zero"] = not np.allclose( aligned_state, updated_state, equal_nan=True ) - return aligned_state, diagnostics + return canonicalize_state_q_rotation_branches(model, aligned_state), diagnostics if method == "root_pose_zero_rest": zero_state = np.zeros(3 * model.nbQ()) - frame_idx, root_pose = first_valid_root_pose_from_triangulation(reconstruction) + frame_idx, root_pose = first_valid_root_pose_from_triangulation( + reconstruction, + root_translation_origin=root_translation_origin, + ) diagnostics["bootstrap_frame_idx"] = None if frame_idx is None else int(frame_idx) diagnostics["used_triangulation_ik"] = False diagnostics["used_root_translation_mid_hips"] = bool(root_pose is not None) @@ -4024,10 +4631,13 @@ def compute_biorbd_kalman_initial_state( diagnostics["aligned_root_translation_to_frame_zero"] = not np.allclose( aligned_state, updated_state, equal_nan=True ) - return aligned_state, diagnostics + return canonicalize_state_q_rotation_branches(model, aligned_state), diagnostics if method == "root_translation_zero_rest": zero_state = np.zeros(3 * model.nbQ()) - frame_idx, root_translation = first_valid_root_translation_from_triangulation(reconstruction) + frame_idx, root_translation = first_valid_root_translation_from_triangulation( + reconstruction, + root_translation_origin=root_translation_origin, + ) diagnostics["bootstrap_frame_idx"] = None if frame_idx is None else int(frame_idx) diagnostics["used_triangulation_ik"] = False diagnostics["used_root_translation_mid_hips"] = bool(root_translation is not None) @@ -4041,7 +4651,7 @@ def compute_biorbd_kalman_initial_state( diagnostics["aligned_root_translation_to_frame_zero"] = not np.allclose( aligned_state, updated_state, equal_nan=True ) - return aligned_state, diagnostics + return canonicalize_state_q_rotation_branches(model, aligned_state), diagnostics raise ValueError(f"Unsupported biorbd kalman init method: {method}") @@ -4066,6 +4676,10 @@ def initial_state_from_ekf_bootstrap( flip_min_gain_px: float = DEFAULT_FLIP_MIN_GAIN_PX, flip_error_threshold_px: float = DEFAULT_EKF_PREDICTION_GATE_ERROR_THRESHOLD_PX, flip_error_delta_threshold_px: float = DEFAULT_EKF_PREDICTION_GATE_ERROR_DELTA_THRESHOLD_PX, + upper_back_sagittal_gain: float = DEFAULT_UPPER_BACK_SAGITTAL_GAIN, + upper_back_pseudo_std_rad: float = DEFAULT_UPPER_BACK_PSEUDO_STD_RAD, + ankle_bed_pseudo_obs: bool = False, + ankle_bed_pseudo_std_m: float = DEFAULT_ANKLE_BED_PSEUDO_STD_M, ) -> tuple[np.ndarray, dict[str, object]]: """Affine `q0` par corrections EKF repetees sur une seule frame. @@ -4117,6 +4731,10 @@ def initial_state_from_ekf_bootstrap( flip_min_gain_px=flip_min_gain_px, flip_error_threshold_px=flip_error_threshold_px, flip_error_delta_threshold_px=flip_error_delta_threshold_px, + upper_back_sagittal_gain=upper_back_sagittal_gain, + upper_back_pseudo_std_rad=upper_back_pseudo_std_rad, + ankle_bed_pseudo_obs=ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=ankle_bed_pseudo_std_m, ) state = np.array(ik_state, copy=True) base_covariance = np.eye(ekf.nx) * 1e-2 @@ -4172,10 +4790,17 @@ def initial_state_from_root_pose_bootstrap( flip_min_gain_px: float = DEFAULT_FLIP_MIN_GAIN_PX, flip_error_threshold_px: float = DEFAULT_EKF_PREDICTION_GATE_ERROR_THRESHOLD_PX, flip_error_delta_threshold_px: float = DEFAULT_EKF_PREDICTION_GATE_ERROR_DELTA_THRESHOLD_PX, + upper_back_sagittal_gain: float = DEFAULT_UPPER_BACK_SAGITTAL_GAIN, + upper_back_pseudo_std_rad: float = DEFAULT_UPPER_BACK_PSEUDO_STD_RAD, + ankle_bed_pseudo_obs: bool = False, + ankle_bed_pseudo_std_m: float = DEFAULT_ANKLE_BED_PSEUDO_STD_M, ) -> tuple[np.ndarray, dict[str, object]]: """Initialise l'EKF 2D depuis une pose racine geometrique, puis bootstrappe.""" zero_state = np.zeros(3 * model.nbQ(), dtype=float) - frame_idx, root_pose = first_valid_root_pose_from_triangulation(reconstruction) + frame_idx, root_pose = first_valid_root_pose_from_triangulation( + reconstruction, + root_translation_origin=root_translation_origin_for_model(model), + ) diagnostics_seed = { "method": "root_pose_bootstrap", "root_pose_frame_idx": None if frame_idx is None else int(frame_idx), @@ -4204,6 +4829,10 @@ def initial_state_from_root_pose_bootstrap( flip_min_gain_px=flip_min_gain_px, flip_error_threshold_px=flip_error_threshold_px, flip_error_delta_threshold_px=flip_error_delta_threshold_px, + upper_back_sagittal_gain=upper_back_sagittal_gain, + upper_back_pseudo_std_rad=upper_back_pseudo_std_rad, + ankle_bed_pseudo_obs=ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=ankle_bed_pseudo_std_m, ) root_seed_state = apply_root_pose_guess_to_state(model, zero_state, root_pose) @@ -4227,6 +4856,10 @@ def initial_state_from_root_pose_bootstrap( flip_min_gain_px=flip_min_gain_px, flip_error_threshold_px=flip_error_threshold_px, flip_error_delta_threshold_px=flip_error_delta_threshold_px, + upper_back_sagittal_gain=upper_back_sagittal_gain, + upper_back_pseudo_std_rad=upper_back_pseudo_std_rad, + ankle_bed_pseudo_obs=ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=ankle_bed_pseudo_std_m, ) diagnostics = dict(diagnostics) diagnostics["method"] = "root_pose_bootstrap" @@ -4256,6 +4889,10 @@ def compute_ekf2d_initial_state( flip_min_gain_px: float = DEFAULT_FLIP_MIN_GAIN_PX, flip_error_threshold_px: float = DEFAULT_EKF_PREDICTION_GATE_ERROR_THRESHOLD_PX, flip_error_delta_threshold_px: float = DEFAULT_EKF_PREDICTION_GATE_ERROR_DELTA_THRESHOLD_PX, + upper_back_sagittal_gain: float = DEFAULT_UPPER_BACK_SAGITTAL_GAIN, + upper_back_pseudo_std_rad: float = DEFAULT_UPPER_BACK_PSEUDO_STD_RAD, + ankle_bed_pseudo_obs: bool = False, + ankle_bed_pseudo_std_m: float = DEFAULT_ANKLE_BED_PSEUDO_STD_M, ) -> tuple[np.ndarray, dict[str, object]]: """Selectionne et calcule l'etat initial des EKF 2D.""" if method == "triangulation_ik": @@ -4291,6 +4928,10 @@ def compute_ekf2d_initial_state( flip_min_gain_px=flip_min_gain_px, flip_error_threshold_px=flip_error_threshold_px, flip_error_delta_threshold_px=flip_error_delta_threshold_px, + upper_back_sagittal_gain=upper_back_sagittal_gain, + upper_back_pseudo_std_rad=upper_back_pseudo_std_rad, + ankle_bed_pseudo_obs=ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=ankle_bed_pseudo_std_m, ) if method == "root_pose_bootstrap": return initial_state_from_root_pose_bootstrap( @@ -4312,6 +4953,10 @@ def compute_ekf2d_initial_state( flip_min_gain_px=flip_min_gain_px, flip_error_threshold_px=flip_error_threshold_px, flip_error_delta_threshold_px=flip_error_delta_threshold_px, + upper_back_sagittal_gain=upper_back_sagittal_gain, + upper_back_pseudo_std_rad=upper_back_pseudo_std_rad, + ankle_bed_pseudo_obs=ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=ankle_bed_pseudo_std_m, ) raise ValueError(f"Unsupported ekf2d initial state method: {method}") @@ -4333,15 +4978,21 @@ def run_ekf( flight_height_threshold_m: float = DEFAULT_FLIGHT_HEIGHT_THRESHOLD_M, flight_min_consecutive_frames: int = DEFAULT_FLIGHT_MIN_CONSECUTIVE_FRAMES, unwrap_root: bool = True, + root_unwrap_mode: str = "off", debug_label: str | None = None, debug_console: bool = False, initial_state: np.ndarray | None = None, model=None, + predictor_mode: str = "acc", flip_method: str | None = None, flip_improvement_ratio: float = DEFAULT_FLIP_IMPROVEMENT_RATIO, flip_min_gain_px: float = DEFAULT_FLIP_MIN_GAIN_PX, flip_error_threshold_px: float = DEFAULT_EKF_PREDICTION_GATE_ERROR_THRESHOLD_PX, flip_error_delta_threshold_px: float = DEFAULT_EKF_PREDICTION_GATE_ERROR_DELTA_THRESHOLD_PX, + upper_back_sagittal_gain: float = DEFAULT_UPPER_BACK_SAGITTAL_GAIN, + upper_back_pseudo_std_rad: float = DEFAULT_UPPER_BACK_PSEUDO_STD_RAD, + ankle_bed_pseudo_obs: bool = False, + ankle_bed_pseudo_std_m: float = DEFAULT_ANKLE_BED_PSEUDO_STD_M, ) -> tuple[dict[str, np.ndarray], dict[str, float]]: """Execute l'EKF multi-vues sur toute la sequence. @@ -4372,11 +5023,16 @@ def run_ekf( root_flight_dynamics=root_flight_dynamics, flight_height_threshold_m=flight_height_threshold_m, flight_min_consecutive_frames=flight_min_consecutive_frames, + predictor_mode=predictor_mode, flip_method=flip_method, flip_improvement_ratio=flip_improvement_ratio, flip_min_gain_px=flip_min_gain_px, flip_error_threshold_px=flip_error_threshold_px, flip_error_delta_threshold_px=flip_error_delta_threshold_px, + upper_back_sagittal_gain=upper_back_sagittal_gain, + upper_back_pseudo_std_rad=upper_back_pseudo_std_rad, + ankle_bed_pseudo_obs=ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=ankle_bed_pseudo_std_m, ) state = ( np.array(initial_state, copy=True) @@ -4401,6 +5057,7 @@ def run_ekf( predicted_state, predicted_covariance, ekf.q_names, ekf.nq, frame_idx, "predict" ) state, covariance, update_status = ekf.update(predicted_state, predicted_covariance, frame_idx) + ekf.record_corrected_state(state) if debug_console: print( debug_state_summary( @@ -4415,9 +5072,10 @@ def run_ekf( update_status_per_frame.append(update_status) timings["loop_s"] = time.perf_counter() - t_loop timings.update({key: float(value) for key, value in ekf.profiling.items()}) + effective_root_unwrap_mode = normalize_root_unwrap_mode(root_unwrap_mode, legacy_unwrap=unwrap_root) q = ( - unwrap_root_rotations(states[:, : ekf.nq], ekf.q_names) - if unwrap_root + unwrap_root_rotations(states[:, : ekf.nq], ekf.q_names, mode=effective_root_unwrap_mode) + if effective_root_unwrap_mode != "off" else np.array(states[:, : ekf.nq], copy=True) ) return ( @@ -4468,6 +5126,8 @@ def warmup_ekf_runtime( flight_height_threshold_m: float, flight_min_consecutive_frames: int, initial_state: np.ndarray, + ankle_bed_pseudo_obs: bool = False, + ankle_bed_pseudo_std_m: float = DEFAULT_ANKLE_BED_PSEUDO_STD_M, ) -> float: """Execute une frame jetable pour externaliser le warm-up du premier EKF. @@ -4492,6 +5152,8 @@ def warmup_ekf_runtime( root_flight_dynamics=root_flight_dynamics, flight_height_threshold_m=flight_height_threshold_m, flight_min_consecutive_frames=flight_min_consecutive_frames, + ankle_bed_pseudo_obs=ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=ankle_bed_pseudo_std_m, ) covariance = np.eye(ekf.nx) * 1e-2 state = np.array(initial_state, copy=True) @@ -4607,6 +5269,7 @@ def compare_kalman_filters( biorbd_kalman_init_method: str = DEFAULT_BIORBD_KALMAN_INIT_METHOD, classic_result: dict[str, np.ndarray] | None = None, unwrap_root: bool = True, + root_unwrap_mode: str = "off", ) -> ComparisonResult: """Compare les `q` du nouvel EKF avec ceux du Kalman `biorbd`.""" import biorbd @@ -4620,6 +5283,7 @@ def compare_kalman_filters( noise_factor=biorbd_kalman_noise_factor, error_factor=biorbd_kalman_error_factor, unwrap_root=unwrap_root, + root_unwrap_mode=root_unwrap_mode, initial_state_method=biorbd_kalman_init_method, ) else: @@ -4653,7 +5317,7 @@ def compare_kalman_filters( def reconstruction_cache_metadata( pose_data: PoseData, - error_threshold_px: float, + error_threshold_px: float | None, min_cameras_for_triangulation: int, epipolar_threshold_px: float, triangulation_method: str, @@ -4670,7 +5334,7 @@ def reconstruction_cache_metadata( "n_frames": int(pose_data.frames.shape[0]), "frame_signature": frame_signature(pose_data.frames), "pose_data_signature": pose_data_signature(pose_data), - "reprojection_threshold_px": float(error_threshold_px), + "reprojection_threshold_px": None if error_threshold_px is None else float(error_threshold_px), "min_cameras_for_triangulation": int(min_cameras_for_triangulation), "epipolar_threshold_px": float(epipolar_threshold_px), "triangulation_method": triangulation_method, @@ -4690,6 +5354,7 @@ def model_stage_metadata( subject_mass_kg: float, initial_rotation_correction: bool, model_variant: str = DEFAULT_MODEL_VARIANT, + symmetrize_limbs: bool = True, ) -> dict[str, object]: """Metadonnees de validite du stage modele.""" return { @@ -4701,6 +5366,7 @@ def model_stage_metadata( "subject_mass_kg": float(subject_mass_kg), "initial_rotation_correction": bool(initial_rotation_correction), "model_variant": str(model_variant), + "symmetrize_limbs": bool(symmetrize_limbs), } @@ -4744,6 +5410,10 @@ def metadata_cache_matches(cache_path: Path, expected_metadata: dict[str, object return False for key, expected_value in expected_metadata.items(): cached_value = cached_metadata.get(key) + if expected_value is None: + if cached_value is not None: + return False + continue if cached_value is None: return False if isinstance(expected_value, float): @@ -4862,6 +5532,7 @@ def run_biorbd_marker_kalman_with_parameters( noise_factor: float, error_factor: float, unwrap_root: bool = True, + root_unwrap_mode: str = "off", initial_state_method: str = DEFAULT_BIORBD_KALMAN_INIT_METHOD, ) -> dict[str, np.ndarray]: """Version parametree du Kalman `biorbd` pour faciliter le tuning du lissage.""" @@ -4900,7 +5571,12 @@ def run_biorbd_marker_kalman_with_parameters( qddot_all[frame_idx, :] = qddot.to_array() q_names = q_names_from_model(model) - q_all = unwrap_root_rotations(q_all, q_names) if unwrap_root else q_all + effective_root_unwrap_mode = normalize_root_unwrap_mode(root_unwrap_mode, legacy_unwrap=unwrap_root) + q_all = ( + unwrap_root_rotations(q_all, q_names, mode=effective_root_unwrap_mode) + if effective_root_unwrap_mode != "off" + else q_all + ) return { "q": q_all, @@ -5228,7 +5904,7 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--pose-data-mode", - choices=("raw", "filtered", "cleaned"), + choices=("raw", "filtered", "cleaned", "annotated"), default="cleaned", help="Source 2D utilisee apres chargement: brut, filtre lisse, ou nettoye avec rejet des outliers.", ) @@ -5281,6 +5957,11 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--output-dir", type=Path, default=Path("output") / "vitpose_ekf") parser.add_argument("--biomod", type=Path, default=Path("output") / "vitpose_ekf" / "vitpose_chain.bioMod") parser.add_argument("--model-variant", choices=SUPPORTED_MODEL_VARIANTS, default=DEFAULT_MODEL_VARIANT) + parser.add_argument( + "--no-symmetrize-limbs", + action="store_true", + help="Conserve des longueurs gauche/droite distinctes au lieu de symétriser les membres.", + ) parser.add_argument("--model-cache", type=Path, default=None, help="Cache NPZ du stage modele.") parser.add_argument("--biorbd-kalman-cache", type=Path, default=None, help="Cache NPZ du Kalman marqueurs biorbd.") parser.add_argument( @@ -5394,6 +6075,17 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Active le verrouillage automatique de certains DoF pres de singularites.", ) + parser.add_argument( + "--ankle-bed-pseudo-obs", + action="store_true", + help="En phase toile (hors aerien), ajoute une pseudo-observation 3D sur X/Z des chevilles pour aider l'EKF 2D.", + ) + parser.add_argument( + "--ankle-bed-pseudo-std-m", + type=float, + default=DEFAULT_ANKLE_BED_PSEUDO_STD_M, + help="Ecart-type (m) de la pseudo-observation 3D X/Z des chevilles sur la toile. Plus petit = contrainte plus forte.", + ) parser.add_argument( "--root-flight-dynamics", action="store_true", @@ -5498,6 +6190,12 @@ def parse_args() -> argparse.Namespace: default=DEFAULT_BIORBD_KALMAN_INIT_METHOD, help="Strategie de warm-start du Kalman marqueurs `biorbd` via setInitState.", ) + parser.add_argument( + "--root-unwrap-mode", + choices=SUPPORTED_ROOT_UNWRAP_MODES, + default="off", + help="Stabilisation des angles de racine: off, single, ou double (reextract+unwrap).", + ) parser.add_argument( "--no-root-unwrap", action="store_true", @@ -5518,6 +6216,9 @@ def parse_args() -> argparse.Namespace: def main() -> None: """Point d'entree CLI.""" args = parse_args() + root_unwrap_mode = normalize_root_unwrap_mode( + ("off" if args.no_root_unwrap else args.root_unwrap_mode), + ) stage_timings_s: dict[str, float] = {} calibrations = load_calibrations(args.calib) selected_camera_names = parse_camera_names(args.camera_names) @@ -5727,6 +6428,7 @@ def main() -> None: args.subject_mass_kg, args.initial_rotation_correction, model_variant=args.model_variant, + symmetrize_limbs=not args.no_symmetrize_limbs, ) if metadata_cache_matches(model_cache_path, model_metadata) and args.biomod.exists(): t0 = time.perf_counter() @@ -5743,6 +6445,7 @@ def main() -> None: reconstruction=reconstruction, apply_initial_root_rotation_correction=args.initial_rotation_correction, model_variant=args.model_variant, + symmetrize_limbs=not args.no_symmetrize_limbs, ) model_compute_time_s = time.perf_counter() - t0 save_model_stage(model_cache_path, lengths, biomod_path, model_metadata, compute_time_s=model_compute_time_s) @@ -5795,6 +6498,8 @@ def main() -> None: enable_dof_locking=args.enable_dof_locking, method=args.ekf2d_initial_state_method, bootstrap_passes=args.ekf2d_bootstrap_passes, + ankle_bed_pseudo_obs=args.ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=args.ankle_bed_pseudo_std_m, ) stage_timings_s["ekf_initial_state_s"] = time.perf_counter() - t0 shared_initial_state_flip_acc = shared_initial_state @@ -5815,6 +6520,8 @@ def main() -> None: enable_dof_locking=args.enable_dof_locking, method=args.ekf2d_initial_state_method, bootstrap_passes=args.ekf2d_bootstrap_passes, + ankle_bed_pseudo_obs=args.ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=args.ankle_bed_pseudo_std_m, ) stage_timings_s["ekf_initial_state_flip_acc_s"] = time.perf_counter() - t0 @@ -5834,6 +6541,8 @@ def main() -> None: flight_height_threshold_m=args.flight_height_threshold_m, flight_min_consecutive_frames=args.flight_min_consecutive_frames, initial_state=shared_initial_state, + ankle_bed_pseudo_obs=args.ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=args.ankle_bed_pseudo_std_m, ) ekf_result_acc, ekf_acc_timings = run_ekf( @@ -5851,9 +6560,12 @@ def main() -> None: root_flight_dynamics=False, flight_height_threshold_m=args.flight_height_threshold_m, flight_min_consecutive_frames=args.flight_min_consecutive_frames, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, initial_state=shared_initial_state, model=shared_biorbd_model, + ankle_bed_pseudo_obs=args.ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=args.ankle_bed_pseudo_std_m, ) stage_timings_s["ekf_2d_acc_init_s"] = ekf_acc_timings["init_s"] stage_timings_s["ekf_2d_acc_loop_s"] = ekf_acc_timings["loop_s"] @@ -5886,6 +6598,8 @@ def main() -> None: flight_height_threshold_m=args.flight_height_threshold_m, flight_min_consecutive_frames=args.flight_min_consecutive_frames, initial_state=shared_initial_state_flip_acc, + ankle_bed_pseudo_obs=args.ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=args.ankle_bed_pseudo_std_m, ) ekf_result_flip_acc, ekf_flip_acc_timings = run_ekf( biomod_path=None, @@ -5902,9 +6616,12 @@ def main() -> None: root_flight_dynamics=False, flight_height_threshold_m=args.flight_height_threshold_m, flight_min_consecutive_frames=args.flight_min_consecutive_frames, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, initial_state=shared_initial_state_flip_acc, model=shared_biorbd_model, + ankle_bed_pseudo_obs=args.ankle_bed_pseudo_obs, + ankle_bed_pseudo_std_m=args.ankle_bed_pseudo_std_m, ) stage_timings_s["ekf_2d_flip_acc_init_s"] = ekf_flip_acc_timings["init_s"] stage_timings_s["ekf_2d_flip_acc_loop_s"] = ekf_flip_acc_timings["loop_s"] @@ -5945,7 +6662,8 @@ def main() -> None: args.fps, noise_factor=args.biorbd_kalman_noise_factor, error_factor=args.biorbd_kalman_error_factor, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, initial_state_method=args.biorbd_kalman_init_method, ) save_biorbd_kalman_cache(biorbd_kalman_cache_path, classic_result, biorbd_metadata) @@ -5962,7 +6680,8 @@ def main() -> None: biorbd_kalman_error_factor=args.biorbd_kalman_error_factor, biorbd_kalman_init_method=args.biorbd_kalman_init_method, classic_result=classic_result, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, ) if ekf_result_dyn is not None: comparison_dyn = compare_kalman_filters( @@ -5975,7 +6694,8 @@ def main() -> None: biorbd_kalman_noise_factor=args.biorbd_kalman_noise_factor, biorbd_kalman_error_factor=args.biorbd_kalman_error_factor, classic_result=classic_result, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, ) if ekf_result_flip_acc is not None: comparison_flip_acc = compare_kalman_filters( @@ -5988,7 +6708,8 @@ def main() -> None: biorbd_kalman_noise_factor=args.biorbd_kalman_noise_factor, biorbd_kalman_error_factor=args.biorbd_kalman_error_factor, classic_result=classic_result, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, ) if ekf_result_flip_dyn is not None: comparison_flip_dyn = compare_kalman_filters( @@ -6001,7 +6722,8 @@ def main() -> None: biorbd_kalman_noise_factor=args.biorbd_kalman_noise_factor, biorbd_kalman_error_factor=args.biorbd_kalman_error_factor, classic_result=classic_result, - unwrap_root=not args.no_root_unwrap, + unwrap_root=(root_unwrap_mode != "off"), + root_unwrap_mode=root_unwrap_mode, ) animation_target = ( try_export_pyorerun_animation(biomod_path, ekf_result_acc["q"], args.fps, args.output_dir)