Download formscout/session.py from build-small-hackathon/small-functional-movement-screening: direct link, hf CLI and curl.
- Browser
- Download file 12.1 kB
-
https://huggingface.co/spaces/build-small-hackathon/small-functional-movement-screening/resolve/main/formscout/session.py
- Command line
-
hf download hf://spaces/build-small-hackathon/small-functional-movement-screening/formscout/session.py
-
curl -L -o session.py https://huggingface.co/spaces/build-small-hackathon/small-functional-movement-screening/resolve/main/formscout/session.py
12.1 kB
| """ | |
| Screening-session accumulator. | |
| Accumulates one SessionEntry per analyzed clip, persists each to a temp session | |
| dir (session.json + analysis.md + key-frame PNGs), and on finish builds a | |
| ReportResult (via ReportAgent) + a PDF (via PdfReportAgent). | |
| Pure orchestration β no Gradio imports. Disk writes tolerate failure with a | |
| logged warning and never block scoring. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import os | |
| import tempfile | |
| import uuid | |
| from dataclasses import dataclass, replace | |
| from formscout.rubric import score_test | |
| from formscout.types import MovementResult, ReportResult, SessionEntry | |
| logger = logging.getLogger(__name__) | |
| # Maps each test to the BiomechFeatures.timing key holding its governing frame. | |
| TIMING_KEY = { | |
| "deep_squat": "deepest_frame", | |
| "hurdle_step": "peak_step_frame", | |
| "inline_lunge": "deepest_lunge_frame", | |
| "shoulder_mobility": "measure_frame", | |
| "active_slr": "peak_raise_frame", | |
| "trunk_stability_pushup": "max_sag_frame", | |
| "rotary_stability": "peak_extension_frame", | |
| } | |
| class Session: | |
| """Mutable session: an id, its temp dir, and accumulated entries.""" | |
| session_id: str | |
| session_dir: str | |
| entries: list # list[SessionEntry] | |
| def new_session() -> Session: | |
| sid = uuid.uuid4().hex[:12] | |
| base = os.path.join(tempfile.gettempdir(), "formscout_sessions", sid) | |
| try: | |
| os.makedirs(os.path.join(base, "keyframes"), exist_ok=True) | |
| except Exception as e: | |
| logger.warning("session dir create failed: %s", e) | |
| return Session(session_id=sid, session_dir=base, entries=[]) | |
| def governing_frame_index(features) -> int | None: | |
| """Return the governing frame index for this test, or None.""" | |
| key = TIMING_KEY.get(features.test_name) | |
| if key is None: | |
| return None | |
| idx = features.timing.get(key) | |
| return int(idx) if isinstance(idx, (int, float)) else None | |
| def worst_compensation_caption(judge, features) -> str: | |
| """Short caption naming the worst compensation for the key-frame still.""" | |
| if judge and getattr(judge, "compensation_tags", None): | |
| return ", ".join(judge.compensation_tags) | |
| failed = [k.replace("_", " ") for k, v in features.alignments.items() if v is False] | |
| return ("compensation: " + ", ".join(failed)) if failed else "key position" | |
| def add_analysis(session, *, ingest, pose2d, features, judge, test_name, side, | |
| draw_trails: bool = False) -> SessionEntry: | |
| """Build a SessionEntry from a completed analysis, render its key-frame, | |
| persist the session, append, and return the entry.""" | |
| movement = MovementResult(test_name=test_name, side=side, confidence=1.0) | |
| rubric = score_test(features) | |
| needs_human = bool((judge and judge.needs_human) or rubric.needs_human) | |
| if needs_human: | |
| score = None | |
| elif judge and judge.score is not None: | |
| score = judge.score | |
| else: | |
| score = rubric.score | |
| keyframe_path = None | |
| idx = governing_frame_index(features) | |
| if idx is not None and 0 <= idx < len(pose2d.keypoints): | |
| from formscout.agents.visualizer import PoseVisualizer | |
| caption = (f"{test_name.replace('_', ' ').title()} " | |
| f"({side}) β {worst_compensation_caption(judge, features)}") | |
| layers = {"skeleton", "trails"} if draw_trails else {"skeleton"} | |
| out_png = os.path.join(session.session_dir, "keyframes", f"{test_name}_{side}.png") | |
| try: | |
| keyframe_path = PoseVisualizer().render_frame(ingest, pose2d, idx, layers, caption, out_png) | |
| except Exception as e: | |
| logger.warning("keyframe render failed: %s", e) | |
| measurements = {} | |
| measurements.update(features.angles) | |
| measurements.update(features.alignments) | |
| laban, flexion, chart_paths = _run_analysis(session, pose2d, test_name, side, idx) | |
| entry = SessionEntry( | |
| test_name=test_name, side=side, score=score, needs_human=needs_human, | |
| rationale=(judge.rationale if judge else rubric.rationale), | |
| compensation_tags=list(judge.compensation_tags) if judge else [], | |
| corrective_hint=(judge.corrective_hint if judge else ""), | |
| measurements=measurements, | |
| confidence=(judge.confidence if judge else rubric.confidence), | |
| view=features.view, | |
| keyframe_path=keyframe_path, | |
| movement=movement, features=features, rubric_score=rubric, judge=judge, | |
| laban=laban, flexion=flexion, chart_paths=chart_paths, | |
| ) | |
| session.entries.append(entry) | |
| _persist(session) | |
| return entry | |
| def _run_analysis(session, pose2d, test_name, side, governing_idx): | |
| """Compute Laban Effort, relevant-joint flexion, and per-test charts. | |
| Returns (laban, flexion, chart_paths); any failure degrades to (None, None, {}) | |
| without blocking the entry.""" | |
| try: | |
| from formscout.analysis import charts as C | |
| from formscout.analysis.laban import compute_laban | |
| from formscout.analysis.relevant_joints import primary_angle, relevant_joints | |
| from formscout.analysis.timeseries import angle_series, relevant_flexion_at | |
| except Exception as e: | |
| logger.warning("analysis engine unavailable: %s", e) | |
| return None, None, {} | |
| try: | |
| laban = compute_laban(pose2d, test_name, pose2d.fps) | |
| gidx = governing_idx if governing_idx is not None else 0 | |
| flexion = relevant_flexion_at(pose2d, test_name, gidx) | |
| series = angle_series(pose2d, test_name) | |
| cdir = os.path.join(session.session_dir, "charts") | |
| os.makedirs(cdir, exist_ok=True) | |
| tag = f"{test_name}_{side}" | |
| produced = { | |
| "angle": C.angle_over_time(series, primary_angle(test_name), governing_idx, | |
| os.path.join(cdir, f"{tag}_angle.png"), | |
| title=f"{test_name.replace('_', ' ').title()} β angle over time"), | |
| "velocity": C.velocity_profile(pose2d.keypoints, pose2d.fps, relevant_joints(test_name), | |
| os.path.join(cdir, f"{tag}_vel.png")), | |
| "radar": C.laban_radar(laban["effort"], os.path.join(cdir, f"{tag}_radar.png")), | |
| "flexion": C.flexion_bars(flexion, os.path.join(cdir, f"{tag}_flex.png")), | |
| } | |
| chart_paths = {k: p for k, p in produced.items() if p} | |
| return laban, flexion, chart_paths | |
| except Exception as e: | |
| logger.warning("analysis/charts failed: %s", e) | |
| return None, None, {} | |
| def finish_session(session) -> tuple[ReportResult | None, str | None]: | |
| """Build the composite report + PDF. Returns (report, pdf_path). | |
| Returns (None, None) for an empty session.""" | |
| if not session.entries: | |
| return None, None | |
| from formscout.agents.report import ReportAgent | |
| report_inputs = [{ | |
| "movement": e.movement, "features": e.features, | |
| "rubric_score": e.rubric_score, "judge": e.judge, "side": e.side, | |
| } for e in session.entries] | |
| report = ReportAgent().run(report_inputs) | |
| pdf_path = None | |
| try: | |
| from formscout.agents.pdf_report import PdfReportAgent | |
| pdf_path = PdfReportAgent().run(report, session.entries, session.session_dir) | |
| except Exception as e: | |
| logger.warning("pdf generation failed: %s", e) | |
| scoresheet_path = None | |
| try: | |
| from formscout.agents.scoresheet import ScoreSheetAgent | |
| scoresheet_path = ScoreSheetAgent().run(report, session.session_dir) | |
| except Exception as e: | |
| logger.warning("scoresheet generation failed: %s", e) | |
| report = replace(report, pdf_path=pdf_path, scoresheet_path=scoresheet_path) | |
| return report, pdf_path | |
| # ββ Persistence βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _jsonable(d: dict) -> dict: | |
| out = {} | |
| for k, v in d.items(): | |
| if isinstance(v, float): | |
| out[k] = round(v, 2) | |
| elif isinstance(v, (int, str, bool)) or v is None: | |
| out[k] = v | |
| else: | |
| out[k] = str(v) | |
| return out | |
| def _entry_display(e: SessionEntry) -> dict: | |
| return { | |
| "test_name": e.test_name, "side": e.side, "score": e.score, | |
| "needs_human": e.needs_human, "rationale": e.rationale, | |
| "compensation_tags": list(e.compensation_tags), "corrective_hint": e.corrective_hint, | |
| "measurements": _jsonable(e.measurements), "confidence": round(e.confidence, 2), | |
| "view": e.view, "keyframe_path": e.keyframe_path, | |
| "laban": e.laban, "flexion": _jsonable_flexion(e.flexion), "chart_paths": e.chart_paths, | |
| } | |
| def _jsonable_flexion(flexion: dict | None) -> dict: | |
| if not flexion: | |
| return {} | |
| return {k: {"deg": round(v["deg"], 1), "openness": v["openness"]} for k, v in flexion.items()} | |
| def _render_markdown(session: Session) -> str: | |
| lines = ["# FormScout β Session Log", ""] | |
| for e in session.entries: | |
| title = e.test_name.replace("_", " ").title() | |
| if e.side in ("left", "right"): | |
| title += f" ({e.side})" | |
| score = "Clinician review required" if e.needs_human else f"{e.score}/3" | |
| lines.append(f"## {title} β {score}") | |
| lines.append(f"*view: {e.view} Β· confidence: {e.confidence:.0%}*") | |
| lines.append("") | |
| lines.append(e.rationale or "") | |
| if e.compensation_tags: | |
| lines.append(f"- **Compensations:** {', '.join(e.compensation_tags)}") | |
| if e.corrective_hint: | |
| lines.append(f"- **Corrective:** {e.corrective_hint}") | |
| # Relevant-joint flexion (degrees + open/closed) | |
| if e.flexion: | |
| lines.append("\n### Relevant joint flexion (key frame)") | |
| lines.append("| Joint angle | Degrees | State |") | |
| lines.append("|---|---|---|") | |
| for name, v in e.flexion.items(): | |
| lines.append(f"| {name.replace('_', ' ')} | {v['deg']:.1f}Β° | {v['openness']} |") | |
| # Laban Effort | |
| if e.laban: | |
| eff, lab = e.laban.get("effort", {}), e.laban.get("labels", {}) | |
| lines.append("\n### Laban Effort (kinematic estimate)") | |
| lines.append("| Factor | Value | Quality |") | |
| lines.append("|---|---|---|") | |
| for k in ("space", "weight", "time", "flow"): | |
| lines.append(f"| {k.title()} | {eff.get(k, 0):.2f} | {lab.get(k, '')} |") | |
| if e.laban.get("body_emphasis"): | |
| emph = ", ".join(f"{n} ({s})" for n, s in e.laban["body_emphasis"]) | |
| lines.append(f"\n- **Body emphasis:** {emph}") | |
| if e.laban.get("leading_joint"): | |
| lines.append(f"- **Leading joint:** {e.laban['leading_joint']}") | |
| lines.append(f"- *{e.laban.get('notes', '')}*") | |
| # Full measurement dump | |
| if e.measurements: | |
| lines.append("\n### All measurements") | |
| lines.append("| Measurement | Value |") | |
| lines.append("|---|---|") | |
| for k, v in e.measurements.items(): | |
| val = f"{v:.2f}" if isinstance(v, float) else str(v) | |
| lines.append(f"| {k.replace('_', ' ')} | {val} |") | |
| if e.features and e.features.symmetry_delta is not None: | |
| lines.append(f"\n- **L/R symmetry delta:** {e.features.symmetry_delta:.1f}") | |
| # Artifacts | |
| if e.keyframe_path: | |
| lines.append(f"\n- Key frame: `{e.keyframe_path}`") | |
| for kind, path in (e.chart_paths or {}).items(): | |
| lines.append(f"- Chart ({kind}): `{path}`") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def _persist(session: Session) -> None: | |
| try: | |
| with open(os.path.join(session.session_dir, "session.json"), "w") as f: | |
| json.dump([_entry_display(e) for e in session.entries], f, indent=2) | |
| with open(os.path.join(session.session_dir, "analysis.md"), "w") as f: | |
| f.write(_render_markdown(session)) | |
| except Exception as e: | |
| logger.warning("session persist failed: %s", e) | |