NLag commited on
Commit
a03121e
·
2 Parent(s): 6beb9c75b7c9ce

Merge remote issue 4 updates

Browse files
Files changed (39) hide show
  1. README.md +43 -20
  2. app.py +16 -139
  3. src/pozify/exercises/__init__.py +4 -0
  4. src/pozify/exercises/base.py +410 -0
  5. src/pozify/exercises/push_up/__init__.py +5 -0
  6. src/pozify/exercises/push_up/analyzer.py +117 -0
  7. src/pozify/exercises/push_up/issue_markers.py +50 -0
  8. src/pozify/{steps/rep_counters/push_up.py → exercises/push_up/strategy.py} +39 -4
  9. src/pozify/exercises/registry.py +31 -0
  10. src/pozify/exercises/shared/__init__.py +5 -0
  11. src/pozify/exercises/shared/analyzer.py +135 -0
  12. src/pozify/exercises/shared/issue_marker.py +192 -0
  13. src/pozify/{steps/rep_counters/base.py → exercises/shared/rep_counter.py} +5 -4
  14. src/pozify/exercises/shoulder_press/__init__.py +5 -0
  15. src/pozify/exercises/shoulder_press/analyzer.py +89 -0
  16. src/pozify/exercises/shoulder_press/issue_markers.py +44 -0
  17. src/pozify/{steps/rep_counters/shoulder_press.py → exercises/shoulder_press/strategy.py} +38 -4
  18. src/pozify/exercises/squat/__init__.py +5 -0
  19. src/pozify/exercises/squat/analyzer.py +126 -0
  20. src/pozify/exercises/squat/issue_markers.py +58 -0
  21. src/pozify/{steps/rep_counters/squat.py → exercises/squat/strategy.py} +37 -4
  22. src/pozify/exercises/unknown/__init__.py +4 -0
  23. src/pozify/exercises/unknown/analyzer.py +26 -0
  24. src/pozify/{steps/rep_counters/registry.py → exercises/unknown/strategy.py} +14 -20
  25. src/pozify/pipeline.py +19 -14
  26. src/pozify/steps/annotated_renderer.py +214 -3
  27. src/pozify/steps/coach_summary.py +8 -8
  28. src/pozify/steps/issue_marker.py +0 -53
  29. src/pozify/steps/rep_analysis.py +0 -58
  30. src/pozify/steps/rep_counter.py +0 -10
  31. src/pozify/steps/rep_counters/__init__.py +0 -5
  32. src/pozify/steps/variation_detector.py +0 -33
  33. tests/test_annotated_renderer.py +20 -0
  34. tests/test_issue_marker.py +197 -0
  35. tests/test_pipeline_contracts.py +7 -0
  36. tests/test_rep_analysis_variation.py +293 -0
  37. tests/test_rep_counter.py +60 -29
  38. web/app.js +51 -0
  39. web/styles.css +78 -1
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. The model and computer-vision steps are intentionally mocked so the team can replace each step with real implementations without changing the pipeline interface.
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 in mock mode by default. To be explicit in scripts, call
41
- `run_pipeline(..., mock=True)` or set:
42
 
43
  ```bash
44
  POZIFY_MOCK_MODE=1 uv run python app.py
45
  ```
46
 
47
- To run the end-to-end app with real video QC, MediaPipe pose extraction, real rep segmentation, and
48
- annotated video rendering, set:
49
 
50
  ```bash
51
  POZIFY_MOCK_MODE=0 uv run python app.py
@@ -92,6 +92,10 @@ user profile + input video
92
  -> final report
93
  ```
94
 
 
 
 
 
95
  ## Project Structure
96
 
97
  ```text
@@ -100,15 +104,28 @@ src/pozify/
100
  contracts.py
101
  pipeline.py
102
  artifacts.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  steps/
104
  video_qc.py
105
  pose_landmarker.py
106
  pose_cleaning.py
107
  exercise_classifier.py
108
- rep_counter.py
109
- rep_analysis.py
110
- variation_detector.py
111
- issue_marker.py
112
  annotated_renderer.py
113
  coach_summary.py
114
  verifier.py
@@ -168,28 +185,34 @@ Each step lives in `src/pozify/steps/` and exposes a `run(...)` function.
168
 
169
  ## Adding Exercises
170
 
171
- Supported exercise metadata is centralized in `src/pozify/exercise_catalog.py`. To add a new mocked
172
  exercise, add one `ExerciseSpec` with:
173
 
174
  - `key` and `display_name`
175
  - `default_variation` and confidence
176
- - `metric_factory` for mock per-rep metrics
177
  - optional `variation_hints`, default `not_issues`, and `mock_issue`
178
 
179
  The Gradio dropdown, profile validation, variation detector, rep analysis, and issue marker read from
180
  this catalog, so new exercises do not require changing each step just to become selectable. Real
181
- exercise-specific algorithms can still replace individual step implementations later while preserving
182
- the same pipeline contracts.
 
 
 
 
 
183
 
184
  Recommended replacement order:
185
 
186
  1. `video_qc.py`: read real video metadata with OpenCV.
187
  2. `pose_backends/`: add or refine pose model adapters such as MediaPipe or MMPose.
188
  3. `exercise_classifier.py`: load the exercise router model.
189
- 4. `rep_counter.py`: implement exercise-specific state machines.
190
- 5. `rep_analysis.py` and `issue_marker.py`: compute real metrics and issue scores.
191
- 6. `annotated_renderer.py`: render skeleton overlays and issue highlights.
192
- 7. `coach_summary.py`: call the selected small language model with retrieved knowledge cards.
 
193
 
194
  ## Exercise Router Training
195
 
@@ -253,12 +276,12 @@ classes from the Riccio dataset, such as bicep curl, are mapped to `unknown`.
253
 
254
  ```bash
255
  uv run python -m unittest discover -s tests
256
- python3 -m py_compile app.py src/pozify/*.py src/pozify/steps/*.py src/pozify/ml/*.py scripts/*.py
257
  uv run python -c "import app; from pozify.pipeline import run_pipeline; print('ok')"
258
  ```
259
 
260
- The unit tests run the full mocked pipeline with no video input and with a small fixture path, then
261
- assert deterministic top-level keys for each JSON artifact.
262
 
263
  ## Git Hooks
264
 
 
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
 
101
  ```text
 
104
  contracts.py
105
  pipeline.py
106
  artifacts.py
107
+ exercises/
108
+ push_up/
109
+ strategy.py
110
+ analyzer.py
111
+ issue_markers.py
112
+ squat/
113
+ strategy.py
114
+ analyzer.py
115
+ issue_markers.py
116
+ shoulder_press/
117
+ strategy.py
118
+ analyzer.py
119
+ issue_markers.py
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
 
185
 
186
  ## Adding Exercises
187
 
188
+ Supported exercise metadata is centralized in `src/pozify/exercise_catalog.py`. To add a new
189
  exercise, add one `ExerciseSpec` with:
190
 
191
  - `key` and `display_name`
192
  - `default_variation` and confidence
193
+ - `metric_factory` for legacy/mock fallback metadata
194
  - optional `variation_hints`, default `not_issues`, and `mock_issue`
195
 
196
  The Gradio dropdown, profile validation, variation detector, rep analysis, and issue marker read from
197
  this catalog, so new exercises do not require changing each step just to become selectable. Real
198
+ exercise-specific code lives under `src/pozify/exercises/<exercise>/`:
199
+
200
+ - `strategy.py`: rep-counting signal, variation logic, and exercise strategy wiring
201
+ - `analyzer.py`: per-rep metrics
202
+ - `issue_markers.py`: frame-level issue rules
203
+
204
+ Shared analyzer and issue marker primitives live under `src/pozify/exercises/shared/`.
205
 
206
  Recommended replacement order:
207
 
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
  ## Exercise Router Training
218
 
 
276
 
277
  ```bash
278
  uv run python -m unittest discover -s tests
279
+ python3 -m py_compile app.py src/pozify/*.py src/pozify/steps/*.py src/pozify/exercises/*.py src/pozify/exercises/*/*.py
280
  uv run python -c "import app; from pozify.pipeline import run_pipeline; print('ok')"
