tiena2cva commited on
Commit
95fec78
·
1 Parent(s): 6661f3a

refactor(exercises): route pipeline through exercise objects

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