Skip to content

Latest commit

 

History

History
1881 lines (1599 loc) · 115 KB

CHANGELOG.md

File metadata and controls

1881 lines (1599 loc) · 115 KB

Changelog

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.

Fixed

  • The videogram_* tracks of extract_tracks were motiongrams (#383): they averaged the motion frame, not the picture. They are now written as motiongram_v.u1 / motiongram_h.u1 under motiongram_* keys, and true videograms come from one extra ffmpeg filter graph (extract_videograms, on by default, videograms=False to skip), trimmed so that column j lines up with motion frame j. read_columns, build_pyramid and render_timeline take both names; an analysis folder written before the rename serves its motion means under motiongram_* and, under videogram_*, with a warning that says what they are.
  • _mocap.read_c3d imported MgError from _utils, where it does not live; mypy on master failed on it.
  • _tracks.read_columns failed on any recording long enough to need a pyramid level. extract_tracks and extract_tracks_parallel write the base videograms only, and read_columns memory-mapped videogram_v.L<k>.u1 without checking it existed, so the first read of a real concert raised FileNotFoundError unless the caller knew to run build_pyramid first. It now builds the levels on first use.

Added

  • People on stage — new _performers module. detect_people runs a YOLO person detector (ultralytics extra) at a low frame rate through an ffmpeg pipe; on_stage keeps the boxes whose heads are in the upper part of the frame and drops the audience cut off by the bottom edge; performer_count reports 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_people and make_proxy / camera_motion take ffmpeg_input_args (e.g. ["-hwaccel", "cuda"]).
  • Camera cuts and PTZ — new _camera module. camera_motion labels each sample of a video still, moving or cut from 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_runs gives the framings (still runs between moves and cuts); camera_state_at samples 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) carries analysis_dir, so a caller can go from extract_tracks_parallel(...) to read_columns/check_tracks without reconstructing the analysis/<stem> path convention.
  • extract_wav is exported from the package root.
  • Events against events — new _events module. event_alignment measures 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_xcorr correlates 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 _canvas module. painting_content reduces 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 by colourgram_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 on MgVideo as painting().
  • 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_trc and read_c3d beside read_qtm_tsv, so Qualisys, OptiTrack, Vicon, Theia3D, Pose2Sim and OpenCap exports all land on the same trajectory contract: (marker_names, data, fs) with data of shape (frames, markers, 3), gaps as NaN (never zeros) and positions converted onto millimetres from the file's declared unit. read_c3d rides the optional ezc3d package, added to the [c3d] extra beside the pure-Python c3d writer dependency; analog channels and events are declaredly not read.
  • FreeMoCap reader — read_freemocap reads a FreeMoCap recording folder onto the same trajectory contract: the body trajectories from output_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 — None with a warning when no record exists, never a guessed default. Hands, face, centre of mass and reprojection errors are declaredly not read.

[1.32.0] — 2026-09-03

Added

  • 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 a chain id, and a new chains key maps each chain to its summed coverage and its [segment, mover] members. A mover's candidates are the n_movers most 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.

Fixed

  • 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 bare AttributeError on a result attribute that was never set. It now raises a RuntimeError naming 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), but postures_from_pose() and actions_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 new pose_cache_landmarks() restores the (frames, 33, 3) contract with visibility 0 marking the zeroed refusals, both consumers use it, and the actions path also unpacks pose_qom correctly (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.

[1.31.0] — 2026-09-03

Added

  • Pupil Labs Neon exports on the video's clock — new _pupillabs module. read_pupil_export reads 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_frames places 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_events lists the events inside the video as EyeEvent spans, eyetracking_rates gives per-bin rates (onset-counted) and means, and gazegram draws 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 on MgVideo as eyetracking(), gazegrams() and eyetracking_timeline().

Changed

  • 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 now motion_audio_coupling() (attribute motion_audio_coupling_figure), and warp_audiovisual_beats() is now warp_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.

[1.30.0] — 2026-09-01

Added

  • The static row: postures, and poses as labels — new _postures module, the static counterpart of _actions under 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 in plans/2026-09-01-position-posture-pose-terminology.md). segment_postures cuts 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_postures groups the configurations a body returns to, without a fixed catalogue of standing and sitting; average_posture gives a recording's habitual carriage as the median configuration; match_postures proposes a pose by example, as a label attached to matching postures — segmentation kept apart from recognition, exactly as in _actions. Bound on MgVideo as postures_from_pose().
  • Detector-agnostic normalisation — normalise_poses recognises the landmark topology by count (MediaPipe-33, YOLO/COCO-17, OpenPose BODY_25, COCO-18 and MPI-15, via ANCHORS_BY_TOPOLOGY) and takes an anchors= 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.

