Spaces:
Running on Zero
Running on Zero
refactor(exercises): route pipeline through exercise objects
Browse files- README.md +17 -18
- src/pozify/exercises/base.py +377 -4
- src/pozify/exercises/push_up/strategy.py +4 -3
- src/pozify/exercises/registry.py +13 -2
- src/pozify/exercises/shared/__init__.py +2 -1
- src/pozify/{steps/rep_counters/base.py → exercises/shared/rep_counter.py} +5 -4
- src/pozify/exercises/shoulder_press/strategy.py +4 -3
- src/pozify/exercises/squat/strategy.py +4 -3
- src/pozify/exercises/unknown/strategy.py +3 -3
- src/pozify/pipeline.py +17 -12
- src/pozify/steps/issue_marker.py +0 -102
- src/pozify/steps/rep_analysis.py +0 -266
- src/pozify/steps/rep_counter.py +0 -10
- src/pozify/steps/rep_counters/__init__.py +0 -3
- src/pozify/steps/variation_detector.py +0 -29
- tests/test_issue_marker.py +30 -6
- tests/test_pipeline_contracts.py +7 -0
- tests/test_rep_analysis_variation.py +52 -39
- tests/test_rep_counter.py +54 -23
README.md
CHANGED
|
@@ -21,7 +21,7 @@ tags:
|
|
| 21 |
|
| 22 |
Pozify is a Gradio + uv base project for a video-based workout form review app.
|
| 23 |
|
| 24 |
-
The current implementation contains the full application pipeline, step inputs/outputs, and JSON data contracts.
|
| 25 |
|
| 26 |
## Run The App
|
| 27 |
|
|
@@ -37,15 +37,15 @@ uv run gradio app.py
|
|
| 37 |
|
| 38 |
The app runs at `http://127.0.0.1:7860` by default.
|
| 39 |
|
| 40 |
-
The pipeline runs
|
| 41 |
-
`run_pipeline(..., mock=True)` or set:
|
| 42 |
|
| 43 |
```bash
|
| 44 |
POZIFY_MOCK_MODE=1 uv run python app.py
|
| 45 |
```
|
| 46 |
|
| 47 |
-
To
|
| 48 |
-
annotated video rendering, set:
|
| 49 |
|
| 50 |
```bash
|
| 51 |
POZIFY_MOCK_MODE=0 uv run python app.py
|
|
@@ -92,8 +92,9 @@ user profile + input video
|
|
| 92 |
-> final report
|
| 93 |
```
|
| 94 |
|
| 95 |
-
After classification, the pipeline creates one object for the detected exercise class
|
| 96 |
-
|
|
|
|
| 97 |
|
| 98 |
## Project Structure
|
| 99 |
|
|
@@ -119,15 +120,12 @@ src/pozify/
|
|
| 119 |
shared/
|
| 120 |
analyzer.py
|
| 121 |
issue_marker.py
|
|
|
|
| 122 |
steps/
|
| 123 |
video_qc.py
|
| 124 |
pose_landmarker.py
|
| 125 |
pose_cleaning.py
|
| 126 |
exercise_classifier.py
|
| 127 |
-
rep_counter.py
|
| 128 |
-
rep_analysis.py
|
| 129 |
-
variation_detector.py
|
| 130 |
-
issue_marker.py
|
| 131 |
annotated_renderer.py
|
| 132 |
coach_summary.py
|
| 133 |
verifier.py
|
|
@@ -210,21 +208,22 @@ Recommended replacement order:
|
|
| 210 |
1. `video_qc.py`: read real video metadata with OpenCV.
|
| 211 |
2. `pose_backends/`: add or refine pose model adapters such as MediaPipe or MMPose.
|
| 212 |
3. `exercise_classifier.py`: load the exercise router model.
|
| 213 |
-
4. `
|
| 214 |
-
5. `
|
| 215 |
-
6. `
|
| 216 |
-
7. `
|
|
|
|
| 217 |
|
| 218 |
## Development Checks
|
| 219 |
|
| 220 |
```bash
|
| 221 |
uv run python -m unittest discover -s tests
|
| 222 |
-
python3 -m py_compile app.py src/pozify/*.py src/pozify/steps/*.py
|
| 223 |
uv run python -c "import app; from pozify.pipeline import run_pipeline; print('ok')"
|
| 224 |
```
|
| 225 |
|
| 226 |
-
The unit tests run the full mocked pipeline
|
| 227 |
-
|
| 228 |
|
| 229 |
## Git Hooks
|
| 230 |
|
|
|
|
| 21 |
|
| 22 |
Pozify is a Gradio + uv base project for a video-based workout form review app.
|
| 23 |
|
| 24 |
+
The current implementation contains the full application pipeline, step inputs/outputs, and JSON data contracts. Uploaded videos use real video QC, MediaPipe pose extraction, and real rep segmentation by default; no-video/demo runs still use mock pose data.
|
| 25 |
|
| 26 |
## Run The App
|
| 27 |
|
|
|
|
| 37 |
|
| 38 |
The app runs at `http://127.0.0.1:7860` by default.
|
| 39 |
|
| 40 |
+
The pipeline runs with real pose extraction by default when a video path is provided. No-video runs
|
| 41 |
+
default to mock mode. To force mock mode in scripts, call `run_pipeline(..., mock=True)` or set:
|
| 42 |
|
| 43 |
```bash
|
| 44 |
POZIFY_MOCK_MODE=1 uv run python app.py
|
| 45 |
```
|
| 46 |
|
| 47 |
+
To force the end-to-end app to use real video QC, MediaPipe pose extraction, real rep segmentation,
|
| 48 |
+
and annotated video rendering even when `POZIFY_MOCK_MODE` is set elsewhere, set:
|
| 49 |
|
| 50 |
```bash
|
| 51 |
POZIFY_MOCK_MODE=0 uv run python app.py
|
|
|
|
| 92 |
-> final report
|
| 93 |
```
|
| 94 |
|
| 95 |
+
After classification, the pipeline creates one object for the detected exercise class with the video
|
| 96 |
+
manifest, cleaned pose sequence, and user profile. Rep counting, rep analysis, variation detection,
|
| 97 |
+
and issue marking then run as methods on that exercise object.
|
| 98 |
|
| 99 |
## Project Structure
|
| 100 |
|
|
|
|
| 120 |
shared/
|
| 121 |
analyzer.py
|
| 122 |
issue_marker.py
|
| 123 |
+
rep_counter.py
|
| 124 |
steps/
|
| 125 |
video_qc.py
|
| 126 |
pose_landmarker.py
|
| 127 |
pose_cleaning.py
|
| 128 |
exercise_classifier.py
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
annotated_renderer.py
|
| 130 |
coach_summary.py
|
| 131 |
verifier.py
|
|
|
|
| 208 |
1. `video_qc.py`: read real video metadata with OpenCV.
|
| 209 |
2. `pose_backends/`: add or refine pose model adapters such as MediaPipe or MMPose.
|
| 210 |
3. `exercise_classifier.py`: load the exercise router model.
|
| 211 |
+
4. `exercises/<exercise>/strategy.py`: implement exercise-specific rep signals and variation logic.
|
| 212 |
+
5. `exercises/<exercise>/analyzer.py`: compute per-rep metrics.
|
| 213 |
+
6. `exercises/<exercise>/issue_markers.py`: compute real issue scores and intervals.
|
| 214 |
+
7. `annotated_renderer.py`: render skeleton overlays and issue highlights.
|
| 215 |
+
8. `coach_summary.py`: call the selected small language model with retrieved knowledge cards.
|
| 216 |
|
| 217 |
## Development Checks
|
| 218 |
|
| 219 |
```bash
|
| 220 |
uv run python -m unittest discover -s tests
|
| 221 |
+
python3 -m py_compile app.py src/pozify/*.py src/pozify/steps/*.py src/pozify/exercises/*.py src/pozify/exercises/*/*.py
|
| 222 |
uv run python -c "import app; from pozify.pipeline import run_pipeline; print('ok')"
|
| 223 |
```
|
| 224 |
|
| 225 |
+
The unit tests run the full mocked pipeline explicitly, then assert deterministic top-level keys for
|
| 226 |
+
each JSON artifact.
|
| 227 |
|
| 228 |
## Git Hooks
|
| 229 |
|
src/pozify/exercises/base.py
CHANGED
|
@@ -1,12 +1,55 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from
|
| 4 |
-
from
|
| 5 |
-
|
| 6 |
-
from pozify.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
class ExerciseStrategy(ExerciseRepCounter):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
|
| 11 |
raise NotImplementedError
|
| 12 |
|
|
@@ -19,6 +62,249 @@ class ExerciseStrategy(ExerciseRepCounter):
|
|
| 19 |
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 20 |
return ()
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def metric(self, analysis: RepAnalysis, name: str) -> float | None:
|
| 23 |
value = analysis.aggregate_metrics.get(name)
|
| 24 |
if isinstance(value, (int, float)):
|
|
@@ -35,3 +321,90 @@ class ExerciseStrategy(ExerciseRepCounter):
|
|
| 35 |
metric_bonus = 0.0 if supporting_metric is None else min(0.1, abs(supporting_metric) * 0.03)
|
| 36 |
pose_bonus = min(0.08, float(analysis.aggregate_metrics.get("pose_valid_ratio", 0.0)) * 0.08)
|
| 37 |
return round(min(0.95, base + rep_bonus + metric_bonus + pose_bonus), 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from statistics import mean, pstdev
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from pozify.contracts import (
|
| 7 |
+
IssueMarker,
|
| 8 |
+
IssueMarkers,
|
| 9 |
+
PoseFrame,
|
| 10 |
+
PoseSequence,
|
| 11 |
+
Rep,
|
| 12 |
+
RepAnalysis,
|
| 13 |
+
RepAnalysisItem,
|
| 14 |
+
Reps,
|
| 15 |
+
UserProfile,
|
| 16 |
+
VideoManifest,
|
| 17 |
+
Variation,
|
| 18 |
+
)
|
| 19 |
+
from pozify.exercise_catalog import get_exercise_spec
|
| 20 |
+
from pozify.exercises.shared.analyzer import (
|
| 21 |
+
ExerciseMetricResult,
|
| 22 |
+
mean_optional,
|
| 23 |
+
round_optional,
|
| 24 |
+
safe_ratio,
|
| 25 |
+
score,
|
| 26 |
+
usable,
|
| 27 |
+
value_series,
|
| 28 |
+
)
|
| 29 |
+
from pozify.exercises.shared.issue_marker import (
|
| 30 |
+
IssueRule,
|
| 31 |
+
frame_scores_for_rule,
|
| 32 |
+
frames_for_rep,
|
| 33 |
+
group_violations,
|
| 34 |
+
marker_from_group,
|
| 35 |
+
minimum_run_length,
|
| 36 |
+
)
|
| 37 |
+
from pozify.exercises.shared.rep_counter import ExerciseRepCounter
|
| 38 |
+
from pozify.steps.rep_signals import average_axis
|
| 39 |
|
| 40 |
|
| 41 |
class ExerciseStrategy(ExerciseRepCounter):
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
*,
|
| 45 |
+
video_manifest: VideoManifest,
|
| 46 |
+
pose_sequence: PoseSequence,
|
| 47 |
+
profile: UserProfile,
|
| 48 |
+
) -> None:
|
| 49 |
+
self.video_manifest = video_manifest
|
| 50 |
+
self.pose_sequence = pose_sequence
|
| 51 |
+
self.profile = profile
|
| 52 |
+
|
| 53 |
def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
|
| 54 |
raise NotImplementedError
|
| 55 |
|
|
|
|
| 62 |
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 63 |
return ()
|
| 64 |
|
| 65 |
+
def analyze_reps(self, reps: Reps) -> RepAnalysis:
|
| 66 |
+
draft_items: list[tuple[Rep, dict[str, Any], float, float, float, list[str]]] = []
|
| 67 |
+
for rep in reps.reps:
|
| 68 |
+
rep_frames = self.frames_for_rep(rep)
|
| 69 |
+
primary_signal = self.primary_signal(rep_frames)
|
| 70 |
+
common_metrics = self.common_rep_metrics(rep, rep_frames, primary_signal)
|
| 71 |
+
exercise_metrics, rom_score, stability_score, symmetry_score, hints = self.metrics(rep_frames)
|
| 72 |
+
metrics = {**common_metrics, **exercise_metrics}
|
| 73 |
+
draft_items.append((rep, metrics, rom_score, stability_score, symmetry_score, hints))
|
| 74 |
+
|
| 75 |
+
average_duration = (
|
| 76 |
+
mean(item[1]["rep_duration_sec"] for item in draft_items)
|
| 77 |
+
if draft_items
|
| 78 |
+
else 0.0
|
| 79 |
+
)
|
| 80 |
+
items: list[RepAnalysisItem] = []
|
| 81 |
+
for rep, metrics, rom_score, stability_score, symmetry_score, hints in draft_items:
|
| 82 |
+
duration = metrics["rep_duration_sec"]
|
| 83 |
+
metrics["tempo_consistency_score"] = (
|
| 84 |
+
score(1.0 - abs(duration - average_duration) / max(average_duration, 0.1))
|
| 85 |
+
if average_duration
|
| 86 |
+
else 0.0
|
| 87 |
+
)
|
| 88 |
+
items.append(
|
| 89 |
+
RepAnalysisItem(
|
| 90 |
+
rep_id=rep.rep_id,
|
| 91 |
+
duration_sec=duration,
|
| 92 |
+
range_of_motion_score=rom_score,
|
| 93 |
+
stability_score=stability_score,
|
| 94 |
+
symmetry_score=symmetry_score,
|
| 95 |
+
metrics=metrics,
|
| 96 |
+
variation_hints=sorted(set(hints)),
|
| 97 |
+
)
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
aggregate_metrics = {
|
| 101 |
+
"avg_rom_score": (
|
| 102 |
+
round(mean(item.range_of_motion_score for item in items), 2) if items else 0.0
|
| 103 |
+
),
|
| 104 |
+
"avg_stability_score": (
|
| 105 |
+
round(mean(item.stability_score for item in items), 2) if items else 0.0
|
| 106 |
+
),
|
| 107 |
+
"avg_symmetry_score": (
|
| 108 |
+
round(mean(item.symmetry_score for item in items), 2) if items else 0.0
|
| 109 |
+
),
|
| 110 |
+
"avg_rep_duration_sec": (
|
| 111 |
+
round(mean(item.duration_sec for item in items), 2) if items else 0.0
|
| 112 |
+
),
|
| 113 |
+
"avg_tempo_consistency_score": self.aggregate_numeric(items, "tempo_consistency_score")
|
| 114 |
+
or 0.0,
|
| 115 |
+
"avg_landmark_confidence": (
|
| 116 |
+
self.aggregate_numeric(items, "landmark_confidence") or self.pose_sequence.pose_valid_ratio
|
| 117 |
+
),
|
| 118 |
+
"fatigue_trend_rom_delta": self.fatigue_trend(items),
|
| 119 |
+
"pose_valid_ratio": self.pose_sequence.pose_valid_ratio,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
for metric_name in (
|
| 123 |
+
"hand_width_ratio",
|
| 124 |
+
"stance_width_ratio",
|
| 125 |
+
"bottom_pause_sec",
|
| 126 |
+
"lockout_quality",
|
| 127 |
+
"wrist_height_asymmetry",
|
| 128 |
+
"wrist_travel",
|
| 129 |
+
"knee_support_score",
|
| 130 |
+
):
|
| 131 |
+
aggregate_value = self.aggregate_numeric(items, metric_name)
|
| 132 |
+
if aggregate_value is not None:
|
| 133 |
+
aggregate_metrics[f"avg_{metric_name}"] = aggregate_value
|
| 134 |
+
|
| 135 |
+
return RepAnalysis(
|
| 136 |
+
exercise=self.exercise,
|
| 137 |
+
items=items,
|
| 138 |
+
aggregate_metrics=aggregate_metrics,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
def resolve_variation(self, analysis: RepAnalysis) -> Variation:
|
| 142 |
+
if self.profile.intended_variation:
|
| 143 |
+
variation = self.profile.intended_variation
|
| 144 |
+
confidence = 0.95
|
| 145 |
+
not_issues = self.profile_not_issues(variation)
|
| 146 |
+
else:
|
| 147 |
+
variation, confidence, not_issues = self.detect_variation(analysis)
|
| 148 |
+
|
| 149 |
+
if analysis.aggregate_metrics.get("avg_rom_score", 1.0) < 0.7:
|
| 150 |
+
not_issues.append("low_rom_requires_user_intent_check")
|
| 151 |
+
if analysis.aggregate_metrics.get("pose_valid_ratio", 1.0) < 0.8:
|
| 152 |
+
not_issues.append("low_pose_confidence_limits_variation_call")
|
| 153 |
+
|
| 154 |
+
return Variation(
|
| 155 |
+
exercise=self.exercise,
|
| 156 |
+
detected_variation=variation,
|
| 157 |
+
variation_confidence=confidence,
|
| 158 |
+
not_issues=sorted(set(not_issues)),
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
def mark_issues(
|
| 162 |
+
self,
|
| 163 |
+
reps: Reps,
|
| 164 |
+
analysis: RepAnalysis,
|
| 165 |
+
variation: Variation,
|
| 166 |
+
) -> IssueMarkers:
|
| 167 |
+
rep_by_id = {rep.rep_id: rep for rep in reps.reps}
|
| 168 |
+
issues: list[IssueMarker] = []
|
| 169 |
+
|
| 170 |
+
for item in analysis.items:
|
| 171 |
+
rep = rep_by_id.get(item.rep_id)
|
| 172 |
+
if rep is None:
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
rep_frames = frames_for_rep(self.pose_sequence, rep)
|
| 176 |
+
if not rep_frames:
|
| 177 |
+
fallback = self.fallback_rep_marker(reps, item, variation)
|
| 178 |
+
if fallback is not None:
|
| 179 |
+
issues.append(fallback)
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
min_run_length = minimum_run_length(rep_frames)
|
| 183 |
+
for rule in self.issue_rules():
|
| 184 |
+
if set(rule.suppress_when_not_issue) & set(variation.not_issues):
|
| 185 |
+
continue
|
| 186 |
+
|
| 187 |
+
scores = frame_scores_for_rule(rep_frames, self.exercise, rule)
|
| 188 |
+
for group in group_violations(scores, min_run_length):
|
| 189 |
+
issues.append(marker_from_group(rule, group, item, variation))
|
| 190 |
+
|
| 191 |
+
return IssueMarkers(
|
| 192 |
+
issues=sorted(
|
| 193 |
+
issues,
|
| 194 |
+
key=lambda issue: (issue.start_frame, issue.end_frame, issue.rep_id, issue.issue),
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
def frames_for_rep(self, rep: Rep) -> list[PoseFrame]:
|
| 199 |
+
rep_frames = [
|
| 200 |
+
frame
|
| 201 |
+
for frame in self.pose_sequence.frames
|
| 202 |
+
if rep.start_frame <= frame.frame_index <= rep.end_frame
|
| 203 |
+
]
|
| 204 |
+
if rep_frames:
|
| 205 |
+
return rep_frames
|
| 206 |
+
|
| 207 |
+
if not self.pose_sequence.frames:
|
| 208 |
+
return []
|
| 209 |
+
closest = min(
|
| 210 |
+
self.pose_sequence.frames,
|
| 211 |
+
key=lambda frame: min(
|
| 212 |
+
abs(frame.frame_index - rep.start_frame),
|
| 213 |
+
abs(frame.frame_index - rep.mid_frame),
|
| 214 |
+
abs(frame.frame_index - rep.end_frame),
|
| 215 |
+
),
|
| 216 |
+
)
|
| 217 |
+
return [closest]
|
| 218 |
+
|
| 219 |
+
def primary_signal(self, frames: list[PoseFrame]) -> list[float | None]:
|
| 220 |
+
if self.exercise == "shoulder_press":
|
| 221 |
+
return value_series(
|
| 222 |
+
frames,
|
| 223 |
+
lambda frame: average_axis(frame, ("left_wrist", "right_wrist"), "y"),
|
| 224 |
+
)
|
| 225 |
+
if self.exercise == "push_up":
|
| 226 |
+
return value_series(
|
| 227 |
+
frames,
|
| 228 |
+
lambda frame: mean_optional(
|
| 229 |
+
[
|
| 230 |
+
average_axis(frame, ("left_shoulder", "right_shoulder"), "y"),
|
| 231 |
+
average_axis(frame, ("left_hip", "right_hip"), "y"),
|
| 232 |
+
]
|
| 233 |
+
),
|
| 234 |
+
)
|
| 235 |
+
return value_series(frames, lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"))
|
| 236 |
+
|
| 237 |
+
def common_rep_metrics(
|
| 238 |
+
self,
|
| 239 |
+
rep: Rep,
|
| 240 |
+
frames: list[PoseFrame],
|
| 241 |
+
primary_signal: list[float | None],
|
| 242 |
+
) -> dict[str, Any]:
|
| 243 |
+
eccentric_duration = round(max(0.0, rep.mid_sec - rep.start_sec), 2)
|
| 244 |
+
concentric_duration = round(max(0.0, rep.end_sec - rep.mid_sec), 2)
|
| 245 |
+
duration = round(max(0.0, rep.end_sec - rep.start_sec), 2)
|
| 246 |
+
smoothness_score, jerk_score = self.smoothness_score(primary_signal)
|
| 247 |
+
stability_axis = value_series(
|
| 248 |
+
frames,
|
| 249 |
+
lambda frame: average_axis(frame, ("left_hip", "right_hip"), "x"),
|
| 250 |
+
)
|
| 251 |
+
stability_noise = self.std(stability_axis) or 0.0
|
| 252 |
+
|
| 253 |
+
return {
|
| 254 |
+
"rep_duration_sec": duration,
|
| 255 |
+
"eccentric_duration_sec": eccentric_duration,
|
| 256 |
+
"concentric_duration_sec": concentric_duration,
|
| 257 |
+
"tempo_ratio": round_optional(safe_ratio(eccentric_duration, concentric_duration), 2),
|
| 258 |
+
"top_pause_sec": self.pause_duration(frames, primary_signal, target="top"),
|
| 259 |
+
"bottom_pause_sec": self.pause_duration(frames, primary_signal, target="bottom"),
|
| 260 |
+
"smoothness_score": smoothness_score,
|
| 261 |
+
"jerk_score": round_optional(jerk_score, 4),
|
| 262 |
+
"landmark_confidence": self.mean_visibility(frames),
|
| 263 |
+
"hip_lateral_drift": round_optional(stability_noise, 4),
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
def fallback_rep_marker(
|
| 267 |
+
self,
|
| 268 |
+
reps: Reps,
|
| 269 |
+
item: RepAnalysisItem,
|
| 270 |
+
variation: Variation,
|
| 271 |
+
) -> IssueMarker | None:
|
| 272 |
+
exercise_spec = get_exercise_spec(reps.exercise)
|
| 273 |
+
if item.stability_score >= 0.78 or exercise_spec.mock_issue is None:
|
| 274 |
+
return None
|
| 275 |
+
|
| 276 |
+
rep = next((rep for rep in reps.reps if rep.rep_id == item.rep_id), None)
|
| 277 |
+
if rep is None:
|
| 278 |
+
return None
|
| 279 |
+
|
| 280 |
+
issue_spec = exercise_spec.mock_issue
|
| 281 |
+
metric_value = (
|
| 282 |
+
item.range_of_motion_score
|
| 283 |
+
if issue_spec.evidence_metric == "range_of_motion_score"
|
| 284 |
+
else item.metrics.get(issue_spec.evidence_metric)
|
| 285 |
+
)
|
| 286 |
+
return IssueMarker(
|
| 287 |
+
rep_id=item.rep_id,
|
| 288 |
+
issue=issue_spec.issue,
|
| 289 |
+
severity=round(1.0 - item.stability_score, 2),
|
| 290 |
+
start_frame=rep.mid_frame,
|
| 291 |
+
end_frame=rep.end_frame,
|
| 292 |
+
start_sec=rep.mid_sec,
|
| 293 |
+
end_sec=rep.end_sec,
|
| 294 |
+
affected_joints=list(issue_spec.affected_joints),
|
| 295 |
+
evidence={
|
| 296 |
+
issue_spec.evidence_metric: metric_value,
|
| 297 |
+
"threshold": issue_spec.threshold,
|
| 298 |
+
"confidence": round(max(0.0, min(1.0, 1.0 - item.stability_score)), 2),
|
| 299 |
+
"variation_context": {
|
| 300 |
+
"detected_variation": variation.detected_variation,
|
| 301 |
+
"variation_confidence": variation.variation_confidence,
|
| 302 |
+
"not_issues": list(variation.not_issues),
|
| 303 |
+
},
|
| 304 |
+
"fallback": "rep_level_metrics",
|
| 305 |
+
},
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
def metric(self, analysis: RepAnalysis, name: str) -> float | None:
|
| 309 |
value = analysis.aggregate_metrics.get(name)
|
| 310 |
if isinstance(value, (int, float)):
|
|
|
|
| 321 |
metric_bonus = 0.0 if supporting_metric is None else min(0.1, abs(supporting_metric) * 0.03)
|
| 322 |
pose_bonus = min(0.08, float(analysis.aggregate_metrics.get("pose_valid_ratio", 0.0)) * 0.08)
|
| 323 |
return round(min(0.95, base + rep_bonus + metric_bonus + pose_bonus), 2)
|
| 324 |
+
|
| 325 |
+
def std(self, values: list[float | None]) -> float | None:
|
| 326 |
+
usable_values = usable(values)
|
| 327 |
+
if len(usable_values) < 2:
|
| 328 |
+
return 0.0 if usable_values else None
|
| 329 |
+
return pstdev(usable_values)
|
| 330 |
+
|
| 331 |
+
def mean_visibility(self, frames: list[PoseFrame]) -> float:
|
| 332 |
+
values: list[float | None] = []
|
| 333 |
+
for frame in frames:
|
| 334 |
+
if "mean_visibility" in frame.pose_quality:
|
| 335 |
+
values.append(float(frame.pose_quality["mean_visibility"]))
|
| 336 |
+
continue
|
| 337 |
+
landmark_values = [
|
| 338 |
+
landmark.get("visibility")
|
| 339 |
+
for landmark in frame.landmarks.values()
|
| 340 |
+
if landmark.get("visibility") is not None
|
| 341 |
+
]
|
| 342 |
+
values.extend(float(value) for value in landmark_values)
|
| 343 |
+
return score(mean_optional(values) if values else 0.0)
|
| 344 |
+
|
| 345 |
+
def smoothness_score(self, signal_values: list[float | None]) -> tuple[float, float | None]:
|
| 346 |
+
usable_values = usable(signal_values)
|
| 347 |
+
if len(usable_values) < 4:
|
| 348 |
+
return 0.5, None
|
| 349 |
+
|
| 350 |
+
deltas = [
|
| 351 |
+
usable_values[index] - usable_values[index - 1]
|
| 352 |
+
for index in range(1, len(usable_values))
|
| 353 |
+
]
|
| 354 |
+
jerks = [deltas[index] - deltas[index - 1] for index in range(1, len(deltas))]
|
| 355 |
+
if not jerks:
|
| 356 |
+
return 0.5, None
|
| 357 |
+
jerk = mean(abs(value) for value in jerks)
|
| 358 |
+
return score(1.0 - jerk * 8.0), jerk
|
| 359 |
+
|
| 360 |
+
def pause_duration(
|
| 361 |
+
self,
|
| 362 |
+
frames: list[PoseFrame],
|
| 363 |
+
signal_values: list[float | None],
|
| 364 |
+
*,
|
| 365 |
+
target: str,
|
| 366 |
+
) -> float:
|
| 367 |
+
usable_values = usable(signal_values)
|
| 368 |
+
if len(usable_values) < 3 or len(frames) < 3:
|
| 369 |
+
return 0.0
|
| 370 |
+
|
| 371 |
+
min_value = min(usable_values)
|
| 372 |
+
max_value = max(usable_values)
|
| 373 |
+
tolerance = max((max_value - min_value) * 0.08, 0.01)
|
| 374 |
+
if target == "bottom":
|
| 375 |
+
active = [value is not None and value >= max_value - tolerance for value in signal_values]
|
| 376 |
+
else:
|
| 377 |
+
active = [value is not None and value <= min_value + tolerance for value in signal_values]
|
| 378 |
+
|
| 379 |
+
longest = 0
|
| 380 |
+
current = 0
|
| 381 |
+
for item in active:
|
| 382 |
+
if item:
|
| 383 |
+
current += 1
|
| 384 |
+
longest = max(longest, current)
|
| 385 |
+
else:
|
| 386 |
+
current = 0
|
| 387 |
+
|
| 388 |
+
if longest <= 1:
|
| 389 |
+
return 0.0
|
| 390 |
+
frame_duration = (frames[-1].timestamp_sec - frames[0].timestamp_sec) / max(
|
| 391 |
+
1, len(frames) - 1
|
| 392 |
+
)
|
| 393 |
+
return round(longest * frame_duration, 2)
|
| 394 |
+
|
| 395 |
+
def aggregate_numeric(self, items: list[RepAnalysisItem], metric_name: str) -> float | None:
|
| 396 |
+
values = [
|
| 397 |
+
item.metrics.get(metric_name)
|
| 398 |
+
for item in items
|
| 399 |
+
if isinstance(item.metrics.get(metric_name), (int, float))
|
| 400 |
+
]
|
| 401 |
+
if not values:
|
| 402 |
+
return None
|
| 403 |
+
return round(sum(float(value) for value in values) / len(values), 4)
|
| 404 |
+
|
| 405 |
+
def fatigue_trend(self, items: list[RepAnalysisItem]) -> float:
|
| 406 |
+
if len(items) < 2:
|
| 407 |
+
return 0.0
|
| 408 |
+
first = items[0].range_of_motion_score
|
| 409 |
+
last = items[-1].range_of_motion_score
|
| 410 |
+
return round(last - first, 4)
|
src/pozify/exercises/push_up/strategy.py
CHANGED
|
@@ -2,12 +2,12 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from pozify.contracts import
|
| 6 |
from pozify.exercises.base import ExerciseStrategy
|
| 7 |
from pozify.exercises.push_up.analyzer import PushUpAnalyzer
|
| 8 |
from pozify.exercises.shared.issue_marker import IssueRule
|
| 9 |
from pozify.exercises.push_up.issue_markers import RULES as ISSUE_RULES
|
| 10 |
-
from pozify.
|
| 11 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 12 |
|
| 13 |
|
|
@@ -17,7 +17,8 @@ class PushUpExercise(PushUpAnalyzer, ExerciseStrategy):
|
|
| 17 |
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 18 |
return ISSUE_RULES
|
| 19 |
|
| 20 |
-
def build_signal(self
|
|
|
|
| 21 |
hip_y = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in sequence.frames]
|
| 22 |
shoulder_y = [average_axis(frame, ("left_shoulder", "right_shoulder"), "y") for frame in sequence.frames]
|
| 23 |
elbow_bend = [
|
|
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from pozify.contracts import RepAnalysis
|
| 6 |
from pozify.exercises.base import ExerciseStrategy
|
| 7 |
from pozify.exercises.push_up.analyzer import PushUpAnalyzer
|
| 8 |
from pozify.exercises.shared.issue_marker import IssueRule
|
| 9 |
from pozify.exercises.push_up.issue_markers import RULES as ISSUE_RULES
|
| 10 |
+
from pozify.exercises.shared.rep_counter import combine, mean_optional, normalized_samples
|
| 11 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 12 |
|
| 13 |
|
|
|
|
| 17 |
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 18 |
return ISSUE_RULES
|
| 19 |
|
| 20 |
+
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
|
| 21 |
+
sequence = self.pose_sequence
|
| 22 |
hip_y = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in sequence.frames]
|
| 23 |
shoulder_y = [average_axis(frame, ("left_shoulder", "right_shoulder"), "y") for frame in sequence.frames]
|
| 24 |
elbow_bend = [
|
src/pozify/exercises/registry.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from pozify.exercises.base import ExerciseStrategy
|
| 4 |
from pozify.exercises.push_up import PushUpExercise
|
| 5 |
from pozify.exercises.shoulder_press import ShoulderPressExercise
|
|
@@ -15,6 +16,16 @@ EXERCISE_CLASSES: dict[str, type[ExerciseStrategy]] = {
|
|
| 15 |
}
|
| 16 |
|
| 17 |
|
| 18 |
-
def create_exercise_strategy(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
exercise_class = EXERCISE_CLASSES.get(exercise, UnknownExercise)
|
| 20 |
-
return exercise_class(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from pozify.contracts import PoseSequence, UserProfile, VideoManifest
|
| 4 |
from pozify.exercises.base import ExerciseStrategy
|
| 5 |
from pozify.exercises.push_up import PushUpExercise
|
| 6 |
from pozify.exercises.shoulder_press import ShoulderPressExercise
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
|
| 19 |
+
def create_exercise_strategy(
|
| 20 |
+
exercise: str,
|
| 21 |
+
*,
|
| 22 |
+
video_manifest: VideoManifest,
|
| 23 |
+
pose_sequence: PoseSequence,
|
| 24 |
+
profile: UserProfile,
|
| 25 |
+
) -> ExerciseStrategy:
|
| 26 |
exercise_class = EXERCISE_CLASSES.get(exercise, UnknownExercise)
|
| 27 |
+
return exercise_class(
|
| 28 |
+
video_manifest=video_manifest,
|
| 29 |
+
pose_sequence=pose_sequence,
|
| 30 |
+
profile=profile,
|
| 31 |
+
)
|
src/pozify/exercises/shared/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
from pozify.exercises.shared.analyzer import ExerciseAnalyzer, ExerciseMetricResult
|
| 2 |
from pozify.exercises.shared.issue_marker import IssueRule
|
|
|
|
| 3 |
|
| 4 |
-
__all__ = ["ExerciseAnalyzer", "ExerciseMetricResult", "IssueRule"]
|
|
|
|
| 1 |
from pozify.exercises.shared.analyzer import ExerciseAnalyzer, ExerciseMetricResult
|
| 2 |
from pozify.exercises.shared.issue_marker import IssueRule
|
| 3 |
+
from pozify.exercises.shared.rep_counter import ExerciseRepCounter
|
| 4 |
|
| 5 |
+
__all__ = ["ExerciseAnalyzer", "ExerciseMetricResult", "ExerciseRepCounter", "IssueRule"]
|
src/pozify/{steps/rep_counters/base.py → exercises/shared/rep_counter.py}
RENAMED
|
@@ -117,17 +117,19 @@ def partial_reps(
|
|
| 117 |
|
| 118 |
class ExerciseRepCounter(ABC):
|
| 119 |
exercise: str
|
|
|
|
| 120 |
min_cycle_frames = MIN_CYCLE_FRAMES
|
| 121 |
min_phase_frames = MIN_PHASE_FRAMES
|
| 122 |
min_signal_range = MIN_SIGNAL_RANGE
|
| 123 |
min_usable_signal_samples = MIN_USABLE_SIGNAL_SAMPLES
|
| 124 |
|
| 125 |
@abstractmethod
|
| 126 |
-
def build_signal(self
|
| 127 |
"""Build the exercise-specific normalized motion signal."""
|
| 128 |
|
| 129 |
-
def count(self
|
| 130 |
-
|
|
|
|
| 131 |
signal_range = debug["raw_signal_range"]
|
| 132 |
extrema = find_local_extrema(samples)
|
| 133 |
min_amplitude = max(self.min_signal_range, signal_range * 0.35)
|
|
@@ -177,4 +179,3 @@ class ExerciseRepCounter(ABC):
|
|
| 177 |
],
|
| 178 |
}
|
| 179 |
return reps, debug_payload
|
| 180 |
-
|
|
|
|
| 117 |
|
| 118 |
class ExerciseRepCounter(ABC):
|
| 119 |
exercise: str
|
| 120 |
+
pose_sequence: PoseSequence
|
| 121 |
min_cycle_frames = MIN_CYCLE_FRAMES
|
| 122 |
min_phase_frames = MIN_PHASE_FRAMES
|
| 123 |
min_signal_range = MIN_SIGNAL_RANGE
|
| 124 |
min_usable_signal_samples = MIN_USABLE_SIGNAL_SAMPLES
|
| 125 |
|
| 126 |
@abstractmethod
|
| 127 |
+
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
|
| 128 |
"""Build the exercise-specific normalized motion signal."""
|
| 129 |
|
| 130 |
+
def count(self) -> tuple[Reps, dict[str, Any]]:
|
| 131 |
+
sequence = self.pose_sequence
|
| 132 |
+
samples, debug = self.build_signal()
|
| 133 |
signal_range = debug["raw_signal_range"]
|
| 134 |
extrema = find_local_extrema(samples)
|
| 135 |
min_amplitude = max(self.min_signal_range, signal_range * 0.35)
|
|
|
|
| 179 |
],
|
| 180 |
}
|
| 181 |
return reps, debug_payload
|
|
|
src/pozify/exercises/shoulder_press/strategy.py
CHANGED
|
@@ -2,12 +2,12 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from pozify.contracts import
|
| 6 |
from pozify.exercises.base import ExerciseStrategy
|
| 7 |
from pozify.exercises.shoulder_press.analyzer import ShoulderPressAnalyzer
|
| 8 |
from pozify.exercises.shared.issue_marker import IssueRule
|
| 9 |
from pozify.exercises.shoulder_press.issue_markers import RULES as ISSUE_RULES
|
| 10 |
-
from pozify.
|
| 11 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 12 |
|
| 13 |
|
|
@@ -17,7 +17,8 @@ class ShoulderPressExercise(ShoulderPressAnalyzer, ExerciseStrategy):
|
|
| 17 |
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 18 |
return ISSUE_RULES
|
| 19 |
|
| 20 |
-
def build_signal(self
|
|
|
|
| 21 |
wrist_y = [average_axis(frame, ("left_wrist", "right_wrist"), "y") for frame in sequence.frames]
|
| 22 |
elbow_bend = [
|
| 23 |
mean_optional(
|
|
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from pozify.contracts import RepAnalysis
|
| 6 |
from pozify.exercises.base import ExerciseStrategy
|
| 7 |
from pozify.exercises.shoulder_press.analyzer import ShoulderPressAnalyzer
|
| 8 |
from pozify.exercises.shared.issue_marker import IssueRule
|
| 9 |
from pozify.exercises.shoulder_press.issue_markers import RULES as ISSUE_RULES
|
| 10 |
+
from pozify.exercises.shared.rep_counter import combine, mean_optional, normalized_samples
|
| 11 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 12 |
|
| 13 |
|
|
|
|
| 17 |
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 18 |
return ISSUE_RULES
|
| 19 |
|
| 20 |
+
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
|
| 21 |
+
sequence = self.pose_sequence
|
| 22 |
wrist_y = [average_axis(frame, ("left_wrist", "right_wrist"), "y") for frame in sequence.frames]
|
| 23 |
elbow_bend = [
|
| 24 |
mean_optional(
|
src/pozify/exercises/squat/strategy.py
CHANGED
|
@@ -2,12 +2,12 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from pozify.contracts import
|
| 6 |
from pozify.exercises.base import ExerciseStrategy
|
| 7 |
from pozify.exercises.squat.analyzer import SquatAnalyzer
|
| 8 |
from pozify.exercises.shared.issue_marker import IssueRule
|
| 9 |
from pozify.exercises.squat.issue_markers import RULES as ISSUE_RULES
|
| 10 |
-
from pozify.
|
| 11 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 12 |
|
| 13 |
|
|
@@ -17,7 +17,8 @@ class SquatExercise(SquatAnalyzer, ExerciseStrategy):
|
|
| 17 |
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 18 |
return ISSUE_RULES
|
| 19 |
|
| 20 |
-
def build_signal(self
|
|
|
|
| 21 |
hip_y = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in sequence.frames]
|
| 22 |
knee_bend = [
|
| 23 |
mean_optional(
|
|
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from pozify.contracts import RepAnalysis
|
| 6 |
from pozify.exercises.base import ExerciseStrategy
|
| 7 |
from pozify.exercises.squat.analyzer import SquatAnalyzer
|
| 8 |
from pozify.exercises.shared.issue_marker import IssueRule
|
| 9 |
from pozify.exercises.squat.issue_markers import RULES as ISSUE_RULES
|
| 10 |
+
from pozify.exercises.shared.rep_counter import combine, mean_optional, normalized_samples
|
| 11 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 12 |
|
| 13 |
|
|
|
|
| 17 |
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 18 |
return ISSUE_RULES
|
| 19 |
|
| 20 |
+
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
|
| 21 |
+
sequence = self.pose_sequence
|
| 22 |
hip_y = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in sequence.frames]
|
| 23 |
knee_bend = [
|
| 24 |
mean_optional(
|
src/pozify/exercises/unknown/strategy.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from pozify.contracts import
|
| 6 |
from pozify.exercise_catalog import get_exercise_spec
|
| 7 |
from pozify.exercises.base import ExerciseStrategy
|
| 8 |
from pozify.exercises.unknown.analyzer import UnknownAnalyzer
|
|
@@ -12,10 +12,10 @@ from pozify.steps.rep_signals import SignalSample
|
|
| 12 |
class UnknownExercise(UnknownAnalyzer, ExerciseStrategy):
|
| 13 |
exercise = "unknown"
|
| 14 |
|
| 15 |
-
def build_signal(self
|
| 16 |
return [], {"selected_signal": "none", "thresholds": {}, "extrema": [], "accepted_reps": []}
|
| 17 |
|
| 18 |
-
def count(self
|
| 19 |
reps = Reps(exercise=self.exercise, reps=[], partial_reps=[{"reason": "unknown_exercise"}])
|
| 20 |
return reps, {"selected_signal": "none", "thresholds": {}, "extrema": [], "accepted_reps": []}
|
| 21 |
|
|
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from pozify.contracts import RepAnalysis, Reps
|
| 6 |
from pozify.exercise_catalog import get_exercise_spec
|
| 7 |
from pozify.exercises.base import ExerciseStrategy
|
| 8 |
from pozify.exercises.unknown.analyzer import UnknownAnalyzer
|
|
|
|
| 12 |
class UnknownExercise(UnknownAnalyzer, ExerciseStrategy):
|
| 13 |
exercise = "unknown"
|
| 14 |
|
| 15 |
+
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
|
| 16 |
return [], {"selected_signal": "none", "thresholds": {}, "extrema": [], "accepted_reps": []}
|
| 17 |
|
| 18 |
+
def count(self) -> tuple[Reps, dict[str, Any]]:
|
| 19 |
reps = Reps(exercise=self.exercise, reps=[], partial_reps=[{"reason": "unknown_exercise"}])
|
| 20 |
return reps, {"selected_signal": "none", "thresholds": {}, "extrema": [], "accepted_reps": []}
|
| 21 |
|
src/pozify/pipeline.py
CHANGED
|
@@ -13,12 +13,8 @@ from pozify.steps import (
|
|
| 13 |
annotated_renderer,
|
| 14 |
coach_summary,
|
| 15 |
exercise_classifier,
|
| 16 |
-
issue_marker,
|
| 17 |
pose_cleaning,
|
| 18 |
pose_landmarker,
|
| 19 |
-
rep_analysis,
|
| 20 |
-
rep_counter,
|
| 21 |
-
variation_detector,
|
| 22 |
verifier,
|
| 23 |
video_qc,
|
| 24 |
)
|
|
@@ -27,8 +23,12 @@ from pozify.steps import (
|
|
| 27 |
RUNS_DIR = Path("runs")
|
| 28 |
|
| 29 |
|
| 30 |
-
def _env_mock_mode() -> bool:
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
return value not in {"0", "false", "no", "off"}
|
| 33 |
|
| 34 |
|
|
@@ -38,7 +38,7 @@ def run_pipeline(
|
|
| 38 |
*,
|
| 39 |
mock: bool | None = None,
|
| 40 |
) -> dict[str, Any]:
|
| 41 |
-
mock_mode = _env_mock_mode() if mock is None else mock
|
| 42 |
|
| 43 |
run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
|
| 44 |
run_dir = RUNS_DIR / run_id
|
|
@@ -74,19 +74,24 @@ def run_pipeline(
|
|
| 74 |
classification = exercise_classifier.run(cleaned_pose_sequence, profile)
|
| 75 |
write_artifact("exercise_classification.json", classification)
|
| 76 |
|
| 77 |
-
exercise = create_exercise_strategy(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
reps, rep_debug =
|
| 80 |
write_artifact("reps.json", reps)
|
| 81 |
write_artifact("rep_debug.json", rep_debug)
|
| 82 |
|
| 83 |
-
analysis =
|
| 84 |
write_artifact("rep_analysis.json", analysis)
|
| 85 |
|
| 86 |
-
variation =
|
| 87 |
write_artifact("variation.json", variation)
|
| 88 |
|
| 89 |
-
issues =
|
| 90 |
write_artifact("issue_markers.json", issues)
|
| 91 |
|
| 92 |
annotated_video_path = annotated_renderer.run(manifest, cleaned_pose_sequence, reps, issues, run_dir)
|
|
|
|
| 13 |
annotated_renderer,
|
| 14 |
coach_summary,
|
| 15 |
exercise_classifier,
|
|
|
|
| 16 |
pose_cleaning,
|
| 17 |
pose_landmarker,
|
|
|
|
|
|
|
|
|
|
| 18 |
verifier,
|
| 19 |
video_qc,
|
| 20 |
)
|
|
|
|
| 23 |
RUNS_DIR = Path("runs")
|
| 24 |
|
| 25 |
|
| 26 |
+
def _env_mock_mode(video_path: str | None) -> bool:
|
| 27 |
+
configured = os.getenv("POZIFY_MOCK_MODE")
|
| 28 |
+
if configured is None:
|
| 29 |
+
return video_path is None
|
| 30 |
+
|
| 31 |
+
value = configured.strip().lower()
|
| 32 |
return value not in {"0", "false", "no", "off"}
|
| 33 |
|
| 34 |
|
|
|
|
| 38 |
*,
|
| 39 |
mock: bool | None = None,
|
| 40 |
) -> dict[str, Any]:
|
| 41 |
+
mock_mode = _env_mock_mode(video_path) if mock is None else mock
|
| 42 |
|
| 43 |
run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
|
| 44 |
run_dir = RUNS_DIR / run_id
|
|
|
|
| 74 |
classification = exercise_classifier.run(cleaned_pose_sequence, profile)
|
| 75 |
write_artifact("exercise_classification.json", classification)
|
| 76 |
|
| 77 |
+
exercise = create_exercise_strategy(
|
| 78 |
+
classification.exercise,
|
| 79 |
+
video_manifest=manifest,
|
| 80 |
+
pose_sequence=cleaned_pose_sequence,
|
| 81 |
+
profile=profile,
|
| 82 |
+
)
|
| 83 |
|
| 84 |
+
reps, rep_debug = exercise.count()
|
| 85 |
write_artifact("reps.json", reps)
|
| 86 |
write_artifact("rep_debug.json", rep_debug)
|
| 87 |
|
| 88 |
+
analysis = exercise.analyze_reps(reps)
|
| 89 |
write_artifact("rep_analysis.json", analysis)
|
| 90 |
|
| 91 |
+
variation = exercise.resolve_variation(analysis)
|
| 92 |
write_artifact("variation.json", variation)
|
| 93 |
|
| 94 |
+
issues = exercise.mark_issues(reps, analysis, variation)
|
| 95 |
write_artifact("issue_markers.json", issues)
|
| 96 |
|
| 97 |
annotated_video_path = annotated_renderer.run(manifest, cleaned_pose_sequence, reps, issues, run_dir)
|
src/pozify/steps/issue_marker.py
DELETED
|
@@ -1,102 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from pozify.contracts import (
|
| 4 |
-
IssueMarker,
|
| 5 |
-
IssueMarkers,
|
| 6 |
-
PoseSequence,
|
| 7 |
-
RepAnalysis,
|
| 8 |
-
RepAnalysisItem,
|
| 9 |
-
Reps,
|
| 10 |
-
Variation,
|
| 11 |
-
)
|
| 12 |
-
from pozify.exercise_catalog import get_exercise_spec
|
| 13 |
-
from pozify.exercises import ExerciseStrategy
|
| 14 |
-
from pozify.exercises.shared.issue_marker import (
|
| 15 |
-
frame_scores_for_rule,
|
| 16 |
-
frames_for_rep,
|
| 17 |
-
group_violations,
|
| 18 |
-
marker_from_group,
|
| 19 |
-
minimum_run_length,
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def _fallback_rep_marker(
|
| 24 |
-
reps: Reps,
|
| 25 |
-
item: RepAnalysisItem,
|
| 26 |
-
variation: Variation,
|
| 27 |
-
) -> IssueMarker | None:
|
| 28 |
-
exercise_spec = get_exercise_spec(reps.exercise)
|
| 29 |
-
if item.stability_score >= 0.78 or exercise_spec.mock_issue is None:
|
| 30 |
-
return None
|
| 31 |
-
|
| 32 |
-
rep = next((rep for rep in reps.reps if rep.rep_id == item.rep_id), None)
|
| 33 |
-
if rep is None:
|
| 34 |
-
return None
|
| 35 |
-
|
| 36 |
-
issue_spec = exercise_spec.mock_issue
|
| 37 |
-
metric_value = (
|
| 38 |
-
item.range_of_motion_score
|
| 39 |
-
if issue_spec.evidence_metric == "range_of_motion_score"
|
| 40 |
-
else item.metrics.get(issue_spec.evidence_metric)
|
| 41 |
-
)
|
| 42 |
-
return IssueMarker(
|
| 43 |
-
rep_id=item.rep_id,
|
| 44 |
-
issue=issue_spec.issue,
|
| 45 |
-
severity=round(1.0 - item.stability_score, 2),
|
| 46 |
-
start_frame=rep.mid_frame,
|
| 47 |
-
end_frame=rep.end_frame,
|
| 48 |
-
start_sec=rep.mid_sec,
|
| 49 |
-
end_sec=rep.end_sec,
|
| 50 |
-
affected_joints=list(issue_spec.affected_joints),
|
| 51 |
-
evidence={
|
| 52 |
-
issue_spec.evidence_metric: metric_value,
|
| 53 |
-
"threshold": issue_spec.threshold,
|
| 54 |
-
"confidence": round(max(0.0, min(1.0, 1.0 - item.stability_score)), 2),
|
| 55 |
-
"variation_context": {
|
| 56 |
-
"detected_variation": variation.detected_variation,
|
| 57 |
-
"variation_confidence": variation.variation_confidence,
|
| 58 |
-
"not_issues": list(variation.not_issues),
|
| 59 |
-
},
|
| 60 |
-
"fallback": "rep_level_metrics",
|
| 61 |
-
},
|
| 62 |
-
)
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def run(
|
| 66 |
-
exercise: ExerciseStrategy,
|
| 67 |
-
reps: Reps,
|
| 68 |
-
analysis: RepAnalysis,
|
| 69 |
-
variation: Variation,
|
| 70 |
-
sequence: PoseSequence | None = None,
|
| 71 |
-
) -> IssueMarkers:
|
| 72 |
-
rep_by_id = {rep.rep_id: rep for rep in reps.reps}
|
| 73 |
-
rules = exercise.issue_rules()
|
| 74 |
-
issues: list[IssueMarker] = []
|
| 75 |
-
|
| 76 |
-
for item in analysis.items:
|
| 77 |
-
rep = rep_by_id.get(item.rep_id)
|
| 78 |
-
if rep is None:
|
| 79 |
-
continue
|
| 80 |
-
|
| 81 |
-
frames = frames_for_rep(sequence, rep)
|
| 82 |
-
if not frames:
|
| 83 |
-
fallback = _fallback_rep_marker(reps, item, variation)
|
| 84 |
-
if fallback is not None:
|
| 85 |
-
issues.append(fallback)
|
| 86 |
-
continue
|
| 87 |
-
|
| 88 |
-
min_run_length = minimum_run_length(frames)
|
| 89 |
-
for rule in rules:
|
| 90 |
-
if set(rule.suppress_when_not_issue) & set(variation.not_issues):
|
| 91 |
-
continue
|
| 92 |
-
|
| 93 |
-
scores = frame_scores_for_rule(frames, exercise.exercise, rule)
|
| 94 |
-
for group in group_violations(scores, min_run_length):
|
| 95 |
-
issues.append(marker_from_group(rule, group, item, variation))
|
| 96 |
-
|
| 97 |
-
return IssueMarkers(
|
| 98 |
-
issues=sorted(
|
| 99 |
-
issues,
|
| 100 |
-
key=lambda issue: (issue.start_frame, issue.end_frame, issue.rep_id, issue.issue),
|
| 101 |
-
)
|
| 102 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/pozify/steps/rep_analysis.py
DELETED
|
@@ -1,266 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from statistics import mean, pstdev
|
| 4 |
-
from typing import Any, Callable
|
| 5 |
-
|
| 6 |
-
from pozify.contracts import (
|
| 7 |
-
PoseFrame,
|
| 8 |
-
PoseSequence,
|
| 9 |
-
Rep,
|
| 10 |
-
RepAnalysis,
|
| 11 |
-
RepAnalysisItem,
|
| 12 |
-
Reps,
|
| 13 |
-
)
|
| 14 |
-
from pozify.exercises import ExerciseStrategy
|
| 15 |
-
from pozify.exercises.shared.analyzer import (
|
| 16 |
-
mean_optional,
|
| 17 |
-
round_optional,
|
| 18 |
-
safe_ratio,
|
| 19 |
-
score,
|
| 20 |
-
usable,
|
| 21 |
-
value_series,
|
| 22 |
-
)
|
| 23 |
-
from pozify.steps.rep_signals import average_axis
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
NumberGetter = Callable[[PoseFrame], float | None]
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def _std(values: list[float | None]) -> float | None:
|
| 30 |
-
usable_values = usable(values)
|
| 31 |
-
if len(usable_values) < 2:
|
| 32 |
-
return 0.0 if usable_values else None
|
| 33 |
-
return pstdev(usable_values)
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def _frames_for_rep(sequence: PoseSequence, rep: Rep) -> list[PoseFrame]:
|
| 37 |
-
frames = [
|
| 38 |
-
frame
|
| 39 |
-
for frame in sequence.frames
|
| 40 |
-
if rep.start_frame <= frame.frame_index <= rep.end_frame
|
| 41 |
-
]
|
| 42 |
-
if frames:
|
| 43 |
-
return frames
|
| 44 |
-
|
| 45 |
-
if not sequence.frames:
|
| 46 |
-
return []
|
| 47 |
-
closest = min(
|
| 48 |
-
sequence.frames,
|
| 49 |
-
key=lambda frame: min(
|
| 50 |
-
abs(frame.frame_index - rep.start_frame),
|
| 51 |
-
abs(frame.frame_index - rep.mid_frame),
|
| 52 |
-
abs(frame.frame_index - rep.end_frame),
|
| 53 |
-
),
|
| 54 |
-
)
|
| 55 |
-
return [closest]
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def _mean_visibility(frames: list[PoseFrame]) -> float:
|
| 59 |
-
values: list[float | None] = []
|
| 60 |
-
for frame in frames:
|
| 61 |
-
if "mean_visibility" in frame.pose_quality:
|
| 62 |
-
values.append(float(frame.pose_quality["mean_visibility"]))
|
| 63 |
-
continue
|
| 64 |
-
landmark_values = [
|
| 65 |
-
landmark.get("visibility")
|
| 66 |
-
for landmark in frame.landmarks.values()
|
| 67 |
-
if landmark.get("visibility") is not None
|
| 68 |
-
]
|
| 69 |
-
values.extend(float(value) for value in landmark_values)
|
| 70 |
-
return score(mean_optional(values) if values else 0.0)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def _smoothness_score(signal_values: list[float | None]) -> tuple[float, float | None]:
|
| 74 |
-
usable_values = usable(signal_values)
|
| 75 |
-
if len(usable_values) < 4:
|
| 76 |
-
return 0.5, None
|
| 77 |
-
|
| 78 |
-
deltas = [
|
| 79 |
-
usable_values[index] - usable_values[index - 1]
|
| 80 |
-
for index in range(1, len(usable_values))
|
| 81 |
-
]
|
| 82 |
-
jerks = [deltas[index] - deltas[index - 1] for index in range(1, len(deltas))]
|
| 83 |
-
if not jerks:
|
| 84 |
-
return 0.5, None
|
| 85 |
-
jerk = mean(abs(value) for value in jerks)
|
| 86 |
-
return score(1.0 - jerk * 8.0), jerk
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def _pause_duration(
|
| 90 |
-
frames: list[PoseFrame],
|
| 91 |
-
signal_values: list[float | None],
|
| 92 |
-
*,
|
| 93 |
-
target: str,
|
| 94 |
-
) -> float:
|
| 95 |
-
usable_values = usable(signal_values)
|
| 96 |
-
if len(usable_values) < 3 or len(frames) < 3:
|
| 97 |
-
return 0.0
|
| 98 |
-
|
| 99 |
-
min_value = min(usable_values)
|
| 100 |
-
max_value = max(usable_values)
|
| 101 |
-
tolerance = max((max_value - min_value) * 0.08, 0.01)
|
| 102 |
-
if target == "bottom":
|
| 103 |
-
active = [value is not None and value >= max_value - tolerance for value in signal_values]
|
| 104 |
-
else:
|
| 105 |
-
active = [value is not None and value <= min_value + tolerance for value in signal_values]
|
| 106 |
-
|
| 107 |
-
longest = 0
|
| 108 |
-
current = 0
|
| 109 |
-
for item in active:
|
| 110 |
-
if item:
|
| 111 |
-
current += 1
|
| 112 |
-
longest = max(longest, current)
|
| 113 |
-
else:
|
| 114 |
-
current = 0
|
| 115 |
-
|
| 116 |
-
if longest <= 1:
|
| 117 |
-
return 0.0
|
| 118 |
-
frame_duration = (frames[-1].timestamp_sec - frames[0].timestamp_sec) / max(1, len(frames) - 1)
|
| 119 |
-
return round(longest * frame_duration, 2)
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def _common_metrics(
|
| 123 |
-
rep: Rep,
|
| 124 |
-
frames: list[PoseFrame],
|
| 125 |
-
primary_signal: list[float | None],
|
| 126 |
-
) -> dict[str, Any]:
|
| 127 |
-
eccentric_duration = round(max(0.0, rep.mid_sec - rep.start_sec), 2)
|
| 128 |
-
concentric_duration = round(max(0.0, rep.end_sec - rep.mid_sec), 2)
|
| 129 |
-
duration = round(max(0.0, rep.end_sec - rep.start_sec), 2)
|
| 130 |
-
smoothness_score, jerk_score = _smoothness_score(primary_signal)
|
| 131 |
-
stability_axis = value_series(
|
| 132 |
-
frames,
|
| 133 |
-
lambda frame: average_axis(frame, ("left_hip", "right_hip"), "x"),
|
| 134 |
-
)
|
| 135 |
-
stability_noise = _std(stability_axis) or 0.0
|
| 136 |
-
|
| 137 |
-
return {
|
| 138 |
-
"rep_duration_sec": duration,
|
| 139 |
-
"eccentric_duration_sec": eccentric_duration,
|
| 140 |
-
"concentric_duration_sec": concentric_duration,
|
| 141 |
-
"tempo_ratio": round_optional(safe_ratio(eccentric_duration, concentric_duration), 2),
|
| 142 |
-
"top_pause_sec": _pause_duration(frames, primary_signal, target="top"),
|
| 143 |
-
"bottom_pause_sec": _pause_duration(frames, primary_signal, target="bottom"),
|
| 144 |
-
"smoothness_score": smoothness_score,
|
| 145 |
-
"jerk_score": round_optional(jerk_score, 4),
|
| 146 |
-
"landmark_confidence": _mean_visibility(frames),
|
| 147 |
-
"hip_lateral_drift": round_optional(stability_noise, 4),
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
def _primary_signal(exercise_key: str, frames: list[PoseFrame]) -> list[float | None]:
|
| 152 |
-
if exercise_key == "shoulder_press":
|
| 153 |
-
return value_series(
|
| 154 |
-
frames,
|
| 155 |
-
lambda frame: average_axis(frame, ("left_wrist", "right_wrist"), "y"),
|
| 156 |
-
)
|
| 157 |
-
if exercise_key == "push_up":
|
| 158 |
-
return value_series(
|
| 159 |
-
frames,
|
| 160 |
-
lambda frame: mean_optional(
|
| 161 |
-
[
|
| 162 |
-
average_axis(frame, ("left_shoulder", "right_shoulder"), "y"),
|
| 163 |
-
average_axis(frame, ("left_hip", "right_hip"), "y"),
|
| 164 |
-
]
|
| 165 |
-
),
|
| 166 |
-
)
|
| 167 |
-
return value_series(frames, lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"))
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def _aggregate_numeric(items: list[RepAnalysisItem], metric_name: str) -> float | None:
|
| 171 |
-
values = [
|
| 172 |
-
item.metrics.get(metric_name)
|
| 173 |
-
for item in items
|
| 174 |
-
if isinstance(item.metrics.get(metric_name), (int, float))
|
| 175 |
-
]
|
| 176 |
-
if not values:
|
| 177 |
-
return None
|
| 178 |
-
return round(sum(float(value) for value in values) / len(values), 4)
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
def _fatigue_trend(items: list[RepAnalysisItem]) -> float:
|
| 182 |
-
if len(items) < 2:
|
| 183 |
-
return 0.0
|
| 184 |
-
first = items[0].range_of_motion_score
|
| 185 |
-
last = items[-1].range_of_motion_score
|
| 186 |
-
return round(last - first, 4)
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
def run(
|
| 190 |
-
exercise: ExerciseStrategy,
|
| 191 |
-
reps: Reps,
|
| 192 |
-
sequence: PoseSequence,
|
| 193 |
-
) -> RepAnalysis:
|
| 194 |
-
draft_items: list[tuple[Rep, dict[str, Any], float, float, float, list[str]]] = []
|
| 195 |
-
for rep in reps.reps:
|
| 196 |
-
frames = _frames_for_rep(sequence, rep)
|
| 197 |
-
primary_signal = _primary_signal(exercise.exercise, frames)
|
| 198 |
-
common_metrics = _common_metrics(rep, frames, primary_signal)
|
| 199 |
-
exercise_metrics, rom_score, stability_score, symmetry_score, hints = exercise.metrics(frames)
|
| 200 |
-
metrics = {**common_metrics, **exercise_metrics}
|
| 201 |
-
draft_items.append((rep, metrics, rom_score, stability_score, symmetry_score, hints))
|
| 202 |
-
|
| 203 |
-
average_duration = (
|
| 204 |
-
mean(item[1]["rep_duration_sec"] for item in draft_items)
|
| 205 |
-
if draft_items
|
| 206 |
-
else 0.0
|
| 207 |
-
)
|
| 208 |
-
items: list[RepAnalysisItem] = []
|
| 209 |
-
for rep, metrics, rom_score, stability_score, symmetry_score, hints in draft_items:
|
| 210 |
-
duration = metrics["rep_duration_sec"]
|
| 211 |
-
metrics["tempo_consistency_score"] = (
|
| 212 |
-
score(1.0 - abs(duration - average_duration) / max(average_duration, 0.1))
|
| 213 |
-
if average_duration
|
| 214 |
-
else 0.0
|
| 215 |
-
)
|
| 216 |
-
items.append(
|
| 217 |
-
RepAnalysisItem(
|
| 218 |
-
rep_id=rep.rep_id,
|
| 219 |
-
duration_sec=duration,
|
| 220 |
-
range_of_motion_score=rom_score,
|
| 221 |
-
stability_score=stability_score,
|
| 222 |
-
symmetry_score=symmetry_score,
|
| 223 |
-
metrics=metrics,
|
| 224 |
-
variation_hints=sorted(set(hints)),
|
| 225 |
-
)
|
| 226 |
-
)
|
| 227 |
-
|
| 228 |
-
aggregate_metrics = {
|
| 229 |
-
"avg_rom_score": (
|
| 230 |
-
round(mean(item.range_of_motion_score for item in items), 2) if items else 0.0
|
| 231 |
-
),
|
| 232 |
-
"avg_stability_score": (
|
| 233 |
-
round(mean(item.stability_score for item in items), 2) if items else 0.0
|
| 234 |
-
),
|
| 235 |
-
"avg_symmetry_score": (
|
| 236 |
-
round(mean(item.symmetry_score for item in items), 2) if items else 0.0
|
| 237 |
-
),
|
| 238 |
-
"avg_rep_duration_sec": (
|
| 239 |
-
round(mean(item.duration_sec for item in items), 2) if items else 0.0
|
| 240 |
-
),
|
| 241 |
-
"avg_tempo_consistency_score": _aggregate_numeric(items, "tempo_consistency_score") or 0.0,
|
| 242 |
-
"avg_landmark_confidence": (
|
| 243 |
-
_aggregate_numeric(items, "landmark_confidence") or sequence.pose_valid_ratio
|
| 244 |
-
),
|
| 245 |
-
"fatigue_trend_rom_delta": _fatigue_trend(items),
|
| 246 |
-
"pose_valid_ratio": sequence.pose_valid_ratio,
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
for metric_name in (
|
| 250 |
-
"hand_width_ratio",
|
| 251 |
-
"stance_width_ratio",
|
| 252 |
-
"bottom_pause_sec",
|
| 253 |
-
"lockout_quality",
|
| 254 |
-
"wrist_height_asymmetry",
|
| 255 |
-
"wrist_travel",
|
| 256 |
-
"knee_support_score",
|
| 257 |
-
):
|
| 258 |
-
aggregate_value = _aggregate_numeric(items, metric_name)
|
| 259 |
-
if aggregate_value is not None:
|
| 260 |
-
aggregate_metrics[f"avg_{metric_name}"] = aggregate_value
|
| 261 |
-
|
| 262 |
-
return RepAnalysis(
|
| 263 |
-
exercise=exercise.exercise,
|
| 264 |
-
items=items,
|
| 265 |
-
aggregate_metrics=aggregate_metrics,
|
| 266 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/pozify/steps/rep_counter.py
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from typing import Any
|
| 4 |
-
|
| 5 |
-
from pozify.contracts import PoseSequence, Reps
|
| 6 |
-
from pozify.exercises import ExerciseStrategy
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def run(exercise: ExerciseStrategy, sequence: PoseSequence) -> tuple[Reps, dict[str, Any]]:
|
| 10 |
-
return exercise.count(sequence)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/pozify/steps/rep_counters/__init__.py
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
from pozify.steps.rep_counters.base import ExerciseRepCounter
|
| 2 |
-
|
| 3 |
-
__all__ = ["ExerciseRepCounter"]
|
|
|
|
|
|
|
|
|
|
|
|
src/pozify/steps/variation_detector.py
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from pozify.contracts import RepAnalysis, UserProfile, Variation
|
| 4 |
-
from pozify.exercises import ExerciseStrategy
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def run(
|
| 8 |
-
exercise: ExerciseStrategy,
|
| 9 |
-
analysis: RepAnalysis,
|
| 10 |
-
profile: UserProfile,
|
| 11 |
-
) -> Variation:
|
| 12 |
-
if profile.intended_variation:
|
| 13 |
-
variation = profile.intended_variation
|
| 14 |
-
confidence = 0.95
|
| 15 |
-
not_issues = exercise.profile_not_issues(variation)
|
| 16 |
-
else:
|
| 17 |
-
variation, confidence, not_issues = exercise.detect_variation(analysis)
|
| 18 |
-
|
| 19 |
-
if analysis.aggregate_metrics.get("avg_rom_score", 1.0) < 0.7:
|
| 20 |
-
not_issues.append("low_rom_requires_user_intent_check")
|
| 21 |
-
if analysis.aggregate_metrics.get("pose_valid_ratio", 1.0) < 0.8:
|
| 22 |
-
not_issues.append("low_pose_confidence_limits_variation_call")
|
| 23 |
-
|
| 24 |
-
return Variation(
|
| 25 |
-
exercise=exercise.exercise,
|
| 26 |
-
detected_variation=variation,
|
| 27 |
-
variation_confidence=confidence,
|
| 28 |
-
not_issues=sorted(set(not_issues)),
|
| 29 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_issue_marker.py
CHANGED
|
@@ -9,14 +9,15 @@ import unittest
|
|
| 9 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 10 |
|
| 11 |
from pozify.contracts import (
|
|
|
|
| 12 |
PoseFrame,
|
| 13 |
PoseSequence,
|
| 14 |
Rep,
|
| 15 |
Reps,
|
| 16 |
UserProfile,
|
|
|
|
| 17 |
)
|
| 18 |
from pozify.exercises import create_exercise_strategy
|
| 19 |
-
from pozify.steps import issue_marker, rep_analysis, variation_detector
|
| 20 |
|
| 21 |
|
| 22 |
def _frame(frame_index: int, landmarks: dict[str, dict[str, float]]) -> PoseFrame:
|
|
@@ -52,6 +53,24 @@ def _profile(exercise: str = "auto") -> UserProfile:
|
|
| 52 |
)
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def _sequence(factory: Callable[[int, float], dict[str, dict[str, float]]], count: int = 30) -> PoseSequence:
|
| 56 |
return PoseSequence(
|
| 57 |
frames=[_frame(frame_index, factory(frame_index, _wave(frame_index, count))) for frame_index in range(count)],
|
|
@@ -122,12 +141,17 @@ def _shoulder_press_landmarks(frame_index: int, lift: float) -> dict[str, dict[s
|
|
| 122 |
}
|
| 123 |
|
| 124 |
|
| 125 |
-
def _run_markers(exercise: str, sequence: PoseSequence):
|
| 126 |
-
exercise_strategy = create_exercise_strategy(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
reps = _reps(exercise, len(sequence.frames) - 1)
|
| 128 |
-
analysis =
|
| 129 |
-
variation =
|
| 130 |
-
return
|
| 131 |
|
| 132 |
|
| 133 |
class IssueMarkerTests(unittest.TestCase):
|
|
|
|
| 9 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 10 |
|
| 11 |
from pozify.contracts import (
|
| 12 |
+
IssueMarkers,
|
| 13 |
PoseFrame,
|
| 14 |
PoseSequence,
|
| 15 |
Rep,
|
| 16 |
Reps,
|
| 17 |
UserProfile,
|
| 18 |
+
VideoManifest,
|
| 19 |
)
|
| 20 |
from pozify.exercises import create_exercise_strategy
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def _frame(frame_index: int, landmarks: dict[str, dict[str, float]]) -> PoseFrame:
|
|
|
|
| 53 |
)
|
| 54 |
|
| 55 |
|
| 56 |
+
def _video_manifest(sequence: PoseSequence) -> VideoManifest:
|
| 57 |
+
return VideoManifest(
|
| 58 |
+
video_path=None,
|
| 59 |
+
fps=30.0,
|
| 60 |
+
duration_sec=round(len(sequence.frames) / 30.0, 3),
|
| 61 |
+
total_frames=len(sequence.frames),
|
| 62 |
+
sampled_frames=len(sequence.frames),
|
| 63 |
+
width=720,
|
| 64 |
+
height=1280,
|
| 65 |
+
codec=None,
|
| 66 |
+
container=None,
|
| 67 |
+
brightness_mean=None,
|
| 68 |
+
blur_laplacian_var=None,
|
| 69 |
+
quality_warnings=[],
|
| 70 |
+
analysis_allowed=True,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
def _sequence(factory: Callable[[int, float], dict[str, dict[str, float]]], count: int = 30) -> PoseSequence:
|
| 75 |
return PoseSequence(
|
| 76 |
frames=[_frame(frame_index, factory(frame_index, _wave(frame_index, count))) for frame_index in range(count)],
|
|
|
|
| 141 |
}
|
| 142 |
|
| 143 |
|
| 144 |
+
def _run_markers(exercise: str, sequence: PoseSequence) -> IssueMarkers:
|
| 145 |
+
exercise_strategy = create_exercise_strategy(
|
| 146 |
+
exercise,
|
| 147 |
+
video_manifest=_video_manifest(sequence),
|
| 148 |
+
pose_sequence=sequence,
|
| 149 |
+
profile=_profile(exercise),
|
| 150 |
+
)
|
| 151 |
reps = _reps(exercise, len(sequence.frames) - 1)
|
| 152 |
+
analysis = exercise_strategy.analyze_reps(reps)
|
| 153 |
+
variation = exercise_strategy.resolve_variation(analysis)
|
| 154 |
+
return exercise_strategy.mark_issues(reps, analysis, variation)
|
| 155 |
|
| 156 |
|
| 157 |
class IssueMarkerTests(unittest.TestCase):
|
tests/test_pipeline_contracts.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
import sys
|
| 5 |
import tempfile
|
| 6 |
import unittest
|
| 7 |
from pathlib import Path
|
|
|
|
| 8 |
|
| 9 |
import cv2
|
| 10 |
import numpy as np
|
|
@@ -227,6 +229,11 @@ class PipelineContractTests(unittest.TestCase):
|
|
| 227 |
report = result["final_report"]
|
| 228 |
self.assertEqual(report["exercise"]["exercise"], exercise)
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
if __name__ == "__main__":
|
| 232 |
unittest.main()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
import os
|
| 5 |
import sys
|
| 6 |
import tempfile
|
| 7 |
import unittest
|
| 8 |
from pathlib import Path
|
| 9 |
+
from unittest.mock import patch
|
| 10 |
|
| 11 |
import cv2
|
| 12 |
import numpy as np
|
|
|
|
| 229 |
report = result["final_report"]
|
| 230 |
self.assertEqual(report["exercise"]["exercise"], exercise)
|
| 231 |
|
| 232 |
+
def test_mock_mode_defaults_to_real_when_video_path_is_present(self) -> None:
|
| 233 |
+
with patch.dict(os.environ, {}, clear=True):
|
| 234 |
+
self.assertFalse(pipeline._env_mock_mode("sample.mp4"))
|
| 235 |
+
self.assertTrue(pipeline._env_mock_mode(None))
|
| 236 |
+
|
| 237 |
|
| 238 |
if __name__ == "__main__":
|
| 239 |
unittest.main()
|
tests/test_rep_analysis_variation.py
CHANGED
|
@@ -14,9 +14,9 @@ from pozify.contracts import (
|
|
| 14 |
Rep,
|
| 15 |
Reps,
|
| 16 |
UserProfile,
|
|
|
|
| 17 |
)
|
| 18 |
from pozify.exercises import create_exercise_strategy
|
| 19 |
-
from pozify.steps import rep_analysis, variation_detector
|
| 20 |
|
| 21 |
|
| 22 |
def _frame(frame_index: int, landmarks: dict[str, dict[str, float]]) -> PoseFrame:
|
|
@@ -189,15 +189,39 @@ def _profile() -> UserProfile:
|
|
| 189 |
)
|
| 190 |
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
class RepAnalysisVariationTests(unittest.TestCase):
|
| 193 |
def test_push_up_metrics_detect_wide_grip_variation(self) -> None:
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
_sequence("push_up"),
|
| 199 |
-
)
|
| 200 |
-
variation = variation_detector.run(exercise, analysis, _profile())
|
| 201 |
|
| 202 |
self.assertEqual(variation.detected_variation, "wide_grip_push_up")
|
| 203 |
self.assertIn("wide_hand_placement", variation.not_issues)
|
|
@@ -205,35 +229,30 @@ class RepAnalysisVariationTests(unittest.TestCase):
|
|
| 205 |
self.assertIn("body_line_score", analysis.items[0].metrics)
|
| 206 |
|
| 207 |
def test_push_up_metrics_detect_knee_push_up_variation(self) -> None:
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
_custom_sequence(_knee_push_up_landmarks),
|
| 213 |
-
)
|
| 214 |
-
variation = variation_detector.run(exercise, analysis, _profile())
|
| 215 |
|
| 216 |
self.assertEqual(variation.detected_variation, "knee_push_up")
|
| 217 |
self.assertIn("knee_contact", variation.not_issues)
|
| 218 |
self.assertGreaterEqual(analysis.aggregate_metrics["avg_knee_support_score"], 0.8)
|
| 219 |
|
| 220 |
def test_straight_leg_push_up_does_not_false_positive_as_knee_push_up(self) -> None:
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
_custom_sequence(_straight_leg_push_up_landmarks),
|
| 226 |
-
)
|
| 227 |
-
variation = variation_detector.run(exercise, analysis, _profile())
|
| 228 |
|
| 229 |
self.assertNotEqual(variation.detected_variation, "knee_push_up")
|
| 230 |
self.assertNotIn("knee_contact", variation.not_issues)
|
| 231 |
self.assertLess(analysis.aggregate_metrics["avg_knee_support_score"], 0.8)
|
| 232 |
|
| 233 |
def test_squat_metrics_detect_wide_stance_variation(self) -> None:
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
| 237 |
|
| 238 |
self.assertEqual(variation.detected_variation, "wide_squat_stance")
|
| 239 |
self.assertIn("wide_stance", variation.not_issues)
|
|
@@ -241,13 +260,10 @@ class RepAnalysisVariationTests(unittest.TestCase):
|
|
| 241 |
self.assertIn("min_knee_angle_deg", analysis.items[0].metrics)
|
| 242 |
|
| 243 |
def test_shoulder_press_metrics_detect_partial_press_variation(self) -> None:
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
_sequence("shoulder_press"),
|
| 249 |
-
)
|
| 250 |
-
variation = variation_detector.run(exercise, analysis, _profile())
|
| 251 |
|
| 252 |
self.assertEqual(variation.detected_variation, "partial_press")
|
| 253 |
self.assertIn("partial_range_of_motion", variation.not_issues)
|
|
@@ -263,13 +279,10 @@ class RepAnalysisVariationTests(unittest.TestCase):
|
|
| 263 |
known_limitations=[],
|
| 264 |
equipment="bodyweight",
|
| 265 |
)
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
_sequence("push_up"),
|
| 271 |
-
)
|
| 272 |
-
variation = variation_detector.run(exercise, analysis, profile)
|
| 273 |
|
| 274 |
self.assertEqual(variation.detected_variation, "close_grip_push_up")
|
| 275 |
self.assertEqual(variation.variation_confidence, 0.95)
|
|
|
|
| 14 |
Rep,
|
| 15 |
Reps,
|
| 16 |
UserProfile,
|
| 17 |
+
VideoManifest,
|
| 18 |
)
|
| 19 |
from pozify.exercises import create_exercise_strategy
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
def _frame(frame_index: int, landmarks: dict[str, dict[str, float]]) -> PoseFrame:
|
|
|
|
| 189 |
)
|
| 190 |
|
| 191 |
|
| 192 |
+
def _video_manifest(sequence: PoseSequence) -> VideoManifest:
|
| 193 |
+
return VideoManifest(
|
| 194 |
+
video_path=None,
|
| 195 |
+
fps=30.0,
|
| 196 |
+
duration_sec=round(len(sequence.frames) / 30.0, 3),
|
| 197 |
+
total_frames=len(sequence.frames),
|
| 198 |
+
sampled_frames=len(sequence.frames),
|
| 199 |
+
width=720,
|
| 200 |
+
height=1280,
|
| 201 |
+
codec=None,
|
| 202 |
+
container=None,
|
| 203 |
+
brightness_mean=None,
|
| 204 |
+
blur_laplacian_var=None,
|
| 205 |
+
quality_warnings=[],
|
| 206 |
+
analysis_allowed=True,
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _exercise_strategy(exercise: str, sequence: PoseSequence, profile: UserProfile | None = None):
|
| 211 |
+
return create_exercise_strategy(
|
| 212 |
+
exercise,
|
| 213 |
+
video_manifest=_video_manifest(sequence),
|
| 214 |
+
pose_sequence=sequence,
|
| 215 |
+
profile=profile or _profile(),
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
class RepAnalysisVariationTests(unittest.TestCase):
|
| 220 |
def test_push_up_metrics_detect_wide_grip_variation(self) -> None:
|
| 221 |
+
sequence = _sequence("push_up")
|
| 222 |
+
exercise = _exercise_strategy("push_up", sequence)
|
| 223 |
+
analysis = exercise.analyze_reps(_reps("push_up"))
|
| 224 |
+
variation = exercise.resolve_variation(analysis)
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
self.assertEqual(variation.detected_variation, "wide_grip_push_up")
|
| 227 |
self.assertIn("wide_hand_placement", variation.not_issues)
|
|
|
|
| 229 |
self.assertIn("body_line_score", analysis.items[0].metrics)
|
| 230 |
|
| 231 |
def test_push_up_metrics_detect_knee_push_up_variation(self) -> None:
|
| 232 |
+
sequence = _custom_sequence(_knee_push_up_landmarks)
|
| 233 |
+
exercise = _exercise_strategy("push_up", sequence)
|
| 234 |
+
analysis = exercise.analyze_reps(_reps("push_up"))
|
| 235 |
+
variation = exercise.resolve_variation(analysis)
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
self.assertEqual(variation.detected_variation, "knee_push_up")
|
| 238 |
self.assertIn("knee_contact", variation.not_issues)
|
| 239 |
self.assertGreaterEqual(analysis.aggregate_metrics["avg_knee_support_score"], 0.8)
|
| 240 |
|
| 241 |
def test_straight_leg_push_up_does_not_false_positive_as_knee_push_up(self) -> None:
|
| 242 |
+
sequence = _custom_sequence(_straight_leg_push_up_landmarks)
|
| 243 |
+
exercise = _exercise_strategy("push_up", sequence)
|
| 244 |
+
analysis = exercise.analyze_reps(_reps("push_up"))
|
| 245 |
+
variation = exercise.resolve_variation(analysis)
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
self.assertNotEqual(variation.detected_variation, "knee_push_up")
|
| 248 |
self.assertNotIn("knee_contact", variation.not_issues)
|
| 249 |
self.assertLess(analysis.aggregate_metrics["avg_knee_support_score"], 0.8)
|
| 250 |
|
| 251 |
def test_squat_metrics_detect_wide_stance_variation(self) -> None:
|
| 252 |
+
sequence = _sequence("squat")
|
| 253 |
+
exercise = _exercise_strategy("squat", sequence)
|
| 254 |
+
analysis = exercise.analyze_reps(_reps("squat"))
|
| 255 |
+
variation = exercise.resolve_variation(analysis)
|
| 256 |
|
| 257 |
self.assertEqual(variation.detected_variation, "wide_squat_stance")
|
| 258 |
self.assertIn("wide_stance", variation.not_issues)
|
|
|
|
| 260 |
self.assertIn("min_knee_angle_deg", analysis.items[0].metrics)
|
| 261 |
|
| 262 |
def test_shoulder_press_metrics_detect_partial_press_variation(self) -> None:
|
| 263 |
+
sequence = _sequence("shoulder_press")
|
| 264 |
+
exercise = _exercise_strategy("shoulder_press", sequence)
|
| 265 |
+
analysis = exercise.analyze_reps(_reps("shoulder_press"))
|
| 266 |
+
variation = exercise.resolve_variation(analysis)
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
self.assertEqual(variation.detected_variation, "partial_press")
|
| 269 |
self.assertIn("partial_range_of_motion", variation.not_issues)
|
|
|
|
| 279 |
known_limitations=[],
|
| 280 |
equipment="bodyweight",
|
| 281 |
)
|
| 282 |
+
sequence = _sequence("push_up")
|
| 283 |
+
exercise = _exercise_strategy("push_up", sequence, profile)
|
| 284 |
+
analysis = exercise.analyze_reps(_reps("push_up"))
|
| 285 |
+
variation = exercise.resolve_variation(analysis)
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
self.assertEqual(variation.detected_variation, "close_grip_push_up")
|
| 288 |
self.assertEqual(variation.variation_confidence, 0.95)
|
tests/test_rep_counter.py
CHANGED
|
@@ -7,12 +7,11 @@ import unittest
|
|
| 7 |
|
| 8 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 9 |
|
| 10 |
-
from pozify.contracts import PoseFrame, PoseSequence
|
| 11 |
from pozify.exercises import create_exercise_strategy
|
| 12 |
from pozify.exercises.push_up import PushUpExercise
|
| 13 |
from pozify.exercises.shoulder_press import ShoulderPressExercise
|
| 14 |
from pozify.exercises.squat import SquatExercise
|
| 15 |
-
from pozify.steps import rep_counter
|
| 16 |
|
| 17 |
|
| 18 |
def _frame(frame_index: int, landmarks: dict[str, dict[str, float]]) -> PoseFrame:
|
|
@@ -107,52 +106,84 @@ def _sequence_for_exercise(exercise: str, cycles: int, *, partial_tail: bool = F
|
|
| 107 |
)
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
class RepCounterTests(unittest.TestCase):
|
| 111 |
def test_segments_squat_reps(self) -> None:
|
| 112 |
-
|
|
|
|
| 113 |
self.assertEqual(len(reps.reps), 3)
|
| 114 |
self.assertEqual(reps.reps[0].start_frame, 0)
|
| 115 |
self.assertTrue(debug["accepted_reps"])
|
| 116 |
|
| 117 |
def test_segments_push_up_reps(self) -> None:
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
_sequence_for_exercise("push_up", 3),
|
| 121 |
-
)
|
| 122 |
self.assertEqual(len(reps.reps), 3)
|
| 123 |
self.assertEqual(reps.partial_reps, [])
|
| 124 |
|
| 125 |
def test_segments_shoulder_press_reps(self) -> None:
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
_sequence_for_exercise("shoulder_press", 3),
|
| 129 |
-
)
|
| 130 |
self.assertEqual(len(reps.reps), 3)
|
| 131 |
self.assertEqual(reps.reps[0].mid_frame, 12)
|
| 132 |
|
| 133 |
def test_reports_partial_last_rep(self) -> None:
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
_sequence_for_exercise("push_up", 2, partial_tail=True),
|
| 137 |
-
)
|
| 138 |
self.assertEqual(len(reps.reps), 2)
|
| 139 |
self.assertIn("ends_mid_rep", {item["reason"] for item in reps.partial_reps})
|
| 140 |
|
| 141 |
def test_unknown_exercise_is_not_segmented(self) -> None:
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
_sequence_for_exercise("push_up", 1),
|
| 145 |
-
)
|
| 146 |
self.assertEqual(reps.reps, [])
|
| 147 |
self.assertEqual(reps.partial_reps, [{"reason": "unknown_exercise"}])
|
| 148 |
self.assertEqual(debug["selected_signal"], "none")
|
| 149 |
|
| 150 |
def test_exercises_resolve_to_specific_strategies(self) -> None:
|
| 151 |
-
|
|
|
|
| 152 |
self.assertIsInstance(push_up, PushUpExercise)
|
| 153 |
-
self.assertIsInstance(
|
| 154 |
-
self.assertIsInstance(
|
| 155 |
-
self.assertIsNot(push_up,
|
| 156 |
|
| 157 |
|
| 158 |
if __name__ == "__main__":
|
|
|
|
| 7 |
|
| 8 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 9 |
|
| 10 |
+
from pozify.contracts import PoseFrame, PoseSequence, UserProfile, VideoManifest
|
| 11 |
from pozify.exercises import create_exercise_strategy
|
| 12 |
from pozify.exercises.push_up import PushUpExercise
|
| 13 |
from pozify.exercises.shoulder_press import ShoulderPressExercise
|
| 14 |
from pozify.exercises.squat import SquatExercise
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
def _frame(frame_index: int, landmarks: dict[str, dict[str, float]]) -> PoseFrame:
|
|
|
|
| 106 |
)
|
| 107 |
|
| 108 |
|
| 109 |
+
def _video_manifest(sequence: PoseSequence) -> VideoManifest:
|
| 110 |
+
return VideoManifest(
|
| 111 |
+
video_path=None,
|
| 112 |
+
fps=30.0,
|
| 113 |
+
duration_sec=round(len(sequence.frames) / 30.0, 3),
|
| 114 |
+
total_frames=len(sequence.frames),
|
| 115 |
+
sampled_frames=len(sequence.frames),
|
| 116 |
+
width=720,
|
| 117 |
+
height=1280,
|
| 118 |
+
codec=None,
|
| 119 |
+
container=None,
|
| 120 |
+
brightness_mean=None,
|
| 121 |
+
blur_laplacian_var=None,
|
| 122 |
+
quality_warnings=[],
|
| 123 |
+
analysis_allowed=True,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _profile(exercise: str = "auto") -> UserProfile:
|
| 128 |
+
return UserProfile(
|
| 129 |
+
goal="beginner_practice",
|
| 130 |
+
experience_level="beginner",
|
| 131 |
+
intended_exercise=exercise,
|
| 132 |
+
intended_variation=None,
|
| 133 |
+
known_limitations=[],
|
| 134 |
+
equipment="bodyweight",
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _exercise_strategy(exercise: str, sequence: PoseSequence):
|
| 139 |
+
return create_exercise_strategy(
|
| 140 |
+
exercise,
|
| 141 |
+
video_manifest=_video_manifest(sequence),
|
| 142 |
+
pose_sequence=sequence,
|
| 143 |
+
profile=_profile(exercise),
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
class RepCounterTests(unittest.TestCase):
|
| 148 |
def test_segments_squat_reps(self) -> None:
|
| 149 |
+
sequence = _sequence_for_exercise("squat", 3)
|
| 150 |
+
reps, debug = _exercise_strategy("squat", sequence).count()
|
| 151 |
self.assertEqual(len(reps.reps), 3)
|
| 152 |
self.assertEqual(reps.reps[0].start_frame, 0)
|
| 153 |
self.assertTrue(debug["accepted_reps"])
|
| 154 |
|
| 155 |
def test_segments_push_up_reps(self) -> None:
|
| 156 |
+
sequence = _sequence_for_exercise("push_up", 3)
|
| 157 |
+
reps, _debug = _exercise_strategy("push_up", sequence).count()
|
|
|
|
|
|
|
| 158 |
self.assertEqual(len(reps.reps), 3)
|
| 159 |
self.assertEqual(reps.partial_reps, [])
|
| 160 |
|
| 161 |
def test_segments_shoulder_press_reps(self) -> None:
|
| 162 |
+
sequence = _sequence_for_exercise("shoulder_press", 3)
|
| 163 |
+
reps, _debug = _exercise_strategy("shoulder_press", sequence).count()
|
|
|
|
|
|
|
| 164 |
self.assertEqual(len(reps.reps), 3)
|
| 165 |
self.assertEqual(reps.reps[0].mid_frame, 12)
|
| 166 |
|
| 167 |
def test_reports_partial_last_rep(self) -> None:
|
| 168 |
+
sequence = _sequence_for_exercise("push_up", 2, partial_tail=True)
|
| 169 |
+
reps, _debug = _exercise_strategy("push_up", sequence).count()
|
|
|
|
|
|
|
| 170 |
self.assertEqual(len(reps.reps), 2)
|
| 171 |
self.assertIn("ends_mid_rep", {item["reason"] for item in reps.partial_reps})
|
| 172 |
|
| 173 |
def test_unknown_exercise_is_not_segmented(self) -> None:
|
| 174 |
+
sequence = _sequence_for_exercise("push_up", 1)
|
| 175 |
+
reps, debug = _exercise_strategy("unknown", sequence).count()
|
|
|
|
|
|
|
| 176 |
self.assertEqual(reps.reps, [])
|
| 177 |
self.assertEqual(reps.partial_reps, [{"reason": "unknown_exercise"}])
|
| 178 |
self.assertEqual(debug["selected_signal"], "none")
|
| 179 |
|
| 180 |
def test_exercises_resolve_to_specific_strategies(self) -> None:
|
| 181 |
+
sequence = _sequence_for_exercise("push_up", 1)
|
| 182 |
+
push_up = _exercise_strategy("push_up", sequence)
|
| 183 |
self.assertIsInstance(push_up, PushUpExercise)
|
| 184 |
+
self.assertIsInstance(_exercise_strategy("shoulder_press", sequence), ShoulderPressExercise)
|
| 185 |
+
self.assertIsInstance(_exercise_strategy("squat", sequence), SquatExercise)
|
| 186 |
+
self.assertIsNot(push_up, _exercise_strategy("push_up", sequence))
|
| 187 |
|
| 188 |
|
| 189 |
if __name__ == "__main__":
|