nvti commited on
Commit
b89e7f7
·
1 Parent(s): de549a1

feat(pose): add pluggable pose backends

Browse files
README.md CHANGED
@@ -25,8 +25,25 @@ The pipeline runs in mock mode by default. To be explicit in scripts, call
25
  POZIFY_MOCK_MODE=1 uv run python app.py
26
  ```
27
 
28
- Setting `POZIFY_MOCK_MODE=0` currently raises `NotImplementedError` until real model-backed
29
- steps are added.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  ## Pipeline
32
 
@@ -117,7 +134,7 @@ Each step lives in `src/pozify/steps/` and exposes a `run(...)` function.
117
  Recommended replacement order:
118
 
119
  1. `video_qc.py`: read real video metadata with OpenCV.
120
- 2. `pose_landmarker.py`: integrate MediaPipe Pose Landmarker.
121
  3. `exercise_classifier.py`: load the exercise router model.
122
  4. `rep_counter.py`: implement exercise-specific state machines.
123
  5. `rep_analysis.py` and `issue_marker.py`: compute real metrics and issue scores.
@@ -134,3 +151,9 @@ uv run python -c "import app; from pozify.pipeline import run_pipeline; print('o
134
 
135
  The unit tests run the full mocked pipeline with no video input and with a small fixture path, then
136
  assert deterministic top-level keys for each JSON artifact.
 
 
 
 
 
 
 
25
  POZIFY_MOCK_MODE=1 uv run python app.py
26
  ```
27
 
28
+ Setting `POZIFY_MOCK_MODE=0` enables real video QC and MediaPipe-backed pose extraction while later
29
+ analysis steps remain mocked. On current Python versions the pose step uses MediaPipe Tasks and
30
+ downloads `pose_landmarker_lite.task` into `/tmp/pozify-models` on first use. To use a pre-downloaded
31
+ model, set:
32
+
33
+ ```bash
34
+ POZIFY_MEDIAPIPE_POSE_MODEL=/path/to/pose_landmarker_lite.task POZIFY_MOCK_MODE=0 uv run python app.py
35
+ ```
36
+
37
+ The pose extractor is selected through a backend interface. MediaPipe is the default real backend:
38
+
39
+ ```bash
40
+ POZIFY_POSE_BACKEND=mediapipe POZIFY_MOCK_MODE=0 uv run python app.py
41
+ ```
42
+
43
+ Backend implementations live in `src/pozify/steps/pose_backends/` and return the same
44
+ `PoseDetection` shape, so downstream steps do not depend on a specific model library. A reserved
45
+ `mmpose` backend class is included as the integration point for OpenMMLab MMPose; implementing it
46
+ requires installing MMPose/MMCV and mapping model keypoints into the shared landmark dictionary.
47
 
48
  ## Pipeline
49
 
 
134
  Recommended replacement order:
135
 
136
  1. `video_qc.py`: read real video metadata with OpenCV.
137
+ 2. `pose_backends/`: add or refine pose model adapters such as MediaPipe or MMPose.
138
  3. `exercise_classifier.py`: load the exercise router model.
139
  4. `rep_counter.py`: implement exercise-specific state machines.
140
  5. `rep_analysis.py` and `issue_marker.py`: compute real metrics and issue scores.
 
151
 
152
  The unit tests run the full mocked pipeline with no video input and with a small fixture path, then
153
  assert deterministic top-level keys for each JSON artifact.
154
+
155
+ To run the real MediaPipe smoke test against `tests/fixtures/sample.MOV`, opt in explicitly:
156
+
157
+ ```bash
158
+ POZIFY_RUN_REAL_POSE_TESTS=1 uv run python -m unittest tests.test_pose_steps.PoseStepTests.test_real_sample_mov_extracts_pose_landmarks
159
+ ```
pyproject.toml CHANGED
@@ -6,6 +6,7 @@ readme = "README.md"
6
  requires-python = ">=3.11"
7
  dependencies = [
8
  "gradio>=4.44.0",
 
9
  "numpy>=1.26.0",
10
  "opencv-python-headless>=4.10.0",
11
  ]
 
6
  requires-python = ">=3.11"
7
  dependencies = [
8
  "gradio>=4.44.0",
9
+ "mediapipe>=0.10.35",
10
  "numpy>=1.26.0",
11
  "opencv-python-headless>=4.10.0",
12
  ]
src/pozify/pipeline.py CHANGED
@@ -38,8 +38,6 @@ def run_pipeline(
38
  mock: bool | None = None,
39
  ) -> dict[str, Any]:
40
  mock_mode = _env_mock_mode() if mock is None else mock
41
- if not mock_mode:
42
- raise NotImplementedError("Only mock pipeline implementations are available right now.")
43
 
44
  run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
45
  run_dir = RUNS_DIR / run_id
@@ -68,7 +66,7 @@ def run_pipeline(
68
  manifest = video_qc.run(video_path)
69
  write_artifact("video_manifest.json", manifest)
70
 
71
- pose_sequence = pose_landmarker.run(manifest)
72
  cleaned_pose_sequence = pose_cleaning.run(pose_sequence)
73
  write_artifact("pose_sequence.json", cleaned_pose_sequence)
74
 
 
38
  mock: bool | None = None,
39
  ) -> dict[str, Any]:
40
  mock_mode = _env_mock_mode() if mock is None else mock
 
 
41
 
42
  run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