Changed

  • 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.

[1.29.0] — 2026-08-31

Added

  • Appearance for the associator — fragment_embeddings collects one appearance vector per track fragment in a single sequential pass (torso-crop hue–saturation histograms, median over each fragment's rows), and associate_fragments(embeddings=...) gains the v2 rules from plans/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.

Fixed

  • room_plate no 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, with refine=True the 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_plate now measures the change and returns the unrefined plate with a warning when it exceeds the new max_refine_change argument (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.

[1.28.0] — 2026-08-30

Added

  • extract_pose_landmarks_rtmpose — the third member of the extractor family: RTMPose through rtmlib and 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. From plans/2026-08-30-fragment-reassociation-design.md, validated on synthetic crossing walkers whose tests assert the refusal itself.

[1.27.0] — 2026-08-30

Added

  • 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 windowed effort_profile, and basic_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_yolo returns every identity's trajectory separately through Ultralytics' tracker, and extract_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.

[1.26.0] — 2026-08-30

Deprecated

  • motiongram_data's orientation="vertical" / "horizontal" — the words named the motion shown where the rest of the toolbox names the position axis kept; use orientation="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.

Added

  • extract_pose_landmarks_yolo — the Ultralytics twin of extract_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].

Changed

  • The motion envelope is documented as what it is. MgMotionVectorViews.magnitude against .time is 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.

[1.25.0] — 2026-08-30

Added

  • 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.
  • BoundedSample in _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 from extract_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.

[1.24.0] — 2026-08-29

Changed

  • 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_image and videogram_x_image / videogram_y_image; the motion-vector views carry motiongram_x / motiongram_y; motiongram_data accepts orientation="x" / "y"; the rendered vector grams take _y / _x suffixes. 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 source carries 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 changes motion_vector_motiongrams's return arrays (now (rows, frames) and (frames, cols)), the _mvgram_h/_mvgram_v images, the motionvectoroverview sheet and motionvectordata's gram fields; arrays saved by earlier versions carry the old convention and should be re-derived or transposed.

Added

  • zoomable_page carries 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.gif and takes frame_ms; the function form returns the list of frames.

Fixed

  • extract_tracks_parallel leaked 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 when terminate() fires; the blocked write restarts around SIGTERM, and the unreaped Popen keeps 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 new stop_ffmpeg_process in _utils closes the pipes first (EPIPE works where the signal cannot), then terminates, waits, and kills on timeout; both _tracks paths use it. The regression test calls _chunk_worker in-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.

[1.23.0] — 2026-08-28

Added

  • 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 — which posegram cannot 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 bands they are the white columns showing how much of a row is actually measured.

  • MgVideo.plate() and MgVideo.multishot() — the room and the chronophotograph as methods. They were the only views reachable solely as module-level functions taking a path, which is how stroboscope() 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.

Changed

  • multishot() absorbed stroboscope(). The two made the same picture two ways, so a reader had to know which before choosing. select='even', background='average' and colorize=True are what stroboscope() 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.

  • multishot rejects 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.

Fixed

  • MediaPipe segmentation had been silently dead since MediaPipe 0.10. _make_segmenter asked for mp.solutions.selfie_segmentation; the Solutions API was removed in favour of Tasks, so on a current install the attribute lookup raised, a bare except swallowed it, and every caller got background subtraction while the docstring promised MediaPipe — saying nothing at all under method='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.

[1.22.0] — 2026-08-28

Added

  • 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_bodies sets 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 median room_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_plate recovered 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_area and max_area default 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 returns None rather than an empty room, since an empty room looks like a result.

[1.21.0] — 2026-08-28

Added

  • 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: None so 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.

  • threshold for motion vectors and min_visibility for 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.

Changed

  • room_plate spreads 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 k equal stretches. The choice is still made on emptiness; it simply cannot all come from one place. stratify=False restores the previous behaviour, and room_plate now 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.

Fixed

  • refine_indices documented itself backwards, summarising as "the ones least like it already" where the code keeps the ones most like it — the emptiest. The prose in room_plate and in the tests was right; only the one-line summary a reader trusts first was inverted.

[1.20.0] — 2026-08-28

Added

  • 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_waterfall says where the body went, pose_segments how its limbs were angled, pose_center how 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 of motiongrams(), 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 with posegram_spatial, which has time on an axis.

Fixed

  • motionvectorgrams wrote 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 takes track_times= now and refuses a mismatched length.

Notes

  • 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.

[1.19.0] — 2026-08-27

Fixed

  • threshold now thresholds the frame difference, which is what it has always been documented to do. It did not. filter_frame_ffmpeg passed [0:v], the original video, as ffmpeg's threshold input, and that filter emits its min where 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.py described 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.05 finally means the same thing everywhere. The numpy filter_frame, used by _impacts and _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.

Added

  • motionvectordata — the motion vectors an inter-frame codec has already computed, read back as numbers rather than drawn as arrows. motionvectors renders 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 with musicalgestures[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 via track=, 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: motionhistory and heatmap are 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=True is 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 source gives 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 summed magnitude is not.

[1.18.0] — 2026-08-26

Added

  • 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.

Changed

  • soundscape_features takes 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.

[1.17.0] — 2026-08-26

Added

  • filmstrip, concordance, tier_map, structure_map in _views — the first figures in this toolbox that know an annotation exists. Everything else here draws the signal; Hierarchy and the ELAN exporter sat on the other side of a gap almost nothing crossed. concordance in 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_page in _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.

Notes

  • structure_map defaults 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.

[1.16.0] — 2026-08-26

Added

  • laughter_segments and laughter_score — where laughter is in a recording, and nothing else about it. Six AudioSet classes, not one: taking only the class called Laughter discards 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 reuses spans_from_probabilities rather than copying an ordering that is easy to get backwards.
  • co_accentuation and co_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.

[1.15.0] — 2026-08-26

Added

  • align_by_audio, locate_probe and envelope_from_audio in _alignment, for locating one recording inside another by its loudness envelope. xcorr_lag already 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 named Cut may 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 to xcorr_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) and vocabularies=. 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 is annat_shade() from Finn Upham's Laughter_Dance.

Fixed

  • to_elan wrote LANG_REF="und" with no matching LANGUAGE element, 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_elan added a free-text annotation tier 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

Added

  • musicalgestures/_tracks.py, a one-pass extractor for long recordings, merged from tracks-wip in #380. It is private and is not imported by musicalgestures/__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 of mg_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.

Fixed

  • A flat 10-second ffprobe timeout put a ceiling on how long a video MGT could open at all. get_framecount allowed 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() raised Could 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 own nb_frames with a RuntimeWarning saying 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.py covers it against a real ffprobe.

1.14.1 — 2026-08-24

Fixed

  • Motion extraction reallocated its output once per frame, so its cost was quadratic in frame count. Every accumulator --- time, qom, com, aom and both motiongrams --- grew by np.append inside the frame loop, and each append copies the whole array. The motiongrams are the worst of it: they grow to height 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.py guards it by counting np.append calls 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

Added

  • 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 takes extract_pose_landmarks result dicts or plain (world, visibility) arrays, so it is not tied to MediaPipe as a source. Closes #355.

    This consolidates concert_fuse3d.py and reh_fuse3d.py from 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 @ Vt and 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_gap now 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.

Removed

  • 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 called save_txt and save_analysis with 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.

Changed

  • 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 || true that 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

Fixed

  • Audio figures mislabelled their time axis, and crashed outright on files longer than an hour. format_time built its tick labels by assigning formatted strings into a numpy float array, so '10.00' became 10.0 and rendered as 10:0, losing a digit on every tick that landed on a whole minute, while '0.13.20' on a file over an hour raised ValueError: 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_time paired a FixedFormatter with a LinearLocator, which matplotlib warns about because the pairing is by position and only defined against a FixedLocator. 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.
  • musicalgestures no longer silences warnings for the whole process. Importing it ran a bare warnings.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 the DeprecationWarnings from audioread that 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 defined if you pressed "c" without having dragged a rectangle first --- which is what its own on-screen prompt invites you to do. ref_point and crop were declared global inside the mouse callback but never bound at module scope, so they existed only once a mouse event had fired. They are bound now, beside the drawing/xi/yi that 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() raised TypeError: 'MgImage' object is not callable on its second call. pixelarray is 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 the MgImage it had just returned. The result now lives under frameaverage_image; the method keeps the pixelarray name 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 named pixelarray on the class itself, which reintroduces the exact collision this fixes, permanently. pixelarray_cv2 had the same problem for the same reason and is fixed the same way, as frameaverage_cv2_image. This is the same class of bug commit 744169f fixed for subtract and sonomotiongram.

Added

  • crop_box_from_points() turns the two dragged corners into an ffmpeg crop box. The arithmetic was inline in cropping_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.

Changed

  • Every result an analysis method stashes on MgVideo is 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_x was the vertical gram and motiongram_y the horizontal one, which is why show() grew mgh/mgv aliases in the first place. They are motiongram_vertical_image, motiongram_horizontal_image, videogram_vertical_image and videogram_horizontal_image now. Every existing show() key, including the legacy mgx/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 self in 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-return went 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 as ssm_figure, not ssm_fig. Every other stashed result on MgVideo already ends in _video, _image or _figure; this one did not, and show(key='ssm') resolves the result by attribute name, so the outlier was visible to users rather than internal. ssm_fig keeps 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 MgAudio figure methods are annotated -> MgFigure | None rather than -> MgFigure: waveform, spectrogram, tempogram, hpss, descriptors, chromagram, mfcc, tempo and beat_statistics. All nine already returned None when they could not compute a figure --- no audio track, an unknown chroma_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 an AttributeError further down. Their bare return statements are explicit return 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.py and _cropvideo.py --- the five files that carried 69 per cent of the backlog --- all at zero. Mostly implicit Optional in signatures, cache annotations, and one name per branch where three subprocess types shared one. The class-scoped imports that bind methods on MgVideo are 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 by no-any-return (25) and union-attr (18), and each needs reading rather than a sweep.
  • More return annotations corrected to match what the code has always done. mg_ssm returns MgList | MgImage | None, mg_motionscore returns float | None (as its own docstring already said), and Flow.dense returns MgVideo | MgFigure --- with velocity=True it has always handed back a figure, while both signature and docstring promised a video.

1.12.1 — 2026-08-22

Fixed

  • 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 the v1.12.0 tag points at a commit whose CI is red, and a tag that fails its own checks is misleading whatever the wheel contains.

Changed

  • 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

Added

  • 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.

Fixed

  • MgVideo('clip.mp4', fps=99) now warns. fps is 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. Use resample(fps=...) to change a file's rate.

Changed

  • Three tests now guard the release itself: that __version__, the newest changelog entry and docs/releases.md all name the same version, and that every released version in the changelog has a git tag. Preparing 1.11.4 found docs/releases.md announcing 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

Fixed

  • 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, _warp and _videoadjust. On NTSC-rate footage that is int(29.97) == 29, so every time and frequency they derived was 3.2 % low, and _flow and _history also 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 -r and librosa sr all take a float, and _impacts already wrapped int() 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. _remap360 already 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.

[1.11.3] — 2026-08-19

Changed

  • bandpass and xcorr_lag now delegate to micromotion, 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.
  • bandpass RAISES 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) against micromotion.bandpass(x, fs, lo, hi). micromotion validates against Nyquist, so a call moved between them without reordering raises rather than returning a plausible array.