281
  ```
282
 
283
+ The unit tests run the full mocked pipeline explicitly, then assert deterministic top-level keys for
284
+ each JSON artifact.
285
 
286
  ## Git Hooks
287
 
app.py CHANGED
@@ -27,141 +27,6 @@ APP_DESCRIPTION = (
27
  )
28
 
29
 
30
- QUALITY_GUIDANCE = {
31
- "too_short": "Record at least 10 seconds so the set contains enough movement context.",
32
- "too_long": "Keep the clip under 60 seconds for the MVP analyzer.",
33
- "too_dark": "Use brighter, even lighting and keep the body visible against the background.",
34
- "too_blurry": "Stabilize the camera and avoid fast panning or heavy motion blur.",
35
- "fps_too_low": "Use a camera mode with at least 15 FPS.",
36
- "resolution_too_low": "Record at 480x360 or higher so joint positions are readable.",
37
- "video_decode_failed": "Upload a playable video file; the current file could not be decoded.",
38
- }
39
-
40
-
41
- def _pretty_json(value: dict[str, Any]) -> str:
42
- return json.dumps(value, ensure_ascii=False, indent=2)
43
-
44
-
45
- def _quality_markdown(video_manifest: dict[str, Any]) -> str:
46
- warnings = video_manifest["quality_warnings"]
47
- if not warnings:
48
- return "## Video Quality\n\nNo quality warnings detected."
49
-
50
- warning_items = "\n".join(f"- `{warning}`: {QUALITY_GUIDANCE[warning]}" for warning in warnings)
51
- status = (
52
- "Analysis is blocked until the video can be decoded reliably."
53
- if not video_manifest["analysis_allowed"]
54
- else "Analysis completed, but capture quality may affect downstream feedback."
55
- )
56
- return f"""## Capture Quality
57
-
58
- {status}
59
-
60
- {warning_items}
61
- """
62
-
63
-
64
- def _mock_status_markdown(report: dict[str, Any]) -> str:
65
- mock_steps = report["artifacts"].get("mock_steps", [])
66
- if not mock_steps:
67
- return ""
68
- steps = ", ".join(f"`{step}`" for step in mock_steps)
69
- return (
70
- "## Pipeline Status\n\n"
71
- "The current run uses real video QC, pose extraction, rep segmentation, and annotated video rendering, "
72
- f"but these steps still use placeholders: {steps}."
73
- )
74
-
75
-
76
- def analyze_video(
77
- video_path: str | None,
78
- goal: str,
79
- experience_level: str,
80
- intended_exercise: str,
81
- intended_variation: str,
82
- limitations: list[str],
83
- equipment: str,
84
- ) -> tuple[str | None, str, str, str]:
85
- result = run_pipeline(
86
- video_path=video_path,
87
- profile_input={
88
- "goal": goal,
89
- "experience_level": experience_level,
90
- "intended_exercise": intended_exercise,
91
- "intended_variation": intended_variation or None,
92
- "known_limitations": limitations,
93
- "equipment": equipment,
94
- },
95
- )
96
-
97
- report = result["final_report"]
98
- video_quality = _quality_markdown(report["video_manifest"])
99
- mock_status = _mock_status_markdown(report)
100
- summary = report["coach_summary"]
101
- if not report["video_manifest"]["analysis_allowed"]:
102
- markdown = f"""{video_quality}
103
-
104
- {mock_status}
105
-
106
- ## Run
107
-
108
- - **Run ID:** `{report["run_id"]}`
109
- - **Saved report:** `{Path(result["run_dir"]) / "final_report.json"}`
110
- """
111
- artifact_path = Path(result["run_dir"]) / "final_report.json"
112
- return (
113
- result["annotated_video_path"],
114
- markdown,
115
- _pretty_json(report),
116
- str(artifact_path),
117
- )
118
-
119
- fallback_note = (
120
- "\nRouter confidence is low or window predictions disagree. Select the exercise manually and rerun.\n"
121
- if report["exercise"]["fallback_required"]
122
- else ""
123
- )
124
- markdown = f"""## Scan Summary
125
-
126
- | Signal | Result |
127
- | --- | --- |
128
- | Exercise router | {report["exercise"]["exercise"]} ({report["exercise"]["confidence"]:.0%}) |
129
- | Router fallback required | {report["exercise"]["fallback_required"]} |
130
- | Variation | {report["variation"]["detected_variation"]} ({report["variation"]["variation_confidence"]:.0%}) |
131
- | Reps | {len(report["reps"]["reps"])} |
132
- | Analysis mode | {report["artifacts"].get("analysis_mode", "unknown")} |
133
- | Pose source | {report["artifacts"].get("pose_source", "unknown")} |
134
- | Primary finding | {summary["main_findings"][0] if summary["main_findings"] else "No finding emitted"} |
135
- | Run ID | `{report["run_id"]}` |
136
- {fallback_note}
137
-
138
- {mock_status}
139
-
140
- ## Coach Notes
141
-
142
- {summary["summary"]}
143
-
144
- ### What Went Well
145
- {chr(10).join(f"- {item}" for item in summary["what_went_well"])}
146
-
147
- ### Top Fixes
148
- {chr(10).join(f"- {item}" for item in summary["top_fixes"])}
149
-
150
- ### Next Session Plan
151
- {chr(10).join(f"- {item}" for item in summary["next_session_plan"])}
152
-
153
- {video_quality}
154
- """
155
-
156
- artifact_path = Path(result["run_dir"]) / "final_report.json"
157
- return (
158
- result["annotated_video_path"],
159
- markdown,
160
- _pretty_json(report),
161
- str(artifact_path),
162
- )
163
-
164
-
165
  server = gr.Server(
166
  title="Pozify",
167
  summary="Video-based workout form review API",
@@ -184,7 +49,13 @@ def index() -> FileResponse:
184
  def config() -> dict[str, Any]:
185
  return {
186
  "description": APP_DESCRIPTION,
187
- "goals": ["strength", "hypertrophy", "endurance", "mobility", "beginner_practice"],
 
 
 
 
 
 
188
  "experience_levels": ["beginner", "intermediate"],
189
  "exercises": ["auto", *USER_SELECTABLE_EXERCISES],
190
  "limitations": ["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
@@ -225,12 +96,16 @@ async def analyze_api(
225
  try:
226
  parsed_limitations = json.loads(limitations)
227
  except json.JSONDecodeError as exc:
228
- raise HTTPException(status_code=400, detail="Limitations must be valid JSON.") from exc
 
 
229
 
230
  if not isinstance(parsed_limitations, list) or not all(
231
  isinstance(item, str) for item in parsed_limitations
232
  ):
233
- raise HTTPException(status_code=400, detail="Limitations must be a JSON list of strings.")
 
 
234
 
235
  video_path: str | None = None
236
  if video is not None and video.filename:
@@ -258,7 +133,9 @@ async def analyze_api(
258
  return {
259
  "run_id": result["run_id"],
260
  "run_dir": result["run_dir"],
261
- "annotated_video_url": _artifact_url(result["run_id"], result["annotated_video_path"]),
 
 
262
  "final_report_url": f"/api/artifacts/{result['run_id']}/final_report.json",
263
  "report": result["final_report"],
264
  }
 
27
  )
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  server = gr.Server(
31
  title="Pozify",
32
  summary="Video-based workout form review API",
 
49
  def config() -> dict[str, Any]:
50
  return {
51
  "description": APP_DESCRIPTION,
52
+ "goals": [
53
+ "strength",
54
+ "hypertrophy",
55
+ "endurance",
56
+ "mobility",
57
+ "beginner_practice",
58
+ ],
59
  "experience_levels": ["beginner", "intermediate"],
60
  "exercises": ["auto", *USER_SELECTABLE_EXERCISES],
61
  "limitations": ["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
 
96
  try:
97
  parsed_limitations = json.loads(limitations)
98
  except json.JSONDecodeError as exc:
99
+ raise HTTPException(
100
+ status_code=400, detail="Limitations must be valid JSON."
101
+ ) from exc
102
 
103
  if not isinstance(parsed_limitations, list) or not all(
104
  isinstance(item, str) for item in parsed_limitations
105
  ):
106
+ raise HTTPException(
107
+ status_code=400, detail="Limitations must be a JSON list of strings."
108
+ )
109
 
110
  video_path: str | None = None
111
  if video is not None and video.filename:
 
133
  return {
134
  "run_id": result["run_id"],
135
  "run_dir": result["run_dir"],
136
+ "annotated_video_url": _artifact_url(
137
+ result["run_id"], result["annotated_video_path"]
138
+ ),
139
  "final_report_url": f"/api/artifacts/{result['run_id']}/final_report.json",
140
  "report": result["final_report"],
141
  }
src/pozify/exercises/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from pozify.exercises.base import ExerciseStrategy
2
+ from pozify.exercises.registry import create_exercise_strategy
3
+
4
+ __all__ = ["ExerciseStrategy", "create_exercise_strategy"]
src/pozify/exercises/base.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
56
+ def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
57
+ raise NotImplementedError
58
+
59
+ def profile_not_issues(self, variation: str) -> list[str]:
60
+ return []
61
+
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)):
311
+ return float(value)
312
+ return None
313
+
314
+ def confidence(
315
+ self,
316
+ base: float,
317
+ analysis: RepAnalysis,
318
+ supporting_metric: float | None,
319
+ ) -> float:
320
+ rep_bonus = min(0.12, len(analysis.items) * 0.03)
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/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from pozify.exercises.push_up.analyzer import PushUpAnalyzer
2
+ from pozify.exercises.push_up.issue_markers import RULES as ISSUE_RULES
3
+ from pozify.exercises.push_up.strategy import PushUpExercise
4
+
5
+ __all__ = ["ISSUE_RULES", "PushUpAnalyzer", "PushUpExercise"]
src/pozify/exercises/push_up/analyzer.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pozify.contracts import PoseFrame
4
+ from pozify.exercises.shared.analyzer import (
5
+ ExerciseMetricResult,
6
+ max_optional,
7
+ mean_optional,
8
+ mean_pair,
9
+ min_optional,
10
+ range_optional,
11
+ round_optional,
12
+ safe_ratio,
13
+ score,
14
+ side_delta,
15
+ std_optional,
16
+ value_series,
17
+ width,
18
+ )
19
+ from pozify.steps.rep_signals import average_axis, body_line_score
20
+
21
+
22
+ class PushUpAnalyzer:
23
+ def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
24
+ elbow_angles = value_series(
25
+ frames,
26
+ lambda frame: mean_pair(
27
+ frame,
28
+ ("left_shoulder", "left_elbow", "left_wrist"),
29
+ ("right_shoulder", "right_elbow", "right_wrist"),
30
+ ),
31
+ )
32
+ elbow_deltas = value_series(
33
+ frames,
34
+ lambda frame: side_delta(
35
+ frame,
36
+ ("left_shoulder", "left_elbow", "left_wrist"),
37
+ ("right_shoulder", "right_elbow", "right_wrist"),
38
+ ),
39
+ )
40
+ body_line = value_series(frames, body_line_score)
41
+ shoulder_y = value_series(
42
+ frames,
43
+ lambda frame: average_axis(frame, ("left_shoulder", "right_shoulder"), "y"),
44
+ )
45
+ hip_y = value_series(frames, lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"))
46
+ ankle_y = value_series(
47
+ frames,
48
+ lambda frame: average_axis(frame, ("left_ankle", "right_ankle"), "y"),
49
+ )
50
+ hand_width = value_series(frames, lambda frame: width(frame, "left_wrist", "right_wrist"))
51
+ shoulder_width = value_series(
52
+ frames,
53
+ lambda frame: width(frame, "left_shoulder", "right_shoulder"),
54
+ )
55
+ elbow_width = value_series(frames, lambda frame: width(frame, "left_elbow", "right_elbow"))
56
+ knee_y = value_series(
57
+ frames,
58
+ lambda frame: average_axis(frame, ("left_knee", "right_knee"), "y"),
59
+ )
60
+
61
+ min_elbow = min_optional(elbow_angles)
62
+ max_elbow = max_optional(elbow_angles)
63
+ elbow_rom = 0.0 if min_elbow is None or max_elbow is None else max_elbow - min_elbow
64
+ chest_depth = range_optional(shoulder_y) or 0.0
65
+ hand_width_ratio = safe_ratio(mean_optional(hand_width), mean_optional(shoulder_width))
66
+ elbow_flare = safe_ratio(mean_optional(elbow_width), mean_optional(shoulder_width))
67
+ body_line_mean = mean_optional(body_line)
68
+ hip_sag_score = None
69
+ if body_line_mean is not None:
70
+ hip_sag_score = max(0.0, 1.0 - body_line_mean)
71
+ knee_support_score = self._knee_support_score(hip_y, knee_y, ankle_y)
72
+
73
+ rom_score = score((elbow_rom / 80.0) * 0.65 + (chest_depth / 0.16) * 0.35)
74
+ hip_stability = 1.0 - min(1.0, (std_optional(hip_y) or 0.0) * 4.0)
75
+ stability_score = score(((body_line_mean or 0.5) * 0.75) + hip_stability * 0.25)
76
+ symmetry_score = score(1.0 - ((mean_optional(elbow_deltas) or 0.0) / 45.0))
77
+
78
+ metrics = {
79
+ "min_elbow_angle_deg": round_optional(min_elbow),
80
+ "max_elbow_angle_deg": round_optional(max_elbow),
81
+ "body_line_score": round_optional(body_line_mean),
82
+ "hip_sag_score": round_optional(hip_sag_score),
83
+ "hip_pike_score": round_optional(max(0.0, ((body_line_mean or 1.0) - 1.0) * -1.0), 4),
84
+ "chest_depth_proxy": round_optional(chest_depth, 4),
85
+ "hand_width_ratio": round_optional(hand_width_ratio, 3),
86
+ "elbow_flare_ratio": round_optional(elbow_flare, 3),
87
+ "lockout_quality": score(((max_elbow or 120.0) - 120.0) / 55.0),
88
+ "knee_support_score": knee_support_score,
89
+ }
90
+ hints = []
91
+ if hand_width_ratio is not None and hand_width_ratio > 1.45:
92
+ hints.append("wide_grip_push_up")
93
+ elif hand_width_ratio is not None and hand_width_ratio < 0.95:
94
+ hints.append("close_grip_push_up")
95
+ if knee_support_score >= 0.8:
96
+ hints.append("knee_push_up")
97
+ return metrics, rom_score, stability_score, symmetry_score, hints
98
+
99
+ def _knee_support_score(
100
+ self,
101
+ hip_y: list[float | None],
102
+ knee_y: list[float | None],
103
+ ankle_y: list[float | None],
104
+ ) -> float:
105
+ scores: list[float] = []
106
+ for hip, knee, ankle in zip(hip_y, knee_y, ankle_y, strict=False):
107
+ if hip is None or knee is None or ankle is None:
108
+ continue
109
+
110
+ hip_to_ankle = abs(ankle - hip)
111
+ if hip_to_ankle <= 1e-6:
112
+ continue
113
+ knee_to_hip = abs(knee - hip)
114
+ bent_leg_score = score(1.0 - knee_to_hip / max(hip_to_ankle * 0.55, 0.01))
115
+ scores.append(bent_leg_score)
116
+
117
+ return score(mean_optional(scores) or 0.0)
src/pozify/exercises/push_up/issue_markers.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pozify.contracts import PoseFrame
4
+ from pozify.exercises.shared.analyzer import mean_optional
5
+ from pozify.exercises.shared.issue_marker import IssueRule
6
+ from pozify.steps.rep_signals import angle_deg, body_line_score
7
+
8
+
9
+ def _elbow_angle(frame: PoseFrame) -> float | None:
10
+ return mean_optional(
11
+ [
12
+ angle_deg(frame, "left_shoulder", "left_elbow", "left_wrist"),
13
+ angle_deg(frame, "right_shoulder", "right_elbow", "right_wrist"),
14
+ ]
15
+ )
16
+
17
+
18
+ RULES: tuple[IssueRule, ...] = (
19
+ IssueRule(
20
+ issue="hip_sag",
21
+ metric_name="body_line_score",
22
+ threshold=0.65,
23
+ comparison="lt",
24
+ affected_joints=(
25
+ "left_hip",
26
+ "right_hip",
27
+ "left_shoulder",
28
+ "right_shoulder",
29
+ "left_ankle",
30
+ "right_ankle",
31
+ ),
32
+ getter=body_line_score,
33
+ ),
34
+ IssueRule(
35
+ issue="incomplete_depth",
36
+ metric_name="elbow_angle_deg",
37
+ threshold=115.0,
38
+ comparison="gt",
39
+ affected_joints=(
40
+ "left_shoulder",
41
+ "right_shoulder",
42
+ "left_elbow",
43
+ "right_elbow",
44
+ "left_wrist",
45
+ "right_wrist",
46
+ ),
47
+ getter=_elbow_angle,
48
+ phase="bottom",
49
+ ),
50
+ )
src/pozify/{steps/rep_counters/push_up.py → exercises/push_up/strategy.py} RENAMED
@@ -2,15 +2,23 @@ from __future__ import annotations
2
 
3
  from typing import Any
4
 
5
- from pozify.contracts import PoseSequence
6
- from pozify.steps.rep_counters.base import ExerciseRepCounter, combine, mean_optional, normalized_samples
 
 
 
 
7
  from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
8
 
9
 
10
- class PushUpRepCounter(ExerciseRepCounter):
11
  exercise = "push_up"
12
 
13
- def build_signal(self, sequence: PoseSequence) -> tuple[list[SignalSample], dict[str, Any]]:
 
 
 
 
14
  hip_y = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in sequence.frames]
15
  shoulder_y = [average_axis(frame, ("left_shoulder", "right_shoulder"), "y") for frame in sequence.frames]
16
  elbow_bend = [
@@ -38,3 +46,30 @@ class PushUpRepCounter(ExerciseRepCounter):
38
  "body_line_mean": round(mean_optional(body_line) or 0.0, 4),
39
  }
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
14
+ class PushUpExercise(PushUpAnalyzer, ExerciseStrategy):
15
  exercise = "push_up"
16
 
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 = [
 
46
  "body_line_mean": round(mean_optional(body_line) or 0.0, 4),
47
  }
48
 
49
+ def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
50
+ hand_width = self.metric(analysis, "avg_hand_width_ratio")
51
+ knee_support = self.metric(analysis, "avg_knee_support_score")
52
+ not_issues: list[str] = []
53
+
54
+ if knee_support is not None and knee_support >= 0.8:
55
+ return "knee_push_up", self.confidence(0.74, analysis, knee_support), ["knee_contact"]
56
+ if hand_width is not None and hand_width >= 1.45:
57
+ return "wide_grip_push_up", self.confidence(0.72, analysis, hand_width), ["wide_hand_placement"]
58
+ if hand_width is not None and hand_width <= 0.95:
59
+ return (
60
+ "close_grip_push_up",
61
+ self.confidence(0.72, analysis, 1.0 - hand_width),
62
+ ["close_hand_placement"],
63
+ )
64
+
65
+ if hand_width is None:
66
+ not_issues.append("hand_width_unverified")
67
+ return "standard_push_up", self.confidence(0.62, analysis, hand_width), not_issues
68
+
69
+ def profile_not_issues(self, variation: str) -> list[str]:
70
+ mapping = {
71
+ "wide_grip_push_up": ["wide_hand_placement"],
72
+ "close_grip_push_up": ["close_hand_placement"],
73
+ "knee_push_up": ["knee_contact"],
74
+ }
75
+ return list(mapping.get(variation, []))
src/pozify/exercises/registry.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
7
+ from pozify.exercises.squat import SquatExercise
8
+ from pozify.exercises.unknown import UnknownExercise
9
+
10
+
11
+ EXERCISE_CLASSES: dict[str, type[ExerciseStrategy]] = {
12
+ "push_up": PushUpExercise,
13
+ "shoulder_press": ShoulderPressExercise,
14
+ "squat": SquatExercise,
15
+ "unknown": UnknownExercise,
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 ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
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/exercises/shared/analyzer.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from statistics import pstdev
4
+ from typing import Any, Callable, Protocol
5
+
6
+ from pozify.contracts import PoseFrame
7
+ from pozify.steps.rep_signals import angle_deg, average_axis, landmark_axis
8
+
9
+
10
+ NumberGetter = Callable[[PoseFrame], float | None]
11
+ ExerciseMetricResult = tuple[dict[str, Any], float, float, float, list[str]]
12
+
13
+
14
+ class ExerciseAnalyzer(Protocol):
15
+ def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
16
+ ...
17
+
18
+
19
+ def round_optional(value: float | None, digits: int = 2) -> float | None:
20
+ if value is None:
21
+ return None
22
+ return round(value, digits)
23
+
24
+
25
+ def score(value: float) -> float:
26
+ return round(min(1.0, max(0.0, value)), 2)
27
+
28
+
29
+ def usable(values: list[float | None]) -> list[float]:
30
+ return [value for value in values if value is not None]
31
+
32
+
33
+ def mean_optional(values: list[float | None]) -> float | None:
34
+ usable_values = usable(values)
35
+ if not usable_values:
36
+ return None
37
+ return sum(usable_values) / len(usable_values)
38
+
39
+
40
+ def min_optional(values: list[float | None]) -> float | None:
41
+ usable_values = usable(values)
42
+ return min(usable_values) if usable_values else None
43
+
44
+
45
+ def max_optional(values: list[float | None]) -> float | None:
46
+ usable_values = usable(values)
47
+ return max(usable_values) if usable_values else None
48
+
49
+
50
+ def range_optional(values: list[float | None]) -> float | None:
51
+ usable_values = usable(values)
52
+ if not usable_values:
53
+ return None
54
+ return max(usable_values) - min(usable_values)
55
+
56
+
57
+ def std_optional(values: list[float | None]) -> float | None:
58
+ usable_values = usable(values)
59
+ if len(usable_values) < 2:
60
+ return 0.0 if usable_values else None
61
+ return pstdev(usable_values)
62
+
63
+
64
+ def safe_ratio(numerator: float | None, denominator: float | None) -> float | None:
65
+ if numerator is None or denominator is None or abs(denominator) <= 1e-6:
66
+ return None
67
+ return numerator / denominator
68
+
69
+
70
+ def width(frame: PoseFrame, left: str, right: str) -> float | None:
71
+ left_x = landmark_axis(frame, left, "x")
72
+ right_x = landmark_axis(frame, right, "x")
73
+ if left_x is None or right_x is None:
74
+ return None
75
+ return abs(right_x - left_x)
76
+
77
+
78
+ def mean_pair(
79
+ frame: PoseFrame,
80
+ first: tuple[str, str, str],
81
+ second: tuple[str, str, str],
82
+ ) -> float | None:
83
+ values = [angle_deg(frame, *first), angle_deg(frame, *second)]
84
+ return mean_optional(values)
85
+
86
+
87
+ def side_delta(
88
+ frame: PoseFrame,
89
+ first: tuple[str, str, str],
90
+ second: tuple[str, str, str],
91
+ ) -> float | None:
92
+ first_value = angle_deg(frame, *first)
93
+ second_value = angle_deg(frame, *second)
94
+ if first_value is None or second_value is None:
95
+ return None
96
+ return abs(first_value - second_value)
97
+
98
+
99
+ def torso_lean_deg(frame: PoseFrame, side: str) -> float | None:
100
+ shoulder_x = landmark_axis(frame, f"{side}_shoulder", "x")
101
+ shoulder_y = landmark_axis(frame, f"{side}_shoulder", "y")
102
+ hip_x = landmark_axis(frame, f"{side}_hip", "x")
103
+ hip_y = landmark_axis(frame, f"{side}_hip", "y")
104
+ if None in {shoulder_x, shoulder_y, hip_x, hip_y}:
105
+ return None
106
+ horizontal_offset = abs(float(shoulder_x) - float(hip_x))
107
+ vertical_offset = abs(float(shoulder_y) - float(hip_y))
108
+ if vertical_offset <= 1e-6:
109
+ return None
110
+ from math import atan2, degrees
111
+
112
+ return degrees(atan2(horizontal_offset, vertical_offset))
113
+
114
+
115
+ def value_series(frames: list[PoseFrame], getter: NumberGetter) -> list[float | None]:
116
+ return [getter(frame) for frame in frames]
117
+
118
+
119
+ def mean_visibility(frames: list[PoseFrame]) -> float:
120
+ values: list[float | None] = []
121
+ for frame in frames:
122
+ if "mean_visibility" in frame.pose_quality:
123
+ values.append(float(frame.pose_quality["mean_visibility"]))
124
+ continue
125
+ landmark_values = [
126
+ landmark.get("visibility")
127
+ for landmark in frame.landmarks.values()
128
+ if landmark.get("visibility") is not None
129
+ ]
130
+ values.extend(float(value) for value in landmark_values)
131
+ return score(mean_optional(values) if values else 0.0)
132
+
133
+
134
+ def average_y(frame: PoseFrame, names: tuple[str, ...]) -> float | None:
135
+ return average_axis(frame, names, "y")
src/pozify/exercises/shared/issue_marker.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from statistics import mean
5
+ from typing import Callable, Literal
6
+
7
+ from pozify.contracts import IssueMarker, PoseFrame, PoseSequence, Rep, RepAnalysisItem, Variation
8
+ from pozify.steps.rep_signals import average_axis, smooth_signal
9
+
10
+
11
+ Comparison = Literal["lt", "gt"]
12
+ Phase = Literal["all", "bottom", "top"]
13
+ MetricGetter = Callable[[PoseFrame], float | None]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class IssueRule:
18
+ issue: str
19
+ metric_name: str
20
+ threshold: float
21
+ comparison: Comparison
22
+ affected_joints: tuple[str, ...]
23
+ getter: MetricGetter
24
+ phase: Phase = "all"
25
+ suppress_when_not_issue: tuple[str, ...] = ()
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class FrameScore:
30
+ frame: PoseFrame
31
+ value: float
32
+ severity: float
33
+
34
+
35
+ def frames_for_rep(sequence: PoseSequence | None, rep: Rep) -> list[PoseFrame]:
36
+ if sequence is None:
37
+ return []
38
+ return [
39
+ frame
40
+ for frame in sequence.frames
41
+ if rep.start_frame <= frame.frame_index <= rep.end_frame
42
+ ]
43
+
44
+
45
+ def phase_mask(frames: list[PoseFrame], exercise: str, phase: Phase) -> list[bool]:
46
+ if phase == "all":
47
+ return [True for _ in frames]
48
+
49
+ if exercise == "shoulder_press":
50
+ values = [average_axis(frame, ("left_wrist", "right_wrist"), "y") for frame in frames]
51
+ top_is_low = True
52
+ elif exercise == "push_up":
53
+ values = [
54
+ average_axis(frame, ("left_shoulder", "right_shoulder"), "y") for frame in frames
55
+ ]
56
+ top_is_low = False
57
+ else:
58
+ values = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in frames]
59
+ top_is_low = False
60
+
61
+ usable_values = [value for value in values if value is not None]
62
+ if not usable_values:
63
+ return [False for _ in frames]
64
+
65
+ minimum = min(usable_values)
66
+ maximum = max(usable_values)
67
+ value_range = maximum - minimum
68
+ if value_range <= 1e-6:
69
+ return [True for _ in frames]
70
+
71
+ if phase == "top":
72
+ cutoff = minimum + value_range * 0.35 if top_is_low else maximum - value_range * 0.35
73
+ return [
74
+ value is not None and (value <= cutoff if top_is_low else value >= cutoff)
75
+ for value in values
76
+ ]
77
+
78
+ cutoff = maximum - value_range * 0.35 if not top_is_low else minimum + value_range * 0.35
79
+ return [
80
+ value is not None and (value >= cutoff if not top_is_low else value <= cutoff)
81
+ for value in values
82
+ ]
83
+
84
+
85
+ def violates(value: float, rule: IssueRule) -> bool:
86
+ if rule.comparison == "lt":
87
+ return value < rule.threshold
88
+ return value > rule.threshold
89
+
90
+
91
+ def severity(value: float, rule: IssueRule) -> float:
92
+ denominator = max(abs(rule.threshold), 1e-6)
93
+ if rule.comparison == "lt":
94
+ return min(1.0, max(0.0, (rule.threshold - value) / denominator))
95
+ return min(1.0, max(0.0, (value - rule.threshold) / denominator))
96
+
97
+
98
+ def minimum_run_length(frames: list[PoseFrame]) -> int:
99
+ return max(2, min(5, round(len(frames) * 0.08)))
100
+
101
+
102
+ def group_violations(
103
+ scores: list[FrameScore | None],
104
+ minimum_length: int,
105
+ ) -> list[list[FrameScore]]:
106
+ groups: list[list[FrameScore]] = []
107
+ current: list[FrameScore] = []
108
+ for score_item in scores:
109
+ if score_item is None:
110
+ if len(current) >= minimum_length:
111
+ groups.append(current)
112
+ current = []
113
+ continue
114
+ current.append(score_item)
115
+
116
+ if len(current) >= minimum_length:
117
+ groups.append(current)
118
+ return groups
119
+
120
+
121
+ def confidence(group: list[FrameScore], rep_item: RepAnalysisItem, variation: Variation) -> float:
122
+ visibility_values = [
123
+ frame_score.frame.pose_quality.get("mean_visibility")
124
+ for frame_score in group
125
+ if frame_score.frame.pose_quality.get("mean_visibility") is not None
126
+ ]
127
+ visibility = mean(float(value) for value in visibility_values) if visibility_values else 0.6
128
+ support = mean(frame_score.severity for frame_score in group)
129
+ value = (visibility * 0.5) + (support * 0.3) + (variation.variation_confidence * 0.2)
130
+ value *= max(0.5, min(1.0, rep_item.stability_score))
131
+ return round(min(1.0, max(0.0, value)), 2)
132
+
133
+
134
+ def marker_from_group(
135
+ rule: IssueRule,
136
+ group: list[FrameScore],
137
+ rep_item: RepAnalysisItem,
138
+ variation: Variation,
139
+ ) -> IssueMarker:
140
+ metric_values = [frame_score.value for frame_score in group]
141
+ peak_score = max(group, key=lambda frame_score: frame_score.severity)
142
+ marker_severity = round(
143
+ min(1.0, max(0.0, mean(frame_score.severity for frame_score in group))),
144
+ 2,
145
+ )
146
+ start = group[0].frame
147
+ end = group[-1].frame
148
+ evidence = {
149
+ rule.metric_name: round(peak_score.value, 4),
150
+ "threshold": rule.threshold,
151
+ "confidence": confidence(group, rep_item, variation),
152
+ "variation_context": {
153
+ "detected_variation": variation.detected_variation,
154
+ "variation_confidence": variation.variation_confidence,
155
+ "not_issues": list(variation.not_issues),
156
+ },
157
+ "mean_metric_value": round(mean(metric_values), 4),
158
+ "supporting_frames": len(group),
159
+ }
160
+ if rep_item.variation_hints:
161
+ evidence["rep_variation_hints"] = list(rep_item.variation_hints)
162
+
163
+ return IssueMarker(
164
+ rep_id=rep_item.rep_id,
165
+ issue=rule.issue,
166
+ severity=marker_severity,
167
+ start_frame=start.frame_index,
168
+ end_frame=end.frame_index,
169
+ start_sec=start.timestamp_sec,
170
+ end_sec=end.timestamp_sec,
171
+ affected_joints=list(rule.affected_joints),
172
+ evidence=evidence,
173
+ )
174
+
175
+
176
+ def frame_scores_for_rule(
177
+ frames: list[PoseFrame],
178
+ exercise: str,
179
+ rule: IssueRule,
180
+ ) -> list[FrameScore | None]:
181
+ raw_values = [rule.getter(frame) for frame in frames]
182
+ values = smooth_signal(raw_values, window_radius=2)
183
+ active_phase = phase_mask(frames, exercise, rule.phase)
184
+ scores: list[FrameScore | None] = []
185
+
186
+ for frame, value, in_phase in zip(frames, values, active_phase, strict=False):
187
+ if value is None or not in_phase or not violates(value, rule):
188
+ scores.append(None)
189
+ continue
190
+ scores.append(FrameScore(frame=frame, value=value, severity=severity(value, rule)))
191
+
192
+ return scores
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, sequence: PoseSequence) -> tuple[list[SignalSample], dict[str, Any]]:
127
  """Build the exercise-specific normalized motion signal."""
128
 
129
- def count(self, sequence: PoseSequence) -> tuple[Reps, dict[str, Any]]:
130
- samples, debug = self.build_signal(sequence)
 
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/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from pozify.exercises.shoulder_press.analyzer import ShoulderPressAnalyzer
2
+ from pozify.exercises.shoulder_press.issue_markers import RULES as ISSUE_RULES
3
+ from pozify.exercises.shoulder_press.strategy import ShoulderPressExercise
4
+
5
+ __all__ = ["ISSUE_RULES", "ShoulderPressAnalyzer", "ShoulderPressExercise"]
src/pozify/exercises/shoulder_press/analyzer.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from statistics import mean
4
+
5
+ from pozify.contracts import PoseFrame
6
+ from pozify.exercises.shared.analyzer import (
7
+ ExerciseMetricResult,
8
+ max_optional,
9
+ mean_optional,
10
+ mean_pair,
11
+ min_optional,
12
+ range_optional,
13
+ round_optional,
14
+ score,
15
+ side_delta,
16
+ value_series,
17
+ )
18
+ from pozify.steps.rep_signals import average_axis, landmark_axis
19
+
20
+
21
+ class ShoulderPressAnalyzer:
22
+ def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
23
+ elbow_angles = value_series(
24
+ frames,
25
+ lambda frame: mean_pair(
26
+ frame,
27
+ ("left_shoulder", "left_elbow", "left_wrist"),
28
+ ("right_shoulder", "right_elbow", "right_wrist"),
29
+ ),
30
+ )
31
+ elbow_deltas = value_series(
32
+ frames,
33
+ lambda frame: side_delta(
34
+ frame,
35
+ ("left_shoulder", "left_elbow", "left_wrist"),
36
+ ("right_shoulder", "right_elbow", "right_wrist"),
37
+ ),
38
+ )
39
+ wrist_y = value_series(
40
+ frames,
41
+ lambda frame: average_axis(frame, ("left_wrist", "right_wrist"), "y"),
42
+ )
43
+ wrist_x = value_series(
44
+ frames,
45
+ lambda frame: average_axis(frame, ("left_wrist", "right_wrist"), "x"),
46
+ )
47
+ left_wrist_y = value_series(frames, lambda frame: landmark_axis(frame, "left_wrist", "y"))
48
+ right_wrist_y = value_series(frames, lambda frame: landmark_axis(frame, "right_wrist", "y"))
49
+ wrist_asymmetry = [
50
+ abs(left - right)
51
+ for left, right in zip(left_wrist_y, right_wrist_y, strict=False)
52
+ if left is not None and right is not None
53
+ ]
54
+ shoulder_y = value_series(
55
+ frames,
56
+ lambda frame: average_axis(frame, ("left_shoulder", "right_shoulder"), "y"),
57
+ )
58
+ hip_y = value_series(frames, lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"))
59
+
60
+ min_elbow = min_optional(elbow_angles)
61
+ max_elbow = max_optional(elbow_angles)
62
+ wrist_travel = range_optional(wrist_y) or 0.0
63
+ wrist_lateral_drift = range_optional(wrist_x) or 0.0
64
+ lockout_quality = score(((max_elbow or 120.0) - 120.0) / 55.0)
65
+ verticality = score(1.0 - wrist_lateral_drift / max(0.01, wrist_travel))
66
+ asymmetry = mean(wrist_asymmetry) if wrist_asymmetry else 0.0
67
+ back_arch_proxy = abs((range_optional(hip_y) or 0.0) - (range_optional(shoulder_y) or 0.0))
68
+
69
+ rom_score = score((wrist_travel / 0.28) * 0.55 + lockout_quality * 0.45)
70
+ stability_score = score(verticality * 0.65 + (1.0 - min(1.0, back_arch_proxy * 4.0)) * 0.35)
71
+ symmetry_score = score(1.0 - max(asymmetry * 5.0, (mean_optional(elbow_deltas) or 0.0) / 45.0))
72
+
73
+ metrics = {
74
+ "min_elbow_angle_deg": round_optional(min_elbow),
75
+ "max_elbow_angle_deg": round_optional(max_elbow),
76
+ "wrist_path_verticality": verticality,
77
+ "lockout_quality": lockout_quality,
78
+ "wrist_height_asymmetry": round_optional(asymmetry, 4),
79
+ "left_right_wrist_delta": round_optional(asymmetry, 4),
80
+ "back_arch_proxy": round_optional(back_arch_proxy, 4),
81
+ "overhead_stability_score": stability_score,
82
+ "wrist_travel": round_optional(wrist_travel, 4),
83
+ }
84
+ hints = []
85
+ if lockout_quality < 0.65:
86
+ hints.append("partial_press")
87
+ if asymmetry > 0.12:
88
+ hints.append("asymmetric_press")
89
+ return metrics, rom_score, stability_score, symmetry_score, hints
src/pozify/exercises/shoulder_press/issue_markers.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pozify.contracts import PoseFrame
4
+ from pozify.exercises.shared.analyzer import mean_optional
5
+ from pozify.exercises.shared.issue_marker import IssueRule
6
+ from pozify.steps.rep_signals import angle_deg, landmark_axis
7
+
8
+
9
+ def _elbow_angle(frame: PoseFrame) -> float | None:
10
+ return mean_optional(
11
+ [
12
+ angle_deg(frame, "left_shoulder", "left_elbow", "left_wrist"),
13
+ angle_deg(frame, "right_shoulder", "right_elbow", "right_wrist"),
14
+ ]
15
+ )
16
+
17
+
18
+ def _wrist_height_asymmetry(frame: PoseFrame) -> float | None:
19
+ left = landmark_axis(frame, "left_wrist", "y")
20
+ right = landmark_axis(frame, "right_wrist", "y")
21
+ if left is None or right is None:
22
+ return None
23
+ return abs(left - right)
24
+
25
+
26
+ RULES: tuple[IssueRule, ...] = (
27
+ IssueRule(
28
+ issue="incomplete_lockout",
29
+ metric_name="elbow_angle_deg",
30
+ threshold=155.0,
31
+ comparison="lt",
32
+ affected_joints=("left_elbow", "right_elbow", "left_wrist", "right_wrist"),
33
+ getter=_elbow_angle,
34
+ phase="top",
35
+ ),
36
+ IssueRule(
37
+ issue="asymmetry",
38
+ metric_name="wrist_height_asymmetry",
39
+ threshold=0.1,
40
+ comparison="gt",
41
+ affected_joints=("left_wrist", "right_wrist", "left_elbow", "right_elbow"),
42
+ getter=_wrist_height_asymmetry,
43
+ ),
44
+ )
src/pozify/{steps/rep_counters/shoulder_press.py → exercises/shoulder_press/strategy.py} RENAMED
@@ -2,15 +2,23 @@ from __future__ import annotations
2
 
3
  from typing import Any
4
 
5
- from pozify.contracts import PoseSequence
6
- from pozify.steps.rep_counters.base import ExerciseRepCounter, combine, mean_optional, normalized_samples
 
 
 
 
7
  from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
8
 
9
 
10
- class ShoulderPressRepCounter(ExerciseRepCounter):
11
  exercise = "shoulder_press"
12
 
13
- def build_signal(self, sequence: PoseSequence) -> tuple[list[SignalSample], dict[str, Any]]:
 
 
 
 
14
  wrist_y = [average_axis(frame, ("left_wrist", "right_wrist"), "y") for frame in sequence.frames]
15
  elbow_bend = [
16
  mean_optional(
@@ -36,3 +44,29 @@ class ShoulderPressRepCounter(ExerciseRepCounter):
36
  "body_line_mean": round(mean_optional(body_line) or 0.0, 4),
37
  }
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
14
+ class ShoulderPressExercise(ShoulderPressAnalyzer, ExerciseStrategy):
15
  exercise = "shoulder_press"
16
 
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(
 
44
  "body_line_mean": round(mean_optional(body_line) or 0.0, 4),
45
  }
46
 
47
+ def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
48
+ lockout_quality = self.metric(analysis, "avg_lockout_quality")
49
+ wrist_travel = self.metric(analysis, "avg_wrist_travel")
50
+ wrist_asymmetry = self.metric(analysis, "avg_wrist_height_asymmetry")
51
+
52
+ if wrist_asymmetry is not None and wrist_asymmetry >= 0.12:
53
+ return (
54
+ "asymmetric_press",
55
+ self.confidence(0.76, analysis, wrist_asymmetry),
56
+ ["intentional_asymmetry_check"],
57
+ )
58
+ if (lockout_quality is not None and lockout_quality <= 0.65) or (
59
+ wrist_travel is not None and wrist_travel < 0.24
60
+ ):
61
+ support = 1.0 - lockout_quality if lockout_quality is not None else wrist_travel
62
+ return "partial_press", self.confidence(0.72, analysis, support), ["partial_range_of_motion"]
63
+
64
+ not_issues = ["lockout_unverified"] if lockout_quality is None else []
65
+ return "standing_shoulder_press", self.confidence(0.62, analysis, lockout_quality), not_issues
66
+
67
+ def profile_not_issues(self, variation: str) -> list[str]:
68
+ mapping = {
69
+ "partial_press": ["partial_range_of_motion"],
70
+ "asymmetric_press": ["intentional_asymmetry_check"],
71
+ }
72
+ return list(mapping.get(variation, []))
src/pozify/exercises/squat/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from pozify.exercises.squat.analyzer import SquatAnalyzer
2
+ from pozify.exercises.squat.issue_markers import RULES as ISSUE_RULES
3
+ from pozify.exercises.squat.strategy import SquatExercise
4
+
5
+ __all__ = ["ISSUE_RULES", "SquatAnalyzer", "SquatExercise"]
src/pozify/exercises/squat/analyzer.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pozify.contracts import PoseFrame
4
+ from pozify.exercises.shared.analyzer import (
5
+ ExerciseMetricResult,
6
+ max_optional,
7
+ mean_optional,
8
+ mean_pair,
9
+ min_optional,
10
+ round_optional,
11
+ safe_ratio,
12
+ score,
13
+ side_delta,
14
+ std_optional,
15
+ torso_lean_deg,
16
+ value_series,
17
+ width,
18
+ )
19
+ from pozify.steps.rep_signals import average_axis
20
+
21
+
22
+ class SquatAnalyzer:
23
+ def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
24
+ knee_angles = value_series(
25
+ frames,
26
+ lambda frame: mean_pair(
27
+ frame,
28
+ ("left_hip", "left_knee", "left_ankle"),
29
+ ("right_hip", "right_knee", "right_ankle"),
30
+ ),
31
+ )
32
+ hip_angles = value_series(
33
+ frames,
34
+ lambda frame: mean_pair(
35
+ frame,
36
+ ("left_shoulder", "left_hip", "left_knee"),
37
+ ("right_shoulder", "right_hip", "right_knee"),
38
+ ),
39
+ )
40
+ knee_deltas = value_series(
41
+ frames,
42
+ lambda frame: side_delta(
43
+ frame,
44
+ ("left_hip", "left_knee", "left_ankle"),
45
+ ("right_hip", "right_knee", "right_ankle"),
46
+ ),
47
+ )
48
+ hip_deltas = value_series(
49
+ frames,
50
+ lambda frame: side_delta(
51
+ frame,
52
+ ("left_shoulder", "left_hip", "left_knee"),
53
+ ("right_shoulder", "right_hip", "right_knee"),
54
+ ),
55
+ )
56
+ hip_y = value_series(frames, lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"))
57
+ knee_y = value_series(
58
+ frames,
59
+ lambda frame: average_axis(frame, ("left_knee", "right_knee"), "y"),
60
+ )
61
+ ankle_width = value_series(frames, lambda frame: width(frame, "left_ankle", "right_ankle"))
62
+ shoulder_width = value_series(
63
+ frames,
64
+ lambda frame: width(frame, "left_shoulder", "right_shoulder"),
65
+ )
66
+ knee_width = value_series(frames, lambda frame: width(frame, "left_knee", "right_knee"))
67
+ torso_lean = value_series(
68
+ frames,
69
+ lambda frame: mean_optional(
70
+ [torso_lean_deg(frame, "left"), torso_lean_deg(frame, "right")]
71
+ ),
72
+ )
73
+
74
+ min_knee = min_optional(knee_angles)
75
+ max_knee = max_optional(knee_angles)
76
+ min_hip = min_optional(hip_angles)
77
+ max_hip = max_optional(hip_angles)
78
+ hip_depth_delta = None
79
+ max_hip_y = max_optional(hip_y)
80
+ mean_knee_y = mean_optional(knee_y)
81
+ if max_hip_y is not None and mean_knee_y is not None:
82
+ hip_depth_delta = max_hip_y - mean_knee_y
83
+
84
+ stance_ratio = safe_ratio(mean_optional(ankle_width), mean_optional(shoulder_width))
85
+ knee_tracking_ratio = safe_ratio(mean_optional(knee_width), mean_optional(ankle_width))
86
+ valgus_proxy = None if knee_tracking_ratio is None else max(0.0, 1.0 - knee_tracking_ratio)
87
+ symmetry_delta = mean_optional(knee_deltas + hip_deltas) or 0.0
88
+ stability_noise = (std_optional(hip_y) or 0.0) + (std_optional(knee_width) or 0.0)
89
+
90
+ knee_rom = 0.0 if min_knee is None or max_knee is None else max_knee - min_knee
91
+ depth_score = score((hip_depth_delta + 0.08) / 0.18) if hip_depth_delta is not None else 0.5
92
+ angle_score = score(knee_rom / 65.0)
93
+ rom_score = score(angle_score * 0.55 + depth_score * 0.45)
94
+ stability_score = score(1.0 - stability_noise * 5.0)
95
+ symmetry_score = score(1.0 - symmetry_delta / 45.0)
96
+
97
+ hip_x = value_series(
98
+ frames,
99
+ lambda frame: average_axis(frame, ("left_hip", "right_hip"), "x"),
100
+ )
101
+ metrics = {
102
+ "min_knee_angle_deg": round_optional(min_knee),
103
+ "max_knee_angle_deg": round_optional(max_knee),
104
+ "min_hip_angle_deg": round_optional(min_hip),
105
+ "max_hip_angle_deg": round_optional(max_hip),
106
+ "hip_depth_delta": round_optional(hip_depth_delta, 4),
107
+ "hip_depth_relative_to_knee": (
108
+ "below_parallel"
109
+ if hip_depth_delta is not None and hip_depth_delta >= 0.03
110
+ else "parallel"
111
+ if hip_depth_delta is not None and hip_depth_delta >= -0.03
112
+ else "above_parallel"
113
+ ),
114
+ "max_torso_lean_deg": round_optional(max_optional(torso_lean)),
115
+ "knee_valgus_proxy": round_optional(valgus_proxy, 4),
116
+ "knee_tracking_score": score(1.0 - (valgus_proxy or 0.0)),
117
+ "stance_width_ratio": round_optional(stance_ratio, 3),
118
+ "hip_shift": round_optional(std_optional(hip_x), 4),
119
+ "bottom_stability_score": stability_score,
120
+ }
121
+ hints = []
122
+ if stance_ratio is not None and stance_ratio > 1.35:
123
+ hints.append("wide_squat_stance")
124
+ elif stance_ratio is not None and stance_ratio < 0.85:
125
+ hints.append("narrow_squat_stance")
126
+ return metrics, rom_score, stability_score, symmetry_score, hints
src/pozify/exercises/squat/issue_markers.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pozify.contracts import PoseFrame
4
+ from pozify.exercises.shared.analyzer import mean_optional, safe_ratio, torso_lean_deg, width
5
+ from pozify.exercises.shared.issue_marker import IssueRule
6
+ from pozify.steps.rep_signals import average_axis
7
+
8
+
9
+ def _depth_delta(frame: PoseFrame) -> float | None:
10
+ hip_y = average_axis(frame, ("left_hip", "right_hip"), "y")
11
+ knee_y = average_axis(frame, ("left_knee", "right_knee"), "y")
12
+ if hip_y is None or knee_y is None:
13
+ return None
14
+ return hip_y - knee_y
15
+
16
+
17
+ def _knee_valgus_proxy(frame: PoseFrame) -> float | None:
18
+ knee_width = width(frame, "left_knee", "right_knee")
19
+ ankle_width = width(frame, "left_ankle", "right_ankle")
20
+ tracking_ratio = safe_ratio(knee_width, ankle_width)
21
+ if tracking_ratio is None:
22
+ return None
23
+ return max(0.0, 1.0 - tracking_ratio)
24
+
25
+
26
+ def _torso_lean(frame: PoseFrame) -> float | None:
27
+ return mean_optional([torso_lean_deg(frame, "left"), torso_lean_deg(frame, "right")])
28
+
29
+
30
+ RULES: tuple[IssueRule, ...] = (
31
+ IssueRule(
32
+ issue="shallow_depth",
33
+ metric_name="hip_depth_delta",
34
+ threshold=-0.03,
35
+ comparison="lt",
36
+ affected_joints=("left_hip", "right_hip", "left_knee", "right_knee"),
37
+ getter=_depth_delta,
38
+ phase="bottom",
39
+ ),
40
+ IssueRule(
41
+ issue="knee_valgus",
42
+ metric_name="knee_valgus_proxy",
43
+ threshold=0.25,
44
+ comparison="gt",
45
+ affected_joints=("left_knee", "right_knee", "left_ankle", "right_ankle"),
46
+ getter=_knee_valgus_proxy,
47
+ phase="bottom",
48
+ ),
49
+ IssueRule(
50
+ issue="excessive_torso_lean",
51
+ metric_name="torso_lean_deg",
52
+ threshold=35.0,
53
+ comparison="gt",
54
+ affected_joints=("left_shoulder", "right_shoulder", "left_hip", "right_hip"),
55
+ getter=_torso_lean,
56
+ phase="bottom",
57
+ ),
58
+ )
src/pozify/{steps/rep_counters/squat.py → exercises/squat/strategy.py} RENAMED
@@ -2,15 +2,23 @@ from __future__ import annotations
2
 
3
  from typing import Any
4
 
5
- from pozify.contracts import PoseSequence
6
- from pozify.steps.rep_counters.base import ExerciseRepCounter, combine, mean_optional, normalized_samples
 
 
 
 
7
  from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
8
 
9
 
10
- class SquatRepCounter(ExerciseRepCounter):
11
  exercise = "squat"
12
 
13
- def build_signal(self, sequence: PoseSequence) -> tuple[list[SignalSample], dict[str, Any]]:
 
 
 
 
14
  hip_y = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in sequence.frames]
15
  knee_bend = [
16
  mean_optional(
@@ -33,3 +41,28 @@ class SquatRepCounter(ExerciseRepCounter):
33
  "body_line_mean": round(mean_optional(body_line) or 0.0, 4),
34
  }
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
14
+ class SquatExercise(SquatAnalyzer, ExerciseStrategy):
15
  exercise = "squat"
16
 
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(
 
41
  "body_line_mean": round(mean_optional(body_line) or 0.0, 4),
42
  }
43
 
44
+ def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
45
+ stance_width = self.metric(analysis, "avg_stance_width_ratio")
46
+ bottom_pause = self.metric(analysis, "avg_bottom_pause_sec")
47
+
48
+ if bottom_pause is not None and bottom_pause >= 0.4:
49
+ return "pause_squat", self.confidence(0.76, analysis, bottom_pause), ["bottom_pause"]
50
+ if stance_width is not None and stance_width >= 1.35:
51
+ return "wide_squat_stance", self.confidence(0.72, analysis, stance_width), ["wide_stance"]
52
+ if stance_width is not None and stance_width <= 0.85:
53
+ return (
54
+ "narrow_squat_stance",
55
+ self.confidence(0.72, analysis, 1.0 - stance_width),
56
+ ["narrow_stance"],
57
+ )
58
+
59
+ not_issues = ["stance_width_unverified"] if stance_width is None else []
60
+ return "normal_squat_stance", self.confidence(0.62, analysis, stance_width), not_issues
61
+
62
+ def profile_not_issues(self, variation: str) -> list[str]:
63
+ mapping = {
64
+ "wide_squat_stance": ["wide_stance"],
65
+ "narrow_squat_stance": ["narrow_stance"],
66
+ "pause_squat": ["bottom_pause"],
67
+ }
68
+ return list(mapping.get(variation, []))
src/pozify/exercises/unknown/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from pozify.exercises.unknown.analyzer import UnknownAnalyzer
2
+ from pozify.exercises.unknown.strategy import UnknownExercise
3
+
4
+ __all__ = ["UnknownAnalyzer", "UnknownExercise"]
src/pozify/exercises/unknown/analyzer.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pozify.contracts import PoseFrame
4
+ from pozify.exercises.shared.analyzer import (
5
+ ExerciseMetricResult,
6
+ mean_visibility,
7
+ range_optional,
8
+ score,
9
+ value_series,
10
+ )
11
+ from pozify.steps.rep_signals import average_axis
12
+
13
+
14
+ class UnknownAnalyzer:
15
+ def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
16
+ movement_signal = value_series(
17
+ frames,
18
+ lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"),
19
+ )
20
+ movement = range_optional(movement_signal) or 0.0
21
+ confidence = mean_visibility(frames)
22
+ metrics = {
23
+ "movement_consistency_score": score(1.0 - movement * 4.0),
24
+ "landmark_confidence": confidence,
25
+ }
26
+ return metrics, 0.0, metrics["movement_consistency_score"], confidence, []
src/pozify/{steps/rep_counters/registry.py → exercises/unknown/strategy.py} RENAMED
@@ -2,33 +2,27 @@ from __future__ import annotations
2
 
3
  from typing import Any
4
 
5
- from pozify.contracts import PoseSequence, Reps
6
- from pozify.steps.rep_counters.base import ExerciseRepCounter
7
- from pozify.steps.rep_counters.push_up import PushUpRepCounter
8
- from pozify.steps.rep_counters.shoulder_press import ShoulderPressRepCounter
9
- from pozify.steps.rep_counters.squat import SquatRepCounter
10
  from pozify.steps.rep_signals import SignalSample
11
 
12
 
13
- class UnknownRepCounter(ExerciseRepCounter):
14
  exercise = "unknown"
15
 
16
- def build_signal(self, sequence: PoseSequence) -> tuple[list[SignalSample], dict[str, Any]]:
17
  return [], {"selected_signal": "none", "thresholds": {}, "extrema": [], "accepted_reps": []}
18
 
19
- def count(self, sequence: PoseSequence) -> tuple[Reps, dict[str, Any]]:
20
  reps = Reps(exercise=self.exercise, reps=[], partial_reps=[{"reason": "unknown_exercise"}])
21
  return reps, {"selected_signal": "none", "thresholds": {}, "extrema": [], "accepted_reps": []}
22
 
23
-
24
- REP_COUNTERS: dict[str, ExerciseRepCounter] = {
25
- "push_up": PushUpRepCounter(),
26
- "shoulder_press": ShoulderPressRepCounter(),
27
- "squat": SquatRepCounter(),
28
- "unknown": UnknownRepCounter(),
29
- }
30
-
31
-
32
- def get_rep_counter(exercise: str) -> ExerciseRepCounter:
33
- return REP_COUNTERS.get(exercise, REP_COUNTERS["unknown"])
34
-
 
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
 
9
  from pozify.steps.rep_signals import SignalSample
10
 
11
 
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
 
22
+ def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
23
+ exercise_spec = get_exercise_spec(analysis.exercise)
24
+ return (
25
+ exercise_spec.default_variation,
26
+ exercise_spec.default_variation_confidence,
27
+ list(exercise_spec.default_variation_not_issues),
28
+ )
 
 
 
 
 
src/pozify/pipeline.py CHANGED
@@ -8,16 +8,13 @@ from uuid import uuid4
8
 
9
  from pozify.artifacts import write_json
10
  from pozify.contracts import UserProfile, to_dict
 
11
  from pozify.steps import (
12
  annotated_renderer,
13
  coach_summary,
14
  exercise_classifier,
15
- issue_marker,
16
  pose_cleaning,
17
  pose_landmarker,
18
- rep_analysis,
19
- rep_counter,
20
- variation_detector,
21
  verifier,
22
  video_qc,
23
  )
@@ -26,8 +23,12 @@ from pozify.steps import (
26
  RUNS_DIR = Path("runs")
27
 
28
 
29
- def _env_mock_mode() -> bool:
30
- value = os.getenv("POZIFY_MOCK_MODE", "1").strip().lower()
 
 
 
 
31
  return value not in {"0", "false", "no", "off"}
32
 
33
 
@@ -37,7 +38,7 @@ def run_pipeline(
37
  *,
38
  mock: bool | None = None,
39
  ) -> dict[str, Any]:
40
- mock_mode = _env_mock_mode() if mock is None else mock
41
 
42
  run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
43
  run_dir = RUNS_DIR / run_id
@@ -73,17 +74,24 @@ def run_pipeline(
73
  classification = exercise_classifier.run(cleaned_pose_sequence, profile, mock=mock_mode)
74
  write_artifact("exercise_classification.json", classification)
75
 
76
- reps, rep_debug = rep_counter.run(classification, cleaned_pose_sequence)
 
 
 
 
 
 
 
77
  write_artifact("reps.json", reps)
78
  write_artifact("rep_debug.json", rep_debug)
79
 
80
- analysis = rep_analysis.run(classification, reps, cleaned_pose_sequence)
81
  write_artifact("rep_analysis.json", analysis)
82
 
83
- variation = variation_detector.run(classification, analysis, profile)
84
  write_artifact("variation.json", variation)
85
 
86
- issues = issue_marker.run(classification, reps, analysis, variation)
87
  write_artifact("issue_markers.json", issues)
88
 
89
  annotated_video_path = annotated_renderer.run(manifest, cleaned_pose_sequence, reps, issues, run_dir)
@@ -95,9 +103,6 @@ def run_pipeline(
95
  )
96
  analysis_mode = "mock" if mock_mode else "real"
97
  mock_steps = [
98
- "rep_analysis",
99
- "variation_detector",
100
- "issue_marker",
101
  "coach_summary",
102
  "verifier",
103
  ]
 
8
 
9
  from pozify.artifacts import write_json
10
  from pozify.contracts import UserProfile, to_dict
11
+ from pozify.exercises import create_exercise_strategy
12
  from pozify.steps import (
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, mock=mock_mode)
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)
 
103
  )
104
  analysis_mode = "mock" if mock_mode else "real"
105
  mock_steps = [
 
 
 
106
  "coach_summary",
107
  "verifier",
108
  ]
src/pozify/steps/annotated_renderer.py CHANGED
@@ -1,6 +1,9 @@
1
  from __future__ import annotations
2
 
 
3
  from pathlib import Path
 
 
4
  from typing import Any
5
 
6
  import cv2
@@ -24,6 +27,186 @@ SKELETON_EDGES = [
24
  ]
25
 
26
  PREFERRED_VIDEO_CODECS = ("avc1", "H264", "mp4v")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
 
29
  def _frame_landmark_points(
@@ -103,7 +286,12 @@ def _draw_overlays(
103
  )
104
 
105
 
106
- def _open_video_writer(output_path: Path, fps: float, width: int, height: int) -> tuple[cv2.VideoWriter | None, str | None]:
 
 
 
 
 
107
  for codec in PREFERRED_VIDEO_CODECS:
108
  writer = cv2.VideoWriter(
109
  str(output_path),
@@ -127,9 +315,21 @@ def run(
127
  if not manifest.analysis_allowed or not manifest.video_path:
128
  return manifest.video_path
129
 
130
- capture = cv2.VideoCapture(manifest.video_path)
 
 
 
 
 
 
 
 
 
 
131
  if not capture.isOpened():
132
  capture.release()
 
 
133
  return manifest.video_path
134
 
135
  fps = manifest.fps if manifest.fps > 0 else 30.0
@@ -140,9 +340,12 @@ def run(
140
  return manifest.video_path
141
 
142
  output_path = run_dir / "annotated_video.mp4"
143
- writer, _codec = _open_video_writer(output_path, fps, width, height)
 
144
  if writer is None:
145
  capture.release()
 
 
146
  return manifest.video_path
147
 
148
  pose_by_frame = {frame.frame_index: frame for frame in pose_sequence.frames}
@@ -180,5 +383,13 @@ def run(
180
  finally:
181
  writer.release()
182
  capture.release()
 
 
 
 
 
 
 
 
183
 
184
  return str(output_path)
 
1
  from __future__ import annotations
2
 
3
+ import json
4
  from pathlib import Path
5
+ import shutil
6
+ import subprocess
7
  from typing import Any
8
 
9
  import cv2
 
27
  ]
28
 
29
  PREFERRED_VIDEO_CODECS = ("avc1", "H264", "mp4v")
30
+ HDR_TRANSFERS = {"arib-std-b67", "smpte2084"}
31
+ HDR_PRIMARIES = {"bt2020"}
32
+ BT709_COLOR_ARGS = (
33
+ "-color_primaries",
34
+ "bt709",
35
+ "-color_trc",
36
+ "bt709",
37
+ "-colorspace",
38
+ "bt709",
39
+ )
40
+
41
+
42
+ def _tool_path(name: str) -> str | None:
43
+ return shutil.which(name)
44
+
45
+
46
+ def _video_color_metadata(video_path: str) -> dict[str, str]:
47
+ ffprobe = _tool_path("ffprobe")
48
+ if ffprobe is None:
49
+ return {}
50
+
51
+ command = [
52
+ ffprobe,
53
+ "-v",
54
+ "error",
55
+ "-select_streams",
56
+ "v:0",
57
+ "-show_entries",
58
+ "stream=color_space,color_transfer,color_primaries,color_range",
59
+ "-of",
60
+ "json",
61
+ video_path,
62
+ ]
63
+ try:
64
+ result = subprocess.run(
65
+ command,
66
+ check=True,
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=10,
70
+ )
71
+ payload = json.loads(result.stdout)
72
+ except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
73
+ return {}
74
+
75
+ streams = payload.get("streams")
76
+ if not isinstance(streams, list) or not streams:
77
+ return {}
78
+ stream = streams[0]
79
+ if not isinstance(stream, dict):
80
+ return {}
81
+ metadata: dict[str, str] = {}
82
+ for key, value in stream.items():
83
+ if value is None or isinstance(value, (dict, list)):
84
+ continue
85
+ normalized_value = str(value).lower()
86
+ if normalized_value == "unknown":
87
+ continue
88
+ metadata[key] = normalized_value
89
+ return metadata
90
+
91
+
92
+ def _needs_sdr_conversion(color_metadata: dict[str, str]) -> bool:
93
+ transfer = color_metadata.get("color_transfer", "")
94
+ primaries = color_metadata.get("color_primaries", "")
95
+ return transfer in HDR_TRANSFERS or primaries in HDR_PRIMARIES
96
+
97
+
98
+ def _sdr_filter(color_metadata: dict[str, str]) -> str:
99
+ transfer = color_metadata.get("color_transfer", "arib-std-b67")
100
+ primaries = color_metadata.get("color_primaries", "bt2020")
101
+ matrix = color_metadata.get("color_space", "bt2020nc")
102
+ if transfer not in HDR_TRANSFERS:
103
+ transfer = "arib-std-b67"
104
+ if primaries not in HDR_PRIMARIES:
105
+ primaries = "bt2020"
106
+ if matrix not in {"bt2020nc", "bt2020c"}:
107
+ matrix = "bt2020nc"
108
+
109
+ return (
110
+ f"zscale=transfer=linear:transferin={transfer}:"
111
+ f"primariesin={primaries}:matrixin={matrix}:npl=100,"
112
+ "tonemap=tonemap=hable:desat=0,"
113
+ "zscale=transfer=bt709:primaries=bt709:matrix=bt709:range=tv,"
114
+ "format=yuv420p"
115
+ )
116
+
117
+
118
+ def _transcode_hdr_to_sdr(
119
+ input_path: Path,
120
+ output_path: Path,
121
+ color_metadata: dict[str, str],
122
+ ) -> bool:
123
+ ffmpeg = _tool_path("ffmpeg")
124
+ if ffmpeg is None:
125
+ return False
126
+
127
+ command = [
128
+ ffmpeg,
129
+ "-y",
130
+ "-v",
131
+ "error",
132
+ "-i",
133
+ str(input_path),
134
+ "-vf",
135
+ _sdr_filter(color_metadata),
136
+ "-an",
137
+ "-c:v",
138
+ "libx264",
139
+ "-preset",
140
+ "veryfast",
141
+ "-crf",
142
+ "18",
143
+ "-pix_fmt",
144
+ "yuv420p",
145
+ *BT709_COLOR_ARGS,
146
+ str(output_path),
147
+ ]
148
+ try:
149
+ subprocess.run(command, check=True, capture_output=True, timeout=120)
150
+ except (subprocess.SubprocessError, OSError):
151
+ return False
152
+ return output_path.exists() and output_path.stat().st_size > 0
153
+
154
+
155
+ def _encode_bt709_output(
156
+ raw_video_path: Path,
157
+ output_path: Path,
158
+ audio_source_path: Path | None,
159
+ ) -> bool:
160
+ ffmpeg = _tool_path("ffmpeg")
161
+ if ffmpeg is None:
162
+ return False
163
+
164
+ command = [
165
+ ffmpeg,
166
+ "-y",
167
+ "-v",
168
+ "error",
169
+ "-i",
170
+ str(raw_video_path),
171
+ ]
172
+ if audio_source_path is not None:
173
+ command.extend(["-i", str(audio_source_path)])
174
+
175
+ command.extend(
176
+ [
177
+ "-map",
178
+ "0:v:0",
179
+ ]
180
+ )
181
+ if audio_source_path is not None:
182
+ command.extend(["-map", "1:a?"])
183
+
184
+ command.extend(
185
+ [
186
+ "-c:v",
187
+ "libx264",
188
+ "-preset",
189
+ "veryfast",
190
+ "-crf",
191
+ "18",
192
+ "-vf",
193
+ "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709,format=yuv420p",
194
+ "-pix_fmt",
195
+ "yuv420p",
196
+ *BT709_COLOR_ARGS,
197
+ ]
198
+ )
199
+ if audio_source_path is not None:
200
+ command.extend(["-c:a", "aac", "-b:a", "128k", "-shortest"])
201
+ else:
202
+ command.append("-an")
203
+ command.append(str(output_path))
204
+
205
+ try:
206
+ subprocess.run(command, check=True, capture_output=True, timeout=120)
207
+ except (subprocess.SubprocessError, OSError):
208
+ return False
209
+ return output_path.exists() and output_path.stat().st_size > 0
210
 
211
 
212
  def _frame_landmark_points(
 
286
  )
287
 
288
 
289
+ def _open_video_writer(
290
+ output_path: Path,
291
+ fps: float,
292
+ width: int,
293
+ height: int,
294
+ ) -> tuple[cv2.VideoWriter | None, str | None]:
295
  for codec in PREFERRED_VIDEO_CODECS:
296
  writer = cv2.VideoWriter(
297
  str(output_path),
 
315
  if not manifest.analysis_allowed or not manifest.video_path:
316
  return manifest.video_path
317
 
318
+ source_path = Path(manifest.video_path)
319
+ color_metadata = _video_color_metadata(manifest.video_path)
320
+ render_input_path = source_path
321
+ temporary_paths: list[Path] = []
322
+ if _needs_sdr_conversion(color_metadata):
323
+ sdr_input_path = run_dir / "renderer_sdr_input.mp4"
324
+ if _transcode_hdr_to_sdr(source_path, sdr_input_path, color_metadata):
325
+ render_input_path = sdr_input_path
326
+ temporary_paths.append(sdr_input_path)
327
+
328
+ capture = cv2.VideoCapture(str(render_input_path))
329
  if not capture.isOpened():
330
  capture.release()
331
+ for temporary_path in temporary_paths:
332
+ temporary_path.unlink(missing_ok=True)
333
  return manifest.video_path
334
 
335
  fps = manifest.fps if manifest.fps > 0 else 30.0
 
340
  return manifest.video_path
341
 
342
  output_path = run_dir / "annotated_video.mp4"
343
+ raw_output_path = run_dir / "annotated_video_raw.mp4" if _tool_path("ffmpeg") else output_path
344
+ writer, _codec = _open_video_writer(raw_output_path, fps, width, height)
345
  if writer is None:
346
  capture.release()
347
+ for temporary_path in temporary_paths:
348
+ temporary_path.unlink(missing_ok=True)
349
  return manifest.video_path
350
 
351
  pose_by_frame = {frame.frame_index: frame for frame in pose_sequence.frames}
 
383
  finally:
384
  writer.release()
385
  capture.release()
386
+ for temporary_path in temporary_paths:
387
+ temporary_path.unlink(missing_ok=True)
388
+
389
+ if raw_output_path != output_path:
390
+ encoded = _encode_bt709_output(raw_output_path, output_path, source_path)
391
+ if not encoded:
392
+ raw_output_path.replace(output_path)
393
+ raw_output_path.unlink(missing_ok=True)
394
 
395
  return str(output_path)
src/pozify/steps/coach_summary.py CHANGED
@@ -22,29 +22,29 @@ def run(
22
  issue_labels = [issue.issue for issue in issues.issues]
23
  main_issue = issue_labels[0] if issue_labels else "no_major_issue"
24
  issue_text = (
25
- f"The current mock issue placeholder is `{main_issue}`, mostly appearing in later reps."
26
  if issues.issues
27
- else "The current mock issue layer did not emit a major issue."
28
  )
29
 
30
  return CoachSummary(
31
  summary=(
32
  f"The pipeline segmented {len(reps.reps)} {classification.exercise} reps from the current pose stream. "
33
- f"Exercise routing, variation labeling, issue labeling, and coaching language still use mock placeholders. "
34
- f"The current placeholder variation is `{variation.detected_variation}`. {issue_text}"
35
  ),
36
  what_went_well=[
37
  f"Current placeholder ROM score is {analysis.aggregate_metrics['avg_rom_score']}.",
38
  f"Current placeholder symmetry score is {analysis.aggregate_metrics['avg_symmetry_score']}.",
39
  ],
40
  main_findings=[
41
- f"Mock issue placeholder `{main_issue}` appears in {len(issues.issues)} rep(s)."
42
  if issues.issues
43
- else "No mock issue placeholder detected."
44
  ],
45
  variation_explanation=(
46
- f"`{variation.detected_variation}` is currently a placeholder variation label. "
47
- f"It should not be treated as a verified movement diagnosis yet."
48
  ),
49
  top_fixes=[
50
  "Keep the same setup and record from a stable camera angle.",
 
22
  issue_labels = [issue.issue for issue in issues.issues]
23
  main_issue = issue_labels[0] if issue_labels else "no_major_issue"
24
  issue_text = (
25
+ f"The strongest frame-level marker is `{main_issue}`, with timestamps and metric evidence in the issue timeline."
26
  if issues.issues
27
+ else "The frame-level issue rules did not find a sustained threshold violation."
28
  )
29
 
30
  return CoachSummary(
31
  summary=(
32
  f"The pipeline segmented {len(reps.reps)} {classification.exercise} reps from the current pose stream. "
33
+ f"Exercise routing, variation labeling, and coaching language may still use lightweight rules or placeholders. "
34
+ f"The detected variation is `{variation.detected_variation}`. {issue_text}"
35
  ),
36
  what_went_well=[
37
  f"Current placeholder ROM score is {analysis.aggregate_metrics['avg_rom_score']}.",
38
  f"Current placeholder symmetry score is {analysis.aggregate_metrics['avg_symmetry_score']}.",
39
  ],
40
  main_findings=[
41
+ f"Frame-level marker `{main_issue}` appears in {len(issues.issues)} interval(s)."
42
  if issues.issues
43
+ else "No sustained issue interval detected."
44
  ],
45
  variation_explanation=(
46
+ f"`{variation.detected_variation}` was included as context for interpreting issue markers. "
47
+ f"It should be reviewed alongside camera angle and pose confidence."
48
  ),
49
  top_fixes=[
50
  "Keep the same setup and record from a stable camera angle.",
src/pozify/steps/issue_marker.py DELETED
@@ -1,53 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pozify.contracts import (
4
- ExerciseClassification,
5
- IssueMarker,
6
- IssueMarkers,
7
- RepAnalysis,
8
- Reps,
9
- Variation,
10
- )
11
- from pozify.exercise_catalog import get_exercise_spec
12
-
13
-
14
- def run(
15
- classification: ExerciseClassification,
16
- reps: Reps,
17
- analysis: RepAnalysis,
18
- variation: Variation,
19
- ) -> IssueMarkers:
20
- issues: list[IssueMarker] = []
21
- exercise_spec = get_exercise_spec(classification.exercise)
22
- for item in analysis.items:
23
- if item.stability_score >= 0.78 or exercise_spec.mock_issue is None:
24
- continue
25
-
26
- rep = next(rep for rep in reps.reps if rep.rep_id == item.rep_id)
27
- issue_spec = exercise_spec.mock_issue
28
- metric_value = (
29
- item.range_of_motion_score
30
- if issue_spec.evidence_metric == "range_of_motion_score"
31
- else item.metrics.get(issue_spec.evidence_metric)
32
- )
33
- evidence = {
34
- issue_spec.evidence_metric: metric_value,
35
- "threshold": issue_spec.threshold,
36
- "variation": variation.detected_variation,
37
- }
38
-
39
- issues.append(
40
- IssueMarker(
41
- rep_id=item.rep_id,
42
- issue=issue_spec.issue,
43
- severity=round(1.0 - item.stability_score, 2),
44
- start_frame=rep.mid_frame,
45
- end_frame=rep.end_frame,
46
- start_sec=rep.mid_sec,
47
- end_sec=rep.end_sec,
48
- affected_joints=list(issue_spec.affected_joints),
49
- evidence=evidence,
50
- )
51
- )
52
-
53
- return IssueMarkers(issues=issues)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/pozify/steps/rep_analysis.py DELETED
@@ -1,58 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from statistics import mean
4
- from typing import Any
5
-
6
- from pozify.contracts import (
7
- ExerciseClassification,
8
- PoseSequence,
9
- RepAnalysis,
10
- RepAnalysisItem,
11
- Reps,
12
- )
13
- from pozify.exercise_catalog import get_exercise_spec
14
-
15
-
16
- def _clamp_score(value: float) -> float:
17
- return round(min(1.0, max(0.0, value)), 2)
18
-
19
-
20
- def _metrics_for_exercise(exercise: str, rep_id: int) -> dict[str, Any]:
21
- fatigue_penalty = max(0, rep_id - 3) * 0.08
22
- return get_exercise_spec(exercise).metric_factory(rep_id, fatigue_penalty)
23
-
24
-
25
- def run(
26
- classification: ExerciseClassification,
27
- reps: Reps,
28
- sequence: PoseSequence,
29
- ) -> RepAnalysis:
30
- items: list[RepAnalysisItem] = []
31
- exercise_spec = get_exercise_spec(classification.exercise)
32
- for rep in reps.reps:
33
- fatigue_penalty = max(0, rep.rep_id - 3) * 0.08
34
- items.append(
35
- RepAnalysisItem(
36
- rep_id=rep.rep_id,
37
- duration_sec=round(rep.end_sec - rep.start_sec, 2),
38
- range_of_motion_score=_clamp_score(0.88 - fatigue_penalty),
39
- stability_score=_clamp_score(0.86 - fatigue_penalty),
40
- symmetry_score=_clamp_score(0.9 - fatigue_penalty / 2),
41
- metrics=_metrics_for_exercise(classification.exercise, rep.rep_id),
42
- variation_hints=list(exercise_spec.variation_hints),
43
- )
44
- )
45
-
46
- aggregate_metrics = {
47
- "avg_rom_score": round(mean(item.range_of_motion_score for item in items), 2) if items else 0.0,
48
- "avg_stability_score": round(mean(item.stability_score for item in items), 2) if items else 0.0,
49
- "avg_symmetry_score": round(mean(item.symmetry_score for item in items), 2) if items else 0.0,
50
- "pose_valid_ratio": sequence.pose_valid_ratio,
51
- "mock": True,
52
- }
53
-
54
- return RepAnalysis(
55
- exercise=classification.exercise,
56
- items=items,
57
- aggregate_metrics=aggregate_metrics,
58
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ExerciseClassification, PoseSequence, Reps
6
- from pozify.steps.rep_counters import get_rep_counter
7
-
8
-
9
- def run(classification: ExerciseClassification, sequence: PoseSequence) -> tuple[Reps, dict[str, Any]]:
10
- return get_rep_counter(classification.exercise).count(sequence)
 
 
 
 
 
 
 
 
 
 
 
src/pozify/steps/rep_counters/__init__.py DELETED
@@ -1,5 +0,0 @@
1
- from pozify.steps.rep_counters.base import ExerciseRepCounter
2
- from pozify.steps.rep_counters.registry import get_rep_counter
3
-
4
- __all__ = ["ExerciseRepCounter", "get_rep_counter"]
5
-
 
 
 
 
 
 
src/pozify/steps/variation_detector.py DELETED
@@ -1,33 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pozify.contracts import ExerciseClassification, RepAnalysis, UserProfile, Variation
4
- from pozify.exercise_catalog import get_exercise_spec
5
-
6
-
7
- def run(
8
- classification: ExerciseClassification,
9
- analysis: RepAnalysis,
10
- profile: UserProfile,
11
- ) -> Variation:
12
- exercise_spec = get_exercise_spec(classification.exercise)
13
- if profile.intended_variation:
14
- variation = profile.intended_variation
15
- confidence = 0.95
16
- else:
17
- variation = exercise_spec.default_variation
18
- confidence = exercise_spec.default_variation_confidence
19
-
20
- not_issues = (
21
- list(exercise_spec.default_variation_not_issues)
22
- if variation == exercise_spec.default_variation
23
- else []
24
- )
25
- if analysis.aggregate_metrics.get("avg_rom_score", 1.0) < 0.7:
26
- not_issues.append("mock_low_rom_requires_user_intent_check")
27
-
28
- return Variation(
29
- exercise=classification.exercise,
30
- detected_variation=variation,
31
- variation_confidence=confidence,
32
- not_issues=not_issues,
33
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_annotated_renderer.py CHANGED
@@ -95,6 +95,26 @@ class AnnotatedRendererTests(unittest.TestCase):
95
  self.assertIsNotNone(output_path)
96
  self.assertTrue(Path(str(output_path)).exists())
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  if __name__ == "__main__":
100
  unittest.main()
 
95
  self.assertIsNotNone(output_path)
96
  self.assertTrue(Path(str(output_path)).exists())
97
 
98
+ def test_hdr_metadata_requires_sdr_conversion(self) -> None:
99
+ self.assertTrue(
100
+ annotated_renderer._needs_sdr_conversion(
101
+ {
102
+ "color_space": "bt2020nc",
103
+ "color_transfer": "arib-std-b67",
104
+ "color_primaries": "bt2020",
105
+ }
106
+ )
107
+ )
108
+ self.assertFalse(
109
+ annotated_renderer._needs_sdr_conversion(
110
+ {
111
+ "color_space": "bt709",
112
+ "color_transfer": "bt709",
113
+ "color_primaries": "bt709",
114
+ }
115
+ )
116
+ )
117
+
118
 
119
  if __name__ == "__main__":
120
  unittest.main()
tests/test_issue_marker.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from pathlib import Path
5
+ import sys
6
+ from typing import Callable
7
+ import unittest
8
+
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:
24
+ return PoseFrame(
25
+ frame_index=frame_index,
26
+ timestamp_sec=round(frame_index / 30.0, 3),
27
+ landmarks=landmarks,
28
+ world_landmarks={},
29
+ pose_quality={"mean_visibility": 0.92, "normalized": True},
30
+ )
31
+
32
+
33
+ def _wave(frame_index: int, cycle_frames: int = 30) -> float:
34
+ return (1.0 - math.cos(2.0 * math.pi * (frame_index / cycle_frames))) / 2.0
35
+
36
+
37
+ def _reps(exercise: str, end_frame: int = 29) -> Reps:
38
+ return Reps(
39
+ exercise=exercise,
40
+ reps=[Rep(1, 0, end_frame // 2, end_frame, 0.0, round((end_frame // 2) / 30.0, 3), round(end_frame / 30.0, 3))],
41
+ partial_reps=[],
42
+ )
43
+
44
+
45
+ def _profile(exercise: str = "auto") -> UserProfile:
46
+ return UserProfile(
47
+ goal="beginner_practice",
48
+ experience_level="beginner",
49
+ intended_exercise=exercise,
50
+ intended_variation=None,
51
+ known_limitations=[],
52
+ equipment="bodyweight",
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)],
77
+ normalized=True,
78
+ smoothing_method="none",
79
+ pose_valid_ratio=1.0,
80
+ )
81
+
82
+
83
+ def _push_up_landmarks(frame_index: int, depth: float, *, noise_only: bool = False) -> dict[str, dict[str, float]]:
84
+ shoulder_y = 0.38 + depth * 0.16
85
+ hip_y = 0.46 + depth * 0.16
86
+ ankle_y = 0.54 + depth * 0.16
87
+ if 10 <= frame_index <= 18 and not noise_only:
88
+ hip_y += 0.45
89
+ if frame_index == 15 and noise_only:
90
+ hip_y += 0.45
91
+
92
+ shoulder_width = 0.4
93
+ hand_width = shoulder_width * 1.7
94
+ return {
95
+ "left_shoulder": {"x": 0.3, "y": shoulder_y},
96
+ "right_shoulder": {"x": 0.7, "y": shoulder_y},
97
+ "left_elbow": {"x": 0.36 + depth * 0.02, "y": 0.46 + depth * 0.1},
98
+ "right_elbow": {"x": 0.64 - depth * 0.02, "y": 0.46 + depth * 0.1},
99
+ "left_wrist": {"x": 0.5 - hand_width / 2, "y": 0.5 + depth * 0.04},
100
+ "right_wrist": {"x": 0.5 + hand_width / 2, "y": 0.5 + depth * 0.04},
101
+ "left_hip": {"x": 0.42, "y": hip_y},
102
+ "right_hip": {"x": 0.58, "y": hip_y},
103
+ "left_knee": {"x": 0.44, "y": hip_y + 0.2},
104
+ "right_knee": {"x": 0.56, "y": hip_y + 0.2},
105
+ "left_ankle": {"x": 0.44, "y": ankle_y},
106
+ "right_ankle": {"x": 0.56, "y": ankle_y},
107
+ }
108
+
109
+
110
+ def _squat_landmarks(frame_index: int, depth: float) -> dict[str, dict[str, float]]:
111
+ del frame_index
112
+ hip_y = 0.54 + depth * 0.08
113
+ knee_y = 0.74
114
+ knee_half_width = 0.04 + (1.0 - depth) * 0.08
115
+ return {
116
+ "left_shoulder": {"x": 0.74, "y": 0.26 + depth * 0.02},
117
+ "right_shoulder": {"x": 0.86, "y": 0.26 + depth * 0.02},
118
+ "left_hip": {"x": 0.43, "y": hip_y},
119
+ "right_hip": {"x": 0.57, "y": hip_y},
120
+ "left_knee": {"x": 0.5 - knee_half_width, "y": knee_y},
121
+ "right_knee": {"x": 0.5 + knee_half_width, "y": knee_y},
122
+ "left_ankle": {"x": 0.3, "y": 0.94},
123
+ "right_ankle": {"x": 0.7, "y": 0.94},
124
+ }
125
+
126
+
127
+ def _shoulder_press_landmarks(frame_index: int, lift: float) -> dict[str, dict[str, float]]:
128
+ del frame_index
129
+ wrist_y = 0.74 - lift * 0.12
130
+ return {
131
+ "left_shoulder": {"x": 0.42, "y": 0.42},
132
+ "right_shoulder": {"x": 0.58, "y": 0.42},
133
+ "left_elbow": {"x": 0.35, "y": 0.56 - lift * 0.04},
134
+ "right_elbow": {"x": 0.65, "y": 0.56 - lift * 0.04},
135
+ "left_wrist": {"x": 0.18, "y": wrist_y + 0.08},
136
+ "right_wrist": {"x": 0.82, "y": wrist_y - 0.08},
137
+ "left_hip": {"x": 0.44, "y": 0.74},
138
+ "right_hip": {"x": 0.56, "y": 0.74},
139
+ "left_ankle": {"x": 0.45, "y": 0.96},
140
+ "right_ankle": {"x": 0.55, "y": 0.96},
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):
158
+ def test_push_up_hip_sag_interval_includes_evidence_and_variation_context(self) -> None:
159
+ markers = _run_markers("push_up", _sequence(_push_up_landmarks))
160
+ hip_sag = next(issue for issue in markers.issues if issue.issue == "hip_sag")
161
+
162
+ self.assertGreaterEqual(hip_sag.end_frame - hip_sag.start_frame, 2)
163
+ self.assertLess(hip_sag.evidence["body_line_score"], hip_sag.evidence["threshold"])
164
+ self.assertIn("confidence", hip_sag.evidence)
165
+ self.assertEqual(
166
+ hip_sag.evidence["variation_context"]["detected_variation"],
167
+ "wide_grip_push_up",
168
+ )
169
+ self.assertIn("wide_hand_placement", hip_sag.evidence["variation_context"]["not_issues"])
170
+ self.assertIn("left_hip", hip_sag.affected_joints)
171
+
172
+ def test_single_frame_push_up_sag_noise_is_filtered_out(self) -> None:
173
+ markers = _run_markers(
174
+ "push_up",
175
+ _sequence(lambda frame_index, depth: _push_up_landmarks(frame_index, depth, noise_only=True)),
176
+ )
177
+
178
+ self.assertNotIn("hip_sag", {issue.issue for issue in markers.issues})
179
+
180
+ def test_squat_rules_emit_depth_valgus_and_torso_lean_intervals(self) -> None:
181
+ markers = _run_markers("squat", _sequence(_squat_landmarks))
182
+ labels = {issue.issue for issue in markers.issues}
183
+
184
+ self.assertIn("shallow_depth", labels)
185
+ self.assertIn("knee_valgus", labels)
186
+ self.assertIn("excessive_torso_lean", labels)
187
+
188
+ def test_shoulder_press_rules_emit_lockout_and_asymmetry_intervals(self) -> None:
189
+ markers = _run_markers("shoulder_press", _sequence(_shoulder_press_landmarks))
190
+ labels = {issue.issue for issue in markers.issues}
191
+
192
+ self.assertIn("incomplete_lockout", labels)
193
+ self.assertIn("asymmetry", labels)
194
+
195
+
196
+ if __name__ == "__main__":
197
+ unittest.main()
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 ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import math
5
+ import sys
6
+ from typing import Callable
7
+ import unittest
8
+
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
+ 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:
23
+ return PoseFrame(
24
+ frame_index=frame_index,
25
+ timestamp_sec=round(frame_index / 30.0, 3),
26
+ landmarks=landmarks,
27
+ world_landmarks={},
28
+ pose_quality={"mean_visibility": 0.95, "normalized": True},
29
+ )
30
+
31
+
32
+ def _wave(frame_index: int, cycle_frames: int) -> float:
33
+ return (1.0 - math.cos(2.0 * math.pi * (frame_index / cycle_frames))) / 2.0
34
+
35
+
36
+ def _push_up_landmarks(depth: float, *, hand_ratio: float = 1.7) -> dict[str, dict[str, float]]:
37
+ shoulder_y = 0.38 + depth * 0.16
38
+ hip_y = 0.46 + depth * 0.16
39
+ ankle_y = 0.54 + depth * 0.16
40
+ elbow_y = 0.46 + depth * 0.1
41
+ wrist_y = 0.5 + depth * 0.04
42
+ shoulder_width = 0.4
43
+ hand_width = shoulder_width * hand_ratio
44
+ return {
45
+ "left_shoulder": {"x": 0.3, "y": shoulder_y},
46
+ "right_shoulder": {"x": 0.7, "y": shoulder_y},
47
+ "left_elbow": {"x": 0.36 + depth * 0.02, "y": elbow_y},
48
+ "right_elbow": {"x": 0.64 - depth * 0.02, "y": elbow_y},
49
+ "left_wrist": {"x": 0.5 - hand_width / 2, "y": wrist_y},
50
+ "right_wrist": {"x": 0.5 + hand_width / 2, "y": wrist_y},
51
+ "left_hip": {"x": 0.42, "y": hip_y},
52
+ "right_hip": {"x": 0.58, "y": hip_y},
53
+ "left_ankle": {"x": 0.44, "y": ankle_y},
54
+ "right_ankle": {"x": 0.56, "y": ankle_y},
55
+ }
56
+
57
+
58
+ def _knee_push_up_landmarks(depth: float, *, hand_ratio: float = 1.1) -> dict[str, dict[str, float]]:
59
+ shoulder_y = 0.38 + depth * 0.16
60
+ hip_y = 0.46 + depth * 0.16
61
+ knee_y = hip_y + 0.03
62
+ ankle_y = hip_y + 0.28
63
+ elbow_y = 0.46 + depth * 0.1
64
+ wrist_y = 0.5 + depth * 0.04
65
+ shoulder_width = 0.4
66
+ hand_width = shoulder_width * hand_ratio
67
+ return {
68
+ "left_shoulder": {"x": 0.3, "y": shoulder_y},
69
+ "right_shoulder": {"x": 0.7, "y": shoulder_y},
70
+ "left_elbow": {"x": 0.36 + depth * 0.02, "y": elbow_y},
71
+ "right_elbow": {"x": 0.64 - depth * 0.02, "y": elbow_y},
72
+ "left_wrist": {"x": 0.5 - hand_width / 2, "y": wrist_y},
73
+ "right_wrist": {"x": 0.5 + hand_width / 2, "y": wrist_y},
74
+ "left_hip": {"x": 0.42, "y": hip_y},
75
+ "right_hip": {"x": 0.58, "y": hip_y},
76
+ "left_knee": {"x": 0.44, "y": knee_y},
77
+ "right_knee": {"x": 0.56, "y": knee_y},
78
+ "left_ankle": {"x": 0.44, "y": ankle_y},
79
+ "right_ankle": {"x": 0.56, "y": ankle_y},
80
+ }
81
+
82
+
83
+ def _straight_leg_push_up_landmarks(depth: float) -> dict[str, dict[str, float]]:
84
+ shoulder_y = 0.38 + depth * 0.1
85
+ hip_y = 0.46 + depth * 0.1
86
+ knee_y = 0.5 + depth * 0.1
87
+ ankle_y = 0.52 + depth * 0.1
88
+ elbow_y = 0.46 + depth * 0.06
89
+ wrist_y = 0.52 + depth * 0.02
90
+ return {
91
+ "left_shoulder": {"x": 0.3, "y": shoulder_y},
92
+ "right_shoulder": {"x": 0.32, "y": shoulder_y},
93
+ "left_elbow": {"x": 0.35, "y": elbow_y},
94
+ "right_elbow": {"x": 0.37, "y": elbow_y},
95
+ "left_wrist": {"x": 0.42, "y": wrist_y},
96
+ "right_wrist": {"x": 0.44, "y": wrist_y},
97
+ "left_hip": {"x": 0.58, "y": hip_y},
98
+ "right_hip": {"x": 0.6, "y": hip_y},
99
+ "left_knee": {"x": 0.72, "y": knee_y},
100
+ "right_knee": {"x": 0.74, "y": knee_y},
101
+ "left_ankle": {"x": 0.88, "y": ankle_y},
102
+ "right_ankle": {"x": 0.9, "y": ankle_y},
103
+ }
104
+
105
+
106
+ def _squat_landmarks(depth: float, *, stance_ratio: float = 1.5) -> dict[str, dict[str, float]]:
107
+ shoulder_width = 0.2
108
+ stance_width = shoulder_width * stance_ratio
109
+ hip_y = 0.52 + depth * 0.22
110
+ shoulder_y = 0.28 + depth * 0.06
111
+ knee_y = 0.7
112
+ return {
113
+ "left_shoulder": {"x": 0.4, "y": shoulder_y},
114
+ "right_shoulder": {"x": 0.6, "y": shoulder_y},
115
+ "left_hip": {"x": 0.43, "y": hip_y},
116
+ "right_hip": {"x": 0.57, "y": hip_y},
117
+ "left_knee": {"x": 0.43 + depth * 0.05, "y": knee_y},
118
+ "right_knee": {"x": 0.57 - depth * 0.05, "y": knee_y},
119
+ "left_ankle": {"x": 0.5 - stance_width / 2, "y": 0.92},
120
+ "right_ankle": {"x": 0.5 + stance_width / 2, "y": 0.92},
121
+ }
122
+
123
+
124
+ def _shoulder_press_landmarks(lift: float, *, partial: bool = True) -> dict[str, dict[str, float]]:
125
+ wrist_top = 0.56 if partial else 0.42
126
+ wrist_y = 0.76 - lift * (0.76 - wrist_top)
127
+ elbow_y = 0.6 - lift * 0.06
128
+ return {
129
+ "left_shoulder": {"x": 0.42, "y": 0.42},
130
+ "right_shoulder": {"x": 0.58, "y": 0.42},
131
+ "left_elbow": {"x": 0.4 - lift * 0.02, "y": elbow_y},
132
+ "right_elbow": {"x": 0.6 + lift * 0.02, "y": elbow_y},
133
+ "left_wrist": {"x": 0.4, "y": wrist_y},
134
+ "right_wrist": {"x": 0.6, "y": wrist_y},
135
+ "left_hip": {"x": 0.44, "y": 0.74},
136
+ "right_hip": {"x": 0.56, "y": 0.74},
137
+ }
138
+
139
+
140
+ def _sequence(exercise: str) -> PoseSequence:
141
+ frames = []
142
+ for frame_index in range(25):
143
+ wave = _wave(frame_index, 24)
144
+ if exercise == "push_up":
145
+ landmarks = _push_up_landmarks(wave)
146
+ elif exercise == "shoulder_press":
147
+ landmarks = _shoulder_press_landmarks(wave)
148
+ else:
149
+ landmarks = _squat_landmarks(wave)
150
+ frames.append(_frame(frame_index, landmarks))
151
+ return PoseSequence(
152
+ frames=frames,
153
+ normalized=True,
154
+ smoothing_method="none",
155
+ pose_valid_ratio=1.0,
156
+ )
157
+
158
+
159
+ def _custom_sequence(
160
+ landmark_factory: Callable[[float], dict[str, dict[str, float]]],
161
+ ) -> PoseSequence:
162
+ frames = []
163
+ for frame_index in range(25):
164
+ frames.append(_frame(frame_index, landmark_factory(_wave(frame_index, 24))))
165
+ return PoseSequence(
166
+ frames=frames,
167
+ normalized=True,
168
+ smoothing_method="none",
169
+ pose_valid_ratio=1.0,
170
+ )
171
+
172
+
173
+ def _reps(exercise: str) -> Reps:
174
+ return Reps(
175
+ exercise=exercise,
176
+ reps=[Rep(1, 0, 12, 24, 0.0, 0.4, 0.8)],
177
+ partial_reps=[],
178
+ )
179
+
180
+
181
+ def _profile() -> UserProfile:
182
+ return UserProfile(
183
+ goal="beginner_practice",
184
+ experience_level="beginner",
185
+ intended_exercise="auto",
186
+ intended_variation=None,
187
+ known_limitations=[],
188
+ equipment="bodyweight",
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)
228
+ self.assertGreater(analysis.items[0].metrics["hand_width_ratio"], 1.45)
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)
259
+ self.assertGreater(analysis.items[0].metrics["stance_width_ratio"], 1.35)
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)
270
+ self.assertIn("lockout_quality", analysis.items[0].metrics)
271
+ self.assertLess(analysis.aggregate_metrics["avg_wrist_travel"], 0.24)
272
+
273
+ def test_profile_intended_variation_overrides_metric_rule(self) -> None:
274
+ profile = UserProfile(
275
+ goal="beginner_practice",
276
+ experience_level="beginner",
277
+ intended_exercise="push_up",
278
+ intended_variation="close_grip_push_up",
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)
289
+ self.assertIn("close_hand_placement", variation.not_issues)
290
+
291
+
292
+ if __name__ == "__main__":
293
+ unittest.main()
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 ExerciseClassification, PoseFrame, PoseSequence
11
- from pozify.steps import rep_counter
12
- from pozify.steps.rep_counters import get_rep_counter
13
- from pozify.steps.rep_counters.push_up import PushUpRepCounter
14
- from pozify.steps.rep_counters.shoulder_press import ShoulderPressRepCounter
15
- from pozify.steps.rep_counters.squat import SquatRepCounter
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 _classification(self, exercise: str) -> ExerciseClassification:
112
- return ExerciseClassification(
113
- exercise=exercise, # type: ignore[arg-type]
114
- confidence=0.95,
115
- window_predictions=[],
116
- fallback_required=False,
117
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
 
 
 
 
 
 
 
 
 
 
 
119
  def test_segments_squat_reps(self) -> None:
120
- reps, debug = rep_counter.run(self._classification("squat"), _sequence_for_exercise("squat", 3))
 
121
  self.assertEqual(len(reps.reps), 3)
122
  self.assertEqual(reps.reps[0].start_frame, 0)
123
  self.assertTrue(debug["accepted_reps"])
124
 
125
  def test_segments_push_up_reps(self) -> None:
126
- reps, _debug = rep_counter.run(self._classification("push_up"), _sequence_for_exercise("push_up", 3))
 
127
  self.assertEqual(len(reps.reps), 3)
128
  self.assertEqual(reps.partial_reps, [])
129
 
130
  def test_segments_shoulder_press_reps(self) -> None:
131
- reps, _debug = rep_counter.run(
132
- self._classification("shoulder_press"),
133
- _sequence_for_exercise("shoulder_press", 3),
134
- )
135
  self.assertEqual(len(reps.reps), 3)
136
  self.assertEqual(reps.reps[0].mid_frame, 12)
137
 
138
  def test_reports_partial_last_rep(self) -> None:
139
- reps, _debug = rep_counter.run(
140
- self._classification("push_up"),
141
- _sequence_for_exercise("push_up", 2, partial_tail=True),
142
- )
143
  self.assertEqual(len(reps.reps), 2)
144
  self.assertIn("ends_mid_rep", {item["reason"] for item in reps.partial_reps})
145
 
146
  def test_unknown_exercise_is_not_segmented(self) -> None:
147
- reps, debug = rep_counter.run(self._classification("unknown"), _sequence_for_exercise("push_up", 1))
 
148
  self.assertEqual(reps.reps, [])
149
  self.assertEqual(reps.partial_reps, [{"reason": "unknown_exercise"}])
150
  self.assertEqual(debug["selected_signal"], "none")
151
 
152
- def test_exercises_resolve_to_specific_rep_counter_strategies(self) -> None:
153
- self.assertIsInstance(get_rep_counter("push_up"), PushUpRepCounter)
154
- self.assertIsInstance(get_rep_counter("shoulder_press"), ShoulderPressRepCounter)
155
- self.assertIsInstance(get_rep_counter("squat"), SquatRepCounter)
 
 
 
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__":
web/app.js CHANGED
@@ -51,10 +51,12 @@ function Summary({ result }) {
51
  const report = result.report;
52
  const summary = report.coach_summary;
53
  const warnings = report.video_manifest.quality_warnings || [];
 
54
  const stats = [
55
  ["Exercise", report.exercise.exercise],
56
  ["Variation", report.variation.detected_variation],
57
  ["Reps", String(report.reps.reps.length)],
 
58
  ["Mode", report.artifacts.analysis_mode],
59
  ];
60
 
@@ -77,6 +79,7 @@ function Summary({ result }) {
77
  h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
78
  h(NoteList, { title: "Next session", items: summary.next_session_plan }),
79
  ),
 
80
  warnings.length
81
  ? h(
82
  "div",
@@ -87,6 +90,54 @@ function Summary({ result }) {
87
  );
88
  }
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  function NoteList({ title, items }) {
91
  return h(
92
  "article",
 
51
  const report = result.report;
52
  const summary = report.coach_summary;
53
  const warnings = report.video_manifest.quality_warnings || [];
54
+ const issues = report.issue_markers?.issues || [];
55
  const stats = [
56
  ["Exercise", report.exercise.exercise],
57
  ["Variation", report.variation.detected_variation],
58
  ["Reps", String(report.reps.reps.length)],
59
+ ["Issues", String(issues.length)],
60
  ["Mode", report.artifacts.analysis_mode],
61
  ];
62
 
 
79
  h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
80
  h(NoteList, { title: "Next session", items: summary.next_session_plan }),
81
  ),
82
+ h(IssueTimeline, { issues }),
83
  warnings.length
84
  ? h(
85
  "div",
 
90
  );
91
  }
92
 
93
+ function issueEvidence(issue) {
94
+ const entries = Object.entries(issue.evidence || {}).filter(
95
+ ([key, value]) =>
96
+ !["threshold", "confidence", "variation_context", "supporting_frames", "fallback"].includes(key) &&
97
+ typeof value !== "object",
98
+ );
99
+ const [metric, value] = entries[0] || ["metric", "n/a"];
100
+ return `${label(metric)} ${value} vs ${issue.evidence?.threshold ?? "n/a"}`;
101
+ }
102
+
103
+ function IssueTimeline({ issues }) {
104
+ return h(
105
+ "section",
106
+ { className: "issue-timeline", "aria-label": "Issue timeline" },
107
+ h(
108
+ "div",
109
+ { className: "timeline-head" },
110
+ h("h3", null, "Issue timeline"),
111
+ h("span", null, issues.length ? `${issues.length} interval${issues.length === 1 ? "" : "s"}` : "clear"),
112
+ ),
113
+ issues.length
114
+ ? h(
115
+ "div",
116
+ { className: "timeline-list" },
117
+ issues.map((issue, index) =>
118
+ h(
119
+ "article",
120
+ { className: "timeline-item", key: `${issue.rep_id}-${issue.issue}-${index}` },
121
+ h(
122
+ "div",
123
+ { className: "timeline-main" },
124
+ h("strong", null, label(issue.issue)),
125
+ h("span", null, `Rep ${issue.rep_id} · ${issue.start_sec.toFixed(2)}s-${issue.end_sec.toFixed(2)}s`),
126
+ ),
127
+ h(
128
+ "div",
129
+ { className: "timeline-meta" },
130
+ h("span", null, `severity ${Math.round(issue.severity * 100)}%`),
131
+ h("span", null, issueEvidence(issue)),
132
+ h("span", null, issue.affected_joints.map(label).join(", ")),
133
+ ),
134
+ ),
135
+ ),
136
+ )
137
+ : h("p", null, "No sustained threshold violations were found."),
138
+ );
139
+ }
140
+
141
  function NoteList({ title, items }) {
142
  return h(
143
  "article",
web/styles.css CHANGED
@@ -373,7 +373,7 @@ input[type="text"]:focus,
373
 
374
  .stat-grid {
375
  display: grid;
376
- grid-template-columns: repeat(4, minmax(0, 1fr));
377
  gap: 10px;
378
  margin: 16px 0;
379
  }
@@ -432,6 +432,78 @@ input[type="text"]:focus,
432
  margin-top: 14px;
433
  }
434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  .quality-list span {
436
  border-radius: 999px;
437
  padding: 7px 10px;
@@ -532,4 +604,9 @@ input[type="text"]:focus,
532
  .panel-head {
533
  display: grid;
534
  }
 
 
 
 
 
535
  }
 
373
 
374
  .stat-grid {
375
  display: grid;
376
+ grid-template-columns: repeat(5, minmax(0, 1fr));
377
  gap: 10px;
378
  margin: 16px 0;
379
  }
 
432
  margin-top: 14px;
433
  }
434
 
435
+ .issue-timeline {
436
+ margin-top: 14px;
437
+ border: 1px solid rgba(37, 44, 31, 0.12);
438
+ border-radius: 8px;
439
+ padding: 14px;
440
+ background: rgba(255, 253, 247, 0.62);
441
+ }
442
+
443
+ .timeline-head {
444
+ display: flex;
445
+ justify-content: space-between;
446
+ gap: 12px;
447
+ align-items: center;
448
+ margin-bottom: 10px;
449
+ }
450
+
451
+ .timeline-head h3 {
452
+ margin: 0;
453
+ color: var(--forest);
454
+ font-size: 0.98rem;
455
+ }
456
+
457
+ .timeline-head span,
458
+ .timeline-meta span {
459
+ color: var(--muted);
460
+ font-size: 0.82rem;
461
+ }
462
+
463
+ .timeline-list {
464
+ display: grid;
465
+ gap: 8px;
466
+ }
467
+
468
+ .timeline-item {
469
+ display: grid;
470
+ grid-template-columns: minmax(150px, 0.36fr) minmax(0, 0.64fr);
471
+ gap: 12px;
472
+ border-left: 3px solid var(--clay);
473
+ border-radius: 8px;
474
+ padding: 10px 12px;
475
+ background: rgba(255, 249, 236, 0.72);
476
+ }
477
+
478
+ .timeline-main strong,
479
+ .timeline-main span {
480
+ display: block;
481
+ }
482
+
483
+ .timeline-main strong {
484
+ color: var(--forest-deep);
485
+ font-size: 0.95rem;
486
+ }
487
+
488
+ .timeline-main span {
489
+ margin-top: 3px;
490
+ color: var(--muted);
491
+ font-size: 0.84rem;
492
+ }
493
+
494
+ .timeline-meta {
495
+ display: flex;
496
+ flex-wrap: wrap;
497
+ gap: 6px;
498
+ align-items: center;
499
+ }
500
+
501
+ .timeline-meta span {
502
+ border-radius: 999px;
503
+ padding: 6px 8px;
504
+ background: rgba(33, 79, 58, 0.08);
505
+ }
506
+
507
  .quality-list span {
508
  border-radius: 999px;
509
  padding: 7px 10px;
 
604
  .panel-head {
605
  display: grid;
606
  }
607
+
608
+ .timeline-item,
609
+ .timeline-head {
610
+ display: grid;
611
+ }
612
  }