Pozify / src /pozify /exercises /base.py
tiena2cva's picture
refactor(exercises): route pipeline through exercise objects
95fec78
Raw
History Blame Contribute Delete
15.3 kB
from __future__ import annotations
from statistics import mean, pstdev
from typing import Any
from pozify.contracts import (
IssueMarker,
IssueMarkers,
PoseFrame,
PoseSequence,
Rep,
RepAnalysis,
RepAnalysisItem,
Reps,
UserProfile,
VideoManifest,
Variation,
)
from pozify.exercise_catalog import get_exercise_spec
from pozify.exercises.shared.analyzer import (
ExerciseMetricResult,
mean_optional,
round_optional,
safe_ratio,
score,
usable,
value_series,
)
from pozify.exercises.shared.issue_marker import (
IssueRule,
frame_scores_for_rule,
frames_for_rep,
group_violations,
marker_from_group,
minimum_run_length,
)
from pozify.exercises.shared.rep_counter import ExerciseRepCounter
from pozify.steps.rep_signals import average_axis
class ExerciseStrategy(ExerciseRepCounter):
def __init__(
self,
*,
video_manifest: VideoManifest,
pose_sequence: PoseSequence,
profile: UserProfile,
) -> None:
self.video_manifest = video_manifest
self.pose_sequence = pose_sequence
self.profile = profile
def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
raise NotImplementedError
def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
raise NotImplementedError
def profile_not_issues(self, variation: str) -> list[str]:
return []
def issue_rules(self) -> tuple[IssueRule, ...]:
return ()
def analyze_reps(self, reps: Reps) -> RepAnalysis:
draft_items: list[tuple[Rep, dict[str, Any], float, float, float, list[str]]] = []
for rep in reps.reps:
rep_frames = self.frames_for_rep(rep)
primary_signal = self.primary_signal(rep_frames)
common_metrics = self.common_rep_metrics(rep, rep_frames, primary_signal)
exercise_metrics, rom_score, stability_score, symmetry_score, hints = self.metrics(rep_frames)
metrics = {**common_metrics, **exercise_metrics}
draft_items.append((rep, metrics, rom_score, stability_score, symmetry_score, hints))
average_duration = (
mean(item[1]["rep_duration_sec"] for item in draft_items)
if draft_items
else 0.0
)
items: list[RepAnalysisItem] = []
for rep, metrics, rom_score, stability_score, symmetry_score, hints in draft_items:
duration = metrics["rep_duration_sec"]
metrics["tempo_consistency_score"] = (
score(1.0 - abs(duration - average_duration) / max(average_duration, 0.1))
if average_duration
else 0.0
)
items.append(
RepAnalysisItem(
rep_id=rep.rep_id,
duration_sec=duration,
range_of_motion_score=rom_score,
stability_score=stability_score,
symmetry_score=symmetry_score,
metrics=metrics,
variation_hints=sorted(set(hints)),
)
)
aggregate_metrics = {
"avg_rom_score": (
round(mean(item.range_of_motion_score for item in items), 2) if items else 0.0
),
"avg_stability_score": (
round(mean(item.stability_score for item in items), 2) if items else 0.0
),
"avg_symmetry_score": (
round(mean(item.symmetry_score for item in items), 2) if items else 0.0
),
"avg_rep_duration_sec": (
round(mean(item.duration_sec for item in items), 2) if items else 0.0
),
"avg_tempo_consistency_score": self.aggregate_numeric(items, "tempo_consistency_score")
or 0.0,
"avg_landmark_confidence": (
self.aggregate_numeric(items, "landmark_confidence") or self.pose_sequence.pose_valid_ratio
),
"fatigue_trend_rom_delta": self.fatigue_trend(items),
"pose_valid_ratio": self.pose_sequence.pose_valid_ratio,
}
for metric_name in (
"hand_width_ratio",
"stance_width_ratio",
"bottom_pause_sec",
"lockout_quality",
"wrist_height_asymmetry",
"wrist_travel",
"knee_support_score",
):
aggregate_value = self.aggregate_numeric(items, metric_name)
if aggregate_value is not None:
aggregate_metrics[f"avg_{metric_name}"] = aggregate_value
return RepAnalysis(
exercise=self.exercise,
items=items,
aggregate_metrics=aggregate_metrics,
)
def resolve_variation(self, analysis: RepAnalysis) -> Variation:
if self.profile.intended_variation:
variation = self.profile.intended_variation
confidence = 0.95
not_issues = self.profile_not_issues(variation)
else:
variation, confidence, not_issues = self.detect_variation(analysis)
if analysis.aggregate_metrics.get("avg_rom_score", 1.0) < 0.7:
not_issues.append("low_rom_requires_user_intent_check")
if analysis.aggregate_metrics.get("pose_valid_ratio", 1.0) < 0.8:
not_issues.append("low_pose_confidence_limits_variation_call")
return Variation(
exercise=self.exercise,
detected_variation=variation,
variation_confidence=confidence,
not_issues=sorted(set(not_issues)),
)
def mark_issues(
self,
reps: Reps,
analysis: RepAnalysis,
variation: Variation,
) -> IssueMarkers:
rep_by_id = {rep.rep_id: rep for rep in reps.reps}
issues: list[IssueMarker] = []
for item in analysis.items:
rep = rep_by_id.get(item.rep_id)
if rep is None:
continue
rep_frames = frames_for_rep(self.pose_sequence, rep)
if not rep_frames:
fallback = self.fallback_rep_marker(reps, item, variation)
if fallback is not None:
issues.append(fallback)
continue
min_run_length = minimum_run_length(rep_frames)
for rule in self.issue_rules():
if set(rule.suppress_when_not_issue) & set(variation.not_issues):
continue
scores = frame_scores_for_rule(rep_frames, self.exercise, rule)
for group in group_violations(scores, min_run_length):
issues.append(marker_from_group(rule, group, item, variation))
return IssueMarkers(
issues=sorted(
issues,
key=lambda issue: (issue.start_frame, issue.end_frame, issue.rep_id, issue.issue),
)
)
def frames_for_rep(self, rep: Rep) -> list[PoseFrame]:
rep_frames = [
frame
for frame in self.pose_sequence.frames
if rep.start_frame <= frame.frame_index <= rep.end_frame
]
if rep_frames:
return rep_frames
if not self.pose_sequence.frames:
return []
closest = min(
self.pose_sequence.frames,
key=lambda frame: min(
abs(frame.frame_index - rep.start_frame),
abs(frame.frame_index - rep.mid_frame),
abs(frame.frame_index - rep.end_frame),
),
)
return [closest]
def primary_signal(self, frames: list[PoseFrame]) -> list[float | None]:
if self.exercise == "shoulder_press":
return value_series(
frames,
lambda frame: average_axis(frame, ("left_wrist", "right_wrist"), "y"),
)
if self.exercise == "push_up":
return value_series(
frames,
lambda frame: mean_optional(
[
average_axis(frame, ("left_shoulder", "right_shoulder"), "y"),
average_axis(frame, ("left_hip", "right_hip"), "y"),
]
),
)
return value_series(frames, lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"))
def common_rep_metrics(
self,
rep: Rep,
frames: list[PoseFrame],
primary_signal: list[float | None],
) -> dict[str, Any]:
eccentric_duration = round(max(0.0, rep.mid_sec - rep.start_sec), 2)
concentric_duration = round(max(0.0, rep.end_sec - rep.mid_sec), 2)
duration = round(max(0.0, rep.end_sec - rep.start_sec), 2)
smoothness_score, jerk_score = self.smoothness_score(primary_signal)
stability_axis = value_series(
frames,
lambda frame: average_axis(frame, ("left_hip", "right_hip"), "x"),
)
stability_noise = self.std(stability_axis) or 0.0
return {
"rep_duration_sec": duration,
"eccentric_duration_sec": eccentric_duration,
"concentric_duration_sec": concentric_duration,
"tempo_ratio": round_optional(safe_ratio(eccentric_duration, concentric_duration), 2),
"top_pause_sec": self.pause_duration(frames, primary_signal, target="top"),
"bottom_pause_sec": self.pause_duration(frames, primary_signal, target="bottom"),
"smoothness_score": smoothness_score,
"jerk_score": round_optional(jerk_score, 4),
"landmark_confidence": self.mean_visibility(frames),
"hip_lateral_drift": round_optional(stability_noise, 4),
}
def fallback_rep_marker(
self,
reps: Reps,
item: RepAnalysisItem,
variation: Variation,
) -> IssueMarker | None:
exercise_spec = get_exercise_spec(reps.exercise)
if item.stability_score >= 0.78 or exercise_spec.mock_issue is None:
return None
rep = next((rep for rep in reps.reps if rep.rep_id == item.rep_id), None)
if rep is None:
return None
issue_spec = exercise_spec.mock_issue
metric_value = (
item.range_of_motion_score
if issue_spec.evidence_metric == "range_of_motion_score"
else item.metrics.get(issue_spec.evidence_metric)
)
return IssueMarker(
rep_id=item.rep_id,
issue=issue_spec.issue,
severity=round(1.0 - item.stability_score, 2),
start_frame=rep.mid_frame,
end_frame=rep.end_frame,
start_sec=rep.mid_sec,
end_sec=rep.end_sec,
affected_joints=list(issue_spec.affected_joints),
evidence={
issue_spec.evidence_metric: metric_value,
"threshold": issue_spec.threshold,
"confidence": round(max(0.0, min(1.0, 1.0 - item.stability_score)), 2),
"variation_context": {
"detected_variation": variation.detected_variation,
"variation_confidence": variation.variation_confidence,
"not_issues": list(variation.not_issues),
},
"fallback": "rep_level_metrics",
},
)
def metric(self, analysis: RepAnalysis, name: str) -> float | None:
value = analysis.aggregate_metrics.get(name)
if isinstance(value, (int, float)):
return float(value)
return None
def confidence(
self,
base: float,
analysis: RepAnalysis,
supporting_metric: float | None,
) -> float:
rep_bonus = min(0.12, len(analysis.items) * 0.03)
metric_bonus = 0.0 if supporting_metric is None else min(0.1, abs(supporting_metric) * 0.03)
pose_bonus = min(0.08, float(analysis.aggregate_metrics.get("pose_valid_ratio", 0.0)) * 0.08)
return round(min(0.95, base + rep_bonus + metric_bonus + pose_bonus), 2)
def std(self, values: list[float | None]) -> float | None:
usable_values = usable(values)
if len(usable_values) < 2:
return 0.0 if usable_values else None
return pstdev(usable_values)
def mean_visibility(self, frames: list[PoseFrame]) -> float:
values: list[float | None] = []
for frame in frames:
if "mean_visibility" in frame.pose_quality:
values.append(float(frame.pose_quality["mean_visibility"]))
continue
landmark_values = [
landmark.get("visibility")
for landmark in frame.landmarks.values()
if landmark.get("visibility") is not None
]
values.extend(float(value) for value in landmark_values)
return score(mean_optional(values) if values else 0.0)
def smoothness_score(self, signal_values: list[float | None]) -> tuple[float, float | None]:
usable_values = usable(signal_values)
if len(usable_values) < 4:
return 0.5, None
deltas = [
usable_values[index] - usable_values[index - 1]
for index in range(1, len(usable_values))
]
jerks = [deltas[index] - deltas[index - 1] for index in range(1, len(deltas))]
if not jerks:
return 0.5, None
jerk = mean(abs(value) for value in jerks)
return score(1.0 - jerk * 8.0), jerk
def pause_duration(
self,
frames: list[PoseFrame],
signal_values: list[float | None],
*,
target: str,
) -> float:
usable_values = usable(signal_values)
if len(usable_values) < 3 or len(frames) < 3:
return 0.0
min_value = min(usable_values)
max_value = max(usable_values)
tolerance = max((max_value - min_value) * 0.08, 0.01)
if target == "bottom":
active = [value is not None and value >= max_value - tolerance for value in signal_values]
else:
active = [value is not None and value <= min_value + tolerance for value in signal_values]
longest = 0
current = 0
for item in active:
if item:
current += 1
longest = max(longest, current)
else:
current = 0
if longest <= 1:
return 0.0
frame_duration = (frames[-1].timestamp_sec - frames[0].timestamp_sec) / max(
1, len(frames) - 1
)
return round(longest * frame_duration, 2)
def aggregate_numeric(self, items: list[RepAnalysisItem], metric_name: str) -> float | None:
values = [
item.metrics.get(metric_name)
for item in items
if isinstance(item.metrics.get(metric_name), (int, float))
]
if not values:
return None
return round(sum(float(value) for value in values) / len(values), 4)
def fatigue_trend(self, items: list[RepAnalysisItem]) -> float:
if len(items) < 2:
return 0.0
first = items[0].range_of_motion_score
last = items[-1].range_of_motion_score
return round(last - first, 4)