Fixed

  • xcorr_lag and micromotion.xcorr_lag returned 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. Requires micromotion>=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.

Added

  • tests/test_analysis.py. musicalgestures._analysis exports 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 that bandpass really removes out-of-band energy rather than merely matching the owner, and pin the deliberate difference between this package's dominant_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.

[1.11.2] — 2026-08-16

Changed

  • Documentation and citation metadata only; no change to any code path.
  • CITATION.cff states 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.

Note on the archive

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.

[1.11.1] — 2026-08-13

Changed

  • 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 at threshold=0.2 it 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 %.

[1.11.0] — 2026-08-12

Fixed

  • pose() on OpenCV 5 fails with a message instead of an AttributeError. OpenCV removed its Caffe importer in 5.0: cv2.dnn.readNetFromCaffe no longer exists and cv2.dnn.readNet refuses 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 as AttributeError: 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') with pip install musicalgestures[pose], or pip 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_gpu skips where OpenPose cannot run. Its two cases had been failing with the bare AttributeError above 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.

Documentation

  • 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 at threshold=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.

[1.10.0] — 2026-08-12

Added

  • normalize=False on mg_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=False exports the untouched sum of pixel values instead. The column is renamed to QomRaw rather than merely rescaled, so that a file cannot be misread later: a Qom column is always per-clip normalised and a QomRaw column never is, whoever opens it and however long afterwards. normalize was already in mg_motion's signature and had never been used.

