Pose Kinematics#

Implemented in C++ (mosaic::compute_kinematics(), src/analysis/pose_kinematics.hpp/.cpp) — not in pose.human_pose, which only produces the raw keypoint detections this module derives Speed/Acceleration from. Used by the Analysis tab’s Pose plugin (User guide) to plot a keypoint’s derived motion alongside its raw position.

Gap-tolerant finite differences#

A naive velocity estimate assumes a constant nominal frame rate. But real detections have gaps: a keypoint can be temporarily occluded or low-confidence for several frames, and simply skipping those samples (rather than interpolating a fabricated path through them) means the time between two valid consecutive samples isn’t always one nominal frame period — it can span a real gap. Using each sample’s own real timestamp handles this correctly:

\[v_i = \frac{\lVert \tilde p_i - \tilde p_{i-1} \rVert}{\Delta t_i}, \qquad \Delta t_i = t_i - t_{i-1}\]
\[a_i = \frac{v_i - v_{i-1}}{\Delta t_i}\]

where \(\tilde p_i\) is the (optionally smoothed — see below) position at valid sample \(i\), and \(a_i\) uses the same \(\Delta t_i\) as the speed sample it’s paired with. A keypoint below the visibility threshold, or a frame with no detected subject at all, is excluded from the sample sequence entirely rather than interpolated.

Centered moving-average smoothing#

Raw frame-to-frame keypoint jitter makes the acceleration estimate above particularly noisy (a second derivative amplifies noise more than a first derivative does). An optional centered moving average smooths positions before the finite differences above are computed:

\[\tilde p_i = \frac{1}{2h+1} \sum_{k=-h}^{h} p_{i+k}\]

using a window half-width \(h\), clamped at the sequence’s edges (no padding — an edge sample averages over however many neighbours actually exist). Centered (not trailing) is deliberately used since this is offline analysis over an already-fully-recorded trajectory — there’s no reason to accept a causal filter’s phase lag when the whole trajectory is already available.

Why average speed is time-weighted#

The reported average speed is not the arithmetic mean of the per-interval speed samples \(v_i\) above. It’s the total path length over the total elapsed time:

\[\bar v = \frac{\sum_i \lVert p_i - p_{i-1} \rVert_{\text{unsmoothed}}}{t_N - t_1}\]

using the unsmoothed positions for distance (smoothing is meant to stabilize the derivative estimate, not misreport the actual path length travelled).

Note

A naive mean of \(v_i\) samples silently over- or under-weights long gaps: since each \(v_i\) already divides by its own \(\Delta t_i\), an unweighted mean of speeds treats a 1-second-long low-visibility gap’s single speed sample exactly the same as a 40ms interval’s speed sample — even though the gap-spanning sample represents 25× more real time. This project’s own code review caught exactly this discrepancy (an unweighted mean understated true average speed whenever a low-visibility gap was present) — the time-weighted formula above is the fix, and is the reason gap-tolerant sampling (above) matters for more than just correctness of the instantaneous samples.

Practical recommendations#

🎚️ Smoothing is off by default — and that’s deliberate

Raising Smoothing (e.g. to 5) meaningfully stabilizes a noisy Acceleration curve at the cost of blurring genuinely fast transients. Leave it at 1 (off) first and only raise it once you’ve confirmed the raw signal actually needs it — don’t smooth by default just because it looks cleaner.

📏 Set Scale honestly, or leave it at 1.0

Without a real calibration linking pixels to physical distance, the Scale (mm/px) field is exactly as accurate as the value you type into it — a wrong or guessed scale produces a confidently wrong physical speed. Leave it at 1.0 (px units) unless you have an actual measured reference distance in the frame to compute the real ratio from.

🕳️ A long low-visibility gap still reports honestly…

…but “honestly” doesn’t mean “meaningfully.” A single reported average speed spanning a multi-second occlusion gap is mathematically correct (see the time-weighted formula above) but may not reflect anything a reader would call the subject’s “real” average speed during that gap — treat any large gap in the exported CSV’s timestamps as a flag to sanity-check the surrounding numbers, not just trust them.

🧑‍🤝‍🧑 Single-subject sessions only

Kinematics are computed against subject index 0 specifically — a multi-subject session’s kinematics are only meaningful if subject 0 genuinely refers to the same physical individual for the whole analyzed span, which this plugin cannot verify on its own.