43
  run_dir = RUNS_DIR / run_id
 
66
  manifest = video_qc.run(video_path)
67
  write_artifact("video_manifest.json", manifest)
68
 
69
+ pose_sequence = pose_landmarker.run(manifest, mock=mock_mode)
70
  cleaned_pose_sequence = pose_cleaning.run(pose_sequence)
71
  write_artifact("pose_sequence.json", cleaned_pose_sequence)
72
 
src/pozify/steps/pose_backends/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pozify.steps.pose_backends.base import PoseBackend, PoseDetection
4
+ from pozify.steps.pose_backends.landmarks import LANDMARK_NAMES, landmark_list_to_dict, landmark_to_dict
5
+ from pozify.steps.pose_backends.mediapipe import MediaPipePoseBackend
6
+ from pozify.steps.pose_backends.mmpose import MMPoseBackend
7
+ from pozify.steps.pose_backends.mock import MockPoseBackend
8
+ from pozify.steps.pose_backends.registry import create_pose_backend
9
+
10
+
11
+ __all__ = [
12
+ "LANDMARK_NAMES",
13
+ "MMPoseBackend",
14
+ "MediaPipePoseBackend",
15
+ "MockPoseBackend",
16
+ "PoseBackend",
17
+ "PoseDetection",
18
+ "create_pose_backend",
19
+ "landmark_list_to_dict",
20
+ "landmark_to_dict",
21
+ ]
src/pozify/steps/pose_backends/base.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Protocol
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class PoseDetection:
9
+ landmarks: dict[str, dict[str, float]]
10
+ world_landmarks: dict[str, dict[str, float]]
11
+ source: str
12
+
13
+
14
+ class PoseBackend(Protocol):
15
+ source: str
16
+
17
+ def __enter__(self) -> "PoseBackend":
18
+ return self
19
+
20
+ def __exit__(self, *args: object) -> None:
21
+ return None
22
+
23
+ def detect(self, rgb_frame: Any | None, *, frame_index: int) -> PoseDetection:
24
+ raise NotImplementedError
src/pozify/steps/pose_backends/landmarks.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ LANDMARK_NAMES = [
7
+ "nose",
8
+ "left_eye_inner",
9
+ "left_eye",
10
+ "left_eye_outer",
11
+ "right_eye_inner",
12
+ "right_eye",
13
+ "right_eye_outer",
14
+ "left_ear",
15
+ "right_ear",
16
+ "mouth_left",
17
+ "mouth_right",
18
+ "left_shoulder",
19
+ "right_shoulder",
20
+ "left_elbow",
21
+ "right_elbow",
22
+ "left_wrist",
23
+ "right_wrist",
24
+ "left_pinky",
25
+ "right_pinky",
26
+ "left_index",
27
+ "right_index",
28
+ "left_thumb",
29
+ "right_thumb",
30
+ "left_hip",
31
+ "right_hip",
32
+ "left_knee",
33
+ "right_knee",
34
+ "left_ankle",
35
+ "right_ankle",
36
+ "left_heel",
37
+ "right_heel",
38
+ "left_foot_index",
39
+ "right_foot_index",
40
+ ]
41
+
42
+
43
+ def landmark_to_dict(landmark: Any) -> dict[str, float]:
44
+ payload = {
45
+ "x": round(float(landmark.x), 6),
46
+ "y": round(float(landmark.y), 6),
47
+ "z": round(float(landmark.z), 6),
48
+ "visibility": round(float(getattr(landmark, "visibility", 0.0)), 6),
49
+ }
50
+ presence = getattr(landmark, "presence", None)
51
+ if presence is not None:
52
+ payload["presence"] = round(float(presence), 6)
53
+ return payload
54
+
55
+
56
+ def landmark_list_to_dict(landmarks: Any | None) -> dict[str, dict[str, float]]:
57
+ if landmarks is None:
58
+ return {}
59
+ landmark_values = landmarks.landmark if hasattr(landmarks, "landmark") else landmarks
60
+ return {
61
+ name: landmark_to_dict(landmark)
62
+ for name, landmark in zip(LANDMARK_NAMES, landmark_values, strict=False)
63
+ }
src/pozify/steps/pose_backends/mediapipe.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from urllib.request import urlretrieve
7
+
8
+ from pozify.steps.pose_backends.base import PoseDetection
9
+ from pozify.steps.pose_backends.landmarks import landmark_list_to_dict
10
+
11
+
12
+ POSE_TASK_MODEL_URL = (
13
+ "https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
14
+ "pose_landmarker_lite/float16/latest/pose_landmarker_lite.task"
15
+ )
16
+
17
+
18
+ class MediaPipePoseBackend:
19
+ source = "mediapipe_pose"
20
+
21
+ def __init__(self) -> None:
22
+ self._pose = self._create_pose()
23
+
24
+ def __enter__(self) -> "MediaPipePoseBackend":
25
+ enter = getattr(self._pose, "__enter__", None)
26
+ if enter is not None:
27
+ enter()
28
+ return self
29
+
30
+ def __exit__(self, *args: object) -> None:
31
+ exit_method = getattr(self._pose, "__exit__", None)
32
+ if exit_method is not None:
33
+ exit_method(*args)
34
+ return
35
+ close = getattr(self._pose, "close", None)
36
+ if close is not None:
37
+ close()
38
+
39
+ def detect(self, rgb_frame: Any | None, *, frame_index: int) -> PoseDetection:
40
+ if rgb_frame is None:
41
+ return PoseDetection(landmarks={}, world_landmarks={}, source=self.source)
42
+ results = self._pose.process(rgb_frame)
43
+ return PoseDetection(
44
+ landmarks=landmark_list_to_dict(getattr(results, "pose_landmarks", None)),
45
+ world_landmarks=landmark_list_to_dict(getattr(results, "pose_world_landmarks", None)),
46
+ source=self.source,
47
+ )
48
+
49
+ def _create_pose(self) -> Any:
50
+ try:
51
+ import mediapipe as mp
52
+ except ImportError as exc:
53
+ raise RuntimeError(
54
+ "MediaPipe is required for the mediapipe pose backend. Install dependencies with uv sync."
55
+ ) from exc
56
+
57
+ if hasattr(mp, "solutions"):
58
+ return mp.solutions.pose.Pose(
59
+ static_image_mode=False,
60
+ model_complexity=1,
61
+ smooth_landmarks=False,
62
+ enable_segmentation=False,
63
+ min_detection_confidence=0.5,
64
+ min_tracking_confidence=0.5,
65
+ )
66
+
67
+ return _MediaPipeTasksPoseAdapter(mp, _ensure_pose_task_model())
68
+
69
+
70
+ class _MediaPipeTasksPoseAdapter:
71
+ def __init__(self, mediapipe_module: Any, model_path: Path) -> None:
72
+ self._mp = mediapipe_module
73
+ self._landmarker = self._create_landmarker(model_path)
74
+
75
+ def _create_landmarker(self, model_path: Path) -> Any:
76
+ base_options = self._mp.tasks.BaseOptions(model_asset_path=str(model_path))
77
+ options = self._mp.tasks.vision.PoseLandmarkerOptions(
78
+ base_options=base_options,
79
+ running_mode=self._mp.tasks.vision.RunningMode.IMAGE,
80
+ num_poses=1,
81
+ min_pose_detection_confidence=0.5,
82
+ min_pose_presence_confidence=0.5,
83
+ min_tracking_confidence=0.5,
84
+ )
85
+ return self._mp.tasks.vision.PoseLandmarker.create_from_options(options)
86
+
87
+ def process(self, rgb_frame: Any) -> Any:
88
+ image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=rgb_frame)
89
+ result = self._landmarker.detect(image)
90
+ pose_landmarks = result.pose_landmarks[0] if result.pose_landmarks else None
91
+ pose_world_landmarks = (
92
+ result.pose_world_landmarks[0] if result.pose_world_landmarks else None
93
+ )
94
+ return type(
95
+ "PoseResult",
96
+ (),
97
+ {
98
+ "pose_landmarks": pose_landmarks,
99
+ "pose_world_landmarks": pose_world_landmarks,
100
+ },
101
+ )()
102
+
103
+ def close(self) -> None:
104
+ close = getattr(self._landmarker, "close", None)
105
+ if close is not None:
106
+ close()
107
+
108
+
109
+ def _pose_task_model_path() -> Path:
110
+ configured_path = os.getenv("POZIFY_MEDIAPIPE_POSE_MODEL")
111
+ if configured_path:
112
+ return Path(configured_path).expanduser()
113
+
114
+ cache_path = Path(os.getenv("POZIFY_MODEL_CACHE", "/tmp/pozify-models"))
115
+ return cache_path / "pose_landmarker_lite.task"
116
+
117
+
118
+ def _ensure_pose_task_model() -> Path:
119
+ model_path = _pose_task_model_path()
120
+ if model_path.exists():
121
+ return model_path
122
+
123
+ model_path.parent.mkdir(parents=True, exist_ok=True)
124
+ urlretrieve(POSE_TASK_MODEL_URL, model_path)
125
+ return model_path
src/pozify/steps/pose_backends/mmpose.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ class MMPoseBackend:
5
+ source = "mmpose"
6
+
7
+ def __init__(self) -> None:
8
+ raise NotImplementedError(
9
+ "The mmpose backend is reserved behind the PoseBackend interface. "
10
+ "Install MMPose/MMCV and implement MMPoseBackend.detect() to map model keypoints "
11
+ "into the shared landmark dictionary."
12
+ )
src/pozify/steps/pose_backends/mock.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pozify.steps.pose_backends.base import PoseDetection
6
+ from pozify.steps.pose_backends.landmarks import LANDMARK_NAMES
7
+
8
+
9
+ def _mock_landmarks(frame_index: int) -> dict[str, dict[str, float]]:
10
+ phase = (frame_index % 90) / 90
11
+ vertical_offset = 0.08 if phase < 0.5 else 0.0
12
+ landmarks: dict[str, dict[str, float]] = {}
13
+ for idx, name in enumerate(LANDMARK_NAMES):
14
+ side_offset = -0.06 if "left" in name else 0.06 if "right" in name else 0.0
15
+ base_y = 0.2 + min(idx, 11) * 0.045
16
+ landmarks[name] = {
17
+ "x": round(0.5 + side_offset, 4),
18
+ "y": round(base_y + vertical_offset, 4),
19
+ "z": round(-0.02 * side_offset, 4),
20
+ "visibility": 0.95,
21
+ }
22
+ return landmarks
23
+
24
+
25
+ class MockPoseBackend:
26
+ source = "mock_pose"
27
+
28
+ def __enter__(self) -> "MockPoseBackend":
29
+ return self
30
+
31
+ def __exit__(self, *args: object) -> None:
32
+ return None
33
+
34
+ def detect(self, rgb_frame: Any | None, *, frame_index: int) -> PoseDetection:
35
+ return PoseDetection(
36
+ landmarks=_mock_landmarks(frame_index),
37
+ world_landmarks={},
38
+ source=self.source,
39
+ )
src/pozify/steps/pose_backends/registry.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pozify.steps.pose_backends.base import PoseBackend
4
+ from pozify.steps.pose_backends.mediapipe import MediaPipePoseBackend
5
+ from pozify.steps.pose_backends.mmpose import MMPoseBackend
6
+ from pozify.steps.pose_backends.mock import MockPoseBackend
7
+
8
+
9
+ def create_pose_backend(name: str) -> PoseBackend:
10
+ normalized_name = name.strip().lower().replace("-", "_")
11
+ if normalized_name == "mock":
12
+ return MockPoseBackend()
13
+ if normalized_name == "mediapipe":
14
+ return MediaPipePoseBackend()
15
+ if normalized_name == "mmpose":
16
+ return MMPoseBackend()
17
+ raise ValueError(f"Unknown pose backend: {name!r}")
src/pozify/steps/pose_cleaning.py CHANGED
@@ -3,28 +3,188 @@ from __future__ import annotations
3
  from pozify.contracts import PoseFrame, PoseSequence
