feat(pose): add pluggable pose backends
Browse files- README.md +26 -3
- pyproject.toml +1 -0
- src/pozify/pipeline.py +1 -3
- src/pozify/steps/pose_backends/__init__.py +21 -0
- src/pozify/steps/pose_backends/base.py +24 -0
- src/pozify/steps/pose_backends/landmarks.py +63 -0
- src/pozify/steps/pose_backends/mediapipe.py +125 -0
- src/pozify/steps/pose_backends/mmpose.py +12 -0
- src/pozify/steps/pose_backends/mock.py +39 -0
- src/pozify/steps/pose_backends/registry.py +17 -0
- src/pozify/steps/pose_cleaning.py +170 -10
- src/pozify/steps/pose_landmarker.py +134 -29
- tests/test_pose_steps.py +155 -0
- uv.lock +0 -0
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`
|
| 29 |
-
steps
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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. `
|
| 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 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
PoseFrame(
|
| 11 |
frame_index=frame.frame_index,
|
| 12 |
timestamp_sec=frame.timestamp_sec,
|
| 13 |
-
landmarks=
|
| 14 |
world_landmarks=frame.world_landmarks,
|
| 15 |
pose_quality={
|
| 16 |
**frame.pose_quality,
|
| 17 |
"cleaned": True,
|
| 18 |
-
"
|
| 19 |
-
"
|
|
|
|
| 20 |
},
|
| 21 |
)
|
| 22 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
return PoseSequence(
|
| 25 |
frames=cleaned_frames,
|
| 26 |
normalized=True,
|
| 27 |
-
smoothing_method="
|
| 28 |
-
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 |
-
|
| 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 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 48 |
-
world_landmarks=
|
| 49 |
pose_quality={
|
| 50 |
-
|
| 51 |
-
"
|
| 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
|
|
|