Working with other packages¶
The intended shape¶
The fourMs packages divide by signal domain, and the dependency arrow points from the heavy packages to the light ones:
| Package | Owns | Weight |
|---|---|---|
musicalgestures (MGT) |
video in, visual features out | ~282 MB — opencv, librosa, numba, scikit-image; ffmpeg binary |
ambiscape |
spatial audio in, soundscape features out | numpy, scipy, soundfile, matplotlib |
musiscape |
music corpora, fingerprints, similarity | + librosa, ambiscape |
micromotion |
motion time series: mocap, IMU, force plate | numpy, scipy, pandas |
micromotion deliberately depends on none of them. Someone analysing accelerometer data
should not install a computer-vision stack to do it, and the reverse arrow — MGT depending on
this package — is the one that makes sense.
There is an existing precedent for how to cross the boundary. MGT's _soundscape.py consumes
ambiscape's output and adapts it at the seam, and ambiscape never imports MGT.
Unresolved overlap with MGT
MGT's unreleased main branch already contains band_limited_qom, accel_to_speed,
read_qtm_tsv, cop_sway_metrics and respiration_rate, credited in their docstrings to
the same source study as this package. They are not in the released 1.6.9, so they are
invisible if you look only at PyPI.
The two implementations do not agree. On the same 200 Hz optical recording:
| mean speed | |
|---|---|
MGT band_limited_qom, its 0.3–15 Hz default |
5.675 mm/s |
MGT band_limited_qom, forced to 0.3–10 Hz |
5.594 mm/s |
micromotion.qom, 0.3–10 Hz |
5.455 mm/s |
4.0 per cent apart at their respective defaults; 2.6 per cent apart on the same band. The residual comes from the differentiation and whether the result is band-limited a second time. Until this is resolved, state which package produced any number you report.
MGT: video motion into micromotion¶
MGT's released motiondata() returns a file path, not an array:
import pandas as pd, musicalgestures as mg
path = mg.MgVideo("dance.mp4").motiondata(data_format="csv") # -> str
df = pd.read_csv(path)
arr = df[["ComX", "ComY"]].to_numpy() # (n_samples, 2)
fs = 1000.0 / df.Time.diff().median() # Time is in milliseconds
Three things to know.
Use the CSV, not the TSV or TXT. Those write Qom with an integer format, so values
normalised to 0–1 truncate to zero.
The Qom column is divided by its own maximum, so it is dimensionless and not comparable
between recordings. The centroid columns are normalised to frame width and height. If you
want a physical speed, feed the centroid columns to micromotion.qom with
kind="position" — but the units are frame fractions, not millimetres, so scale them first
or treat the result as relative.
Prefer MgVideo.fps over recovering the rate from Time, which is rounded to integer
milliseconds and loses precision at rates like 29.97.
ambiscape: soundscape features alongside motion¶
ambiscape emits a dict[str, np.ndarray] in an .npz, on four fixed time bases:
| Vector | Rate | Contents |
|---|---|---|
t_hi |
50 Hz | env_hi |
t_fast |
8 Hz | fast_db, fast_dba |
t |
1 Hz | rms_w, centroid, az, el, diffuse, octave bands |
min_t |
1/60 Hz | minspec |
Times are absolute seconds from session midnight and are not monotonic across overlapping takes. Levels are uncalibrated dBFS, not SPL.
To relate a soundscape to a body, downsample both to a common rate and align:
F = np.load("features.npz")
lag = mm.search_lag(F["t"], F["rms_w"], t_motion, qom_1hz, max_lag_s=300)
ambiscape computes no quantity of motion and explicitly defers video analysis to MGT, so there is no overlap to worry about there.
musiscape and respy¶
musiscape emits only per-track scalars — it time-averages its per-frame features away
before writing features.json. Nothing to interoperate with.
respy is not a fourMs package; it is a personal one by a RITMO colleague, single release,
2023. It emits a time-indexed pandas.DataFrame and infers its sampling rate from the index
with a round(), which mis-reads genuine 12.5 or 62.5 Hz recordings. Its published metadata
declares no dependencies, so pip install respy installs nothing and fails on import. Adapt
it at the boundary if you need it:
fs = 1.0 / np.mean(np.diff(df.index)) # do not round
micromotion.detect_breaths covers the common case without the dependency.