4
 
5
 
6
- def run(sequence: PoseSequence) -> PoseSequence:
7
- cleaned_frames: list[PoseFrame] = []
8
- for frame in sequence.frames:
9
- cleaned_frames.append(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  PoseFrame(
11
  frame_index=frame.frame_index,
12
  timestamp_sec=frame.timestamp_sec,
13
- landmarks=frame.landmarks,
14
  world_landmarks=frame.world_landmarks,
15
  pose_quality={
16
  **frame.pose_quality,
17
  "cleaned": True,
18
- "normalization_origin": "mid_hip_mock",
19
- "normalization_scale": "torso_length_mock",
 
20
  },
21
  )
22
  )
 
 
 
 
 
 
 
 
23
 
24
  return PoseSequence(
25
  frames=cleaned_frames,
26
  normalized=True,
27
- smoothing_method="mock_exponential_smoothing",
28
- pose_valid_ratio=sequence.pose_valid_ratio,
29
  )
30
-
 
3
  from pozify.contracts import PoseFrame, PoseSequence
4
 
5
 
6
+ MAX_INTERPOLATION_GAP = 3
7
+ SMOOTHING_ALPHA = 0.45
8
+ SMOOTHED_FIELDS = ("x", "y", "z")
9
+
10
+
11
+ def _copy_landmarks(
12
+ landmarks: dict[str, dict[str, float]],
13
+ ) -> dict[str, dict[str, float]]:
14
+ return {name: dict(values) for name, values in landmarks.items()}
15
+
16
+
17
+ def _copy_frame(frame: PoseFrame, landmarks: dict[str, dict[str, float]] | None = None) -> PoseFrame:
18
+ return PoseFrame(
19
+ frame_index=frame.frame_index,
20
+ timestamp_sec=frame.timestamp_sec,
21
+ landmarks=_copy_landmarks(landmarks if landmarks is not None else frame.landmarks),
22
+ world_landmarks=_copy_landmarks(frame.world_landmarks),
23
+ pose_quality=dict(frame.pose_quality),
24
+ )
25
+
26
+
27
+ def _interpolate_landmarks(
28
+ start: dict[str, dict[str, float]],
29
+ end: dict[str, dict[str, float]],
30
+ fraction: float,
31
+ ) -> dict[str, dict[str, float]]:
32
+ interpolated: dict[str, dict[str, float]] = {}
33
+ for name in sorted(start.keys() & end.keys()):
34
+ start_values = start[name]
35
+ end_values = end[name]
36
+ values: dict[str, float] = {}
37
+ for field in ("x", "y", "z", "visibility", "presence"):
38
+ if field in start_values and field in end_values:
39
+ values[field] = round(
40
+ float(start_values[field])
41
+ + (float(end_values[field]) - float(start_values[field])) * fraction,
42
+ 6,
43
+ )
44
+ if values:
45
+ interpolated[name] = values
46
+ return interpolated
47
+
48
+
49
+ def _interpolate_short_gaps(frames: list[PoseFrame]) -> list[PoseFrame]:
50
+ cleaned = [_copy_frame(frame) for frame in frames]
51
+ index = 0
52
+ while index < len(cleaned):
53
+ if cleaned[index].landmarks:
54
+ index += 1
55
+ continue
56
+
57
+ gap_start = index
58
+ while index < len(cleaned) and not cleaned[index].landmarks:
59
+ index += 1
60
+ gap_end = index - 1
61
+ previous_index = gap_start - 1
62
+ next_index = index
63
+ gap_size = gap_end - gap_start + 1
64
+
65
+ if (
66
+ previous_index < 0
67
+ or next_index >= len(cleaned)
68
+ or gap_size > MAX_INTERPOLATION_GAP
69
+ or not cleaned[previous_index].landmarks
70
+ or not cleaned[next_index].landmarks
71
+ ):
72
+ continue
73
+
74
+ for offset, frame_index in enumerate(range(gap_start, gap_end + 1), start=1):
75
+ fraction = offset / (gap_size + 1)
76
+ landmarks = _interpolate_landmarks(
77
+ cleaned[previous_index].landmarks,
78
+ cleaned[next_index].landmarks,
79
+ fraction,
80
+ )
81
+ cleaned[frame_index] = PoseFrame(
82
+ frame_index=cleaned[frame_index].frame_index,
83
+ timestamp_sec=cleaned[frame_index].timestamp_sec,
84
+ landmarks=landmarks,
85
+ world_landmarks=cleaned[frame_index].world_landmarks,
86
+ pose_quality={
87
+ **cleaned[frame_index].pose_quality,
88
+ "interpolated": bool(landmarks),
89
+ "interpolation_gap_frames": gap_size,
90
+ },
91
+ )
92
+ return cleaned
93
+
94
+
95
+ def _add_smoothed_fields(frames: list[PoseFrame]) -> list[PoseFrame]:
96
+ previous: dict[str, dict[str, float]] = {}
97
+ smoothed_frames: list[PoseFrame] = []
98
+
99
+ for frame in frames:
100
+ landmarks = _copy_landmarks(frame.landmarks)
101
+ for name, values in landmarks.items():
102
+ previous_values = previous.get(name, {})
103
+ current_smoothed: dict[str, float] = {}
104
+ for field in SMOOTHED_FIELDS:
105
+ if field not in values:
106
+ continue
107
+ previous_value = previous_values.get(f"smoothed_{field}", values[field])
108
+ smoothed = previous_value * (1.0 - SMOOTHING_ALPHA) + values[field] * SMOOTHING_ALPHA
109
+ values[f"smoothed_{field}"] = round(smoothed, 6)
110
+ current_smoothed[f"smoothed_{field}"] = values[f"smoothed_{field}"]
111
+ previous[name] = current_smoothed
112
+
113
+ smoothed_frames.append(
114
+ PoseFrame(
115
+ frame_index=frame.frame_index,
116
+ timestamp_sec=frame.timestamp_sec,
117
+ landmarks=landmarks,
118
+ world_landmarks=frame.world_landmarks,
119
+ pose_quality=frame.pose_quality,
120
+ )
121
+ )
122
+ return smoothed_frames
123
+
124
+
125
+ def _normalization_origin_and_scale(
126
+ landmarks: dict[str, dict[str, float]],
127
+ ) -> tuple[float, float, float, bool]:
128
+ required = ("left_hip", "right_hip", "left_shoulder", "right_shoulder")
129
+ if not all(name in landmarks for name in required):
130
+ return 0.0, 0.0, 1.0, False
131
+
132
+ left_hip = landmarks["left_hip"]
133
+ right_hip = landmarks["right_hip"]
134
+ left_shoulder = landmarks["left_shoulder"]
135
+ right_shoulder = landmarks["right_shoulder"]
136
+ origin_x = (left_hip["x"] + right_hip["x"]) / 2.0
137
+ origin_y = (left_hip["y"] + right_hip["y"]) / 2.0
138
+ mid_shoulder_x = (left_shoulder["x"] + right_shoulder["x"]) / 2.0
139
+ mid_shoulder_y = (left_shoulder["y"] + right_shoulder["y"]) / 2.0
140
+ torso_length = ((mid_shoulder_x - origin_x) ** 2 + (mid_shoulder_y - origin_y) ** 2) ** 0.5
141
+ if torso_length <= 1e-6:
142
+ return origin_x, origin_y, 1.0, False
143
+ return origin_x, origin_y, torso_length, True
144
+
145
+
146
+ def _add_normalized_fields(frames: list[PoseFrame]) -> list[PoseFrame]:
147
+ normalized_frames: list[PoseFrame] = []
148
+ for frame in frames:
149
+ landmarks = _copy_landmarks(frame.landmarks)
150
+ origin_x, origin_y, scale, normalized = _normalization_origin_and_scale(landmarks)
151
+ for values in landmarks.values():
152
+ source_x = values.get("smoothed_x", values.get("x"))
153
+ source_y = values.get("smoothed_y", values.get("y"))
154
+ source_z = values.get("smoothed_z", values.get("z"))
155
+ if source_x is None or source_y is None or source_z is None:
156
+ continue
157
+ values["normalized_x"] = round((source_x - origin_x) / scale, 6)
158
+ values["normalized_y"] = round((source_y - origin_y) / scale, 6)
159
+ values["normalized_z"] = round(source_z / scale, 6)
160
+
161
+ normalized_frames.append(
162
  PoseFrame(
163
  frame_index=frame.frame_index,
164
  timestamp_sec=frame.timestamp_sec,
165
+ landmarks=landmarks,
166
  world_landmarks=frame.world_landmarks,
167
  pose_quality={
168
  **frame.pose_quality,
169
  "cleaned": True,
170
+ "normalized": normalized,
171
+ "normalization_origin": "mid_hip",
172
+ "normalization_scale": "mid_shoulder_to_mid_hip",
173
  },
174
  )
175
  )
