Skip to content

Commit ba9f5ea

Browse files
alexarjeclaude
andcommitted
pose(): export markers to C3D (data_format='c3d')
Add 'c3d' as a pose data_format: each landmark is written as a 3D point (x, y, z=0 in pixel coordinates; missing detections flagged invalid) to a .c3d motion-capture file via the optional 'c3d' package. Works for OpenPose and MediaPipe backends and the cached re-render path; combinable with text formats, e.g. data_format=['csv','c3d']. Add c3d optional dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 905c474 commit ba9f5ea

2 files changed

Lines changed: 68 additions & 7 deletions

File tree

musicalgestures/_pose.py

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,9 @@ def pose(
138138
without re-running the network — e.g. run `style='markers'` then `style='skeleton'` fast.
139139
Defaults to True.
140140
save_data (bool, optional): Whether we save the predicted pose data to a file. Defaults to True.
141-
data_format (str, optional): Specifies format of pose-data. Accepted values are 'csv', 'tsv'
142-
and 'txt'. For multiple output formats, use list, eg. ['csv', 'txt']. Defaults to 'csv'.
141+
data_format (str, optional): Specifies format of pose-data. Accepted values are 'csv', 'tsv',
142+
'txt' and 'c3d' (motion-capture format; requires the optional ``c3d`` package). For multiple
143+
output formats, use a list, e.g. ['csv', 'c3d']. Defaults to 'csv'.
143144
save_video (bool, optional): Whether we save the video with the estimated pose overlaid on it.
144145
Defaults to True.
145146
style (str, optional): How to draw the pose. `'both'` draws markers (keypoints) connected by
@@ -577,8 +578,11 @@ def save_single_file(of, width, height, model, data, data_format, target_name_da
577578
target_name_data=target_name_data, overwrite=overwrite)
578579

579580
if save_data:
580-
save_txt(of, self.width, self.height, model, data, data_format,
581-
target_name_data=target_name_data, overwrite=overwrite)
581+
text_format = _handle_c3d(of, data, OPENPOSE_NAMES.get(model.lower()), self.fps,
582+
self.width, self.height, data_format, target_name_data, overwrite)
583+
if text_format is not None:
584+
save_txt(of, self.width, self.height, model, data, text_format,
585+
target_name_data=target_name_data, overwrite=overwrite)
582586

583587
# Render the average-pose and trajectories images from the collected keypoints
584588
names = OPENPOSE_NAMES.get(model.lower())
@@ -729,7 +733,9 @@ def _rerender_pose_from_cache(self, style='both', overlay=True, background='blac
729733
for nm in names:
730734
headers.append(f'{nm} X')
731735
headers.append(f'{nm} Y')
732-
_save_pose_txt(of, data, headers, data_format, target_name_data, overwrite)
736+
text_format = _handle_c3d(of, data, names, fps, width, height, data_format, target_name_data, overwrite)
737+
if text_format is not None:
738+
_save_pose_txt(of, data, headers, text_format, target_name_data, overwrite)
733739

734740
avg_frame = (avg_acc / avg_n).astype(np.uint8) if (avg_acc is not None and avg_n > 0) else None
735741
average_image, trajectories_image = _render_pose_extras(
@@ -906,7 +912,11 @@ def _pose_mediapipe(
906912
for name in MEDIAPIPE_LANDMARK_NAMES:
907913
headers.append(name.replace('_', ' ').title() + ' X')
908914
headers.append(name.replace('_', ' ').title() + ' Y')
909-
_save_pose_txt(of, data, headers, data_format, target_name_data, overwrite)
915+
c3d_names = [name.replace('_', ' ').title() for name in MEDIAPIPE_LANDMARK_NAMES]
916+
text_format = _handle_c3d(of, data, c3d_names, self.fps, self.width, self.height,
917+
data_format, target_name_data, overwrite)
918+
if text_format is not None:
919+
_save_pose_txt(of, data, headers, text_format, target_name_data, overwrite)
910920

911921
# Render the average-pose and trajectories images from the collected keypoints
912922
names = [name.replace('_', ' ').title() for name in MEDIAPIPE_LANDMARK_NAMES]
@@ -935,6 +945,56 @@ def _pose_mediapipe(
935945
return self
936946

937947

948+
def _save_pose_c3d(of, data, names, fps, width, height, target_name_data=None, overwrite=False):
949+
"""
950+
Save pose keypoints to a C3D motion-capture file.
951+
952+
Each landmark becomes a 3D point (x, y, z=0) in pixel coordinates; frames with a
953+
missing detection (0,0) are flagged invalid (residual = -1). Requires the optional
954+
``c3d`` package (``pip install c3d``).
955+
"""
956+
try:
957+
import c3d
958+
except ImportError as exc:
959+
from musicalgestures._utils import MgError
960+
raise MgError("Saving pose data as C3D requires the 'c3d' package. "
961+
"Install it with: pip install c3d") from exc
962+
963+
out_path = (of + '_pose.c3d') if target_name_data is None else (os.path.splitext(target_name_data)[0] + '.c3d')
964+
if not overwrite:
965+
out_path = generate_outfilename(out_path)
966+
967+
n_points = len(names)
968+
writer = c3d.Writer(point_rate=float(fps))
969+
for row in data:
970+
coords = np.asarray(row[1:1 + 2 * n_points], dtype=np.float32)
971+
points = np.zeros((n_points, 5), dtype=np.float32)
972+
for j in range(n_points):
973+
x, y = coords[2 * j], coords[2 * j + 1]
974+
if x == 0 and y == 0: # missing detection
975+
points[j] = [0, 0, 0, -1, 0]
976+
else:
977+
points[j] = [x * width, y * height, 0.0, 0.0, 0]
978+
writer.add_frames([(points, np.zeros((0, 1), dtype=np.float32))])
979+
980+
writer.set_point_labels(names)
981+
with open(out_path, 'wb') as h:
982+
writer.write(h)
983+
return out_path
984+
985+
986+
def _handle_c3d(of, data, names, fps, width, height, data_format, target_name_data, overwrite):
987+
"""Save a .c3d file if 'c3d' is among the requested formats; return the remaining
988+
(text) formats to be handled by the text saver, or None if there are none."""
989+
formats = list(data_format) if isinstance(data_format, (list, tuple)) else [data_format]
990+
if any(str(f).lower() == 'c3d' for f in formats):
991+
_save_pose_c3d(of, data, names, fps, width, height, target_name_data, overwrite)
992+
text = [f for f in formats if str(f).lower() != 'c3d']
993+
if not text:
994+
return None
995+
return text if isinstance(data_format, (list, tuple)) else text[0]
996+
997+
938998
def _save_pose_txt(of, data, headers, data_format, target_name_data, overwrite):
939999
"""Save pose data to one or more text files (csv / tsv / txt)."""
9401000

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@ dependencies = [
3737

3838
[project.optional-dependencies]
3939
pose = ["mediapipe>=0.10"]
40+
c3d = ["c3d>=0.5"]
4041
ml = ["scikit-learn>=1.2", "torch>=2.0", "torchvision>=0.15"]
4142
cli = ["click>=8.0"]
4243
dev = ["pytest>=7", "pytest-cov>=4", "ruff>=0.4", "mypy>=1.5", "nox>=2023.4"]
43-
full = ["musicalgestures[pose,ml,cli]"]
44+
full = ["musicalgestures[pose,ml,cli,c3d]"]
4445

4546
[project.urls]
4647
Homepage = "https://github.com/fourMs/MGT-python"

0 commit comments

Comments
 (0)