Skip to content

Commit c891fca

Browse files
bricksdontcatherine-o-brienGerrySantclaude
authored
Add MMPose wholebody estimator (#231)
* Add MMPose wholebody estimator loader Adds mmposewholebody.py wrapping MMPoseInferencer('wholebody') to load a video into a Pose using the canonical COCO Wholebody 133 header from cocowholebody133_header.py. MMPose is an optional dependency; importing the module without it installed raises ImportError immediately (same pattern as holistic.py / mediapipe). Frames where no person is detected are not skipped — a zeroed, fully-masked row is inserted instead so the output frame count stays aligned with the video. Downstream code can distinguish "no detection" from a real keypoint via the mask. Tests cover: header/components (no MMPose required), output shape and metadata, empty-frame masking, all-empty video, and version default. MMPoseInferencer is mocked via sys.modules stubs so the test suite runs without MMPose installed. Co-Authored-By: catherine-o-brien <catherine-o-brien@users.noreply.github.com> Co-Authored-By: GerrySant <GerrySant@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: update README section 6 to distinguish core vs experimental estimators Restructures "Integration with External Data Sources" to label OpenPose support as core and AlphaPose / MMPose as experimental. Adds MMPose wholebody loader example with install instructions. Co-Authored-By: catherine-o-brien <catherine-o-brien@users.noreply.github.com> Co-Authored-By: GerrySant <GerrySant@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * build: add mmpose optional install target to pyproject.toml Adds a [project.optional-dependencies] mmpose group listing mmcv, mmengine, mmdet, and mmpose (all >=their first stable 1.x/2.x releases), installable via pip install pose_format[mmpose]. Co-Authored-By: catherine-o-brien <catherine-o-brien@users.noreply.github.com> Co-Authored-By: GerrySant <GerrySant@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Pin mmpose optional deps to tested versions, fix mmcv install guidance Version floors tightened to match a known-good OpenMMLab 2.x combination (mmcv 2.1.0 / mmengine 0.10.7 / mmdet 3.3.0 / mmpose 1.3.2) verified by ZurichNLP's install script. Also clarifies that mmcv for GPU requires the OpenMMLab CUDA-specific index, not plain pip install. Co-Authored-By: catherine-o-brien <catherine-o-brien@users.noreply.github.com> Co-Authored-By: GerrySant <GerrySant@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Replace core/experimental prose with summary table in section 6 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update README.md * Remove MMPose and Mediapipe Holistic from External Data Sources section MMPose runs inference on raw video rather than loading a pre-existing keypoint format, so it does not belong in this section. Mediapipe Holistic is handled via the CLI (section 2) and also does not fit here. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Rename load_mmposewholebody -> estimate_mmpose_wholebody "load" implies ingesting a pre-existing keypoint file (like OpenPose/AlphaPose loaders); this function runs inference on raw video, so "estimate" is more accurate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Revert all README changes from this branch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: catherine-o-brien <catherine-o-brien@users.noreply.github.com> Co-authored-by: GerrySant <GerrySant@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c676bce commit c891fca

3 files changed

Lines changed: 238 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import numpy as np
2+
import numpy.ma as ma
3+
4+
try:
5+
from mmpose.apis import MMPoseInferencer
6+
except ImportError:
7+
raise ImportError(
8+
"Please install MMPose and its dependencies. For GPU support, mmcv must be installed\n"
9+
"from the OpenMMLab CUDA-specific index (see https://mmcv.readthedocs.io/en/latest/get_started/installation.html).\n"
10+
"The remaining packages: pip install 'mmpose>=1.3.2' 'mmengine>=0.10.7' 'mmdet>=3.3.0'"
11+
)
12+
13+
from ..numpy.pose_body import NumPyPoseBody
14+
from ..pose import Pose
15+
from ..pose_header import PoseHeader, PoseHeaderDimensions
16+
from .cocowholebody133_header import cocowholebody_components
17+
18+
NUM_KEYPOINTS = 133
19+
20+
21+
def estimate_mmpose_wholebody(input_path: str,
22+
version: float = 0.2,
23+
fps: float = 24,
24+
width: int = 1000,
25+
height: int = 1000,
26+
depth: int = 0) -> Pose:
27+
"""
28+
Run MMPose wholebody inference on a video and return a Pose object.
29+
30+
Parameters
31+
----------
32+
input_path : str
33+
Path to the input video file.
34+
version : float
35+
Pose format version written to the header.
36+
fps : float
37+
Frames per second stored in the pose body.
38+
width : int
39+
Frame width in pixels stored in the header dimensions.
40+
height : int
41+
Frame height in pixels stored in the header dimensions.
42+
depth : int
43+
Depth dimension size (0 for 2D poses).
44+
45+
Returns
46+
-------
47+
Pose
48+
Loaded pose with header and body.
49+
"""
50+
header = PoseHeader(
51+
version=version,
52+
dimensions=PoseHeaderDimensions(width=width, height=height, depth=depth),
53+
components=cocowholebody_components(),
54+
)
55+
body = _process_video(input_path, fps)
56+
return Pose(header, body)
57+
58+
59+
def _process_video(input_path: str, fps: float, use_cpu: bool = False) -> NumPyPoseBody:
60+
"""
61+
Run MMPose wholebody inference and convert frame results to NumPyPoseBody.
62+
63+
Parameters
64+
----------
65+
input_path : str
66+
Path to the input video file.
67+
fps : float
68+
Frames per second to store in the pose body.
69+
use_cpu : bool
70+
If True, run inference on CPU (slow; useful when no GPU is available).
71+
72+
Returns
73+
-------
74+
NumPyPoseBody
75+
"""
76+
device = 'cpu' if use_cpu else None
77+
inferencer_kwargs = {'wholebody': True}
78+
if device is not None:
79+
inferencer_kwargs['device'] = device
80+
81+
inferencer = MMPoseInferencer('wholebody', **({'device': device} if device else {}))
82+
result_generator = inferencer(input_path, show=False, return_vis=False)
83+
84+
frames_xy = []
85+
frames_conf = []
86+
frames_mask = [] # True = valid, False = masked out (no detection)
87+
88+
for result in result_generator:
89+
predictions_by_frame = result['predictions'] # list of per-person dicts for this frame
90+
91+
if len(predictions_by_frame) == 0 or len(predictions_by_frame[0]) == 0:
92+
# No person detected in this frame. Insert a zeroed, fully-masked row so
93+
# the frame count stays aligned with the video. Callers can distinguish
94+
# "no detection" from a real zero-coordinate keypoint via the mask.
95+
frames_xy.append(np.zeros((1, NUM_KEYPOINTS, 2), dtype=np.float32))
96+
frames_conf.append(np.zeros((1, NUM_KEYPOINTS), dtype=np.float32))
97+
frames_mask.append(True) # True = mask this frame entirely
98+
else:
99+
person = predictions_by_frame[0][0]
100+
frames_xy.append(np.array(person['keypoints'], dtype=np.float32)[None]) # (1, 133, 2)
101+
frames_conf.append(np.array(person['keypoint_scores'], dtype=np.float32)[None]) # (1, 133)
102+
frames_mask.append(False) # False = keep (not masked)
103+
104+
xy_data = np.concatenate(frames_xy, axis=0)[:, None, :, :] # (T, 1, 133, 2)
105+
conf_data = np.concatenate(frames_conf, axis=0)[:, None, :] # (T, 1, 133)
106+
107+
# Build the masked array: mask=True on empty frames so downstream code
108+
# can treat them as missing rather than as detected-at-origin.
109+
mask = np.array(frames_mask) # (T,)
110+
xy_mask = mask[:, None, None, None] * np.ones_like(xy_data, dtype=bool)
111+
masked_xy = ma.array(xy_data, mask=xy_mask)
112+
113+
return NumPyPoseBody(fps=fps, data=masked_xy, confidence=conf_data)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import sys
2+
from unittest.mock import MagicMock
3+
4+
import numpy as np
5+
import numpy.ma as ma
6+
import pytest
7+
8+
# Stub the MMPose package and its dependencies before our module is imported.
9+
# mmposewholebody.py does `from mmpose.apis import MMPoseInferencer` at module level,
10+
# so sys.modules must be populated before the first import of that module.
11+
for _mod in ["mmpose", "mmpose.apis", "mmcv", "mmengine", "mmdet"]:
12+
sys.modules.setdefault(_mod, MagicMock())
13+
14+
from pose_format.utils import mmposewholebody # noqa: E402 — must come after the stubs above
15+
from pose_format.utils.mmposewholebody import estimate_mmpose_wholebody # noqa: E402
16+
from pose_format.utils.cocowholebody133_header import cocowholebody_components # noqa: E402
17+
18+
NUM_KEYPOINTS = 133
19+
20+
21+
# ---------------------------------------------------------------------------
22+
# Header / components tests — no MMPose installation required
23+
# ---------------------------------------------------------------------------
24+
25+
def test_components_total_keypoints():
26+
assert sum(len(c.points) for c in cocowholebody_components()) == NUM_KEYPOINTS
27+
28+
29+
def test_components_names():
30+
names = [c.name for c in cocowholebody_components()]
31+
assert names == ["BODY", "FACE", "LEFT_HAND", "RIGHT_HAND"]
32+
33+
34+
def test_components_point_format():
35+
for c in cocowholebody_components():
36+
assert c.format == "XYC"
37+
38+
39+
# ---------------------------------------------------------------------------
40+
# Helpers
41+
# ---------------------------------------------------------------------------
42+
43+
def _fake_result(num_keypoints: int = NUM_KEYPOINTS):
44+
"""Single-frame MMPose result with one detected person."""
45+
return {
46+
"predictions": [[{
47+
"keypoints": np.random.rand(num_keypoints, 2).tolist(),
48+
"keypoint_scores": np.random.rand(num_keypoints).tolist(),
49+
}]]
50+
}
51+
52+
53+
def _empty_result():
54+
"""Single-frame MMPose result with no detected person."""
55+
return {"predictions": []}
56+
57+
58+
def _make_inferencer(results):
59+
"""Return a patched MMPoseInferencer class whose instance yields `results`."""
60+
fake_instance = MagicMock()
61+
fake_instance.return_value = iter(results)
62+
return MagicMock(return_value=fake_instance)
63+
64+
65+
# ---------------------------------------------------------------------------
66+
# Loader tests (MMPoseInferencer is mocked)
67+
# ---------------------------------------------------------------------------
68+
69+
def test_load_shape(monkeypatch, tmp_path):
70+
"""Output Pose has the right frame/keypoint shape."""
71+
monkeypatch.setattr(mmposewholebody, "MMPoseInferencer",
72+
_make_inferencer([_fake_result(), _fake_result(), _fake_result()]))
73+
pose = estimate_mmpose_wholebody(str(tmp_path / "video.mp4"), fps=25.0, width=1280, height=720)
74+
75+
assert pose.body.data.shape == (3, 1, NUM_KEYPOINTS, 2)
76+
assert pose.body.fps == 25.0
77+
assert pose.header.dimensions.width == 1280
78+
assert pose.header.dimensions.height == 720
79+
80+
81+
def test_load_component_names(monkeypatch, tmp_path):
82+
monkeypatch.setattr(mmposewholebody, "MMPoseInferencer",
83+
_make_inferencer([_fake_result()]))
84+
pose = estimate_mmpose_wholebody(str(tmp_path / "video.mp4"))
85+
86+
assert [c.name for c in pose.header.components] == ["BODY", "FACE", "LEFT_HAND", "RIGHT_HAND"]
87+
88+
89+
def test_empty_frame_is_masked(monkeypatch, tmp_path):
90+
"""Frames with no detection are present in the output but fully masked."""
91+
results = [_fake_result(), _empty_result(), _fake_result()]
92+
monkeypatch.setattr(mmposewholebody, "MMPoseInferencer", _make_inferencer(results))
93+
pose = estimate_mmpose_wholebody(str(tmp_path / "video.mp4"))
94+
95+
# All three frames must be present so frame count matches the video.
96+
assert pose.body.data.shape[0] == 3
97+
98+
# Frame 1 (index 1) must be fully masked; frames 0 and 2 must not be.
99+
assert pose.body.data[1].mask.all(), "empty frame should be fully masked"
100+
assert not pose.body.data[0].mask.all(), "detected frame should not be fully masked"
101+
assert not pose.body.data[2].mask.all(), "detected frame should not be fully masked"
102+
103+
104+
def test_all_empty_frames(monkeypatch, tmp_path):
105+
"""A video where no person is ever detected produces a fully masked Pose."""
106+
results = [_empty_result(), _empty_result()]
107+
monkeypatch.setattr(mmposewholebody, "MMPoseInferencer", _make_inferencer(results))
108+
pose = estimate_mmpose_wholebody(str(tmp_path / "video.mp4"))
109+
110+
assert pose.body.data.shape[0] == 2
111+
assert pose.body.data.mask.all()
112+
113+
114+
def test_version_default(monkeypatch, tmp_path):
115+
monkeypatch.setattr(mmposewholebody, "MMPoseInferencer",
116+
_make_inferencer([_fake_result()]))
117+
pose = estimate_mmpose_wholebody(str(tmp_path / "video.mp4"))
118+
assert pose.header.version == 0.2

src/python/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ mediapipe = [
3333
"mediapipe<0.10.30",
3434
]
3535

36+
mmpose = [
37+
"mmcv>=2.1.0",
38+
"mmengine>=0.10.7",
39+
"mmdet>=3.3.0",
40+
"mmpose>=1.3.2",
41+
]
42+
3643
[tool.setuptools]
3744
packages = [
3845
"pose_format",

0 commit comments

Comments
 (0)