Skip to content

Commit a09b8fc

Browse files
alexarjeclaude
andcommitted
Release 1.20.0: posegrams that follow the motiongram convention
Three new pose views and two fixes. posegrams() is the pose counterpart of motiongrams(), and oriented the same way: horizontal has time across with image y down, vertical has time down with image x across. MGT's two views deliberately run in different directions so each shares a spatial axis with the picture and the pair can be laid around the frame. An earlier version drew both with time on x, which breaks that and cannot be placed beside a motiongram of the same recording. Because pose gives an actual position rather than a region of changed pixels, these are the true location over time. pose_spatial_map gives pose a "where" view of the same KIND as the pixel measures' heat maps -- an image of the frame. The spatial GRAM has time on an axis and looked, correctly, like a squashed posegram when used for that. Fixed: motionvectorgrams wrote a 309 MB PNG on a real recording, and motionscape(track=) assumed a track at the video frame rate, which the overview's own P-frame magnitude is not. Pass frame_size with landmarks. MediaPipe places landmarks it cannot see OUTSIDE the picture -- largest y was 1529 in a 360-tall frame -- so inferring the frame from the data scales a plot by an extrapolation rather than by the body. 977 passed, mypy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDfNkA78LYdttCbqQ9oqfE
1 parent b205d40 commit a09b8fc

8 files changed

Lines changed: 365 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,52 @@ All notable changes to MGT-python will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.20.0] — 2026-08-28
9+
10+
### Added
11+
- **`posegram`** — one row per pose landmark, ordered head to foot, brightness for how fast
12+
that landmark moved. The body-anchored counterpart of a motiongram: `pose_waterfall` says
13+
where the body went, `pose_segments` how its limbs were angled, `pose_center` how its
14+
centre moved, and none of them said *what was moving at 04:12*.
15+
16+
The row order is the design. MediaPipe emits its 33 landmarks in model order, which
17+
scatters the body across the page; ordered anatomically a moving limb is a contiguous
18+
band and the axis reads head / arms / hands / torso / legs.
19+
20+
- **`posegrams`** — the pose counterpart of `motiongrams()`, and **oriented the same way**:
21+
the horizontal view has time running across with image *y* down the page, the vertical
22+
view has time running down with image *x* across. That is not decoration — MGT's two
23+
views deliberately run in different directions so each shares a spatial axis with the
24+
picture and the pair can be laid around the video frame. Because pose gives an actual
25+
position rather than a region of changed pixels, these show the **true location** over
26+
time.
27+
28+
- **`posegram_spatial`** — the single-orientation form of the above, kept for when only one
29+
view is wanted. `weight='presence'` shows where the body is regardless of movement,
30+
which is a different question: a dancer standing still has presence and no speed.
31+
32+
- **`pose_spatial_map`** — where the body was, as an image of the frame, so pose has a
33+
"where" view of the same kind as the pixel measures' accumulated heat maps. Not to be
34+
confused with `posegram_spatial`, which has time on an axis.
35+
36+
### Fixed
37+
- **`motionvectorgrams` wrote a 309 MB PNG.** One pixel column per P-frame stretched to the
38+
video's height is 77,592 × 1920 on a 158-minute session — 149 megapixels for something
39+
meant to be glanced at. The array still keeps every column; the image is capped at 4000 px
40+
wide, pooling columns by **maximum** so a one-second accent is not averaged away.
41+
42+
- **`motionscape(track=…)` assumed the track ran at the video's frame rate**, which the
43+
overview's own P-frame magnitude does not — it arrives at about a quarter of it, so the
44+
time axis came out wrong by that factor while looking entirely reasonable. It takes
45+
`track_times=` now and refuses a mismatched length.
46+
47+
### Notes
48+
- Pose landmark arrays should be passed with the frame they were extracted in.
49+
**MediaPipe estimates landmarks it cannot see and places them outside the picture** — on a
50+
real 640×360 extraction the largest y was 1529 — so inferring the frame from the data
51+
scales a plot by an extrapolation rather than by the body. The new functions take an
52+
explicit frame size and fall back to a high percentile rather than the maximum.
53+
854
## [1.19.0] — 2026-08-27
955