Fixed

  • TSV export wrote quantity of motion as 0 or 1. The writer formatted the column with %d while the value had already been divided by its maximum, so np.savetxt truncated every fraction to an integer. Normalised values now write as floats; QomRaw writes as an integer, which is what it is. This changes the content of TSV exports, which ARJ confirms have not been relied on.

Changed

  • _motionvideo states 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 of threshold and normalize, not of turning filters off.

[1.9.2] — 2026-08-08

Added

  • 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.

Fixed

  • The version has one source. pyproject.toml reads musicalgestures.__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 edited pyproject.toml alone. tests/test_version.py fails if a static version reappears there, if the build stops reading the module attribute, or if the two numbers diverge.

[1.9.1] — 2026-08-06

Added

  • get_pose_model_path(): public helper for the shared MediaPipe pose-model download/cache, promoted from the private MediaPipePoseEstimator._get_model_path (#360).
  • extract_pose_landmarks() accepts t0/duration parameters to analyse a sub-window of a long video without decoding the whole file; returned timestamps stay on the source clock (#354).

Fixed

  • show(key='blur') looked up a non-existent attribute and silently did nothing; it now shows the face-anonymised video, and raises FileNotFoundError when blur_faces() has not been run.
  • subtract() and sonomotiongram() stored their results over their own bound methods, so a second call on the same object crashed. Results are now stored as subtract_video and sonomotiongram_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 path motiondata() returns (previously <name>_motiondata.csv).
  • The autoshow parameter on the MgAudio figure 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.

[1.9.0] — 2026-08-06

Added

  • 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 regular MgVideo), and projection auto-detection.

Changed

  • 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, length vs duration, and others), new demo figures and GIFs from genuine tool output.

