Daphne commited on
Commit
dab45ec
·
1 Parent(s): 4526b91

Detect exercise variations from rep metrics

Browse files
Files changed (1) hide show
  1. src/pozify/steps/variation_detector.py +114 -10
src/pozify/steps/variation_detector.py CHANGED
@@ -4,30 +4,134 @@ from pozify.contracts import ExerciseClassification, RepAnalysis, UserProfile, V
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
  )
 
4
  from pozify.exercise_catalog import get_exercise_spec
5
 
6
 
7
+ def _metric(analysis: RepAnalysis, name: str) -> float | None:
8
+ value = analysis.aggregate_metrics.get(name)
9
+ if isinstance(value, (int, float)):
10
+ return float(value)
11
+ return None
12
+
13
+
14
+ def _confidence(base: float, analysis: RepAnalysis, supporting_metric: float | None) -> float:
15
+ rep_bonus = min(0.12, len(analysis.items) * 0.03)
16
+ metric_bonus = 0.0 if supporting_metric is None else min(0.1, abs(supporting_metric) * 0.03)
17
+ pose_bonus = min(0.08, float(analysis.aggregate_metrics.get("pose_valid_ratio", 0.0)) * 0.08)
18
+ return round(min(0.95, base + rep_bonus + metric_bonus + pose_bonus), 2)
19
+
20
+
21
+ def _push_up_variation(analysis: RepAnalysis) -> tuple[str, float, list[str]]:
22
+ hand_width = _metric(analysis, "avg_hand_width_ratio")
23
+ knee_support = _metric(analysis, "avg_knee_support_score")
24
+ not_issues: list[str] = []
25
+
26
+ if knee_support is not None and knee_support >= 0.65:
27
+ return "knee_push_up", _confidence(0.74, analysis, knee_support), ["knee_contact"]
28
+ if hand_width is not None and hand_width >= 1.45:
29
+ return "wide_grip_push_up", _confidence(0.72, analysis, hand_width), ["wide_hand_placement"]
30
+ if hand_width is not None and hand_width <= 0.95:
31
+ return (
32
+ "close_grip_push_up",
33
+ _confidence(0.72, analysis, 1.0 - hand_width),
34
+ ["close_hand_placement"],
35
+ )
36
+
37
+ if hand_width is None:
38
+ not_issues.append("hand_width_unverified")
39
+ return "standard_push_up", _confidence(0.62, analysis, hand_width), not_issues
40
+
41
+
42
+ def _squat_variation(analysis: RepAnalysis) -> tuple[str, float, list[str]]:
43
+ stance_width = _metric(analysis, "avg_stance_width_ratio")
44
+ bottom_pause = _metric(analysis, "avg_bottom_pause_sec")
45
+
46
+ if bottom_pause is not None and bottom_pause >= 0.4:
47
+ return "pause_squat", _confidence(0.76, analysis, bottom_pause), ["bottom_pause"]
48
+ if stance_width is not None and stance_width >= 1.35:
49
+ return "wide_squat_stance", _confidence(0.72, analysis, stance_width), ["wide_stance"]
50
+ if stance_width is not None and stance_width <= 0.85:
51
+ return (
52
+ "narrow_squat_stance",
53
+ _confidence(0.72, analysis, 1.0 - stance_width),
54
+ ["narrow_stance"],
55
+ )
56
+
57
+ not_issues = ["stance_width_unverified"] if stance_width is None else []
58
+ return "normal_squat_stance", _confidence(0.62, analysis, stance_width), not_issues
59
+
60
+
61
+ def _shoulder_press_variation(analysis: RepAnalysis) -> tuple[str, float, list[str]]:
62
+ lockout_quality = _metric(analysis, "avg_lockout_quality")
63
+ wrist_travel = _metric(analysis, "avg_wrist_travel")
64
+ wrist_asymmetry = _metric(analysis, "avg_wrist_height_asymmetry")
65
+
66
+ if wrist_asymmetry is not None and wrist_asymmetry >= 0.12:
67
+ return (
68
+ "asymmetric_press",
69
+ _confidence(0.76, analysis, wrist_asymmetry),
70
+ ["intentional_asymmetry_check"],
71
+ )
72
+ if (lockout_quality is not None and lockout_quality <= 0.65) or (
73
+ wrist_travel is not None and wrist_travel < 0.24
74
+ ):
75
+ support = 1.0 - lockout_quality if lockout_quality is not None else wrist_travel
76
+ return "partial_press", _confidence(0.72, analysis, support), ["partial_range_of_motion"]
77
+
78
+ not_issues = ["lockout_unverified"] if lockout_quality is None else []
79
+ return "standing_shoulder_press", _confidence(0.62, analysis, lockout_quality), not_issues
80
+
81
+
82
+ def _detected_variation(
83
+ classification: ExerciseClassification,
84
+ analysis: RepAnalysis,
85
+ ) -> tuple[str, float, list[str]]:
86
+ if classification.exercise == "push_up":
87
+ return _push_up_variation(analysis)
88
+ if classification.exercise == "squat":
89
+ return _squat_variation(analysis)
90
+ if classification.exercise == "shoulder_press":
91
+ return _shoulder_press_variation(analysis)
92
+
93
+ exercise_spec = get_exercise_spec(classification.exercise)
94
+ return (
95
+ exercise_spec.default_variation,
96
+ exercise_spec.default_variation_confidence,
97
+ list(exercise_spec.default_variation_not_issues),
98
+ )
99
+
100
+
101
+ def _profile_not_issues(variation: str) -> list[str]:
102
+ mapping: dict[str, list[str]] = {
103
+ "wide_grip_push_up": ["wide_hand_placement"],
104
+ "close_grip_push_up": ["close_hand_placement"],
105
+ "knee_push_up": ["knee_contact"],
106
+ "wide_squat_stance": ["wide_stance"],
107
+ "narrow_squat_stance": ["narrow_stance"],
108
+ "pause_squat": ["bottom_pause"],
109
+ "partial_press": ["partial_range_of_motion"],
110
+ "asymmetric_press": ["intentional_asymmetry_check"],
111
+ }
112
+ return list(mapping.get(variation, []))
113
+
114
+
115
  def run(
116
  classification: ExerciseClassification,
117
  analysis: RepAnalysis,
118
  profile: UserProfile,
119
  ) -> Variation:
 
120
  if profile.intended_variation:
121
  variation = profile.intended_variation
122
  confidence = 0.95
123
+ not_issues = _profile_not_issues(variation)
124
  else:
125
+ variation, confidence, not_issues = _detected_variation(classification, analysis)
 
126
 
 
 
 
 
 
127
  if analysis.aggregate_metrics.get("avg_rom_score", 1.0) < 0.7:
128
+ not_issues.append("low_rom_requires_user_intent_check")
129
+ if analysis.aggregate_metrics.get("pose_valid_ratio", 1.0) < 0.8:
130
+ not_issues.append("low_pose_confidence_limits_variation_call")
131
 
132
  return Variation(
133
  exercise=classification.exercise,
134
  detected_variation=variation,
135
  variation_confidence=confidence,
136
+ not_issues=sorted(set(not_issues)),
137
  )