BladeSzaSza's picture
fix: define REPO_NAME in hf_upload.sh (ensure_blade_space referenced it)
4948993 verified
Raw
History Blame Contribute Delete
6.78 kB
"""
Matplotlib chart generators for the screening report.
Every function returns a PNG path on success or None on failure (never raises),
so a chart problem degrades the report but never blocks scoring. Charts use the
Silas palette and an Agg backend so they render headless on the Space.
"""
from __future__ import annotations
import logging
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
from formscout.analysis.relevant_joints import COCO_NAMES # noqa: E402
logger = logging.getLogger(__name__)
TEAL = "#2b8a8a"
GOLD = "#e0a43b"
SAGE = "#9cbcad"
INK = "#243a34"
RED = "#d9534f"
_PALETTE = [TEAL, GOLD, SAGE, "#7a5ca0", "#c2683c", "#3c8dbc"]
def _save(fig, out_png: str) -> str | None:
try:
fig.savefig(out_png, dpi=110, bbox_inches="tight", facecolor="white")
return out_png
except Exception as e:
logger.warning("chart save failed: %s", e)
return None
finally:
plt.close(fig)
def angle_over_time(series: dict, primary: str | None, governing_idx: int | None,
out_png: str, title: str = "Joint angle over time") -> str | None:
"""Angle-vs-frame for the relevant angles; primary emphasised, key-frame marked."""
try:
if not series:
return None
fig, ax = plt.subplots(figsize=(6.4, 3.2))
for i, (name, vals) in enumerate(series.items()):
arr = np.array(vals, dtype=float)
is_primary = name == primary
ax.plot(np.arange(len(arr)), arr,
color=(TEAL if is_primary else _PALETTE[i % len(_PALETTE)]),
lw=2.4 if is_primary else 1.3,
alpha=1.0 if is_primary else 0.6,
label=name.replace("_", " ") + (" ★" if is_primary else ""))
if governing_idx is not None:
ax.axvline(governing_idx, color=GOLD, ls="--", lw=1.5, label="key frame")
ax.set_xlabel("frame")
ax.set_ylabel("degrees")
ax.set_title(title, color=INK)
ax.legend(fontsize=7, loc="best")
ax.grid(True, alpha=0.2)
return _save(fig, out_png)
except Exception as e:
logger.warning("angle_over_time failed: %s", e)
return None
def velocity_profile(keypoints: list, fps: float, joints: list[int],
out_png: str, title: str = "Joint speed over time") -> str | None:
"""Per-frame speed (px/s) of the relevant joints across the clip."""
try:
from formscout.agents.visualizer import compute_joint_velocity
vel = compute_joint_velocity(keypoints, fps or 30.0)
plot_joints = [j for j in joints if j in vel] or list(vel.keys())[:4]
if not plot_joints:
return None
fig, ax = plt.subplots(figsize=(6.4, 3.2))
for i, j in enumerate(plot_joints):
ax.plot(vel[j], color=_PALETTE[i % len(_PALETTE)], lw=1.6,
label=COCO_NAMES.get(j, str(j)).replace("_", " "))
ax.set_xlabel("frame")
ax.set_ylabel("speed (px/s)")
ax.set_title(title, color=INK)
ax.legend(fontsize=7, loc="best")
ax.grid(True, alpha=0.2)
return _save(fig, out_png)
except Exception as e:
logger.warning("velocity_profile failed: %s", e)
return None
def laban_radar(effort: dict, out_png: str, title: str = "Laban Effort") -> str | None:
"""4-axis radar of the Effort factors (Space, Weight, Time, Flow)."""
try:
axes_order = ["space", "weight", "time", "flow"]
labels = ["Space\n(direct)", "Weight\n(strong)", "Time\n(sudden)", "Flow\n(free)"]
vals = [float(effort.get(k, 0.0)) for k in axes_order]
angles = np.linspace(0, 2 * np.pi, len(axes_order), endpoint=False).tolist()
vals_loop = vals + vals[:1]
angles_loop = angles + angles[:1]
fig, ax = plt.subplots(figsize=(4.2, 4.2), subplot_kw={"polar": True})
ax.plot(angles_loop, vals_loop, color=TEAL, lw=2)
ax.fill(angles_loop, vals_loop, color=TEAL, alpha=0.25)
ax.set_xticks(angles)
ax.set_xticklabels(labels, fontsize=8, color=INK)
ax.set_ylim(0, 1)
ax.set_yticks([0.25, 0.5, 0.75, 1.0])
ax.set_yticklabels(["", "0.5", "", "1.0"], fontsize=7)
ax.set_title(title, color=INK, pad=18)
return _save(fig, out_png)
except Exception as e:
logger.warning("laban_radar failed: %s", e)
return None
def flexion_bars(flexion: dict, out_png: str,
title: str = "Relevant joint flexion") -> str | None:
"""Horizontal bars of relevant joint angles (deg) at the key frame."""
try:
if not flexion:
return None
names = [n.replace("_", " ") for n in flexion]
degs = [flexion[n]["deg"] for n in flexion]
colors = [TEAL if d >= 160 else GOLD if d >= 110 else RED for d in degs]
fig, ax = plt.subplots(figsize=(6.0, max(1.6, 0.5 * len(names) + 0.8)))
y = np.arange(len(names))
ax.barh(y, degs, color=colors)
ax.set_yticks(y)
ax.set_yticklabels(names, fontsize=8)
ax.set_xlim(0, 200)
ax.axvline(160, color=SAGE, ls=":", lw=1)
for yi, d in zip(y, degs):
ax.text(d + 3, yi, f"{d:.0f}°", va="center", fontsize=8, color=INK)
ax.set_xlabel("interior angle (°) · higher = more open")
ax.set_title(title, color=INK)
ax.invert_yaxis()
return _save(fig, out_png)
except Exception as e:
logger.warning("flexion_bars failed: %s", e)
return None
def symmetry_bars(asymmetries: list, out_png: str,
title: str = "Left / right symmetry") -> str | None:
"""Grouped L vs R score bars for bilateral tests."""
try:
rows = [a for a in asymmetries
if a.get("left_score") is not None and a.get("right_score") is not None]
if not rows:
return None
names = [a["test"].replace("_", " ") for a in rows]
left = [a["left_score"] for a in rows]
right = [a["right_score"] for a in rows]
x = np.arange(len(names))
w = 0.36
fig, ax = plt.subplots(figsize=(6.4, 3.2))
ax.bar(x - w / 2, left, w, color=TEAL, label="left")
ax.bar(x + w / 2, right, w, color=GOLD, label="right")
ax.set_xticks(x)
ax.set_xticklabels(names, fontsize=8, rotation=15, ha="right")
ax.set_ylim(0, 3.4)
ax.set_ylabel("score (0–3)")
ax.set_title(title, color=INK)
ax.legend(fontsize=8)
ax.grid(True, axis="y", alpha=0.2)
return _save(fig, out_png)
except Exception as e:
logger.warning("symmetry_bars failed: %s", e)
return None