All notable changes to MGT-python will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Documentation: README gains "What you can reuse" and "What it does not do", naming Kinetics Toolkit, PyEyesWeb and librosa as the tools for what this package leaves out (2026-09-18).
The unreleased features below were developed for and are exercised end to end by avsegmenter, which segments concert and lecture recordings.
- The
videogram_*tracks ofextract_trackswere motiongrams (#383): they averaged the motion frame, not the picture. They are now written asmotiongram_v.u1/motiongram_h.u1undermotiongram_*keys, and true videograms come from one extra ffmpeg filter graph (extract_videograms, on by default,videograms=Falseto skip), trimmed so that column j lines up with motion frame j.read_columns,build_pyramidandrender_timelinetake both names; an analysis folder written before the rename serves its motion means undermotiongram_*and, undervideogram_*, with a warning that says what they are. _mocap.read_c3dimportedMgErrorfrom_utils, where it does not live; mypy on master failed on it._tracks.read_columnsfailed on any recording long enough to need a pyramid level.extract_tracksandextract_tracks_parallelwrite the base videograms only, andread_columnsmemory-mappedvideogram_v.L<k>.u1without checking it existed, so the first read of a real concert raisedFileNotFoundErrorunless the caller knew to runbuild_pyramidfirst. It now builds the levels on first use.
- People on stage — new
_performersmodule.detect_peopleruns a YOLO person detector (ultralyticsextra) at a low frame rate through an ffmpeg pipe;on_stagekeeps the boxes whose heads are in the upper part of the frame and drops the audience cut off by the bottom edge;performer_countreports how many perform in a span as a high percentile of the per-frame counts (the wide shots), with median and maximum beside it. Exact for a soloist, a duo and a five-piece band from an operated concert camera; a choir is under-counted. performer_count(..., stat="typical")takes the median over framings instead of the widest, for lecture halls where the widest framings are the hall and the slides;detect_peopleandmake_proxy/camera_motiontakeffmpeg_input_args(e.g.["-hwaccel", "cuda"]).- Camera cuts and PTZ — new
_cameramodule.camera_motionlabels each sample of a videostill,movingorcutfrom ORB matches and a partial-affine RANSAC fit between consecutive frames of a small 2 fps proxy (make_proxy), and returns cuts, shots and the share of time in each state;still_runsgives the framings (still runs between moves and cuts);camera_state_atsamples the state at arbitrary times, so motion measures can exclude camera motion.performer_count(..., camera=...)counts per framing (the 75th percentile of each, the widest framing being the estimate), which is what makes the count right with an operated camera. tracks.json(and the dict both extractors return) carriesanalysis_dir, so a caller can go fromextract_tracks_parallel(...)toread_columns/check_trackswithout reconstructing theanalysis/<stem>path convention.extract_wavis exported from the package root.- Events against events — new
_eventsmodule.event_alignmentmeasures how far each event of one stream (strokes, footfalls, looks) falls from the nearest event of another (note onsets, beats, cues) against uniformly placed surrogate references, and says whether the two attract, avoid or sit at chance, with the signed lag of the nearest reference telling which came first;event_xcorrcorrelates the binned event trains. On the painter–pianist session this separated three takes no envelope correlation had. - Cross-recurrence —
_correlate.cross_recurrence: recurrence rate at a fixed radius quantile, determinism, mean line length and the diagonal recurrence profile of two series, each against circular-shift surrogates, so a high determinism from two smooth series is not mistaken for coordination. - The painting as a time series — new
_canvasmodule.painting_contentreduces a canvas video to one hand-free median frame per second and measures painted share, monotone coverage, chromatic share, the hue histogram (drawn as a colourgram bycolourgram_image), warm/cool balance, edge density, composition (composition: paint centre and spread, left–right symmetry, edge-orientation histogram and anisotropy) and a dominant-colour palette per minute. Bound onMgVideoaspainting(). - Head turns from the IMU —
_pupillabs.head_turns: yaw relative to a running median, and the spans where the head is turned away from what the wearer has been facing. - TRC and C3D readers —
read_trcandread_c3dbesideread_qtm_tsv, so Qualisys, OptiTrack, Vicon, Theia3D, Pose2Sim and OpenCap exports all land on the same trajectory contract:(marker_names, data, fs)withdataof shape(frames, markers, 3), gaps as NaN (never zeros) and positions converted onto millimetres from the file's declared unit.read_c3drides the optionalezc3dpackage, added to the[c3d]extra beside the pure-Pythonc3dwriter dependency; analog channels and events are declaredly not read. - FreeMoCap reader —
read_freemocapreads a FreeMoCap recording folder onto the same trajectory contract: the body trajectories fromoutput_data/mediapipe_body_3d_xyz.npy(millimetres and NaN gaps already, passed through unchanged) with MediaPipe's 33 landmark names, and the frame rate derived from the recording's own timestamp records the way FreeMoCap itself derives it —Nonewith a warning when no record exists, never a guessed default. Hands, face, centre of mass and reprojection errors are declaredly not read.
- ReID v2.1: chains across breaks —
associate_fragments(embeddings=...)now links each segment's movers into persistent chains by the same strictly-more-separated appearance rule that links fragments: every segment mover gains achainid, and a newchainskey maps each chain to its summed coverage and its[segment, mover]members. A mover's candidates are then_moversmost recently ended chains — the exact counterpart of the fragment-level state; comparing against all history was measured to link almost nothing, since unlinked chains of the same person converge and the margin rule correctly refuses. Where the margin does not clear, a new chain starts; no cross-break identity is ever guessed. Measured on the co-located dance day: the longest per-dancer aggregate grows from one 6.6-minute segment to a 12.7-minute chain over five segments, with 119 chains spanning breaks. Zero new decodes, as priced in the v2 design. - The tutorial notebook is a course — the 259-cell encyclopedia is now a
28-cell gentle introduction in eight parts, from loading a video through the
signature visualisations, the quantity of motion, the body, segments and
sound to the audio–motion tempo comparison, executed once with images only
(6.8 MB where 16 stood) and following the project's prose rules. The Colab
install cell brings the
[pose]extra, and a Colab button joins the README's badge row.
- A failed pose-weights download now raises one clear error — when the
OpenPose fallback cannot fetch its weights (the hosted body_25 caffemodel
currently returns 404),
pose()used to print and return, so the caller's next line failed with a bareAttributeErroron a result attribute that was never set. It now raises aRuntimeErrornaming the fix:pip install "musicalgestures[pose]"for the MediaPipe default, which needs no weight download. The tutorial notebook's Colab install cell installs the[pose]extra for the same reason. - The pose cache reaches the higher layers in the right shape —
pose()caches one flat row per frame ([time_ms, x0, y0, ...], zeroed below threshold), butpostures_from_pose()andactions_from_motion()'s pose path consumed it as a landmark array: the postures segmenter hit its shape guard and returned zero postures for every recording, and the actions path failed outright. A newpose_cache_landmarks()restores the(frames, 33, 3)contract with visibility 0 marking the zeroed refusals, both consumers use it, and the actions path also unpackspose_qomcorrectly (the QoM is a scalar; the cuttable series is the speed envelope) and masks refusals as NaN rather than leaps to the frame corner. Measured on the bundled dancer: two genuine postures (an opening stillness and a held moment at 44 s) and 18 actions where both previously reported nothing.
- Pupil Labs Neon exports on the video's clock — new
_pupillabsmodule.read_pupil_exportreads a Pupil Cloud timeseries export (gaze at 200 Hz, 3D eye states, IMU, fixations, saccades, blinks, events, scene camera intrinsics) and keeps every table in seconds after the recording start;pupil_to_framesplaces a video on that clock by naming the event it starts at (or an offset) and bins everything to the video's frames --- gaze position and angular velocity, worn, fixation/saccade/blink flags with the export's ids, pupil size masked around blinks and interpolated, head gyroscope magnitude, dynamic acceleration and orientation --- so eye and head data sit in the same frame-indexed table as quantity of motion.eye_eventslists the events inside the video asEyeEventspans,eyetracking_ratesgives per-bin rates (onset-counted) and means, andgazegramdraws where the wearer looked per time bin as the gaze counterpart of a motiongram. Gaze inside blinks is set to missing rather than trusted, and the Cloud face detector's boxes are read but not interpreted (on the live-painting corpus this was written for they fired on the painting more than on people). Bound onMgVideoaseyetracking(),gazegrams()andeyetracking_timeline().
- Cross-modal names match their level of description — following the four
levels of Sensing Sound and Music (physical, digital, perception,
interpretation), pairings no longer cross rows:
body_audio_coupling()is nowmotion_audio_coupling()(attributemotion_audio_coupling_figure), andwarp_audiovisual_beats()is nowwarp_audiomotion_beats(). The old names keep working through one release and warn; the comparison-suite prose, figure titles and printed messages say audio–motion throughout, and "sound file" is "audio file" wherever the thing named is a file.
- The static row: postures, and poses as labels — new
_posturesmodule, the static counterpart of_actionsunder the toolbox's two-row scheme (motion/action/gesture over position/posture/pose, following Sound Actions pp. 68–69; the five-field terminology review behind the wording is inplans/2026-09-01-position-posture-pose-terminology.md).segment_posturescuts landmark trajectories into held configurations on a Movement–Hold criterion (stationarity judged at the 95th-percentile landmark, in body-normalised torso lengths per second, so thresholds transfer between bodies and spaces);key_posturesgroups the configurations a body returns to, without a fixed catalogue of standing and sitting;average_posturegives a recording's habitual carriage as the median configuration;match_posturesproposes a pose by example, as a label attached to matching postures — segmentation kept apart from recognition, exactly as in_actions. Bound onMgVideoaspostures_from_pose(). - Detector-agnostic normalisation —
normalise_posesrecognises the landmark topology by count (MediaPipe-33, YOLO/COCO-17, OpenPose BODY_25, COCO-18 and MPI-15, viaANCHORS_BY_TOPOLOGY) and takes ananchors=override for unknown skeletons, so the higher-level layers work unchanged when the tracking model is swapped. Validated on this project's YOLO-tracked dance corpus through code written against MediaPipe.
- Jitter-robust stationarity — configuration change is measured as the displacement of the window-median configuration across a 0.4 s stride, after corpus footage showed a seated dancer's high-confidence knee fluttering within 1.2 px (flat across lags — pure detector jitter) yet reading as 0.15 torso lengths per second on a 43 px foreshortened torso. Jitter does not accumulate across the stride; a real transition does.
- Appearance for the associator —
fragment_embeddingscollects one appearance vector per track fragment in a single sequential pass (torso-crop hue–saturation histograms, median over each fragment's rows), andassociate_fragments(embeddings=...)gains the v2 rules fromplans/2026-08-30-reid-v2-design.md: an ambiguous crossing is decided by appearance only when one candidate is STRICTLY more separated than the material's own within-appearance spread, and position-refused re-entries may be appearance-bridged across a longer gap. Identical appearances decide nothing, and refusal remains output — both behaviours asserted by tests.
room_plateno longer hands back a plate its own refinement made worse — on material where the subject rarely leaves the frame (standstill recordings), the frames most like the first plate are the ones with the subject in place, and re-taking the median over them made the subject solid where the full sample had washed them out; measured on a 2012 standstill performance, refinement changed 3.7 per cent of the plate — a performer standing in an otherwise empty room, withrefine=Truethe default. Under a median first pass the kept frames are the ones that AGREE with the plate, so a material change is concentration, not cleaning:room_platenow measures the change and returns the unrefined plate with a warning when it exceeds the newmax_refine_changeargument (default 0.02, above a body's residue and below a body). Ordinary corpora are untouched, and the module's docstrings, which promised the opposite frame selection to the one the code makes, now tell one story.
extract_pose_landmarks_rtmpose— the third member of the extractor family: RTMPose throughrtmliband ONNX runtime (no MMPose stack), Apache-licensed end to end, on the same trajectory-array contract and 17-point COCO topology as the YOLO twin, so all three extractors feed the same detector-agreement tooling. New optional extra:pip install musicalgestures[rtmpose]. Chooses CUDA when onnxruntime offers it and records the choice in the result.associate_fragments— track fragments chained into persistent movers with position and time only, under an exclusivity constraint and a measured plausibility bound, refusing where honesty demands: a crossing at a fragment boundary becomes a recorded break for a human to adjudicate, never a guess. Fromplans/2026-08-30-fragment-reassociation-design.md, validated on synthetic crossing walkers whose tests assert the refusal itself.
- The Effort layer (#373): MGT's own operationalisation of Laban's four Effort
factors ---
sparc(Balasubramanian et al. 2015, validated against the canonical minimum-jerk battery and cross-checked against pyeyesweb dev-only),effort_time,effort_weight,effort_space,effort_flow, the windowedeffort_profile, andbasic_effort_actions: Laban's eight named actions as median-anchored Weight x Time x Space octants, after Laban (1971) via Haga (2008, Table 20), offered as proposals. Grounded per Haga: effort is fluctuation, not level; video reads dynamics through kinematics; QoM is the activation contour Effort qualifies. - Per-person tracking for YOLO pose —
extract_pose_tracks_yoloreturns every identity's trajectory separately through Ultralytics' tracker, andextract_pose_landmarks_yolo(track=True)follows the most persistent identity instead of the per-frame highest-confidence detection. Measured on a dance corpus, the per-frame selection teleports the trajectory whenever two bodies share the frame — two dancers, or a dancer and their life-size projection on a videoconference screen — and tracking is the cure. skeleton_timeline— a timeline of stick figures: posture at sampled moments drawn on a real time axis, each figure normalised by its own torso so the strip reads posture rather than position. Moments without a usable detection are skipped, so gaps are honest gaps in the tracking.
motiongram_data'sorientation="vertical"/"horizontal"— the words named the motion shown where the rest of the toolbox names the position axis kept; useorientation="y"/"x". The old values warn and keep working until 2.0. This completes the deprecation set: everything scheduled for removal at 2.0 now warns.
extract_pose_landmarks_yolo— the Ultralytics twin ofextract_pose_landmarks(#359), on the same trajectory-array contract: same decode pipe, same result dictionary, so two detectors compare on a shared clock with the anchor-and-match tooling. The topology is the 17-point COCO set (COCO_KEYPOINT_NAMES), the third channel is keypoint confidence, and a zero-confidence keypoint is NaN rather than a fabricated origin point. Ultralytics is a new optional extra:pip install musicalgestures[yolo].
- The motion envelope is documented as what it is.
MgMotionVectorViews.magnitudeagainst.timeis the temporal view at the encode's reference cadence (~12.6 Hz on a 50 fps B-frame encode) for a tenth of a decode's cost; the docstring now carries its measured limits — median r = 0.82 against exact QoM, 0.57 where little moves, B-frames excluded by default and why.
zoomable_page(start_s=...)— one section of a long recording, paged on its own clock. The cached track is sliced, and hierarchy spans given on the session clock are clipped to the range and shifted onto the page's, so a warm-up or a performance can be studied without the hours around it.BoundedSamplein_noisefloor— a uniform sample of a stream, bounded in memory whatever the stream's length: batches are kept with the current probability, and when the store passes twice its cap it is uniformly halved and the probability halves with it.texture_mask— which cells of a picture carry enough texture to trust motion vectors on. An encoder's motion search is unconstrained where nothing textures the block, so its vectors there are rate decisions that propagate from real motion elsewhere; masking cells below a percentile of the picture's own cell textures removed a negative motion-to-dwell correlation that had made one room's maps unreadable, while leaving well-textured rooms untouched. In_plate, sized to the 16 px macroblock lattice by default.- The zoomable page plays its recording.
zoomable_page(player=...)puts a video element above the strips, referenced by RELATIVE name so the page stays serverless and needs only the folder it ships in: clicking the timeline seeks the video, and a playhead runs across every band during playback. The page degrades to the strips alone when the file is not beside it. MgVideo.zoompage()— the page for any video, in one call: the motion track and gram fromextract_tracks(computed on first call, cached beside the video), the audio band from the video's own soundtrack when it has one, and the video itself as the player.
- The grams are named by the position axis each keeps: x and y. "Horizontal" and
"vertical" could describe the display or the motion, and the two point opposite
ways — the wide picture shows vertical motion. One letter now answers everything
derivably: the x-motiongram keeps horizontal position, shows sideways travel, and
is the tall picture with time downward; the y-motiongram keeps vertical position
and is the wide one. Canonical attributes are
motiongram_x_image/motiongram_y_imageandvideogram_x_image/videogram_y_image; the motion-vector views carrymotiongram_x/motiongram_y;motiongram_dataacceptsorientation="x"/"y"; the rendered vector grams take_y/_xsuffixes. Every picture-named attribute and field keeps working with a deprecation warning until 2.0. - Motion-vector displacements are corrected for the reference cadence. FFmpeg's
sourcecarries only the sign of a vector's reference, so a P-frame following a run of B-frames reported its whole multi-frame displacement as one frame's — 3× to 4× on typical B-heavy encodes. Past-referencing vectors are now divided by the display-order gap to the previous reference frame, recovered from the picture-type cadence while decoding. This changes every motion-vector number on B-frame encodes: displacements,magnitude, the grams and everything built on them. Two residuals remain and are documented: multi-reference blocks reaching older frames, and future-referencing B-frame vectors, which default filtering excludes. - The motion-vector motiongrams now use the classic orientations, named for the
picture. The vertical motiongram is the tall image — the frame's width across,
time running downward — and carries sideways travel; the horizontal motiongram is
the wide image — time rightward, the frame's height down — and carries vertical
travel. Previously the vector path drew both wide and had the two names swapped
relative to
motiongrams(). This changesmotion_vector_motiongrams's return arrays (now(rows, frames)and(frames, cols)), the_mvgram_h/_mvgram_vimages, themotionvectoroverviewsheet andmotionvectordata's gram fields; arrays saved by earlier versions carry the old convention and should be re-derived or transposed.
zoomable_pagecarries sound, and a choice of strips.audio=embeds an audio band that switches between a signed min/max waveform and a log-mel spectrogram, on the same clock as everything else;video=takes named (rows, time) arrays — a videogram and a motiongram, say — and the page offers a switch when there is more than one. Everything stays embedded in the single file, and the stated resolution budget covers the new strips too.multishot(animate=True)— the chronophotograph as a looping GIF of its own build-up: the bare room first, then one more body per frame in time order, with the finished composite held at the end. The same moments, chosen the same way; the animation is the composite gaining its bodies, not a different selection. The method form writes_multishot.gifand takesframe_ms; the function form returns the list of frames.
extract_tracks_parallelleaked one live ffmpeg per finished chunk. Every bounded chunk's decoder holds more frames than the worker reads, so it is blocked mid-write to the full pipe whenterminate()fires; the blocked write restarts around SIGTERM, and the unreapedPopenkeeps the pipe open for the life of the long-lived pool worker. Roughly 500 MB per chunk, accumulating for the whole run — 51 of them held 23 GB and took a 32 GB machine down mid-extraction. The newstop_ffmpeg_processin_utilscloses the pipes first (EPIPE works where the signal cannot), then terminates, waits, and kills on timeout; both_trackspaths use it. The regression test calls_chunk_workerin-process, because an end-to-end test passes against the broken code: closing the pool kills the workers and their orphans, so the leak only exists while a run is still going.
-
pose_timeline— postures and trajectories over time, in three views behind one function.view='strip'puts postures at regular instants side by side, centred and scaled so two are comparable when the dancer stood at different distances;view='room'draws skeletons where they actually stood with the route over them;view='bands'carries each body region's joint angles, so a held posture is a flat band — whichposegramcannot show, since it carries landmark speed and a held limb has none.Two kinds of trace on the strip, which combine:
'temporal'gives every landmark a fading history behind its own posture, and the fade is the information — a constant alpha says a limb was in several places without saying which it reached last.'spatial'threads head, pelvis and feet through the postures. They are smoothed differently on purpose, and the docs say where the spatial lines stop working: in fast floor work the head and pelvis genuinely swing through large arcs, and no filter wide enough to calm them there is narrow enough to keep the arcs elsewhere.Gaps stay gaps. Frames the detector missed are not interpolated — that would invent posture — and in
bandsthey are the white columns showing how much of a row is actually measured. -
MgVideo.plate()andMgVideo.multishot()— the room and the chronophotograph as methods. They were the only views reachable solely as module-level functions taking a path, which is howstroboscope()came to be overlooked while a second chronophotography function was written beside it. -
multishot(region=...)— restrict where a body may be for it to count. A researcher sitting at the side of the room differs from the plate like anybody else, and being a person, no person detector will drop them; only where they are can.
-
multishot()absorbedstroboscope(). The two made the same picture two ways, so a reader had to know which before choosing.select='even',background='average'andcolorize=Trueare whatstroboscope()did; the defaults — spatial separation on a median plate — are the opinionated half, because even sampling is what makes two bodies land in the same place and a mean average keeps a faint ghost of everyone who crossed.stroboscope()now warns and delegates; it goes at 2.0. -
posegram's rows read down the body: head, torso, arms, hands, legs, with the shoulders moved into the trunk they are part of.pose_timeline's bands match, so the two can be read row for row. This changes existing posegrams — rows sit in a different order than in any figure made before this release. -
multishotrejects a body that reaches the frame edge rather than one with a large share against it. A silhouette can be 95 per cent inside the picture and still be somebody with their hand cut off: the share against the edge is small precisely because the rest of the body is large.
-
MediaPipe segmentation had been silently dead since MediaPipe 0.10.
_make_segmenterasked formp.solutions.selfie_segmentation; the Solutions API was removed in favour of Tasks, so on a current install the attribute lookup raised, a bareexceptswallowed it, and every caller got background subtraction while the docstring promised MediaPipe — saying nothing at all undermethod='auto'. Rebuilt on the Tasks pose landmarker, reusing the model this package already downloads. The legacy path is tried first for older installs, and the fallback now warns.It masks only what the pose detector finds, which the docstring now says.
-
multishot— many moments of a recording in one picture. The room is recovered as a plate, bodies are cut out of frames spread through the recording, and all of them are laid back onto it, so one image carries where somebody was, how they were shaped, and how far apart the moments were.Frames are chosen for separation, not at intervals. Evenly spaced frames put bodies on top of each other as often as not, and two overlapping silhouettes read as one smear rather than as two moments.
n_bodiessets how many to place; past a dozen or so they overlap whatever the selection does, because a room is finite.How it relates to
stroboscope(), which has done chronophotography since 1.6. That method samples at even intervals onto a mean average frame and tints each silhouette by time; this one selects for spatial separation and composites onto the medianroom_plate. Even sampling is what makes bodies collide, and a mean average keeps a faint ghost of everyone who crossed. Against that,stroboscope()'s MediaPipe segmentation is the better mask and its colour ramp says something this does not. Both are documented with a table saying which to reach for.It assumes a subject who moves through space, and says so. Tested on piano recordings outside the corpus it was written for — 640×480, a seated player — the defaults held without tuning and
room_platerecovered a clean studio although the pianist is in nearly every frame, but the composite came out as heads and hands stacked in one place, because separation is spatial and he does not travel. That is the picture reflecting the recording rather than a fault to tune away.Body-size bounds are arguments, not constants —
min_areaandmax_areadefault to 0.4 and 6 per cent of frame, describing a whole person at studio distance, and want moving when the framing differs. A bound matching nothing returnsNonerather than an empty room, since an empty room looks like a result.
-
noise_floor,frame_difference_floor,motion_vector_floor— a motion gate measured from the recording instead of guessed at. Every measure has a floor: sensor noise for frame differencing, the encoder's rate decisions for motion vectors, sub-pixel drift for flow. A threshold in absolute units cannot serve two recordings whose floors differ — measured on a dance corpus, one fixed setting lit 0.52 times the area the dancers covered in one recording and 1.90 times it in another.The room plate says which pixels have nobody in front of them, and whatever their frame-to-frame difference shows, nothing there moved. The gate is a quantile of that distribution, so the parameter is a false-positive rate rather than a magnitude.
It can refuse. Otsu will split pure noise and report a threshold with no sign of distress; an estimator that always answers has the same fault. This declines when there are too few samples and when the gate would keep almost none of the moving part — a camera move, a lighting change, an empty recording — and a refusal carries
threshold: Noneso there is nothing to reach for by accident.What it does not buy: equalising the false-positive rate makes spatial maps comparable across recordings and makes magnitudes less comparable, since each recording lands at its own operating point. Both approaches are kept — fixed thresholds when magnitudes are compared, measured floors when pictures are.
-
plate_spread— how much of the recording a plate's frames were drawn from, 0 to 1. -
thresholdfor motion vectors andmin_visibilityfor pose, each in the units that measure actually has: pixels of displacement, and confidence. Both default to off, because a gate switched on by default would silently change results already computed. Note that H.264 codes to quarter-pel, so a vector gate at or below 0.25 px removes nothing at all.
-
room_platespreads its second pass over the recording. It re-took the median over the emptiest tenth of sampled frames, which is right in principle — except that the emptiest frames are not spread through a recording. They cluster in whatever stretch nobody was working. A stepladder stood in one room for ten minutes of a two-hour session, those frames were the emptiest, the refinement took all of them, and the ladder entered the plate as furniture. That region then read as occupied 18.6 per cent of the time — a body reported where there was none.The second pass now takes the emptiest frame from each of
kequal stretches. The choice is still made on emptiness; it simply cannot all come from one place.stratify=Falserestores the previous behaviour, androom_platenow warns when the frames it used span less than half the recording.This changes occupancy figures. On six test recordings the plates moved by 0.2 to 1.3 per cent of pixels — small in aggregate and decisive where it landed, since the one that moved most did so almost entirely inside the ladder.
refine_indicesdocumented itself backwards, summarising as "the ones least like it already" where the code keeps the ones most like it — the emptiest. The prose inroom_plateand in the tests was right; only the one-line summary a reader trusts first was inverted.
-
posegram— one row per pose landmark, ordered head to foot, brightness for how fast that landmark moved. The body-anchored counterpart of a motiongram:pose_waterfallsays where the body went,pose_segmentshow its limbs were angled,pose_centerhow its centre moved, and none of them said what was moving at 04:12.The row order is the design. MediaPipe emits its 33 landmarks in model order, which scatters the body across the page; ordered anatomically a moving limb is a contiguous band and the axis reads head / arms / hands / torso / legs.
-
posegrams— the pose counterpart ofmotiongrams(), and oriented the same way: the horizontal view has time running across with image y down the page, the vertical view has time running down with image x across. That is not decoration — 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 video frame. Because pose gives an actual position rather than a region of changed pixels, these show the true location over time. -
posegram_spatial— the single-orientation form of the above, kept for when only one view is wanted.weight='presence'shows where the body is regardless of movement, which is a different question: a dancer standing still has presence and no speed. -
pose_spatial_map— where the body was, as an image of the frame, so pose has a "where" view of the same kind as the pixel measures' accumulated heat maps. Not to be confused withposegram_spatial, which has time on an axis.
-
motionvectorgramswrote a 309 MB PNG. One pixel column per P-frame stretched to the video's height is 77,592 × 1920 on a 158-minute session — 149 megapixels for something meant to be glanced at. The array still keeps every column; the image is capped at 4000 px wide, pooling columns by maximum so a one-second accent is not averaged away. -
motionscape(track=…)assumed the track ran at the video's frame rate, which the overview's own P-frame magnitude does not — it arrives at about a quarter of it, so the time axis came out wrong by that factor while looking entirely reasonable. It takestrack_times=now and refuses a mismatched length.
- Pose landmark arrays should be passed with the frame they were extracted in. MediaPipe estimates landmarks it cannot see and places them outside the picture — on a real 640×360 extraction the largest y was 1529 — so inferring the frame from the data scales a plot by an extrapolation rather than by the body. The new functions take an explicit frame size and fall back to a high percentile rather than the maximum.
-
thresholdnow thresholds the frame difference, which is what it has always been documented to do. It did not.filter_frame_ffmpegpassed[0:v], the original video, as ffmpeg'sthresholdinput, and that filter emits itsminwhere its first input is below the threshold. So the parameter masked motion wherever the picture was dark and passed every difference through untouched wherever the picture was bright: a darkness mask, not a noise gate._motionvideo.pydescribed it as "discards small pixel differences, which removes sensor noise". It never removed any.This changes every quantity-of-motion number this package has produced. On a corpus of six 100-minute recordings the median quantity of motion falls by up to 87 per cent, which is how much of it was sub-threshold noise, and dynamic range opens up correspondingly — one recording's 99th-percentile-to-median ratio goes from 3.3 to 19.3. Recordings that looked like they had little dynamic range mostly had a lot of noise. Anything comparing motion magnitudes computed before this release should be recomputed.
It also means
threshold=0.05finally means the same thing everywhere. The numpyfilter_frame, used by_impactsand_directograms, always thresholded the difference, so the same argument had two behaviours depending on which function you called.filtertype='Binary'was already correct and is unchanged.Verified on a synthetic clip whose answer is known by construction — a 3-level change over regions of brightness 200 and 5 — because on ordinary footage both behaviours produce a plausible-looking motion image. No existing test failed when this was fixed, which is why it survived: nothing in the suite depended on the difference.
-
motionvectordata— the motion vectors an inter-frame codec has already computed, read back as numbers rather than drawn as arrows.motionvectorsrenders them and needs only ffmpeg; this returns one row per frame and needs PyAV, since ffprobe reports that the side data is present but will not print it and ffmpeg has no numeric dump. Install withmusicalgestures[motionvectors].It is close to free: on a 103-minute recording, decoding took 2.2 s and decoding with the vectors 2.7 s, against 27 minutes to difference the pixels over the same file.
Filter by
picture_type. Against exact quantity of motion on a 100-minute corpus the magnitude correlates at r = 0.87 on P-frames alone and r = 0.54 with B-frames pooled in, because a B-frame's vectors point both ways over varying temporal distances and are not the same quantity. That is why the picture type is on the result rather than discarded. -
motionvectoroverview— one sheet per recording from a single decode, carrying all three kinds of representation a first pass over unfamiliar material wants: spatial (where motion went, and which way), temporal (how much, over the whole recording) and spatio-temporal (the two motiongrams). Built for triage of archive video rather than for measurement. About five minutes for a 100-minute 1920x1080 recording, against roughly twenty-four for the same views computed one at a time. -
motionscape— a Sapp-style scape of quantity of motion: each row a window length, the apex a single window covering the whole recording. It answers what a flat curve cannot — at what scale does this recording stop looking like one thing — since evenly continuous material is flat all the way up while separated bursts merge into one mass at the height of the structure that joins them. Takes any per-frame track viatrack=, so a frame-differenced quantity of motion scapes exactly as readily as a vector one. Built from a cumulative sum, so cost does not grow with window length. -
motionvectorhistory,motionvectorgrams,motionvectorwaterfall— the spatial views, all from one decode of the same macroblock grid. The history image is the one that does something the existing tools cannot:motionhistoryandheatmapare built on frame differences, which have no sign, so somebody entering a region and leaving it look identical. A vector is a displacement, so the history image can colour each part of the room by the direction motion took there.Measured across codecs, resolutions, frame rates and compression levels: the vectors run 5 to 25 times faster than differencing the same file, resolution barely affects agreement (0.73 to 0.82 from 320 to 1920 wide), higher frame rates help (0.89 at 50 fps), and heavier compression does not hurt. Codec decides whether it works at all — ffmpeg exports vectors for H.264 and MPEG-4 Part 2, and returns none for HEVC or VP9, which now raises a warning rather than silently returning zeros that read as stillness.
In the history image, hue is direction, value is amount and saturation is directional coherence --- how consistently a cell moved one way. Saturating every cell fully was the first attempt and produced a uniform wash, because over a long recording every cell accumulates something; grey now reads as "moved, but not any one way", which is the honest description of most of a room. Coherence is under-sampled above about 1.5 Hz on 50 fps footage, since P-frames arrive at roughly a quarter of the frame rate: fine for a body crossing a room, wrong for a shaking hand.
Frame-threaded decoding drops motion vectors, and not the same ones twice. Three identical runs of one clip returned 28,702, 29,649 and 29,975 vectors threaded, and the same number every time unthreaded. On a real 1920x1080 recording the loss is 222 vectors in 25.7 million, so threading stays the default and
deterministic=Trueis there for anything that must reproduce exactly, at about 4.5 times the cost.Two limits are documented rather than hidden. The vectors are the encoder's decisions, not a measurement of the scene, so the signal is good where there is movement (median r = 0.86) and poor where there is not (0.57). And
sourcegives the direction of the reference but never its distance, so multi-frame predictions read as multiples of the per-frame displacement --- the medians are robust to this, the summedmagnitudeis not.
room_plate,occupancy_track— the empty room as a per-pixel median over sampled frames, refined once over the emptiest tenth, and how much of the frame anybody fills. Occupancy answers what motion cannot: a dancer standing still has no motion and plenty of occupancy. Median and not mean, because a mean keeps a ghost of everyone who crossed.restless_map,restless_regions— pixels that change whatever is in front of them. Median absolute deviation rather than range: a screen changes in nearly every frame and a dancer occupies a pixel occasionally, and a range-based measure marks them alike, which would mask the subject along with the distraction.
soundscape_featurestakes a video, not only an ambiscape session folder. Its audio is extracted once and reused; an unknown extension is refused rather than handed to ffmpeg to fail on. It also carries the spectrum now --- centroid, flatness, peak, diffuseness and ten octave bands --- where it previously surfaced level alone, so a room can be described as more than loud or quiet.
filmstrip,concordance,tier_map,structure_mapin_views— the first figures in this toolbox that know an annotation exists. Everything else here draws the signal;Hierarchyand the ELAN exporter sat on the other side of a gap almost nothing crossed.concordancein particular has no equivalent: it puts every instance of one category side by side, which is what keeps a category consistent when there are 183 of them to code.zoomable_pagein_zoomview— one self-contained offline HTML file that zooms from a whole session to a single action. Min and max per bucket, never a mean, and an explicit warning drawn on the page when the viewer zooms past the embedded resolution.
structure_mapdefaults to audio features, and the default is measured rather than assumed. On a corpus containing three performances of one devised piece, audio features separate those from the rehearsal by +0.234 to +0.252 in mean cosine similarity; videogram columns manage +0.029 and a hand-built activity profile −0.007. Smoothing and time-delay embedding are per-feature for the same reason: they rescue the videogram version and cost the audio one a third of its discrimination. The docstring carries the numbers, because a self-similarity matrix always produces a plausible-looking picture and that is exactly what makes one dangerous to ship untested.
laughter_segmentsandlaughter_score— where laughter is in a recording, and nothing else about it. Six AudioSet classes, not one: taking only the class calledLaughterdiscards the giggling and chuckling a rehearsal is mostly made of. The strongest class, not their sum, because the six are not independent and a belly laugh raises two at once. Validated against 79 hand-coded laughs on a real corpus: ROC AUC 0.823 against a loudness baseline's 0.741, and 91 per cent precision in the top 5 per cent of windows. Span assembly reusesspans_from_probabilitiesrather than copying an ordering that is easy to get backwards.co_accentuationandco_accentuation_curve— whether motion accents coincide with audio onsets, after Serdar and Jensenius, MOCO '26. Each index is judged against a circular-shift null that preserves the onsets' number and rhythm and destroys only their relation to the motion, because a raw fraction of coincidence rises with density and an index of 0.8 means nothing until you know what 0.8 was worth by accident. A window with no motion peaks reports NaN, not zero. The measure is asymmetric on purpose: it asks what fraction of motion peaks found a sound, and swapping the arguments asks the other question.
align_by_audio,locate_probeandenvelope_from_audioin_alignment, for locating one recording inside another by its loudness envelope.xcorr_lagalready answered how far apart two signals are that cover the same stretch of time; these answer where a short recording sits inside a long one — a hand-cut excerpt, a second camera, a re-encoded copy. They probe rather than correlating whole files, because a file namedCutmay be several pieces spliced together and only independent windows reveal it, and they summarise by the offset that recurs rather than the median, because when most probes match nothing the middle of the list is meaningless. A probe that matches nothing reports no match: every cross-correlation has a maximum, and the maximum of noise is still a maximum. Verified against five hand-measured offsets on a real corpus, reproducing all five including one where only 6 of 16 probes matched._correlate.lagged_correlation, correlation swept across a lag range with two corrections that a bare sweep lacks: Bonferroni over the lags examined, and Bartlett's effective sample size. Consecutive samples of a smooth envelope are not independent observations; without the second correction two unrelated AR(1) series report p = 8.4e-10. Related toxcorr_lag, which is the right tool when the lag itself is the answer rather than a claim about it._cooccurrence, saying which annotation layers coincide and by how much —overlap_seconds,label_by_overlap,cooccurrence_table. Any two layers, not a fixed pair. Overlapping reference spans are merged before anything is counted, and the four cells of the table sum to the recording's duration._elan.read_elan_csv, reading ELAN's exported tab-delimited text — the format collaborators actually send. Every span keeps the provenance line naming the media it was annotated against, because taking the times and discarding that is how annotations land on the wrong timeline.to_elan(..., nest=False)andvocabularies=. Nesting each level inside the previous is right for a hierarchy and wrong for layers that merely coincide: speech is not inside motion, and another researcher's reference tier is not inside anything of ours. Controlled vocabularies give an annotator a drop-down instead of free text.render_timeline(..., shade=), drawing a span's extent rather than only the line where it began. On the corpus this was built for, a reader assuming a span ran to the next boundary line would have been wrong by 21 minutes. The idiom isannat_shade()from Finn Upham'sLaughter_Dance.
to_elanwroteLANG_REF="und"with no matchingLANGUAGEelement, which EAF 3.0 requires — well-formed XML that ELAN objects to, the same class of fault as the export ELAN would not open.to_elanadded a free-textannotationtier the caller never asked for, which in a flat export sits beside the caller's own empty tier and invites an annotator to split her notes between two indistinguishable places. It is now added only when nesting.
1.14.2 — 2026-08-25
-
musicalgestures/_tracks.py, a one-pass extractor for long recordings, merged fromtracks-wipin #380. It is private and is not imported bymusicalgestures/__init__.py, so the public API is unchanged by this release --- hence a patch rather than a minor version. It converts each motion frame to greyscale once and takes the quantity of motion and both videogram columns from that, writing into a preallocated memory-mapped file rather than a growing array; on 120 s of 1080p the motiongrams are 71 per cent ofmg_motion's cost and the area of motion another 12.It is listed here because a 428-line module arriving unmentioned is the same gap this release exists to close. Treat it as not yet part of the supported surface.
-
A flat 10-second ffprobe timeout put a ceiling on how long a video MGT could open at all.
get_framecountallowed ffprobe ten seconds to count packets and, on timeout, escalated to-count_frames, which fully decodes the file and is far slower --- so the fallback timed out as well and the call raised. The effect had nothing to do with any analysis: a recording longer than roughly twenty minutes could not be read.Measured on a 2 h 38 min 1080p recording, where counting packets takes 22 s:
MgVideo()raisedCould not count frames. (Is this a video file?)on an ordinary MP4. It now opens in 21 s and reports 475,680 frames.Two changes. The allowance scales with the file ---
framecount_timeout(), kept a separate function so a test can shorten it and exercise the real timeout path against a real ffprobe rather than a mock of one. And a timeout no longer escalates to the slower method: it falls back to the container's ownnb_frameswith aRuntimeWarningsaying so, because that figure is off by one on some AVIs and absent on some WebM. An approximate answer with a warning beats refusing the file.No measured value changes. This is the path that opens a file, not one that analyses it; frame counts that were returned before are returned unchanged, and the fallback only runs where the previous code raised.
tests/test_framecount_long_video.pycovers it against a real ffprobe.
1.14.1 — 2026-08-24
-
Motion extraction reallocated its output once per frame, so its cost was quadratic in frame count. Every accumulator ---
time,qom,com,aomand both motiongrams --- grew bynp.appendinside the frame loop, and each append copies the whole array. The motiongrams are the worst of it: they grow toheight x frames x 3, so the copying is quadratic in bytes as well as in calls. The numbers were correct throughout, which is why nothing caught it.Measured on 1080p: 69 s, 148 s and 366 s for 30 s, 60 s and 120 s of video, an exponent rising from 1.10 to 1.31 as the quadratic term took over. Extrapolated to a 2 h 38 min recording that is about 215 hours. After the fix the same clips take 64 s, 123 s and 242 s --- linear, and about 5.3 hours for that recording, or 1.4 hours with
motion_analysis='qom', save_motiongrams=False. The fault was invisible on clips and fatal on a session.Accumulators are lists now, converted once after the loop, and the motiongrams are concatenated once. Output is byte-identical, verified on 30 s, 60 s and 120 s of 1080p video, so no existing result changes.
tests/test_motion_scaling.pyguards it by countingnp.appendcalls rather than seconds. The timing test written first passed against the unfixed code --- at a small frame size the quadratic term is still noise --- so it was replaced by one that asserts the property directly.
1.14.0 — 2026-08-24
-
fuse_pose_views()fuses MediaPipe world landmarks from two or more uncalibrated camera views into one consensus skeleton: a single Umeyama rotation-and-scale alignment per view, estimated from the rigid torso landmarks and averaged over the take, then a visibility-weighted average per landmark, with the cross-view residual in millimetres as a quality measure. It takesextract_pose_landmarksresult dicts or plain(world, visibility)arrays, so it is not tied to MediaPipe as a source. Closes #355.This consolidates
concert_fuse3d.pyandreh_fuse3d.pyfrom the Westney-comparisons study, which held byte-identical copies of the algorithm and differed by one line, a hardcoded list of pieces. It reproduces that study's published fusion on all four concert excerpts to within float32 storage precision, rotations agreeing to 1e-9 and the residuals unchanged.Two guards the study code did not have. Its rotation average projected the mean matrix back with a bare
U @ Vtand no determinant check, so a mean falling nearer a reflection than a rotation would have mirrored the skeleton silently; that is now rejected. And its gap filling said "fill short gaps" in a comment while filling every gap of any length and holding the ends flat, so a long dropout became a straight line through it with nothing marking it;max_gapnow bounds this. The default is still fill-everything, because that is what the existing results were computed with.The residual measures consistency, not accuracy: views that agree can agree on a wrong pose.
motion_mp()is retired. It rendered a motion video across several processes, coordinating them with a socket server and an argparse child process, and it raised on its first call: two fields were read as one between the parent and the workers, and behind that failure it calledsave_txtandsave_analysiswith arguments in the wrong positions. It therefore produced nothing for anyone who tried it, and had no test.motion()does the same work in one process and is tested. The method remains as a stub that says all this and points there; the two modules behind it are gone. Closes #370.
- The mypy step in CI blocks merges now.
mypy musicalgestures/reports no issues across all 69 source files, from 248 when issue #350 was opened, so the|| truethat made it advisory is gone. The checker version is pinned alongside it, because the count this reports moves with the checker as well as with the code: on identical source, mypy 1.20.1 reported 169 errors where 2.3.1 reported 90. Closes #350. - The last of that backlog: two frame rates typed as integers, which they are not; two GPU objects created under a flag and used under the same flag, which a checker will not correlate; three output names resolved in one branch and used in another; and two functions carrying no return annotation at all.
1.13.0 — 2026-08-23
- Audio figures mislabelled their time axis, and crashed outright on files longer than an hour.
format_timebuilt its tick labels by assigning formatted strings into a numpy float array, so'10.00'became10.0and rendered as10:0, losing a digit on every tick that landed on a whole minute, while'0.13.20'on a file over an hour raisedValueError: could not convert string to float. The labels are built as strings now. The method is called from ten places, so this affected every audio figure. - Tick labels are no longer merely near the ticks they describe.
format_timepaired aFixedFormatterwith aLinearLocator, which matplotlib warns about because the pairing is by position and only defined against aFixedLocator. Ten ticks are placed explicitly across the axis and labelled with the corresponding times in the original file --- the two differ whenever frames were skipped, which is the case the method exists for. musicalgesturesno longer silences warnings for the whole process. Importing it ran a barewarnings.filterwarnings("ignore"), which switched off every warning from that moment on, including warnings raised by this package and by the caller's own code. It is scoped to theDeprecationWarnings fromaudioreadthat it was presumably added for, which report that stdlib modules a librosa dependency imports are slated for removal, and which a user of this toolbox can do nothing about. The three bugs above were hidden behind it, and so were the deprecation warnings on the renamed attributes below, which now reach the people who need them.- The cropping window raised
NameError: name 'ref_point' is not definedif you pressed "c" without having dragged a rectangle first --- which is what its own on-screen prompt invites you to do.ref_pointandcropwere declaredglobalinside the mouse callback but never bound at module scope, so they existed only once a mouse event had fired. They are bound now, beside thedrawing/xi/yithat already were, and selecting nothing says what to do instead: "No crop area was selected. Drag a rectangle across the frame with the left mouse button held down, then press 'c' to crop." pixelarray()raisedTypeError: 'MgImage' object is not callableon its second call.pixelarrayis both the method that computes the frame average and the name it stored the result under, so the first call replaced the bound method on the instance with theMgImageit had just returned. The result now lives underframeaverage_image; the method keeps thepixelarrayname and nothing shadows it. Unlike the other renames in this release, there is no deprecated alias for the old attribute name, deliberately: an alias here would be a property namedpixelarrayon the class itself, which reintroduces the exact collision this fixes, permanently.pixelarray_cv2had the same problem for the same reason and is fixed the same way, asframeaverage_cv2_image. This is the same class of bug commit 744169f fixed forsubtractandsonomotiongram.
crop_box_from_points()turns the two dragged corners into an ffmpeg crop box. The arithmetic was inline incropping_window, where reaching it needed a window and a mouse, so none of it was tested. Either corner may be dragged first and all four directions normalise to the same box; that is now nine tests rather than an assumption.
- Every result an analysis method stashes on
MgVideois now declared on the class and named after what it holds:motion_plot_image,ssm_combined_image,movement_beat_statistics_figure,pose_average_image,pose_trajectories_image, and the four grams below. Each old name keeps working as a deprecated alias and is removed in 2.0. Closes #346. - The motiongrams and videograms are named for what they show rather than for the axis that was
collapsed to make them:
motiongram_xwas the vertical gram andmotiongram_ythe horizontal one, which is whyshow()grewmgh/mgvaliases in the first place. They aremotiongram_vertical_image,motiongram_horizontal_image,videogram_vertical_imageandvideogram_horizontal_imagenow. Every existingshow()key, including the legacymgx/mgy, resolves as before. - Declaring the attributes made the producing functions typeable, and typing them turned the type
checker on over code it had never read. mypy does not check the body of a function carrying no
annotations at all, so annotating
selfin the producers opened ~31 pre-existing problems to view for the first time:mypy musicalgestures/reports 90 errors where it reported 74, and the difference is what it can now see rather than anything newly written. The errors this work set out to close did close ---no-any-returnwent from 25 to 7 --- and the toolbox's results now autocomplete in an editor. The remaining 90 are the real number to work against. The count is mypy-version-dependent: on the same code and config, mypy 1.20.1 reports 169 errors and mypy 2.3.1 reports 90, and CI installs mypy unpinned, so anyone acting on these numbers should pin the version first. ssm()stores its result asssm_figure, notssm_fig. Every other stashed result on MgVideo already ends in_video,_imageor_figure; this one did not, andshow(key='ssm')resolves the result by attribute name, so the outlier was visible to users rather than internal.ssm_figkeeps working as a deprecated alias and is removed in 2.0. The alias is a property rather than a second attribute, so the two names cannot drift apart when either side is reassigned. Part of issue #346, which collects the remaining outliers (motion_plot,ssm_combined,pixelarray).- Nine
MgAudiofigure methods are annotated-> MgFigure | Nonerather than-> MgFigure:waveform,spectrogram,tempogram,hpss,descriptors,chromagram,mfcc,tempoandbeat_statistics. All nine already returned None when they could not compute a figure --- no audio track, an unknownchroma_type, too few beats --- so this documents what they did rather than changing it, and each docstring now says when you get None back instead of leaving it to be found through anAttributeErrorfurther down. Their barereturnstatements are explicitreturn None. - Internal typing, issue #350:
mypy musicalgestures/goes from 248 errors in 32 files to 74 in 19, with_video.py,_utils.py,_360video.py,_audio.pyand_cropvideo.py--- the five files that carried 69 per cent of the backlog --- all at zero. Mostly implicitOptionalin signatures, cache annotations, and one name per branch where three subprocess types shared one. The class-scoped imports that bind methods onMgVideoare the toolbox's central idiom and carry permanent# type: ignore[misc]markers rather than being restructured. CI's mypy step stays non-blocking until the remaining 74 are dealt with; they are dominated byno-any-return(25) andunion-attr(18), and each needs reading rather than a sweep. - More return annotations corrected to match what the code has always done.
mg_ssmreturnsMgList | MgImage | None,mg_motionscorereturnsfloat | None(as its own docstring already said), andFlow.densereturnsMgVideo | MgFigure--- withvelocity=Trueit has always handed back a figure, while both signature and docstring promised a video.
1.12.1 — 2026-08-22
- The contact-sheet test fixture used
os.link, which cannot cross drives on Windows, so CI failed on all three Windows jobs. Test-only: the 1.12.0 package is correct and this changes nothing a user runs. It is released because thev1.12.0tag points at a commit whose CI is red, and a tag that fails its own checks is misleading whatever the wheel contains.
- The PyPI publish workflow now refuses to run unless CI succeeded on the exact commit being released. Publishing fires on a published release, which was independent of CI, which is how 1.12.0 went out from a red commit in the first place.
1.12.0 — 2026-08-22
-
contact_sheet()tiles ONE frame from EACH of many videos into a sheet, so a corpus can be scanned by eye.grid()does the other case, many frames from one video. On a 366-day export of daily recordings every framing fault found was found by a person looking at a sheet like this and not by a measurement — a crop pointed at a colleague, a frame that cut the subject's feet — because a day framed unlike its neighbours announces itself without a threshold having to be chosen in advance.A file that cannot be read gets a tile labelled UNREADABLE rather than a black one. A dark day and an unopenable file look identical otherwise, and on that corpus a day was investigated as a fault when the sheet had simply been built while the file was still being written.
MgVideo('clip.mp4', fps=99)now warns.fpsis the rate an ARRAY is encoded at; for a file input the constructor reads the rate from the file and overwrites the argument, so it did nothing — and code written on the belief that it had set the rate was wrong with nothing in any output to show it. The docs had carried this as a standing warning. Useresample(fps=...)to change a file's rate.
- Three tests now guard the release itself: that
__version__, the newest changelog entry anddocs/releases.mdall name the same version, and that every released version in the changelog has a git tag. Preparing 1.11.4 founddocs/releases.mdannouncing 1.8.0 while PyPI served 1.11.3, and 1.11.3 published with no tag at all. Neither breaks an install, which is why neither had been noticed. v1.11.3 has since been tagged at its own release commit.
1.11.4 — 2026-08-22
-
The frame rate is no longer truncated to an integer. Seven call sites read
int(vidcap.get(cv2.CAP_PROP_FPS))—_directograms,_flow(twice),_history,_impacts,_warpand_videoadjust. On NTSC-rate footage that isint(29.97) == 29, so every time and frequency they derived was 3.2 % low, and_flowand_historyalso wrote the truncated value into the output file's declared rate, turning a 29.97 fps input into a 29 fps file. The docs carried this as a known defect; it is fixed and the note now says so.Every use was checked to be safe with a non-integer rate: divisions,
cv2.VideoWriter, ffmpeg-rand librosasrall take a float, and_impactsalready wrappedint()where it needed a whole number. Figures produced before this change are not comparable with figures produced after it on non-integer-rate footage. -
Averaged frames are ROUNDED rather than truncated. Every averaging path finished with
(acc / n).astype(np.uint8), which discards the fractional part instead of rounding it. On a synthetic stack the result sits 0.497 levels below the true mean and half the pixels differ by one from the rounded answer, always downward — a small, systematic bias in a frame whose purpose is to be a clean background to subtract. Seven places in four files:_spacetime._average_frame,_heatmap, the three pose average frames, and the two motiongram row and column means._remap360already rounded, which is where the convention came from.Output pixel values may therefore change by one level. Nothing about the API changes.
Pinned by two tests in
tests/test_average.py, because the existing suite passed either way: that truncation is strictly below the true mean where rounding is not, and a guard that reads the package for any accumulator divided by a count and cast without rounding.
bandpassandxcorr_lagnow delegate tomicromotion, which owns filtering and lag estimation for the toolbox family. Their signatures and return types are unchanged, so existing calls keep working, but there is one implementation behind both packages instead of two that had drifted apart.bandpassRAISES on a band that is unusable at the given sampling rate. It used to return the signal unfiltered, which handed back data the caller believed was band-limited and was not, a silent wrong answer propagating into everything computed from it. The argument orders still differ between the two packages:musicalgestures.bandpass(signal, lo, hi, fs)againstmicromotion.bandpass(x, fs, lo, hi). micromotion validates against Nyquist, so a call moved between them without reordering raises rather than returning a plausible array.
xcorr_lagandmicromotion.xcorr_lagreturned opposite signs for the same pair of signals. This package was the correct one, agreeing with its own docstring throughout, and micromotion is corrected in its 1.13.0. Requiresmicromotion>=1.13.0.- The tie-breaking rule that prefers the smallest-magnitude lag among near-tied maxima, which originated here and matters for periodic envelopes, moved into micromotion with the delegation, so nothing was lost by it.
tests/test_analysis.py.musicalgestures._analysisexports six functions at package top level and no test referenced it, which is why the two divergences above went unseen. The new tests pin the delegation, pin thatbandpassreally removes out-of-band energy rather than merely matching the owner, and pin the deliberate difference between this package'sdominant_frequency(FFT peak, 0.5–8.0 Hz, for locomotion and dance) and micromotion's (Welch peak, 0.3–4.0 Hz, for postural micromotion). That difference is stated in the docstring now rather than only in a source comment in__init__.py.
- Documentation and citation metadata only; no change to any code path.
CITATION.cffstates the software's authors, its version and its DOI, and the README carries a DOI badge, so the record Zenodo archives is built from the repository rather than by hand.- The one public definition without a docstring,
musicalgestures.examples, is described. - The documentation says where this package stops and its three sibling toolboxes start.
This is the first release since the Zenodo GitHub integration was switched on. The integration
cannot see a deposition it did not create, so this release begins a NEW concept DOI and the
hand-made lineage 10.5281/zenodo.21949007 is frozen at 1.11.1. The repository is repointed at the
new lineage immediately after this release, and deposit/_curation/toolbox_doi_check.py in the
Still Standing project verifies it.
- The threshold figure recorded in 1.11.0 is superseded by the full corpus. That release
quoted a 0.4 % median cost at
threshold=0.05, measured on 83 clips. The run finished at 345: the cost is 0.2 %, the direction is still consistent and significant at the lower thresholds (202 of 345 at the default, sign test p = 0.002), and atthreshold=0.2it stops being significant at all (187 of 345, p = 0.13). The conclusion is unchanged and stronger --- the picture-legible default is free, for practical purposes, for machine analysis of this material. The sequence is recorded in the docstring because it is the useful part: 6 clips said no effect, 23 said 1.2 %, 83 said 0.4 %, 345 says 0.2 %.
-
pose()on OpenCV 5 fails with a message instead of anAttributeError. OpenCV removed its Caffe importer in 5.0:cv2.dnn.readNetFromCaffeno longer exists andcv2.dnn.readNetrefuses the format rather than falling back. The OpenPose backends here — BODY_25, COCO and MPI — are Caffe models, so on such a build they cannot run at all. The failure used to surface asAttributeError: module 'cv2.dnn' has no attribute 'readNetFromCaffe'from deep inside the run, and only after offering to download 200 MB of weights that the environment could never load.The check now happens before the weights are looked for, and names both ways out:
pose(model= 'mediapipe')withpip install musicalgestures[pose], orpip install 'opencv-python<5'to keep the OpenPose skeletons. It is deliberately not an automatic switch to MediaPipe — its 33 landmarks are a different skeleton from BODY_25's 25, so a silent substitution would return data that looks like what was asked for and is not. -
The MediaPipe fallback no longer falls back into a wall. With MediaPipe missing,
pose()announced a fallback to BODY_25 and then failed on it, because that fallback assumed OpenCV could always load a Caffe model. Where it cannot, there is nowhere to fall back to, and the error now says so and names the one install that would work. -
Test_pose_gpuskips where OpenPose cannot run. Its two cases had been failing with the bareAttributeErrorabove on any OpenCV 5 machine, which is what made the incompatibility read as a fault in the pose code rather than in the environment. They test device selection on the OpenPose path, so where that path does not exist there is nothing to select and the tests skip with the reason stated.
- What the visualisation threshold costs a measurement is now measured, not
just flagged.
_motionvideo's header said there was "no reason to think the value that looks best is the value that measures best". On 83 clips of a corpus of everyday sound-producing actions, scored by how far the action stands above the lead-in it interrupts: no threshold improves the separation, more clips lose contrast than gain it at every step (55 of 83 atthreshold=0.05, sign test p = 0.004), and the median cost at the default is 0.4 % against a per-clip spread of 0.39 to 2.66. The default is very nearly free for machine analysis of this material. Two cautions are recorded with it: one criterion was scored, and smaller samples of the same code gave 1.2 % at 23 clips and no effect at 6. - Also recorded: a threshold moves any landmark computed from the same series, so a statistic measured across that landmark shows a much larger apparent effect than the signal loss --- 23 % against 9 % here.
MediaPipe is unaffected by any of this and remains the default backend; it carries its own weights
and never touches cv2.dnn. Verified end to end on OpenCV 5.0.0 with MediaPipe 1.0.0: 163 frames
of 33 landmarks off a real clip, a person found in 44 % of them.
-
normalize=Falseonmg_motion, for machine analysis. The exported quantity of motion has always been divided by each clip's own maximum, so every clip peaks at exactly 1.0. That is what a plot on a 0–1 axis wants — the same expression appears in the plotting code — and it is invisible in the numbers themselves, which is why it surprises people. It makes quantity of motion incomparable between clips: a small gesture and a violent one both reach 1.0, and one bright frame sets the scale for everything around it.normalize=Falseexports the untouched sum of pixel values instead. The column is renamed toQomRawrather than merely rescaled, so that a file cannot be misread later: aQomcolumn is always per-clip normalised and aQomRawcolumn never is, whoever opens it and however long afterwards.normalizewas already inmg_motion's signature and had never been used.
- TSV export wrote quantity of motion as 0 or 1. The writer formatted the column with
%dwhile the value had already been divided by its maximum, sonp.savetxttruncated every fraction to an integer. Normalised values now write as floats;QomRawwrites as an integer, which is what it is. This changes the content of TSV exports, which ARJ confirms have not been relied on.
-
_motionvideostates what its defaults are for. They are tuned to produce a legible picture, not a measurement — the right choice for motion videos, motiongrams and plots, and worth saying now the same functions are used to produce numbers. Three are visualisation choices:threshold=0.05, which removes sensor noise and small real motion alike;filtertype='Regular'; and the per-clip normalisation above.Nothing is smoothed by default. The whole default chain is
format=gray → tblend=all_mode=difference → threshold, and the smoothing that exists is off unless asked for —atadenoise(adaptive temporal averaging over 129 frames, the only temporal one),use_median(spatial),blur(spatial). A machine-analysis configuration is therefore a matter ofthresholdandnormalize, not of turning filters off.
musicalgestures.__version__. The package exposed no version attribute at all, so code could only learn the version through the installed metadata, and a script run against a source checkout could not learn it at all.
- The version has one source.
pyproject.tomlreadsmusicalgestures.__version__through a dynamic version rather than carrying its own copy, so adding the attribute did not create a second number to drift. That drift is not hypothetical across these toolboxes: ambiscape shipped three releases reporting a version other than their own and musiscape shipped one, in both cases because a bump editedpyproject.tomlalone.tests/test_version.pyfails if a static version reappears there, if the build stops reading the module attribute, or if the two numbers diverge.
get_pose_model_path(): public helper for the shared MediaPipe pose-model download/cache, promoted from the privateMediaPipePoseEstimator._get_model_path(#360).extract_pose_landmarks()acceptst0/durationparameters to analyse a sub-window of a long video without decoding the whole file; returned timestamps stay on the source clock (#354).
show(key='blur')looked up a non-existent attribute and silently did nothing; it now shows the face-anonymised video, and raisesFileNotFoundErrorwhenblur_faces()has not been run.subtract()andsonomotiongram()stored their results over their own bound methods, so a second call on the same object crashed. Results are now stored assubtract_videoandsonomotiongram_audio.info('summary')reported the frame count as the duration in seconds.- The default motion-data CSV is now written to
<name>_motion.csv, matching the tsv/txt branches and the pathmotiondata()returns (previously<name>_motiondata.csv). - The
autoshowparameter on theMgAudiofigure methods (waveform(),spectrogram(),tempogram(),hpss(),descriptors(),chromagram(),mfcc(),tempo(),beat_statistics()) was accepted but ignored; it now displays the figure inline when running in a notebook.
- 360-degree directional analysis on
Mg360Video:anglegram()(time by azimuth visual motion energy, cos-latitude weighted, ambisonics-aligned),aem_overlay()(audio energy maps from ambiscape exports rendered on the video or the anglegram via a file interface),view()(non-destructive perspective crops returning a regularMgVideo), and projection auto-detection.
- API documentation migrated from handsdown-generated pages to mkdocstrings: docstrings now render at build time, page paths unchanged.
- Documentation overhaul: beginner-oriented README, thirteen doc-vs-code
errors fixed (motiongram index order,
lengthvsduration, and others), new demo figures and GIFs from genuine tool output.
-
group_qom,pose_qomandnormalized_qomreturn different numbers. They are re-exported frommicromotion, which released 1.0.0 today, and the change is a correctness fix rather than a refinement. They averaged over every marker at every frame while the underlyingband_limited_qominterpolates gaps, so an occluded marker contributed a near-zero speed and still counted in the divisor. The result tracked how much the cameras saw rather than how much the body moved: 16 to 17 per cent low on a realistic dropout pattern, with the speed series correlating up to +0.70 with the per-frame count of visible markers.The new default,
normalize="visible", excludes each marker at the frames where that marker was absent, and lands within 0.8 per cent of the unoccluded value. Passnormalize="worn"to reproduce a figure published with an earlier release, and say which you used. Pose data from a clean, well-lit recording with no dropouts is unaffected; occluded mocap is affected most. -
The
micromotionfloor is>=1.0.0, which is a correctness floor rather than a documentation one: below it the re-exported function returns the confounded number.
- The committed API pages published a band this package no longer uses. They showed
group_qom(points, fs, lo=0.3, hi=15.0)and deep-linked intomusicalgestures/_qom.pyat line numbers that stopped existing when those functions moved tomicromotionon 2026-07-29. The pages are regenerated from the current source. docs/user-guide/pose-tracking.mdanddocs/user-guide/sound-movement-toolkit.mdsaid the band is 0.3–5 Hz and passedlo=0.3, hi=15.0. It ismicromotion.BAND, 0.2–5 Hz. The regeneration script does not touch hand-written pages, so these were corrected by hand.- The
micromotionfloor was>=0.3. No such release exists on PyPI below 0.6, and the functions re-exported here arrived far later, so the constraint permitted installations in whichfrom musicalgestures import group_qomfails. It is now>=0.15.2, which is also the floor that makes the committed API pages true, since they are generated from that package's docstrings.
- The three re-export shims now point at https://fourms.github.io/micromotion/ for the API reference. Their generated pages describe the shim rather than the functions, which is correct but left a reader with nowhere to go.
- GoPro MAX
.360support:gopro360_to_dual_fisheye()with the field of view as a parameter, andgopro360_dual_fisheye_average(), which time-averages a recording straight to a dual-fisheye image without writing a video first. It decimates BEFORE the remap rather than after, which is about fifteen times faster for an identical result.
- The remap-table scratch directories were created in four places and removed in none, and one call
site discarded the path so there was no way to. A build over 364 recordings left 22 GB across 348
directories and filled the disk.
_gopro_remap_stageis now a context manager. cv2.imwritefailures were discarded throughout_remap360. It reports failure by return value, so a full disk produced a PNG that was never written and an error several steps later that read like a corrupt recording. Writes now raise.
- Three regression tests for the above, each verified to fail against the previous commit.
-
Sound–movement analysis toolkit (#351, #352, #353)—a family of plain-numpy functions ported from the author's ro / stillstanding / Westney / cymbal research pipelines, all importable directly from
musicalgestures:_peaks.pick_peaks— the canonical adaptive peak-picker shared by every detector below._pulse—Cycle,group_strokes,segment_cycles,cycle_table,fit_accelerando,motion_onsets: onset grouping into rhythmic cycles and accelerando fitting._alignment—xcorr_lag,envelope_lag,per_cycle_motion_delta,anchor_and_match,offset_stats,sliding_correlation,envelope_agreement: cross-modal lead/lag and coupling._qom—band_limited_qom,accel_to_speed,group_qom,pose_qom,body_scale,normalized_qom,grid_qom,envelope,bin_series: quantity-of-motion cores, including body-scale (framing-invariant) normalization._audiofeatures—rms_envelope,spectral_flux,spectral_flux_onsets,energy_onsets,t60_backward_decay,attack_spectral_centroid: scipy-only audio features and onsets.motiongram_data()(_motionanalysis) gained anorientation='vertical'|'horizontal'option._posture— 12 posturography/sway-dynamics functions (cop_sway_metrics,stabilogram_diffusion,dfa,sample_entropy, ...)._physio—respiration_rate,spectral_band_fractions._mocap—read_qtm_tsv,compare_modality_envelopes(its owndominant_frequencyis intentionally not re-exported at top level to avoid shadowing_analysis.dominant_frequency)._posetools—extract_pose_landmarks(needs the new optional[pose]extra:pip install musicalgestures[pose]; supports both the legacy MediaPipe Solutions API and the newer Tasks API),midpoint,limb_speed_from_landmarks,impact_events.
See the README's "Sound–Movement Analysis Toolkit" section and the new user guide page for usage; API reference pages were regenerated for all nine new modules.
motiondescriptors()(#210)—higher-level scalar movement descriptors from the quantity-of-motion signal: motion energy (mean squared QoM), motion smoothness (SPARC, spectral arc length, a dimensionless validated smoothness metric), motion entropy (normalised 0–1 Shannon entropy of the QoM magnitude distribution), and spectral descriptors (dominant frequency + spectral centroid from the Hann-windowed QoM power spectrum). Returns anMgFigure(QoM time series + power spectrum) with the scalars and full spectrum in.data, and writes a_motiondescriptors.csv. Hann windowing is the documented default (withwindow='none'for rectangular), answering the windowing question raised in the issue; the dominant frequency and spectral centroid are searched within a movement band (fmin/fmax, default 0.2–10 Hz).
- Lightweight animated GIFs for the video-producing methods (motion, history, motion vectors,
Eulerian magnification, dense/sparse optical flow), a
motiondescriptorsfigure, and a new visual Gallery in the examples page;scripts/generate_example_media.pyregenerates them. README, the video-analysis user guide and the wiki updated formotiondescriptors().
- Accurate frame counts (#242, #239).
get_framecount(fast=True)—used to setMgVideo.length—read the container'snb_framesmetadata, which is unreliable (off by one on many AVIs, absent on WebM); this was the "extra frame after conversion" reported in #239. It now counts demuxed video packets (-count_packets), which matches the true decoded frame count across containers while still avoiding a full decode (it is in fact marginally faster than the old metadata query).fast=Falsestill does the exhaustive decode-and-count. Added a regression test that pinsfast == exactfor AVI/MP4/WebM.
- Faster import (#349):
import musicalgesturesdropped from ~0.65s to ~0.52s by deferringimport numba(it pulls in LLVM). The@jitkernels in_directograms,_impactsand_warpare now compiled lazily on first use. The nested directogram case (directogramcallsmatrix3D_norm) is handled by compiling the inner kernel and rebinding the module global before the outer kernel compiles, the issue that reverted the first attempt. Addstests/test_numba_kernels.pyto pin the deferral and nested-jit behaviour. - Also added the missing parameter type hints to
mg_warp_audiovisual_beats(a follow-on to #345).
- Parameter type hints across the public API (#345). Every public analysis method now
annotates its parameters (not just its return type): the motion methods, motiongrams/
motionvideo/motiondata/motionplots, optical flow (
Flow.dense/sparseand the velocity helpers), pose (pose,pose_waterfall/segments/center/distance), the space-time visualisations, directograms/impacts, SSM, cropping, heatmap, history, videograms, the audio methods and the audio–movement suite, plus the public_utilshelpers and theMgVideoinline methods. Modules now usefrom __future__ import annotationsso hints are lazy (no runtime/import-speed cost) and use modernstr | Nonesyntax. Combined with thepy.typedmarker shipped in 1.6.4, downstream IDEs and type checkers now see a fully-typed public API.
- Fixed the CI mypy step (#345), which was aborting on a third-party file (
tifffileships 3.12-only syntax, fatal on our 3.10 target) before it ever checked our code. Settings are now centralised in[tool.mypy](follow_imports=skip, exclude deprecated/3rdparty) and shared by CI andnox -s typecheck. The step stays non-blocking while the internal-typing backlog is worked through (#350).
- Cached average-frame decode for the space-time analyses (#347).
stroboscope,silhouette_waterfallandspacetime_volumeall reconstruct the same average background frame by decoding the whole video; this is now computed once and cached perMgVideo(keyed by filename, invalidated when it changes), so chained space-time calls reuse it instead of re-decoding. Joins the existing derived-signal caches (quantity-of-motion, audio envelope). Full raw-frame shared decoding is intentionally avoided. Caching the decoded frame stack is too memory-heavy for typical videos (>1 GB), so only small derived signals are cached.
1.6.4–2026-06-28
- Type hints +
py.typed—theMgVideo/MgAudio/MgImage/MgFigureconstructors are typed and ~45 public methods now declare their return types (the audio suite, space-time visualisations, the audio–movement reports, motion/pose/flow/ssm/videograms, etc.). Apy.typedmarker is shipped (PEP 561) so the hints are consumed by downstream type checkers. (Parameter annotations and stricter CI mypy remain an ongoing follow-up; see #345.)
- Extended the
resolve_filename()output-path helper (1.6.3) to the remaining single-target methods: the space-time visualisations, the audio–movement reports,beat_statistics/tempo_similarity, bothvideogramspasses,history/history_cv2,pixelarray/pixelarray_cv2, andflow.dense/flow.sparse/the velocity plot (#344). ~38 sites total now use the shared helper, removing the copy-pastetarget_name/overwritebug class for all standard single-target outputs.
1.6.3–2026-06-27
- Faster import:
import musicalgesturesdropped from ~1.5s to ~0.7s by lazy-loading heavy dependencies (scipy.signal,scipy.stats,IPython.display) that are only needed by specific methods. - Pose model download now uses Python's
urllibinstead of a bundled Windowswget.exeand platform-specific shell scripts, which removes the 3.8 MB binary from the wheel and is fully cross-platform.
- Added a
resolve_filename()helper that centralises thetarget_name/overwriteoutput-path logic (the pattern whose copy-paste variants caused the earliergrid()/blend()bugs); adopted it across the single-output video methods and allMgAudiomethods. - Added regression tests for the audio–movement suite,
resample(), the pose renderers, and the core-class conveniences (353 → 371 tests).
1.6.2–2026-06-27
pose()defaults to the MediaPipe backend (the optional[pose]extra). On installs without MediaPipe it now falls back to the OpenPosebody_25backend (with a clear message) instead of raising, sopose()works out of the box. (This also fixes the CI test jobs.)- Several result attributes shadowed their own methods on the instance, so a second call failed:
tempo_similarity,phase_synchrony,structure_comparison,body_audio_coupling,dynamics_coupling(now stored as*_figure) and the warp result (nowself.warp_video). blend()ignored itsfilenameargument (it always readself.filename).grid()ignored itstarget_nameargument (it always wrote<input>_grid.png).- Corrected stale
overwritedocstrings (now defaultTrue), documented the previously undocumentedconvertparameter ondirectograms/impacts/history_cv2, and fixed several quickstart errors (invalidscale=example;_motiondata.csv/_descriptors.csv/.avinames).
- Informative
reprforMgVideo(frames, fps, size, audio) andMgAudio. MgVideo.duration(seconds) andMgVideo.n_framesproperties, andMgAudio.duration— clearing up thelengthfootgun (frame count for video vs seconds for audio).MgImage.save(path)andMgFigure.save(path)to copy/save a result to a chosen location.- Documentation: an optional-dependency/extras matrix, core-class conveniences, a "Which method
should I use?" disambiguation table, and
pose_center()/pose_distance()with examples + images.
1.6.1–2026-06-27
pose_center()— centre the pose data on its global centroid (a 2D port of the MoCap Toolboxmccenter); plots the centred trajectories and saves a CSV of centred coordinates.pose_distance()— per-marker cumulative distance travelled plus the across-marker average (a 2D port ofmccumdist); plots cumulative curves + a ranked total-per-marker bar chart and saves a CSV.
- Consolidated the audio–movement material into a dedicated Audio-Video Processing & Analysis
documentation page; the docs module was renamed accordingly (the public
warp_audiovisual_beats()method is unchanged).
- Cache the movement quantity-of-motion and audio onset/RMS envelopes per
MgVideo, so running several audio–movement analyses in a row no longer re-decodes the same video/audio (~6× faster on subsequent calls). - Removed ~26 MB of generated test artifacts that had been committed under
musicalgestures/examples/(and added.gitignorerules to keep them out); de-duplicated the pose-keypoint cache fallback across thepose_*methods; surfaced CSV-save errors as warnings.
1.6.0–2026-06-27
- Audio–movement analysis suite for comparing a single performer's sound and motion:
tempo_similarity()— audio tempo vs movement tempo (ratio, nearest harmonic, cross-correlation peak/lag, zero-lag correlation), with a CSV.phase_synchrony()— phase-locking value (PLV) between the audio and movement rhythm, with a polar phase-difference histogram.structure_comparison()— audio self-similarity (MFCC) vs movement self-similarity (frame appearance) plus a difference map and structural-agreement score.body_audio_coupling()— correlates each pose marker's speed with the audio onset envelope; body map + ranked bar chart + CSV.dynamics_coupling()— audio loudness (RMS) vs quantity of motion, zero/best-lag correlation.
pose_segments()— circular (polar rose) motion plots and per-segment circular statistics (mean angle, resultant length R, circular std, range of motion, mean angular speed) + CSV.resample(fps=…, speed=…, skip=…)— retime an already-loaded video and return a new MgVideo: duration-preserving frame-rate change, playback-speed factor (audio kept in sync), and/or integer frame decimation.pose_waterfall(style=…)gained'markers','skeleton', and'both'styles (in addition to'trajectories'), plusaxes=False(clean render) andcrop=True(trim to the data).silhouette_waterfall()gainedaxes=Falseandcrop=True.
pose(background='white')now also makes the trajectories image white (the trajectory background follows the posebackground); usetrajectory_backgroundto override.
- Removed the white frame around the average-pose image (it now fills edge-to-edge).
pose_waterfall(crop=True)no longer leaves a large empty margin (the 3D axes fills the figure and the data cube is zoomed), including withaxes=False.
1.5.0–2026-06-27
- Motiongram/videogram output filenames now use direction-of-movement suffixes:
_mgh/_vgh(horizontal) and_mgv/_vgv(vertical), replacing the axis-based_mgy/_vgyand_mgx/_vgx. Applied tomotion(),motiongrams(),videograms(),motion_mp(), and thessm()intermediates. Theshow(key=...)keys ('horizontal'/'vertical',mgh/mgv/vgh/vgv, and the legacymgx/mgy/vgx/vgy) all still resolve correctly.
silhouette_waterfall()andpose_waterfall()acceptaxes=Falseto render without any axes, ticks, labels, panes, or title (a clean 3D image).
1.4.9–2026-06-27
pose_waterfall(style=...): new'markers','skeleton', and'both'styles that draw the pose atn_samplestime slices (colour-by-time by default), in addition to the existing'trajectories'style.pose(trajectory_background=...): choose the trajectories-image background —'black','white', or'transparent'(legacytransparent_trajectoriesstill honoured).- Orientation aliases for
show(key=...):mgh/vgh(horizontal) andmgv/vgv(vertical).
overwritenow defaults toTruefor all functions: outputs are overwritten in place instead of auto-incrementing the filename. Passoverwrite=Falsefor the old behaviour.MgVideo.beat_statistics()now defaults tosource='motion'(analyses the movement rhythm), so it differs fromvideo.audio.beat_statistics(). Usesource='audio'for the audio track.pose():convertdefaults toNone("auto"). MediaPipe reads the source directly (no intermediate AVI), while OpenPose still converts for frame-accurate decoding. The result video is written in the original container (mp4 in → mp4 out; no avi→mp4 round-trip).- Pose images decluttered: removed the titles from the trajectories and waterfall images and
the average-pose image, and removed the average-pose colorbar. The trajectories image omits
marker-name labels by default (
trajectory_labels=Trueto re-enable).
- The
show(key=...)orientation keys for motiongrams/videograms were swapped:'horizontal'now selects the horizontal-movement gram and'vertical'the vertical one, with correct titles (in bothMgVideo.showandMgList.show).
1.4.8–2026-06-27
pose_waterfall(): a 3D spatio-temporal waterfall where each marker's trajectory flows through (x, time, y) space, a pose-based counterpart tosilhouette_waterfall(). Reuses cached pose keypoints when available;color_by='marker'/'time', marker subsets supported.pose(marker_history=N): draw a motion trail for each marker over the last N frames (works in the OpenPose, MediaPipe, and cached re-render paths).pose(trajectory_labels=True): re-enable per-marker name labels on the trajectories image.
pose()now defaults to the MediaPipe backend instead of OpenPosebody_25. MediaPipe is fast on plain CPU, needs no CUDA-enabled OpenCV build, and gives 33 landmarks with depth + visibility. The OpenPose models (body_25/coco/mpi) remain available for multi-person scenes and CUDA setups.pose(overlay=False, background='white')now draws a black skeleton and markers (a print-friendly inverted look) instead of the previous dark-blue/dark-red scheme.- The marker-trajectories image no longer shows per-marker name labels by default.
tempogram(): added a colorbar (matching the chromagram), shows the estimated tempo rounded to one decimal with "BPM" in the title, and removed the dotted estimated-tempo line.videograms()/motiongrams()display keys are now'horizontal'/'vertical'.
motionhistory():normalizenow defaults toFalseand is guarded so it no longer amplifies faint residual trails into a washed-out ("blown up") image when the final frames are static.- Documentation: refreshed the docs site, wiki, and READMEs for the MediaPipe default and the
new pose options; updated the stale
releases.mdpage (was pinned to an old version) to track the changelog.
1.4.7–2026-06-27
- GPU detection:
_utils.pynever importedcv2, soget_cuda_device_count()andcuda_build_available()always reported no CUDA. The GPU was never used even with a CUDA-enabled OpenCV. Now fixed:pose(device='gpu')(OpenPose),flow.dense(use_gpu=True), andflow.sparse(use_gpu=True)use the GPU on a CUDA build. - GPU sparse optical flow: corrected point shapes (1×N CV_32FC2) and
calc()return handling so the CUDA path works (it was never exercised before the detection fix). MgList.show(key='mgx'/'vgx'/…)on motiongrams/videograms results no longer crashes. It selects the matching panel.- CI on macOS/Windows: skip
.oggconversion tests when the FFmpeg build lacks libtheora; force the Matplotlib Agg backend in tests (no more Windows_tkintercrash). - Audio cache: methods inherited by
MgVideo(e.g.video.spectrogram()) no longer fail on a missing_y_cacheattribute.
pose(use_cache=True): reuse cached keypoints to re-render a differentstyle/overlay/backgroundwithout re-running inference.pose(data_format='c3d'): export markers to a C3D motion-capture file (optionalc3ddep).tempogram(onset_strength=False): single-panel tempogram matching the chromagram size.beat_statistics(source='motion')onMgVideo: rhythmic-timing statistics from movement onsets.
- Average-pose image: labels show numbers only (normalised QoM 0–1 | dominant frequency Hz), with stronger de-overlap; QoM reported normalised instead of px/frame.
motiontempo(): QoM normalised to 0–1, panel renamed to "Motion spectrum", with average quantity-of-motion and average beat-frequency lines + numbers.
1.4.6–2026-06-27
- Space-time person visualisations (
musicalgestures/_spacetime.py):stroboscope()(chronophotography),silhouette_waterfall(),motionhistory()(Motion History Image), andspacetime_volume()(3D x,y,t silhouette point cloud). Silhouettes use MediaPipe selfie segmentation when available, falling back to background subtraction (method='auto'|'mediapipe'|'bgsub'). ssm(features='motiongrams', combine=True)— a single self-similarity matrix from the concatenated horizontal + vertical motiongram features (both axes of motion in one display).pose()stylenow also applies to the average-pose image (markers/skeleton/both).
pose(): the no-CUDA fallback now uses the MediaPipe CPU (XNNPACK) delegate (fast, reliable) rather than the fragile OpenGL-ES GPU delegate. Newquiet=Truesuppresses MediaPipe's native C++/GL console logs; explicitdevice='gpu'prints a one-line caveat.
- Orientation: portrait/phone videos that store a rotation flag are now normalised at load (the rotation is baked into the pixels and the flag removed), so cv2-based processes no longer come out rotated 90°. All processes keep the original orientation.
1.4.5–2026-06-26
pose()now also exports an average-pose image (each marker coloured/labelled by its average quantity of motion in px/frame and dominant movement frequency in Hz, with a per-marker stats CSV) and an all-trajectories image. Marker labels are laid out to avoid overlapping (with leader lines). Options:save_average_pose,save_trajectories,transparent_trajectories(transparent background, auto-enabled when trajectories are the only export, for overlaying on video).pose()rendering controls:style('both'/'markers'/'skeleton'),overlay(draw on the video or a plain background), andbackground('black'/'white', with contrast-adapted colours).convert=Falseflag onpose(),flow.dense()/flow.sparse(),directograms(),impacts(),motion_mp(), andhistory_cv2()to skip the AVI conversion and read the source (e.g. mp4) directly.
- Display model: analysis results (
MgImage/MgFigure) no longer auto-render as a notebook cell's last expression. Display happens only viashow(). HTML is available viato_html().MgList.as_figure()andinfo('frame')updated accordingly (info('frame')returns anMgImage). blur_faces()writes MP4/libx264 (via the FFmpeg pipe) instead of MJPEG-AVI, keeping the source container by default.
MgAudiocaches the decoded audio, so multiple audio analyses decode the file once.ffprobe()results are cached per file (path+mtime+size), so the metadata helpers share a single subprocess call.
- Fixed all "invalid escape sequence"
SyntaxWarnings on import. - Documented that
MgVideo.lengthis a frame count (MgAudio.lengthis seconds). - Removed 30 unused imports.
1.4.4–2026-06-26
MgVideo.eulerian()— Eulerian Video Magnification (Wu et al., SIGGRAPH 2012) to reveal subtle changes.mode='color'amplifies subtle colour changes (pulse/breathing) via a Gaussian pyramid + ideal FFT band-pass (two-pass, low memory);mode='motion'amplifies subtle motion via a Laplacian pyramid + streaming IIR band-pass with spatial-wavelength attenuation. Reads/writes through the FFmpeg pipe so any format works, addressing the format/memory limitations of existing PyEVM ports (closes #212).MgVideo.sonomotiongram()— sonifies the motiongram by treating it as a magnitude spectrogram (spatial position → frequency, motion intensity → amplitude) and resynthesising audio via inverse STFT (Griffin–Lim). Returns an MgAudio (closes #171).MgVideo.motionvectors()— visualises the motion vectors carried by inter-frame codecs (MPEG/H.264/H.265) using FFmpeg's codecview filter (closes #254).
- EVM/sonomotiongram timing:
MgVideo.lengthis a frame count (not seconds), so audio duration is computed aslength/fpsand progress is tracked in frames.
1.4.3–2026-06-26
MgVideo.heatmap(): a motion heatmap showing which parts of the video change the most (accumulated frame differences, colour-mapped, optionally overlaid on the average frame).MgVideo.motiontempo(): estimates the dominant movement tempo from the quantity of motion via FFT, reported in Hz and BPM (addresses #158).descriptors(save_data=True, data_format=...): save the per-frame audio descriptor time series to csv/tsv/txt, mirroring motiondata (closes #124).pose()GPU via MediaPipe:MediaPipePoseEstimatorgains adeviceparameter and uses MediaPipe's GPU delegate (CPU fallback). Whendevice='gpu'is requested for an OpenPose model but OpenCV lacks CUDA,pose()auto-switches to the MediaPipe backend so the GPU is actually used.cuda_build_available()andcuda_unavailable_reason()helpers.
- Display model:
MgImage/MgFigureno longer auto-render as a notebook cell's last expression (the rich_repr_html_/_repr_mimebundle_hooks were removed; the HTML helper is kept asto_html()). Display now happens only viashow(), removing the duplicate (small + large) outputs for the audio figure methods and makingaverage()display only whenshow()is called. - Audio figure methods always close the pyplot figure after saving.
MgFigure.show()renders the saved image (inline in notebooks).- GPU-fallback messages in
pose(), optical flow, and CenterFace now explain the real cause (pip OpenCV is built without CUDA) instead of implying a missing GPU.
spectrogram()/descriptors(): pin the time axis to the actual spectrogram extent so a container duration longer than the decoded audio no longer leaves trailing whitespace or mislabels the timeline.
1.4.2–2026-06-26
- Critical: repaired a
thresholdoldcorruption (from a botchedthresh→thresholdreplace) that broke the FFmpegthresholdfilter, leavingmotion(),motiongrams()and related functions producing no frames and crashing. Motion analysis works again. skipwith large values no longer crashes:atempofilters are chained for ratios above FFmpeg's per-filter limit of 100, and colons are stripped from output filenames.- Restored consistent behaviour of the threshold/filtertype options in
motiongrams().
info(type='summary')now reports video codec/profile, pixel format, colour space, and audio codec/sample-rate/bit-rate alongside resolution, frames, fps, and duration.audio.mfcc(),audio.tempo()(beat tracking with tempo, beat times, inter-beat intervals and beat regularity), andaudio.beat_statistics()(circular timing analysis).musicalgestures/_analysis.py: general signal/statistics utilities (smooth,bandpass,dominant_frequency,circular_stats,rayleigh_test,synchrony), exported at package level.
1.4.1–2026-06-26
average()now correctly ignores bothmethod=andnormalize=legacy kwargs (1.4.0 only filterednormalize=).- Rename
cols→columnsparameter inmg_grid. - Added
audio.chromagram()method.
1.4.0–2026-06-26
- Migrated project metadata and build configuration to
pyproject.toml(PEP 517/518/621).setup.pyandsetup.cfgare now stubs pointing to the new file. - Raised minimum Python version to 3.10; updated CI matrix to test 3.10, 3.11, and 3.12.
- Added a separate
lintCI job (ruff + mypy) to catch style and type issues early. - Added
noxfile.pyfor reproducible local development environments (nox -s tests,nox -s lint,nox -s coverage). - Added optional dependency extras:
musicalgestures[pose],[ml],[cli],[dev],[full]. - Added
musicalgestures/_enums.py:StrEnum-based enum types (FilterType,BlurType,CropMode,PoseModel,PoseDevice,DataFormat) with case-insensitive lookup. Fully backward-compatible with existing string parameters. - Added
musicalgestures/_exceptions.py: typed exception hierarchy (MgError→MgInputError,MgProcessingError,MgIOError,MgDependencyError). - Added
musicalgestures/_logging.py: module-levellogging.getLogger('musicalgestures')logger with aNullHandlerand aset_log_level()helper.
- Added
musicalgestures/_features.py:MgFeatures– a named time-series container for motion and audio descriptors. Supportsto_numpy(),to_dataframe(),to_json(),from_json(),from_dataframe(), NumPy array protocol, and a rich Jupyter_repr_html_display. - Added
musicalgestures/_stream.py:MgVideoReader– a context-manager-based streaming frame iterator (lazy, low-memory, FFmpeg-backed). - Added
_repr_html_()and_repr_mimebundle_()toMgImageandMgFigurefor rich inline display in Jupyter notebooks.
- Added
musicalgestures/_pose_estimator.py: abstractPoseEstimatorbase class,PoseEstimatorResultcontainer,MediaPipePoseEstimator(Google MediaPipe Pose, 33 landmarks, no model download required),OpenPosePoseEstimator(compatibility shim for the legacy OpenPose backend), andget_pose_estimator()factory function.
- Added
musicalgestures/_pipeline.py:MgPipeline– a scikit-learn–style pipeline that chains namedMgStepobjects. Supportstransform(),fit(),fit_transform(), and adescribe()method. - Added
musicalgestures/_dataset.py:MgDataset– labelled collection of media files withfrom_directory(),from_json(),train_test_split(),filter(),to_json(), and Jupyter_repr_html_. Also includesMgCorpus(directory-scanning convenience subclass) andMediaItem.
- Added
CHANGELOG.mdfollowing the Keep-a-Changelog format. - Added
CONTRIBUTING.mdwith a complete developer guide.
- Added
musicalgestures/cli.py: click-based command-line interface (musicalgestures info,motion,videograms,average,history,motiongrams,convert). - Updated
musicalgestures/__init__.pyto export all new public classes (MgFeatures,MgVideoReader,MgPipeline,MgStep,MgDataset,MgCorpus,MediaItem,PoseEstimator,MediaPipePoseEstimator,PoseEstimatorResult,get_pose_estimator, enums, exceptions,set_log_level).
tqdmadded as a core dependency (progress bars in downstream usage).- CI now installs the package from source (
pip install -e ".[dev]") and runspytest. thresholdparameter name is now consistent across all public APIs (previously some paths usedthresh).contrastandbrightnessparameters use integer range −100 to 100 (previously ambiguous).blurparameter only accepts'None'and'Average'(removed undocumented'Medium').
average()method restored as an alias forblend(component_mode='average')(was accidentally removed in a prior refactor).average()silently accepts legacy kwargsmethod=andnormalize=for backward compatibility.motiongrams()andmotion()silently acceptnormalize=kwarg for backward compatibility.- OGG video conversion now explicitly specifies
libtheora/libvorbiscodecs. from_numpy()path bug fixed whenpathattribute is set.- Motiondata fallback corrected for invalid data formats.
- Optical flow video export fixed (replaced broken cv2.VideoWriter with FFmpeg subprocess).
- History feature string-weights parsing fixed.
- Cropping window on Linux no longer stalls on repeated use.
- All examples in
docs/examples.mdupdated to match the current API.
- Python 3.7 and 3.8 are no longer supported. The minimum required version is Python 3.10.
1.3.3–2024-01-01
- Minor changes for v1.3.3.