[1.8.0] — 2026-08-05

Changed

  • group_qom, pose_qom and normalized_qom return different numbers. They are re-exported from micromotion, 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 underlying band_limited_qom interpolates 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. Pass normalize="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 micromotion floor is >=1.0.0, which is a correctness floor rather than a documentation one: below it the re-exported function returns the confounded number.

[1.7.1] — 2026-08-05

Fixed

  • 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 into musicalgestures/_qom.py at line numbers that stopped existing when those functions moved to micromotion on 2026-07-29. The pages are regenerated from the current source.
  • docs/user-guide/pose-tracking.md and docs/user-guide/sound-movement-toolkit.md said the band is 0.3–5 Hz and passed lo=0.3, hi=15.0. It is micromotion.BAND, 0.2–5 Hz. The regeneration script does not touch hand-written pages, so these were corrected by hand.
  • The micromotion floor 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 which from musicalgestures import group_qom fails. 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.

Changed

  • 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.

[1.7.0] — 2026-08-03

Added

  • GoPro MAX .360 support: gopro360_to_dual_fisheye() with the field of view as a parameter, and gopro360_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.

Fixed

  • 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_stage is now a context manager.
  • cv2.imwrite failures 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.

Tests

  • Three regression tests for the above, each verified to fail against the previous commit.

Added

  • 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 an orientation='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 own dominant_frequency is 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.


[1.6.9]–2026-06-28