1056
### Fixed

‎CITATION.cff‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,5 @@ keywords:
5353
- musical gestures
5454
license: GPL-3.0-or-later
5555
doi: "10.5281/zenodo.21965729"
56-
version: 1.19.0
57-
date-released: "2026-08-26"
56+
version: "1.20.0"
57+
date-released: "2026-08-28"

‎docs/releases.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Release Notes
22

3-
The current stable release is **MGT-python 1.19.0**.
3+
The current stable release is **MGT-python 1.20.0**.
44

55
Install or upgrade from PyPI:
66

‎musicalgestures/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# this attribute rather than carrying its own copy. Two static copies
55
# drift the moment a bump touches one of them, which is how ambiscape
66
# shipped three releases misreporting themselves and musiscape one.
7-
__version__ = "1.19.0"
7+
__version__ = "1.20.0"
88
from musicalgestures._input_test import mg_input_test
99
from musicalgestures._videoreader import mg_videoreader
1010
from musicalgestures._flow import Flow

‎musicalgestures/_posegram.py‎

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,197 @@ def mg_posegram_spatial(self: "musicalgestures.MgVideo", landmarks=None, times=N
319319
data={"gram": gram, "time": times, "axis": axis, "weight": weight},
320320
layers=None, image=target_name)
321321
return self.posegram_spatial_figure
322+
323+
324+
def pose_spatial_map(landmarks, width, height, bins=(180, 320), weight="speed",
325+
smooth=1.5):
326+
"""Where the body was in the frame, as an image — the pose answer to a heat map.
327+
328+
The pixel measures give a "where" panel by accumulating their per-pixel quantity over
329+
the whole recording: an image of the room with a bright patch where things happened.
330+
This is the same kind of object from landmarks, so the four can sit side by side.
331+
332+
Not to be confused with `pose_spatial_gram`, which has **time** on one axis. That is a
333+
motiongram-like view and looks, correctly, like a squashed posegram; it answers "at
334+
what height, when", where this answers "where in the room, over the whole recording".
335+
336+
Args:
337+
landmarks (np.ndarray): `(frames, 33, 3)` in pixel coordinates.
338+
width, height (int): The frame the landmarks were extracted in. Not inferred from
339+
the data --- MediaPipe places landmarks it cannot see outside the picture, and
340+
the maximum is one of those rather than the body.
341+
bins (tuple, optional): `(rows, columns)` of the output image. Defaults to
342+
(180, 320), a 16:9-ish grid fine enough to show a limb and coarse enough that
343+
33 points a frame fill it.
344+
weight (str, optional): `'speed'` brightens where landmarks moved fast,
345+
`'presence'` where they simply were. Defaults to `'speed'`.
346+
smooth (float, optional): Gaussian blur in output cells, so 33 points a frame read
347+
as a body rather than as confetti. Defaults to 1.5; 0 disables.
348+
349+
Returns:
350+
np.ndarray: `(rows, columns)`, an image in the frame's own coordinates.
351+
352+
Notes:
353+
Landmarks outside the frame contribute nothing rather than being clamped to an
354+
edge. Clamping would pile every lost limb onto the border and draw a bright rim
355+
that no body ever made.
356+
"""
357+
import numpy as np
358+
359+
a = np.asarray(landmarks, dtype=np.float64)
360+
rows, cols = int(bins[0]), int(bins[1])
361+
x, y = a[:, :, 0], a[:, :, 1]
362+
363+
if weight == "speed":
364+
w = pose_activity(a).T
365+
elif weight == "presence":
366+
w = np.where(np.isfinite(x) & np.isfinite(y), 1.0, 0.0)
367+
else:
368+
raise ValueError(f"weight must be 'speed' or 'presence', not {weight!r}")
369+
370+
c = np.floor(x / max(float(width), 1e-9) * cols)
371+
r = np.floor(y / max(float(height), 1e-9) * rows)
372+
ok = (np.isfinite(c) & np.isfinite(r) & np.isfinite(w)
373+
& (c >= 0) & (c < cols) & (r >= 0) & (r < rows) & (w > 0))
374+
if not ok.any():
375+
return np.zeros((rows, cols))
376+
flat = (r[ok].astype(np.int64) * cols + c[ok].astype(np.int64))
377+
m = np.bincount(flat, weights=w[ok], minlength=rows * cols).reshape(rows, cols)
378+
379+
if smooth and smooth > 0:
380+
try:
381+
from scipy.ndimage import gaussian_filter
382+
m = gaussian_filter(m, smooth)
383+
except ImportError: # pragma: no cover - scipy optional
384+
pass
385+
return m
386+
387+
388+
def posegram_arrays(landmarks, width, height, bins=200, weight="speed", spread=1.5):
389+
"""The horizontal and vertical posegrams, oriented as MGT's motiongrams are.
390+
391+
MGT's two views deliberately run in different directions, so that each shares a
392+
spatial axis with the picture and the pair can be laid around the video frame:
393+
394+
* **horizontal** — a column per frame, tiled left to right. Time runs across, image
395+
**y** runs down. Shape `(bins, frames)`.
396+
* **vertical** — a row per frame, tiled top to bottom. Time runs down, image **x**
397+
runs across. Shape `(frames, bins)`.
398+
399+
Drawing both with time on the x axis, as an earlier version did, breaks that: the
400+
result cannot be placed beside a motiongram of the same recording because its spatial
401+
axis no longer lines up with the frame.
402+
403+
Returns:
404+
tuple: `(horizontal, vertical)`.
405+
"""
406+
import numpy as np
407+
408+
horizontal = pose_spatial_gram(landmarks, height=height, width=width, bins=bins,
409+
axis="vertical", weight=weight, spread=spread)
410+
across = pose_spatial_gram(landmarks, height=height, width=width, bins=bins,
411+
axis="horizontal", weight=weight, spread=spread)
412+
#: Transposed, so a frame is a ROW and time runs down the page.
413+
return horizontal, np.asarray(across).T
414+
415+
416+
def mg_posegrams(self: "musicalgestures.MgVideo", landmarks=None, times=None,
417+
frame_size=None, bins: int = 200, weight: str = "speed",
418+
colormap: str = "magma", gamma: float = 0.5, max_width: int = 4000,
419+
dpi: int = 130, target_name: str | None = None,
420+
overwrite: bool = True) -> "musicalgestures.MgList":
421+
"""Posegrams of where the body actually was, in the frame's own coordinates.
422+
423+
The pose counterpart of `motiongrams()`, and oriented the same way: the horizontal
424+
view has time running across with image y down the page, the vertical view has time
425+
running down with image x across. Laid around a video frame they line up with it, and
426+
laid beside a motiongram of the same recording they can be read against it — a body
427+
crossing the room draws the same diagonal in both.
428+
429+
Because pose gives an actual position rather than a region of changed pixels, these
430+
are the *true location* over time, not an estimate of where change happened.
431+
432+
Args:
433+
landmarks (np.ndarray, optional): `(frames, 33, 3)` from a previous extraction.
434+
Defaults to None, which runs pose here.
435+
times (array-like, optional): Seconds per frame; usually needed, since pose is
436+
normally extracted at a reduced rate.
437+
frame_size (tuple, optional): `(width, height)` the landmarks were extracted in.
438+
**Pass this whenever you pass `landmarks`** --- see this module's notes on
439+
MediaPipe placing unseen landmarks outside the picture.
440+
bins (int, optional): Cells along the spatial axis. Defaults to 200.
441+
weight (str, optional): `'speed'` or `'presence'`. Defaults to `'speed'`.
442+
colormap, gamma, max_width, dpi, target_name, overwrite: as elsewhere.
443+
444+
Returns:
445+
MgList: the horizontal and vertical posegrams, in that order.
446+
"""
447+
import matplotlib.pyplot as plt
448+
import numpy as np
449+
450+
from musicalgestures._mglist import MgList
451+
from musicalgestures._utils import MgImage
452+
453+
of, fex = os.path.splitext(self.filename)
454+
base = resolve_filename(of, '_posegram.png', target_name, overwrite)
455+
stem, _ = os.path.splitext(base)
456+
457+
if landmarks is None:
458+
from musicalgestures._posetools import extract_pose_landmarks
459+
r = extract_pose_landmarks(self.filename, quiet=True, verbose=False)
460+
landmarks, times = r["landmarks"], r["time"]
461+
width, height = r["width"], r["height"]
462+
elif frame_size is not None:
463+
width, height = float(frame_size[0]), float(frame_size[1])
464+
else:
465+
a = np.asarray(landmarks, dtype=np.float64)
466+
width = float(np.nanpercentile(a[:, :, 0], 99.5)) * 1.05
467+
height = float(np.nanpercentile(a[:, :, 1], 99.5)) * 1.05
468+
469+
horizontal, vertical = posegram_arrays(landmarks, width=width, height=height,
470+
bins=bins, weight=weight)
471+
times = (np.asarray(times, dtype=np.float64) if times is not None
472+
else np.arange(horizontal.shape[1]) / float(self.fps))
473+
minutes = (times[-1] / 60) if len(times) else 1
474+
475+
def _shade(a):
476+
finite = a[np.isfinite(a) & (a > 0)]
477+
ceiling = np.percentile(finite, 99) if finite.size else 1.0
478+
return np.power(np.clip(a / max(ceiling, 1e-9), 0, 1), gamma)
479+
480+
images = []
481+
for gram, suffix, horizontal_time in ((horizontal, "_h", True),
482+
(vertical, "_v", False)):
483+
drawn = gram
484+
axis = 1 if horizontal_time else 0
485+
if drawn.shape[axis] > max_width:
486+
edges = np.linspace(0, drawn.shape[axis], max_width + 1).astype(int)
487+
if horizontal_time:
488+
drawn = np.stack([drawn[:, a:max(b, a + 1)].max(axis=1)
489+
for a, b in zip(edges[:-1], edges[1:])], axis=1)
490+
else:
491+
drawn = np.stack([drawn[a:max(b, a + 1)].max(axis=0)
492+
for a, b in zip(edges[:-1], edges[1:])])
493+
if horizontal_time:
494+
fig, ax = plt.subplots(figsize=(15, 5), dpi=dpi)
495+
ax.imshow(_shade(drawn), cmap=colormap, aspect="auto",
496+
interpolation="nearest", extent=(0, minutes, bins, 0))
497+
ax.set_xlabel("minutes")
498+
ax.set_ylabel("top of frame → bottom")
499+
ax.set_title("horizontal posegram — time across, image y down")
500+
else:
501+
fig, ax = plt.subplots(figsize=(6.5, 12), dpi=dpi)
502+
ax.imshow(_shade(drawn), cmap=colormap, aspect="auto",
503+
interpolation="nearest", extent=(0, bins, minutes, 0))
504+
ax.set_ylabel("minutes")
505+
ax.set_xlabel("left of frame → right")
506+
ax.set_title("vertical posegram — time down, image x across")
507+
ax.set_xticks(ax.get_xticks()) if False else None
508+
fig.tight_layout()
509+
path = f"{stem}{suffix}.png"
510+
fig.savefig(path)
511+
plt.close(fig)
512+
images.append(MgImage(path))
513+
514+
self.posegrams_images = MgList(images)
515+
return self.posegrams_images