176
+ return normalized_frames
177
+
178
+
179
+ def run(sequence: PoseSequence) -> PoseSequence:
180
+ interpolated_frames = _interpolate_short_gaps(sequence.frames)
181
+ smoothed_frames = _add_smoothed_fields(interpolated_frames)
182
+ cleaned_frames = _add_normalized_fields(smoothed_frames)
183
+ valid_frames = sum(1 for frame in cleaned_frames if frame.landmarks)
184
 
185
  return PoseSequence(
186
  frames=cleaned_frames,
187
  normalized=True,
188
+ smoothing_method="exponential_smoothing",
189
+ pose_valid_ratio=round(valid_frames / len(cleaned_frames), 4) if cleaned_frames else 0.0,
190
  )
 
src/pozify/steps/pose_landmarker.py CHANGED
@@ -1,54 +1,144 @@
1
  from __future__ import annotations
2
 
 
 
 
 
 
 
3
  from pozify.contracts import PoseFrame, PoseSequence, VideoManifest
 
 
 
 
 
4
  from pozify.steps.video_qc import sample_frame_indices
5
 
6
 
7
- LANDMARK_NAMES = [
8
- "nose",
9
  "left_shoulder",
10
  "right_shoulder",
11
- "left_elbow",
12
- "right_elbow",
13
- "left_wrist",
14
- "right_wrist",
15
  "left_hip",
16
  "right_hip",
17
  "left_knee",
18
  "right_knee",
19
  "left_ankle",
20
  "right_ankle",
21
- ]
22
-
23
-
24
- def _mock_landmarks(frame_index: int) -> dict[str, dict[str, float]]:
25
- phase = (frame_index % 90) / 90
26
- vertical_offset = 0.08 if phase < 0.5 else 0.0
27
- landmarks: dict[str, dict[str, float]] = {}
28
- for idx, name in enumerate(LANDMARK_NAMES):
29
- side_offset = -0.06 if "left" in name else 0.06 if "right" in name else 0.0
30
- base_y = 0.2 + min(idx, 11) * 0.045
31
- landmarks[name] = {
32
- "x": round(0.5 + side_offset, 4),
33
- "y": round(base_y + vertical_offset, 4),
34
- "z": round(-0.02 * side_offset, 4),
35
- "visibility": 0.95,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  }
37
- return landmarks
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- def run(manifest: VideoManifest) -> PoseSequence:
41
  frames: list[PoseFrame] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  for frame_index in sample_frame_indices(manifest.total_frames):
 