Added

  • 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 an MgFigure (QoM time series + power spectrum) with the scalars and full spectrum in .data, and writes a _motiondescriptors.csv. Hann windowing is the documented default (with window='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).

Documentation

  • Lightweight animated GIFs for the video-producing methods (motion, history, motion vectors, Eulerian magnification, dense/sparse optical flow), a motiondescriptors figure, and a new visual Gallery in the examples page; scripts/generate_example_media.py regenerates them. README, the video-analysis user guide and the wiki updated for motiondescriptors().

[1.6.8]–2026-06-28

Fixed

  • Accurate frame counts (#242, #239). get_framecount(fast=True)—used to set MgVideo.length—read the container's nb_frames metadata, 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=False still does the exhaustive decode-and-count. Added a regression test that pins fast == exact for AVI/MP4/WebM.

[1.6.7]–2026-06-28

Changed

  • Faster import (#349): import musicalgestures dropped from ~0.65s to ~0.52s by deferring import numba (it pulls in LLVM). The @jit kernels in _directograms, _impacts and _warp are now compiled lazily on first use. The nested directogram case (directogram calls matrix3D_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. Adds tests/test_numba_kernels.py to pin the deferral and nested-jit behaviour.
  • Also added the missing parameter type hints to mg_warp_audiovisual_beats (a follow-on to #345).

[1.6.6]–2026-06-28

Added

  • 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/sparse and 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 _utils helpers and the MgVideo inline methods. Modules now use from __future__ import annotations so hints are lazy (no runtime/import-speed cost) and use modern str | None syntax. Combined with the py.typed marker shipped in 1.6.4, downstream IDEs and type checkers now see a fully-typed public API.

Internal

  • Fixed the CI mypy step (#345), which was aborting on a third-party file (tifffile ships 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 and nox -s typecheck. The step stays non-blocking while the internal-typing backlog is worked through (#350).

[1.6.5]–2026-06-28

Internal

  • Cached average-frame decode for the space-time analyses (#347). stroboscope, silhouette_waterfall and spacetime_volume all reconstruct the same average background frame by decoding the whole video; this is now computed once and cached per MgVideo (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

Added

  • Type hints + py.typed—the MgVideo/MgAudio/MgImage/MgFigure constructors 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.). A py.typed marker 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.)

Internal

  • 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, both videograms passes, history/history_cv2, pixelarray/ pixelarray_cv2, and flow.dense/flow.sparse/the velocity plot (#344). ~38 sites total now use the shared helper, removing the copy-paste target_name/overwrite bug class for all standard single-target outputs.

1.6.3–2026-06-27

Changed

  • Faster import: import musicalgestures dropped 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 urllib instead of a bundled Windows wget.exe and platform-specific shell scripts, which removes the 3.8 MB binary from the wheel and is fully cross-platform.

Internal

  • Added a resolve_filename() helper that centralises the target_name/overwrite output-path logic (the pattern whose copy-paste variants caused the earlier grid()/blend() bugs); adopted it across the single-output video methods and all MgAudio methods.
  • 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

Fixed

  • pose() defaults to the MediaPipe backend (the optional [pose] extra). On installs without MediaPipe it now falls back to the OpenPose body_25 backend (with a clear message) instead of raising, so pose() 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 (now self.warp_video).
  • blend() ignored its filename argument (it always read self.filename).
  • grid() ignored its target_name argument (it always wrote <input>_grid.png).
  • Corrected stale overwrite docstrings (now default True), documented the previously undocumented convert parameter on directograms/impacts/history_cv2, and fixed several quickstart errors (invalid scale= example; _motiondata.csv/_descriptors.csv/.avi names).

Added

  • Informative repr for MgVideo (frames, fps, size, audio) and MgAudio.
  • MgVideo.duration (seconds) and MgVideo.n_frames properties, and MgAudio.duration — clearing up the length footgun (frame count for video vs seconds for audio).
  • MgImage.save(path) and MgFigure.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

Added

  • pose_center() — centre the pose data on its global centroid (a 2D port of the MoCap Toolbox mccenter); 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 of mccumdist); plots cumulative curves + a ranked total-per-marker bar chart and saves a CSV.

Changed

  • 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).

Fixed / internal

  • 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 .gitignore rules to keep them out); de-duplicated the pose-keypoint cache fallback across the pose_* methods; surfaced CSV-save errors as warnings.

1.6.0–2026-06-27

Added

  • 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'), plus axes=False (clean render) and crop=True (trim to the data).
  • silhouette_waterfall() gained axes=False and crop=True.

Changed

  • pose(background='white') now also makes the trajectories image white (the trajectory background follows the pose background); use trajectory_background to override.

Fixed

  • 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 with axes=False.

1.5.0–2026-06-27

Changed

  • Motiongram/videogram output filenames now use direction-of-movement suffixes: _mgh/_vgh (horizontal) and _mgv/_vgv (vertical), replacing the axis-based _mgy/_vgy and _mgx/_vgx. Applied to motion(), motiongrams(), videograms(), motion_mp(), and the ssm() intermediates. The show(key=...) keys ('horizontal'/'vertical', mgh/mgv/vgh/vgv, and the legacy mgx/mgy/vgx/vgy) all still resolve correctly.

Added

  • silhouette_waterfall() and pose_waterfall() accept axes=False to render without any axes, ticks, labels, panes, or title (a clean 3D image).

1.4.9–2026-06-27

Added

  • pose_waterfall(style=...): new 'markers', 'skeleton', and 'both' styles that draw the pose at n_samples time 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' (legacy transparent_trajectories still honoured).
  • Orientation aliases for show(key=...): mgh/vgh (horizontal) and mgv/vgv (vertical).

Changed

  • overwrite now defaults to True for all functions: outputs are overwritten in place instead of auto-incrementing the filename. Pass overwrite=False for the old behaviour.
  • MgVideo.beat_statistics() now defaults to source='motion' (analyses the movement rhythm), so it differs from video.audio.beat_statistics(). Use source='audio' for the audio track.
  • pose(): convert defaults to None ("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=True to re-enable).

Fixed

  • 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 both MgVideo.show and MgList.show).

1.4.8–2026-06-27

Added

  • pose_waterfall(): a 3D spatio-temporal waterfall where each marker's trajectory flows through (x, time, y) space, a pose-based counterpart to silhouette_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.

Changed

  • pose() now defaults to the MediaPipe backend instead of OpenPose body_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'.

Fixed

  • motionhistory(): normalize now defaults to False and 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.md page (was pinned to an old version) to track the changelog.

1.4.7–2026-06-27

Fixed

  • GPU detection: _utils.py never imported cv2, so get_cuda_device_count() and cuda_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), and flow.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 .ogg conversion tests when the FFmpeg build lacks libtheora; force the Matplotlib Agg backend in tests (no more Windows _tkinter crash).
  • Audio cache: methods inherited by MgVideo (e.g. video.spectrogram()) no longer fail on a missing _y_cache attribute.

Added

  • pose(use_cache=True): reuse cached keypoints to re-render a different style/overlay/background without re-running inference.
  • pose(data_format='c3d'): export markers to a C3D motion-capture file (optional c3d dep).
  • tempogram(onset_strength=False): single-panel tempogram matching the chromagram size.
  • beat_statistics(source='motion') on MgVideo: rhythmic-timing statistics from movement onsets.

Changed

  • 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

Added

  • Space-time person visualisations (musicalgestures/_spacetime.py): stroboscope() (chronophotography), silhouette_waterfall(), motionhistory() (Motion History Image), and spacetime_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() style now also applies to the average-pose image (markers/skeleton/both).

Changed

  • pose(): the no-CUDA fallback now uses the MediaPipe CPU (XNNPACK) delegate (fast, reliable) rather than the fragile OpenGL-ES GPU delegate. New quiet=True suppresses MediaPipe's native C++/GL console logs; explicit device='gpu' prints a one-line caveat.

Fixed

  • 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

Added

  • 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), and background ('black'/'white', with contrast-adapted colours).
  • convert=False flag on pose(), flow.dense()/flow.sparse(), directograms(), impacts(), motion_mp(), and history_cv2() to skip the AVI conversion and read the source (e.g. mp4) directly.

