@@ -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
0 commit comments