BladeSzaSza's picture
fix: define REPO_NAME in hf_upload.sh (ensure_blade_space referenced it)
4948993 verified
Raw
History Blame Contribute Delete
4.99 kB
"""
Laban Movement Analysis β€” Effort factors from pose kinematics.
Computes the four Effort factors over the joints relevant to a screening test:
- Space (indirect 0 … direct 1) β€” path directness of the leading joint
- Weight (light 0 … strong 1) β€” motion-energy of the relevant joints
- Time (sustained 0 … sudden 1) β€” impulsivity (peak vs mean speed)
- Flow (bound 0 … free 1) β€” smoothness (inverse normalised jerk)
These are reproducible kinematic heuristics, not clinical LMA notation β€” the
report labels them as such. Distances are normalised by torso length so the
factors are scale-invariant across cameras. Pure function β€” no model, no I/O.
"""
from __future__ import annotations
import math
from formscout.agents.biomechanics import _get_joint
from formscout.analysis.relevant_joints import (
COCO_NAMES, L_HIP, L_SHOULDER, R_HIP, R_SHOULDER, relevant_joints,
)
# Heuristic calibration references (movement is normalised to torso-lengths/sec).
_WEIGHT_REF = 1.0 # energy (bl/s)^2 giving ~0.63 weight
_TIME_LO, _TIME_HI = 1.5, 4.0 # peak/mean speed ratio mapped to [0, 1]
_FLOW_JERK_REF = 6.0 # normalised jerk (bl/s^3) giving ~0.37 flow
_LABELS = {
"space": ("indirect", "direct"),
"weight": ("light", "strong"),
"time": ("sustained", "sudden"),
"flow": ("bound", "free"),
}
def _torso_scale(frames) -> float:
"""Median shoulder-hip distance across frames; 1.0 if unmeasurable."""
lengths = []
for kps in frames:
for sh, hip in ((L_SHOULDER, L_HIP), (R_SHOULDER, R_HIP)):
a, b = _get_joint(kps, sh), _get_joint(kps, hip)
if a and b:
lengths.append(math.hypot(a[0] - b[0], a[1] - b[1]))
if not lengths:
return 1.0
lengths.sort()
med = lengths[len(lengths) // 2]
return med if med > 1e-6 else 1.0
def _joint_kinematics(frames, joint_id: int, dt: float, scale: float) -> dict | None:
"""Speed/accel/jerk/directness for one joint trajectory (torso-length units)."""
pts = [_get_joint(kps, joint_id) for kps in frames]
valid = [(i, p) for i, p in enumerate(pts) if p is not None]
if len(valid) < 3:
return None
speeds, path_len = [], 0.0
for (i0, p0), (i1, p1) in zip(valid, valid[1:]):
d = math.hypot(p1[0] - p0[0], p1[1] - p0[1]) / scale
path_len += d
gap = max(1, i1 - i0)
speeds.append(d / (gap * dt))
if not speeds:
return None
net = math.hypot(valid[-1][1][0] - valid[0][1][0],
valid[-1][1][1] - valid[0][1][1]) / scale
directness = net / path_len if path_len > 1e-6 else 0.0
accels = [abs(speeds[i + 1] - speeds[i]) / dt for i in range(len(speeds) - 1)]
jerks = [abs(accels[i + 1] - accels[i]) / dt for i in range(len(accels) - 1)]
mean_speed = sum(speeds) / len(speeds)
peak_speed = max(speeds)
return {
"mean_speed": mean_speed,
"peak_speed": peak_speed,
"energy": sum(s * s for s in speeds) / len(speeds),
"ratio": peak_speed / (mean_speed + 1e-6),
"mean_jerk": (sum(jerks) / len(jerks)) if jerks else 0.0,
"directness": min(1.0, max(0.0, directness)),
}
def _clip01(x: float) -> float:
return min(1.0, max(0.0, x))
def compute_laban(pose2d, test_name: str, fps: float) -> dict:
"""Return the four Effort factors, their labels, and body emphasis."""
frames = pose2d.keypoints
dt = 1.0 / fps if fps and fps > 0 else 1.0 / 30.0
scale = _torso_scale(frames)
joints = relevant_joints(test_name) or list(range(17))
kin = {j: k for j in joints if (k := _joint_kinematics(frames, j, dt, scale))}
if not kin:
return {
"effort": {"space": 0.0, "weight": 0.0, "time": 0.0, "flow": 0.0},
"labels": {k: v[0] for k, v in _LABELS.items()},
"body_emphasis": [],
"notes": "insufficient motion to estimate Effort",
}
leader = max(kin, key=lambda j: kin[j]["mean_speed"])
lead = kin[leader]
weight = _clip01(1.0 - math.exp(-(sum(k["energy"] for k in kin.values()) / len(kin)) / _WEIGHT_REF))
time = _clip01((lead["ratio"] - _TIME_LO) / (_TIME_HI - _TIME_LO))
flow = _clip01(math.exp(-lead["mean_jerk"] / _FLOW_JERK_REF))
space = _clip01(lead["directness"])
effort = {"space": space, "weight": weight, "time": time, "flow": flow}
labels = {k: _LABELS[k][1] if v >= 0.5 else _LABELS[k][0] for k, v in effort.items()}
emphasis = sorted(kin.items(), key=lambda kv: kv[1]["mean_speed"], reverse=True)[:3]
body_emphasis = [(COCO_NAMES.get(j, str(j)), round(k["mean_speed"], 3)) for j, k in emphasis]
return {
"effort": {k: round(v, 3) for k, v in effort.items()},
"labels": labels,
"body_emphasis": body_emphasis,
"leading_joint": COCO_NAMES.get(leader, str(leader)),
"notes": "kinematic Effort estimate (heuristic, not clinical LMA notation)",
}