Changed

  • Display model: analysis results (MgImage/MgFigure) no longer auto-render as a notebook cell's last expression. Display happens only via show(). HTML is available via to_html(). MgList.as_figure() and info('frame') updated accordingly (info('frame') returns an MgImage).
  • blur_faces() writes MP4/libx264 (via the FFmpeg pipe) instead of MJPEG-AVI, keeping the source container by default.

Performance

  • MgAudio caches 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

  • Fixed all "invalid escape sequence" SyntaxWarnings on import.
  • Documented that MgVideo.length is a frame count (MgAudio.length is seconds).
  • Removed 30 unused imports.

1.4.4–2026-06-26

Added

  • 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).

Fixed

  • EVM/sonomotiongram timing: MgVideo.length is a frame count (not seconds), so audio duration is computed as length/fps and progress is tracked in frames.

1.4.3–2026-06-26

Added

  • 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: MediaPipePoseEstimator gains a device parameter and uses MediaPipe's GPU delegate (CPU fallback). When device='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() and cuda_unavailable_reason() helpers.

Changed

  • Display model: MgImage/MgFigure no longer auto-render as a notebook cell's last expression (the rich _repr_html_/_repr_mimebundle_ hooks were removed; the HTML helper is kept as to_html()). Display now happens only via show(), removing the duplicate (small + large) outputs for the audio figure methods and making average() display only when show() 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.

