Refactor into modular app structure
Split monolithic files into focused modules: - app/core: settings, logging, lifecycle - app/signaling: websocket server, ICE parser, message models - app/webrtc: peer session, video receiver, frame source - app/vision: pose landmarker wrapper, model config, pose types - app/exercises/dead_bug: detector, metrics, rules, state machine, types - app/rendering: skeleton renderer, status overlay, window display - app/audio: rep announcer - app/diagnostics: perf timer, crash handler - configs: environment-based settings - tests: unit tests for rules, state machine, ICE parser - run.py: entry point
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
|
||||
from app.exercises.dead_bug.metrics import calculate_metrics
|
||||
from app.exercises.dead_bug.rules import has_required_visibility
|
||||
from app.exercises.dead_bug.state_machine import DeadBugStateMachine
|
||||
from app.exercises.dead_bug.types import DeadBugMetrics, DeadBugPhase, DeadBugResult, Point
|
||||
from app.rendering.overlay_renderer import draw_status_overlay
|
||||
from app.rendering.skeleton_renderer import draw_landmarks
|
||||
from app.vision.frame_utils import bgr_to_rgba
|
||||
from app.vision.pose_landmarker import PoseLandmarkerWrapper
|
||||
from app.vision.pose_types import (
|
||||
LEFT_ANKLE,
|
||||
LEFT_ELBOW,
|
||||
LEFT_HIP,
|
||||
LEFT_KNEE,
|
||||
LEFT_SHOULDER,
|
||||
LEFT_WRIST,
|
||||
REQUIRED_LANDMARKS,
|
||||
RIGHT_ANKLE,
|
||||
RIGHT_ELBOW,
|
||||
RIGHT_HIP,
|
||||
RIGHT_KNEE,
|
||||
RIGHT_SHOULDER,
|
||||
RIGHT_WRIST,
|
||||
)
|
||||
|
||||
|
||||
class DeadBugDetector:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model_path: str | None = None,
|
||||
visibility_threshold: float = 0.45,
|
||||
extension_confirm_frames: int = 4,
|
||||
reset_confirm_frames: int = 3,
|
||||
prefer_gpu: bool = True,
|
||||
) -> None:
|
||||
self.visibility_threshold = visibility_threshold
|
||||
|
||||
self._latest_result = None
|
||||
self._result_lock = threading.Lock()
|
||||
self._result_event = threading.Event()
|
||||
self._inflight = False
|
||||
self._inflight_started_at = 0.0
|
||||
|
||||
def on_result(pose_result, _image, _timestamp_ms):
|
||||
with self._result_lock:
|
||||
self._latest_result = pose_result
|
||||
self._inflight = False
|
||||
self._inflight_started_at = 0.0
|
||||
self._result_event.set()
|
||||
|
||||
self._landmarker = PoseLandmarkerWrapper(
|
||||
model_path=model_path,
|
||||
prefer_gpu=prefer_gpu,
|
||||
result_callback=on_result,
|
||||
)
|
||||
|
||||
self._state = DeadBugStateMachine(
|
||||
extension_confirm_frames=extension_confirm_frames,
|
||||
reset_confirm_frames=reset_confirm_frames,
|
||||
)
|
||||
|
||||
self._last_timestamp_ms = -1
|
||||
|
||||
def close(self) -> None:
|
||||
self._landmarker.close()
|
||||
|
||||
def process_frame(self, bgr_frame: np.ndarray, timestamp_ms: int) -> tuple[np.ndarray, DeadBugResult]:
|
||||
timestamp_ms = self._normalize_timestamp(timestamp_ms)
|
||||
|
||||
with self._result_lock:
|
||||
if self._inflight and time.monotonic() - self._inflight_started_at > 0.5:
|
||||
logger.warning("MediaPipe detect_async timed out; allowing next frame submission")
|
||||
self._inflight = False
|
||||
self._inflight_started_at = 0.0
|
||||
should_submit = not self._inflight
|
||||
if should_submit:
|
||||
self._inflight = True
|
||||
self._inflight_started_at = time.monotonic()
|
||||
|
||||
if should_submit:
|
||||
rgba_frame = bgr_to_rgba(bgr_frame)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGBA, data=rgba_frame)
|
||||
self._result_event.clear()
|
||||
try:
|
||||
self._landmarker.detect_async(mp_image, timestamp_ms)
|
||||
except Exception:
|
||||
with self._result_lock:
|
||||
self._inflight = False
|
||||
self._inflight_started_at = 0.0
|
||||
raise
|
||||
self._result_event.wait(timeout=0.08)
|
||||
|
||||
with self._result_lock:
|
||||
pose_result = self._latest_result
|
||||
|
||||
annotated = bgr_frame.copy()
|
||||
|
||||
if pose_result is None or not pose_result.pose_landmarks:
|
||||
result = DeadBugResult(
|
||||
rep_count=self._state.rep_count,
|
||||
phase=DeadBugPhase.NO_POSE,
|
||||
side=self._state.active_side,
|
||||
is_standard=False,
|
||||
feedback=["No full body detected"],
|
||||
metrics=None,
|
||||
)
|
||||
draw_status_overlay(annotated, result)
|
||||
return annotated, result
|
||||
|
||||
landmarks = [Point(lm.x, lm.y, lm.z, getattr(lm, "visibility", 1.0)) for lm in pose_result.pose_landmarks[0]]
|
||||
draw_landmarks(annotated, landmarks, REQUIRED_LANDMARKS, visibility_threshold=self.visibility_threshold)
|
||||
|
||||
if not has_required_visibility(landmarks, REQUIRED_LANDMARKS, self.visibility_threshold):
|
||||
result = DeadBugResult(
|
||||
rep_count=self._state.rep_count,
|
||||
phase=DeadBugPhase.NO_POSE,
|
||||
side=self._state.active_side,
|
||||
is_standard=False,
|
||||
feedback=["Keep shoulders, elbows, wrists, hips, knees, ankles visible"],
|
||||
metrics=None,
|
||||
)
|
||||
draw_status_overlay(annotated, result)
|
||||
return annotated, result
|
||||
|
||||
raw = calculate_metrics(
|
||||
landmarks,
|
||||
left_shoulder=LEFT_SHOULDER,
|
||||
right_shoulder=RIGHT_SHOULDER,
|
||||
left_elbow=LEFT_ELBOW,
|
||||
right_elbow=RIGHT_ELBOW,
|
||||
left_wrist=LEFT_WRIST,
|
||||
right_wrist=RIGHT_WRIST,
|
||||
left_hip=LEFT_HIP,
|
||||
right_hip=RIGHT_HIP,
|
||||
left_knee=LEFT_KNEE,
|
||||
right_knee=RIGHT_KNEE,
|
||||
left_ankle=LEFT_ANKLE,
|
||||
right_ankle=RIGHT_ANKLE,
|
||||
visibility_threshold=self.visibility_threshold,
|
||||
)
|
||||
|
||||
metrics = DeadBugMetrics(
|
||||
left_arm_extended=raw["left_arm_extended"],
|
||||
right_arm_extended=raw["right_arm_extended"],
|
||||
left_leg_extended=raw["left_leg_extended"],
|
||||
right_leg_extended=raw["right_leg_extended"],
|
||||
left_elbow_angle=raw["left_elbow_angle"],
|
||||
right_elbow_angle=raw["right_elbow_angle"],
|
||||
left_knee_angle=raw["left_knee_angle"],
|
||||
right_knee_angle=raw["right_knee_angle"],
|
||||
feedback=raw["feedback"],
|
||||
)
|
||||
|
||||
result = self._state.update(metrics)
|
||||
draw_status_overlay(annotated, result)
|
||||
return annotated, result
|
||||
|
||||
def _normalize_timestamp(self, timestamp_ms: int) -> int:
|
||||
if timestamp_ms <= self._last_timestamp_ms:
|
||||
timestamp_ms = self._last_timestamp_ms + 1
|
||||
self._last_timestamp_ms = timestamp_ms
|
||||
return timestamp_ms
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from app.exercises.dead_bug.types import Point
|
||||
|
||||
|
||||
def angle(a: Point, b: Point, c: Point) -> float:
|
||||
ba = np.array([a.x - b.x, a.y - b.y], dtype=np.float32)
|
||||
bc = np.array([c.x - b.x, c.y - b.y], dtype=np.float32)
|
||||
denom = float(np.linalg.norm(ba) * np.linalg.norm(bc))
|
||||
if denom == 0:
|
||||
return 0.0
|
||||
cos_value = float(np.dot(ba, bc) / denom)
|
||||
return float(np.degrees(np.arccos(np.clip(cos_value, -1.0, 1.0))))
|
||||
|
||||
|
||||
def distance(a: Point, b: Point) -> float:
|
||||
return float(np.hypot(a.x - b.x, a.y - b.y))
|
||||
|
||||
|
||||
def calculate_metrics(
|
||||
lm: list[Point],
|
||||
*,
|
||||
left_shoulder: int,
|
||||
right_shoulder: int,
|
||||
left_elbow: int,
|
||||
right_elbow: int,
|
||||
left_wrist: int,
|
||||
right_wrist: int,
|
||||
left_hip: int,
|
||||
right_hip: int,
|
||||
left_knee: int,
|
||||
right_knee: int,
|
||||
left_ankle: int,
|
||||
right_ankle: int,
|
||||
visibility_threshold: float = 0.45,
|
||||
) -> dict:
|
||||
left_elbow_angle = angle(lm[left_shoulder], lm[left_elbow], lm[left_wrist])
|
||||
right_elbow_angle = angle(lm[right_shoulder], lm[right_elbow], lm[right_wrist])
|
||||
left_knee_angle = angle(lm[left_hip], lm[left_knee], lm[left_ankle])
|
||||
right_knee_angle = angle(lm[right_hip], lm[right_knee], lm[right_ankle])
|
||||
|
||||
shoulder_width = distance(lm[left_shoulder], lm[right_shoulder])
|
||||
hip_width = distance(lm[left_hip], lm[right_hip])
|
||||
scale = max(shoulder_width, hip_width, 0.08)
|
||||
|
||||
left_arm_extended = (
|
||||
left_elbow_angle >= 145
|
||||
and distance(lm[left_shoulder], lm[left_wrist]) >= scale * 1.15
|
||||
and lm[left_wrist].y <= lm[left_shoulder].y + scale * 0.35
|
||||
)
|
||||
right_arm_extended = (
|
||||
right_elbow_angle >= 145
|
||||
and distance(lm[right_shoulder], lm[right_wrist]) >= scale * 1.15
|
||||
and lm[right_wrist].y <= lm[right_shoulder].y + scale * 0.35
|
||||
)
|
||||
|
||||
left_leg_extended = (
|
||||
left_knee_angle >= 150
|
||||
and distance(lm[left_hip], lm[left_ankle]) >= scale * 1.55
|
||||
and lm[left_ankle].y >= lm[left_knee].y - scale * 0.2
|
||||
)
|
||||
right_leg_extended = (
|
||||
right_knee_angle >= 150
|
||||
and distance(lm[right_hip], lm[right_ankle]) >= scale * 1.55
|
||||
and lm[right_ankle].y >= lm[right_knee].y - scale * 0.2
|
||||
)
|
||||
|
||||
feedback: list[str] = []
|
||||
if left_arm_extended and left_elbow_angle < 160:
|
||||
feedback.append("Straighten left arm")
|
||||
if right_arm_extended and right_elbow_angle < 160:
|
||||
feedback.append("Straighten right arm")
|
||||
if left_leg_extended and left_knee_angle < 165:
|
||||
feedback.append("Straighten left leg")
|
||||
if right_leg_extended and right_knee_angle < 165:
|
||||
feedback.append("Straighten right leg")
|
||||
|
||||
return {
|
||||
"left_arm_extended": left_arm_extended,
|
||||
"right_arm_extended": right_arm_extended,
|
||||
"left_leg_extended": left_leg_extended,
|
||||
"right_leg_extended": right_leg_extended,
|
||||
"left_elbow_angle": left_elbow_angle,
|
||||
"right_elbow_angle": right_elbow_angle,
|
||||
"left_knee_angle": left_knee_angle,
|
||||
"right_knee_angle": right_knee_angle,
|
||||
"scale": scale,
|
||||
"feedback": feedback,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.exercises.dead_bug.types import DeadBugMetrics, Point
|
||||
|
||||
|
||||
def has_required_visibility(landmarks: list[Point], required_indices: tuple[int, ...], visibility_threshold: float) -> bool:
|
||||
return all(landmarks[i].visibility >= visibility_threshold for i in required_indices)
|
||||
|
||||
|
||||
def detect_diagonal_extension(metrics: DeadBugMetrics) -> str | None:
|
||||
if metrics.left_leg_extended and metrics.right_leg_extended:
|
||||
return None
|
||||
|
||||
if metrics.right_leg_extended and metrics.left_arm_extended:
|
||||
return "left_arm_right_leg"
|
||||
if metrics.left_leg_extended and metrics.right_arm_extended:
|
||||
return "right_arm_left_leg"
|
||||
return None
|
||||
|
||||
|
||||
def is_ready_position(metrics: DeadBugMetrics) -> bool:
|
||||
knees_bent = metrics.left_knee_angle <= 140 and metrics.right_knee_angle <= 140
|
||||
legs_not_extended = not metrics.left_leg_extended and not metrics.right_leg_extended
|
||||
return knees_bent and legs_not_extended and detect_diagonal_extension(metrics) is None
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.exercises.dead_bug.rules import detect_diagonal_extension, is_ready_position
|
||||
from app.exercises.dead_bug.types import DeadBugMetrics, DeadBugPhase, DeadBugResult
|
||||
|
||||
|
||||
class DeadBugStateMachine:
|
||||
def __init__(self, *, extension_confirm_frames: int = 4, reset_confirm_frames: int = 3) -> None:
|
||||
self.extension_confirm_frames = extension_confirm_frames
|
||||
self.reset_confirm_frames = reset_confirm_frames
|
||||
|
||||
self.rep_count = 0
|
||||
self.phase = DeadBugPhase.READY
|
||||
self.active_side: str | None = None
|
||||
self._candidate_side: str | None = None
|
||||
self._candidate_frames = 0
|
||||
self._reset_frames = 0
|
||||
|
||||
def update(self, metrics: DeadBugMetrics) -> DeadBugResult:
|
||||
side = detect_diagonal_extension(metrics)
|
||||
ready = is_ready_position(metrics)
|
||||
|
||||
if side is None:
|
||||
self._candidate_side = None
|
||||
self._candidate_frames = 0
|
||||
elif side == self._candidate_side:
|
||||
self._candidate_frames += 1
|
||||
else:
|
||||
self._candidate_side = side
|
||||
self._candidate_frames = 1
|
||||
|
||||
if self.phase in (DeadBugPhase.READY, DeadBugPhase.NO_POSE):
|
||||
if self._candidate_frames >= self.extension_confirm_frames and side is not None:
|
||||
self.phase = DeadBugPhase.EXTENDING
|
||||
self.active_side = side
|
||||
self._reset_frames = 0
|
||||
elif self.phase == DeadBugPhase.EXTENDING:
|
||||
if side == self.active_side:
|
||||
self.phase = DeadBugPhase.NEED_RESET
|
||||
elif self.phase == DeadBugPhase.NEED_RESET:
|
||||
if ready:
|
||||
self._reset_frames += 1
|
||||
if self._reset_frames >= self.reset_confirm_frames:
|
||||
self.rep_count += 1
|
||||
self.phase = DeadBugPhase.READY
|
||||
self.active_side = None
|
||||
self._candidate_side = None
|
||||
self._candidate_frames = 0
|
||||
self._reset_frames = 0
|
||||
else:
|
||||
self._reset_frames = 0
|
||||
|
||||
feedback = list(metrics.feedback)
|
||||
if side is None and not ready:
|
||||
feedback.append("Extend opposite arm and leg only")
|
||||
if ready:
|
||||
feedback.append("Ready position")
|
||||
elif side == "left_arm_right_leg":
|
||||
feedback.append("Left arm + right leg")
|
||||
elif side == "right_arm_left_leg":
|
||||
feedback.append("Right arm + left leg")
|
||||
|
||||
is_standard = side is not None and not metrics.feedback
|
||||
return DeadBugResult(
|
||||
rep_count=self.rep_count,
|
||||
phase=self.phase,
|
||||
side=side,
|
||||
is_standard=is_standard,
|
||||
feedback=feedback[:3],
|
||||
metrics=metrics,
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DeadBugPhase(str, Enum):
|
||||
READY = "ready"
|
||||
EXTENDING = "extending"
|
||||
NEED_RESET = "need_reset"
|
||||
NO_POSE = "no_pose"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Point:
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
visibility: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeadBugMetrics:
|
||||
left_arm_extended: bool
|
||||
right_arm_extended: bool
|
||||
left_leg_extended: bool
|
||||
right_leg_extended: bool
|
||||
left_elbow_angle: float
|
||||
right_elbow_angle: float
|
||||
left_knee_angle: float
|
||||
right_knee_angle: float
|
||||
feedback: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeadBugResult:
|
||||
rep_count: int
|
||||
phase: DeadBugPhase
|
||||
side: str | None
|
||||
is_standard: bool
|
||||
feedback: list[str]
|
||||
metrics: DeadBugMetrics | None
|
||||
Reference in New Issue
Block a user