| """ |
| 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 |
| import numpy as np |
|
|
| from formscout.analysis.relevant_joints import COCO_NAMES |
|
|
| 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 |
|
|