Fixed

  • 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

Fixed

  • Critical: repaired a thresholdold corruption (from a botched thresh→threshold replace) that broke the FFmpeg threshold filter, leaving motion(), motiongrams() and related functions producing no frames and crashing. Motion analysis works again.
  • skip with large values no longer crashes: atempo filters 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().

Added

  • 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), and audio.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

Fixed

  • average() now correctly ignores both method= and normalize= legacy kwargs (1.4.0 only filtered normalize=).
  • Rename cols → columns parameter in mg_grid.
  • Added audio.chromagram() method.

1.4.0–2026-06-26

Added

Phase 1–Foundation

  • Migrated project metadata and build configuration to pyproject.toml (PEP 517/518/621). setup.py and setup.cfg are 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 lint CI job (ruff + mypy) to catch style and type issues early.
  • Added noxfile.py for 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-level logging.getLogger('musicalgestures') logger with a NullHandler and a set_log_level() helper.

Phase 2–Data Structures

  • Added musicalgestures/_features.py: MgFeatures – a named time-series container for motion and audio descriptors. Supports to_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_() to MgImage and MgFigure for rich inline display in Jupyter notebooks.

Phase 3–Pose Modernisation

  • Added musicalgestures/_pose_estimator.py: abstract PoseEstimator base class, PoseEstimatorResult container, MediaPipePoseEstimator (Google MediaPipe Pose, 33 landmarks, no model download required), OpenPosePoseEstimator (compatibility shim for the legacy OpenPose backend), and get_pose_estimator() factory function.

Phase 4–ML Integration

  • Added musicalgestures/_pipeline.py: MgPipeline – a scikit-learn–style pipeline that chains named MgStep objects. Supports transform(), fit(), fit_transform(), and a describe() method.
  • Added musicalgestures/_dataset.py: MgDataset – labelled collection of media files with from_directory(), from_json(), train_test_split(), filter(), to_json(), and Jupyter _repr_html_. Also includes MgCorpus (directory-scanning convenience subclass) and MediaItem.

Phase 5–Documentation

  • Added CHANGELOG.md following the Keep-a-Changelog format.
  • Added CONTRIBUTING.md with a complete developer guide.

Phase 6–Ecosystem

  • Added musicalgestures/cli.py: click-based command-line interface (musicalgestures info, motion, videograms, average, history, motiongrams, convert).
  • Updated musicalgestures/__init__.py to export all new public classes (MgFeatures, MgVideoReader, MgPipeline, MgStep, MgDataset, MgCorpus, MediaItem, PoseEstimator, MediaPipePoseEstimator, PoseEstimatorResult, get_pose_estimator, enums, exceptions, set_log_level).

Changed

  • tqdm added as a core dependency (progress bars in downstream usage).
  • CI now installs the package from source (pip install -e ".[dev]") and runs pytest.
  • threshold parameter name is now consistent across all public APIs (previously some paths used thresh).
  • contrast and brightness parameters use integer range −100 to 100 (previously ambiguous).
  • blur parameter only accepts 'None' and 'Average' (removed undocumented 'Medium').

Fixed

  • average() method restored as an alias for blend(component_mode='average') (was accidentally removed in a prior refactor).
  • average() silently accepts legacy kwargs method= and normalize= for backward compatibility.
  • motiongrams() and motion() silently accept normalize= kwarg for backward compatibility.
  • OGG video conversion now explicitly specifies libtheora/libvorbis codecs.
  • from_numpy() path bug fixed when path attribute 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.md updated to match the current API.

Deprecated

  • Python 3.7 and 3.8 are no longer supported. The minimum required version is Python 3.10.

1.3.3–2024-01-01

Changed

  • Minor changes for v1.3.3.