43
  frames.append(
44
  PoseFrame(
45
  frame_index=frame_index,
46
- timestamp_sec=round(frame_index / manifest.fps, 3),
47
- landmarks=_mock_landmarks(frame_index),
48
- world_landmarks={},
49
  pose_quality={
50
- "mean_visibility": 0.95,
51
- "critical_landmarks_visible": True,
52
  "mock": True,
53
  },
54
  )
@@ -58,5 +148,20 @@ def run(manifest: VideoManifest) -> PoseSequence:
58
  frames=frames,
59
  normalized=False,
60
  smoothing_method="none",
61
- pose_valid_ratio=1.0,
62
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ from collections.abc import Iterator
4
+ import os
5
+ from typing import Any
6
+
7
+ import cv2
8
+
9
  from pozify.contracts import PoseFrame, PoseSequence, VideoManifest
10
+ from pozify.steps.pose_backends import (
11
+ MockPoseBackend,
12
+ PoseBackend,
13
+ create_pose_backend,
14
+ )
15
  from pozify.steps.video_qc import sample_frame_indices
16
 
17
 
18
+ CRITICAL_LANDMARKS = {
 
19
  "left_shoulder",
20
  "right_shoulder",
 
 
 
 
21
  "left_hip",
22
  "right_hip",
23
  "left_knee",
24
  "right_knee",
25
  "left_ankle",
26
  "right_ankle",
27
+ }
28
+ FULL_BODY_LANDMARKS = CRITICAL_LANDMARKS | {"left_wrist", "right_wrist"}
29
+ DEFAULT_POSE_SAMPLE_COUNT = 120
30
+
31
+
32
+ def _env_pose_backend() -> str:
33
+ return os.getenv("POZIFY_POSE_BACKEND", "mediapipe")
34
+
35
+
36
+ def _iter_video_frames(manifest: VideoManifest) -> Iterator[tuple[int, Any]]:
37
+ if not manifest.video_path or manifest.total_frames <= 0:
38
+ return
39
+
40
+ capture = cv2.VideoCapture(manifest.video_path)
41
+ try:
42
+ if not capture.isOpened():
43
+ return
44
+ sample_count = min(DEFAULT_POSE_SAMPLE_COUNT, manifest.total_frames)
45
+ for frame_index in sample_frame_indices(manifest.total_frames, sample_count):
46
+ capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
47
+ ok, frame = capture.read()
48
+ if ok and frame is not None:
49
+ yield frame_index, frame
50
+ finally:
51
+ capture.release()
52
+
53
+
54
+ def _pose_quality(landmarks: dict[str, dict[str, float]]) -> dict[str, Any]:
55
+ if not landmarks:
56
+ return {
57
+ "mean_visibility": 0.0,
58
+ "critical_landmarks_visible": False,
59
+ "full_body_visibility_proxy": 0.0,
60
+ "landmark_count": 0,
61
+ "pose_warning": "pose_not_detected",
62
  }
 
63
 
64
+ visibility_values = [landmark.get("visibility", 0.0) for landmark in landmarks.values()]
65
+ critical_values = [
66
+ landmarks[name].get("visibility", 0.0)
67
+ for name in CRITICAL_LANDMARKS
68
+ if name in landmarks
69
+ ]
70
+ full_body_values = [
71
+ landmarks[name].get("visibility", 0.0)
72
+ for name in FULL_BODY_LANDMARKS
73
+ if name in landmarks
74
+ ]
75
+ critical_visible = (
76
+ len(critical_values) == len(CRITICAL_LANDMARKS)
77
+ and min(critical_values, default=0.0) >= 0.5
78
+ )
79
+ return {
80
+ "mean_visibility": round(sum(visibility_values) / len(visibility_values), 4),
81
+ "critical_landmarks_visible": critical_visible,
82
+ "full_body_visibility_proxy": round(sum(full_body_values) / len(full_body_values), 4)
83
+ if full_body_values
84
+ else 0.0,
85
+ "landmark_count": len(landmarks),
86
+ }
87
+
88
+
89
+ def _empty_sequence() -> PoseSequence:
90
+ return PoseSequence(frames=[], normalized=False, smoothing_method="none", pose_valid_ratio=0.0)
91
+
92
+
93
+ def _run_with_backend(manifest: VideoManifest, backend: PoseBackend) -> PoseSequence:
94
+ if not manifest.analysis_allowed or not manifest.video_path:
95
+ return _empty_sequence()
96
 
 
97
  frames: list[PoseFrame] = []
98
+ valid_frames = 0
99
+
100
+ with backend as extractor:
101
+ for frame_index, frame in _iter_video_frames(manifest):
102
+ rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
103
+ detection = extractor.detect(rgb_frame, frame_index=frame_index)
104
+ if detection.landmarks:
105
+ valid_frames += 1
106
+ frames.append(
107
+ PoseFrame(
108
+ frame_index=frame_index,
109
+ timestamp_sec=round(frame_index / manifest.fps, 3) if manifest.fps else 0.0,
110
+ landmarks=detection.landmarks,
111
+ world_landmarks=detection.world_landmarks,
112
+ pose_quality={
113
+ **_pose_quality(detection.landmarks),
114
+ "source": detection.source,
115
+ },
116
+ )
117
+ )
118
+
119
+ pose_valid_ratio = round(valid_frames / len(frames), 4) if frames else 0.0
120
+ return PoseSequence(
121
+ frames=frames,
122
+ normalized=False,
123
+ smoothing_method="none",
124
+ pose_valid_ratio=pose_valid_ratio,
125
+ )
126
+
127
+
128
+ def _run_mock(manifest: VideoManifest) -> PoseSequence:
129
+ frames: list[PoseFrame] = []
130
+ backend = MockPoseBackend()
131
  for frame_index in sample_frame_indices(manifest.total_frames):
132
+ detection = backend.detect(None, frame_index=frame_index)
133
  frames.append(
134
  PoseFrame(
135
  frame_index=frame_index,
136
+ timestamp_sec=round(frame_index / manifest.fps, 3) if manifest.fps else 0.0,
137
+ landmarks=detection.landmarks,
138
+ world_landmarks=detection.world_landmarks,
139
  pose_quality={
140
+ **_pose_quality(detection.landmarks),
141
+ "source": detection.source,
142
  "mock": True,
143
  },
144
  )
 
148
  frames=frames,
149
  normalized=False,
150
  smoothing_method="none",
151
+ pose_valid_ratio=1.0 if frames else 0.0,
152
  )
153
+
154
+
155
+ def run(
156
+ manifest: VideoManifest,
157
+ *,
158
+ mock: bool = False,
159
+ backend_name: str | None = None,
160
+ backend: PoseBackend | None = None,
161
+ ) -> PoseSequence:
162
+ if mock:
163
+ return _run_mock(manifest)
164
+ if backend is not None:
165
+ return _run_with_backend(manifest, backend)
166
+ selected_backend = create_pose_backend(backend_name or _env_pose_backend())
167
+ return _run_with_backend(manifest, selected_backend)
tests/test_pose_steps.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+
10
+ import cv2
11
+ import numpy as np
12
+
13
+ FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
16
+
17
+ from pozify.contracts import PoseFrame, PoseSequence, VideoManifest
18
+ from pozify.steps import pose_cleaning, pose_landmarker
19
+ from pozify.steps.pose_backends import PoseDetection, landmark_list_to_dict
20
+
21
+
22
+ def _landmark(x: float, y: float, z: float = 0.0, visibility: float = 0.9) -> SimpleNamespace:
23
+ return SimpleNamespace(x=x, y=y, z=z, visibility=visibility)
24
+
25
+
26
+ def _landmark_result(offset: float = 0.0) -> SimpleNamespace:
27
+ landmarks = [
28
+ _landmark(0.2 + offset + index * 0.001, 0.1 + index * 0.01, -0.01, 0.91)
29
+ for index in range(33)
30
+ ]
31
+ return SimpleNamespace(
32
+ pose_landmarks=SimpleNamespace(landmark=landmarks),
33
+ pose_world_landmarks=SimpleNamespace(landmark=landmarks),
34
+ )
35
+
36
+
37
+ class _FakePose:
38
+ source = "fake_pose"
39
+
40
+ def __init__(self) -> None:
41
+ self.calls = 0
42
+
43
+ def __enter__(self) -> "_FakePose":
44
+ return self
45
+
46
+ def __exit__(self, *args: object) -> None:
47
+ return None
48
+
49
+ def detect(self, _frame: object, *, frame_index: int) -> PoseDetection:
50
+ self.calls += 1
51
+ result = _landmark_result(offset=self.calls * 0.01)
52
+ return PoseDetection(
53
+ landmarks=landmark_list_to_dict(result.pose_landmarks),
54
+ world_landmarks=landmark_list_to_dict(result.pose_world_landmarks),
55
+ source=self.source,
56
+ )
57
+
58
+
59
+ class PoseStepTests(unittest.TestCase):
60
+ def setUp(self) -> None:
61
+ self.temp_dir = tempfile.TemporaryDirectory()
62
+
63
+ def tearDown(self) -> None:
64
+ self.temp_dir.cleanup()
65
+
66
+ def _write_video(self, frame_count: int = 4) -> Path:
67
+ path = Path(self.temp_dir.name) / "pose.mp4"
68
+ writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (640, 480))
69
+ self.assertTrue(writer.isOpened())
70
+ for frame_index in range(frame_count):
71
+ frame = np.full((480, 640, 3), 120 + frame_index, dtype=np.uint8)
72
+ writer.write(frame)
73
+ writer.release()
74
+ return path
75
+
76
+ def test_pose_landmarker_keeps_all_33_landmarks_and_quality(self) -> None:
77
+ path = self._write_video()
78
+ manifest = VideoManifest(
79
+ video_path=str(path),
80
+ fps=30.0,
81
+ duration_sec=0.133,
82
+ total_frames=4,
83
+ sampled_frames=4,
84
+ width=640,
85
+ height=480,
86
+ codec="mp4v",
87
+ container="mp4",
88
+ brightness_mean=120.0,
89
+ blur_laplacian_var=100.0,
90
+ quality_warnings=[],
91
+ analysis_allowed=True,
92
+ )
93
+ sequence = pose_landmarker.run(manifest, backend=_FakePose())
94
+
95
+ self.assertEqual(len(sequence.frames), 4)
96
+ self.assertEqual(len(sequence.frames[0].landmarks), 33)
97
+ self.assertIn("left_foot_index", sequence.frames[0].landmarks)
98
+ self.assertEqual(len(sequence.frames[0].world_landmarks), 33)
99
+ self.assertEqual(sequence.frames[0].pose_quality["source"], "fake_pose")
100
+ self.assertGreater(sequence.frames[0].pose_quality["mean_visibility"], 0.9)
101
+ self.assertTrue(sequence.frames[0].pose_quality["critical_landmarks_visible"])
102
+ self.assertEqual(sequence.pose_valid_ratio, 1.0)
103
+
104
+ def test_pose_cleaning_interpolates_smooths_and_adds_normalized_fields(self) -> None:
105
+ first_landmarks = _landmark_result(offset=0.0).pose_landmarks
106
+ last_landmarks = _landmark_result(offset=0.2).pose_landmarks
107
+ first = landmark_list_to_dict(first_landmarks)
108
+ last = landmark_list_to_dict(last_landmarks)
109
+ sequence = PoseSequence(
110
+ frames=[
111
+ PoseFrame(0, 0.0, first, {}, {"mean_visibility": 0.9}),
112
+ PoseFrame(1, 0.033, {}, {}, {"mean_visibility": 0.0}),
113
+ PoseFrame(2, 0.067, last, {}, {"mean_visibility": 0.9}),
114
+ ],
115
+ normalized=False,
116
+ smoothing_method="none",
117
+ pose_valid_ratio=0.667,
118
+ )
119
+
120
+ cleaned = pose_cleaning.run(sequence)
121
+
122
+ self.assertTrue(cleaned.normalized)
123
+ self.assertEqual(cleaned.smoothing_method, "exponential_smoothing")
124
+ self.assertEqual(cleaned.pose_valid_ratio, 1.0)
125
+ self.assertTrue(cleaned.frames[1].pose_quality["interpolated"])
126
+ shoulder = cleaned.frames[1].landmarks["left_shoulder"]
127
+ self.assertIn("x", shoulder)
128
+ self.assertIn("smoothed_x", shoulder)
129
+ self.assertIn("normalized_x", shoulder)
130
+ self.assertTrue(cleaned.frames[1].pose_quality["normalized"])
131
+
132
+ @unittest.skipUnless(
133
+ os.getenv("POZIFY_RUN_REAL_POSE_TESTS") == "1",
134
+ "set POZIFY_RUN_REAL_POSE_TESTS=1 to run the real MediaPipe fixture smoke test",
135
+ )
136
+ def test_real_sample_mov_extracts_pose_landmarks(self) -> None:
137
+ path = FIXTURES_DIR / "sample.MOV"
138
+ self.assertTrue(path.exists(), path)
139
+
140
+ from pozify.steps import video_qc
141
+
142
+ manifest = video_qc.run(str(path))
143
+ sequence = pose_landmarker.run(manifest, mock=False, backend_name="mediapipe")
144
+ cleaned = pose_cleaning.run(sequence)
145
+
146
+ self.assertTrue(manifest.analysis_allowed)
147
+ self.assertGreater(len(sequence.frames), 0)
148
+ self.assertGreater(sequence.pose_valid_ratio, 0.0)
149
+ self.assertEqual(len(sequence.frames[0].landmarks), 33)
150
+ self.assertEqual(sequence.frames[0].pose_quality["source"], "mediapipe_pose")
151
+ self.assertTrue(cleaned.normalized)
152
+
153
+
154
+ if __name__ == "__main__":
155
+ unittest.main()
uv.lock CHANGED
The diff for this file is too large to render. See raw diff