Pozify / src /pozify /exercises /shared /rep_counter.py
tiena2cva's picture
refactor(exercises): route pipeline through exercise objects
95fec78
Raw
History Blame Contribute Delete
6.49 kB
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import asdict
from typing import Any
from pozify.contracts import PoseSequence, Rep, Reps
from pozify.steps.rep_signals import SignalSample, normalize_optional, samples_from_values, smooth_signal
from pozify.steps.rep_state_machine import RepSegment, find_local_extrema, segment_low_high_low
MIN_CYCLE_FRAMES = 12
MIN_PHASE_FRAMES = 4
MIN_USABLE_SIGNAL_SAMPLES = 9
MIN_SIGNAL_RANGE = 0.22
def mean_optional(values: list[float | None]) -> float | None:
usable = [value for value in values if value is not None]
if not usable:
return None
return sum(usable) / len(usable)
def combine(primary: list[float | None], secondary: list[float | None], *, weight: float) -> list[float | None]:
normalized_secondary = normalize_optional(secondary)
combined: list[float | None] = []
for primary_value, secondary_value in zip(primary, normalized_secondary, strict=False):
if primary_value is None:
combined.append(None)
continue
if secondary_value is None:
combined.append(primary_value)
continue
combined.append(primary_value + secondary_value * weight)
return combined
def normalized_samples(
sequence: PoseSequence,
raw_signal: list[float | None],
) -> tuple[list[SignalSample], float]:
smoothed_signal = smooth_signal(raw_signal)
normalized_signal = normalize_optional(smoothed_signal)
samples = samples_from_values(sequence, normalized_signal)
signal_range = max((value for value in normalized_signal if value is not None), default=0.0) - min(
(value for value in normalized_signal if value is not None),
default=0.0,
)
return samples, round(signal_range, 4)
def segments_to_reps(segments: list[RepSegment]) -> list[Rep]:
return [
Rep(
rep_id=index + 1,
start_frame=segment.start.frame_index,
mid_frame=segment.middle.frame_index,
end_frame=segment.end.frame_index,
start_sec=round(segment.start.timestamp_sec, 3),
mid_sec=round(segment.middle.timestamp_sec, 3),
end_sec=round(segment.end.timestamp_sec, 3),
)
for index, segment in enumerate(segments)
]
def partial_reps(
sequence: PoseSequence,
segments: list[RepSegment],
samples: list[SignalSample],
*,
signal_range: float,
) -> list[dict[str, Any]]:
if not samples:
return [{"reason": "low_signal_quality"}]
partials: list[dict[str, Any]] = []
if not segments:
if signal_range >= MIN_SIGNAL_RANGE * 0.7:
partials.append(
{
"reason": "insufficient_rom",
"start_frame": samples[0].frame_index,
"end_frame": samples[-1].frame_index,
"start_sec": round(samples[0].timestamp_sec, 3),
"end_sec": round(samples[-1].timestamp_sec, 3),
}
)
return partials
first_segment = segments[0]
if first_segment.start.frame_index - samples[0].frame_index >= MIN_PHASE_FRAMES:
partials.append(
{
"reason": "starts_mid_rep",
"start_frame": samples[0].frame_index,
"end_frame": first_segment.start.frame_index,
"start_sec": round(samples[0].timestamp_sec, 3),
"end_sec": round(first_segment.start.timestamp_sec, 3),
}
)
last_segment = segments[-1]
if samples[-1].frame_index - last_segment.end.frame_index >= MIN_PHASE_FRAMES:
partials.append(
{
"reason": "ends_mid_rep",
"start_frame": last_segment.end.frame_index,
"end_frame": samples[-1].frame_index,
"start_sec": round(last_segment.end.timestamp_sec, 3),
"end_sec": round(samples[-1].timestamp_sec, 3),
}
)
return partials
class ExerciseRepCounter(ABC):
exercise: str
pose_sequence: PoseSequence
min_cycle_frames = MIN_CYCLE_FRAMES
min_phase_frames = MIN_PHASE_FRAMES
min_signal_range = MIN_SIGNAL_RANGE
min_usable_signal_samples = MIN_USABLE_SIGNAL_SAMPLES
@abstractmethod
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
"""Build the exercise-specific normalized motion signal."""
def count(self) -> tuple[Reps, dict[str, Any]]:
sequence = self.pose_sequence
samples, debug = self.build_signal()
signal_range = debug["raw_signal_range"]
extrema = find_local_extrema(samples)
min_amplitude = max(self.min_signal_range, signal_range * 0.35)
segments = (
segment_low_high_low(
extrema,
min_cycle_frames=self.min_cycle_frames,
min_phase_frames=self.min_phase_frames,
min_amplitude=min_amplitude,
)
if len(samples) >= self.min_usable_signal_samples
else []
)
partials = partial_reps(sequence, segments, samples, signal_range=signal_range)
if sequence.pose_valid_ratio < 0.8:
partials.append({"reason": "low_pose_valid_ratio"})
reps = Reps(
exercise=self.exercise,
reps=segments_to_reps(segments),
partial_reps=partials,
)
debug_payload = {
**debug,
"thresholds": {
"min_cycle_frames": self.min_cycle_frames,
"min_phase_frames": self.min_phase_frames,
"min_amplitude": round(min_amplitude, 4),
},
"extrema": [
{
"kind": extrema_item.kind,
"frame_index": extrema_item.sample.frame_index,
"timestamp_sec": round(extrema_item.sample.timestamp_sec, 3),
"value": round(extrema_item.sample.value, 4),
}
for extrema_item in extrema
],
"accepted_reps": [
{
"start": asdict(segment.start),
"middle": asdict(segment.middle),
"end": asdict(segment.end),
"amplitude": round(segment.amplitude, 4),
}
for segment in segments
],
}
return reps, debug_payload