‎musicalgestures/_video.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ def __init__(
195195
motionscape_figure: MgFigure
196196
posegram_figure: MgFigure
197197
posegram_spatial_figure: MgFigure
198+
posegrams_images: "musicalgestures.MgList"
198199
movement_beat_statistics_figure: MgFigure
199200
phase_synchrony_figure: MgFigure
200201
pose_average_image: MgImage
@@ -321,6 +322,7 @@ def motion_mp(self, *args, **kwargs):
321322
from musicalgestures._posegram import ( # type: ignore[misc]
322323
mg_posegram as posegram,
323324
mg_posegram_spatial as posegram_spatial,
325+
mg_posegrams as posegrams,
324326
)
325327

326328
def __repr__(self) -> str:

‎tests/test_pose_spatial_map.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""A 2-D map of where the body was, in the frame's own coordinates.
2+
3+
The other measures' "where" panels are images of the room: accumulate the per-pixel
4+
difference, or the flow magnitude, over the whole recording and you get a picture with a
5+
bright patch where things happened. The pose equivalent has to be the same KIND of object
6+
--- a (height, width) image --- and not a gram with time on one axis, which is what a
7+
spatial *gram* is and which looks, correctly, like a squashed posegram.
8+
"""
9+
import numpy as np
10+
import pytest
11+
12+
from musicalgestures._posegram import pose_spatial_map
13+
14+
15+
def _pose(n, n_landmarks=33):
16+
a = np.zeros((n, n_landmarks, 3))
17+
a[:, :, 0] = 320.0
18+
a[:, :, 1] = 240.0
19+
a[:, :, 2] = 1.0
20+
return a
21+
22+
23+
class Test_it_is_an_image_of_the_frame:
24+
def test_shape_is_height_by_width(self):
25+
m = pose_spatial_map(_pose(120), width=640, height=480, bins=(90, 120))
26+
assert m.shape == (90, 120)
27+
28+
def test_a_landmark_crossing_left_to_right_lights_a_horizontal_band(self):
29+
n = 300
30+
a = _pose(n)
31+
#: Only the travelling landmark is present. With the other 32 parked at one
32+
#: point they outvote it 32 to 1 and the brightest row is theirs, not its ---
33+
#: which is what the first version of this test actually measured.
34+
a[:, :, :] = np.nan
35+
a[:, 15, 0] = np.linspace(40, 600, n)
36+
a[:, 15, 1] = 120.0
37+
a[:, 15, 2] = 1.0
38+
m = pose_spatial_map(a, width=640, height=480, bins=(96, 128), weight="presence")
39+
rows = m.sum(axis=1)
40+
band = int(np.argmax(rows))
41+
# 120/480 of 96 rows is row 24
42+
assert abs(band - 24) <= 4
43+
assert m[band].max() > 0
44+
lit_cols = np.flatnonzero(m[band] > 0)
45+
assert lit_cols.max() - lit_cols.min() > 40 # it swept across
46+
47+
def test_a_landmark_that_stays_put_lights_one_place(self):
48+
m = pose_spatial_map(_pose(200), width=640, height=480, bins=(96, 128),
49+
weight="presence")
50+
lit = np.argwhere(m > m.max() * 0.5)
51+
assert np.ptp(lit[:, 0]) < 8 and np.ptp(lit[:, 1]) < 8
52+
53+
54+
class Test_what_it_weights_by:
55+
def test_speed_ignores_a_body_that_is_present_but_still(self):
56+
m = pose_spatial_map(_pose(150), width=640, height=480, bins=(60, 80),
57+
weight="speed")
58+
assert m.max() == pytest.approx(0.0, abs=1e-9)
59+
60+
def test_landmarks_outside_the_frame_are_dropped_not_clamped(self):
61+
"""MediaPipe places unseen landmarks outside the picture. Clamping them would
62+
pile a lost limb onto the edge and draw a bright rim no body ever made."""
63+
a = _pose(120)
64+
a[:, 31, 0] = 5000.0
65+
a[:, 31, 1] = 5000.0
66+
m = pose_spatial_map(a, width=640, height=480, bins=(60, 80), weight="presence")
67+
assert m[-1, -1] == pytest.approx(0.0, abs=1e-9)
68+
assert np.isfinite(m).all()

0 commit comments

Comments
 (0)