"""
FormScout — Gradio app entrypoint.
Screening aid for Functional Movement Screen (FMS) scoring.
NOT a diagnosis. NOT an injury predictor.
Custom scout/trail themed UI with score dial, pipeline visualization,
rubric breakdown, and persistent safety banner.
"""
from __future__ import annotations
import os
import tempfile
import gradio as gr
from formscout.pipeline import Director
from formscout.rubric import score_test
from formscout.ui.theme import formscout_theme, FORMSCOUT_CSS
from formscout import config
from formscout import session as session_mod
from formscout.startup import ensure_checkpoints
# ─── ZeroGPU ──────────────────────────────────────────────────────────────────
# On an HF Spaces ZeroGPU runtime the heavy analysis MUST run inside an
# @spaces.GPU function, and that function must already exist at import time:
# ZeroGPU scans for one during startup and aborts the Space with
# "No @spaces.GPU function detected during startup" if none is registered.
# We decorate process_video (the Start Analysis handler) so a single GPU window
# covers the whole pipeline — pose, optional 3D, and the Qwen3-VL judge. Off a
# ZeroGPU Space the `spaces` package is absent (or its decorator is effect-free),
# so local runs and CPU Spaces are unaffected.
try:
import spaces
gpu_task = spaces.GPU(duration=config.ZEROGPU_DURATION)
except Exception: # local dev / non-ZeroGPU — decorate as a no-op
def gpu_task(fn):
return fn
ensure_checkpoints()
# ─── Constants ───────────────────────────────────────────────────────────────
DISCLAIMER = (
"⚠️ **Screening aid — not a diagnosis. "
"Pain or clearing tests require a clinician.**"
)
FMS_TESTS = [
("Deep Squat", "deep_squat"),
("Hurdle Step", "hurdle_step"),
("In-Line Lunge", "inline_lunge"),
("Shoulder Mobility", "shoulder_mobility"),
("Active Straight-Leg Raise", "active_slr"),
("Trunk Stability Push-Up", "trunk_stability_pushup"),
("Rotary Stability", "rotary_stability"),
]
SCORE_DESCRIPTIONS = {
3: "Movement performed to criterion — no compensation",
2: "Movement completed with compensation or regression",
1: "Unable to perform the movement pattern",
0: "Pain reported — clinician referral required",
}
# Per-test illustration shown as a movement guide (test_key -> filename).
_ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "screenings")
SCREENING_ASSETS = {
"deep_squat": "deep-squat.png",
"hurdle_step": "hurdle-step.png",
"inline_lunge": "inline-lunge.png",
"shoulder_mobility": "shoulder-mobility.png",
"active_slr": "active-straight-leg-raise.png",
"trunk_stability_pushup": "trunk-stability-push-up.png",
"rotary_stability": "rotary-stability.png",
}
def _screening_image(test_display_name: str) -> str | None:
"""Resolve the movement-guide image path for a dropdown display name."""
key = {name: val for name, val in FMS_TESTS}.get(test_display_name)
fname = SCREENING_ASSETS.get(key)
if not fname:
return None
path = os.path.join(_ASSET_DIR, fname)
return path if os.path.exists(path) else None
# ─── Processing ──────────────────────────────────────────────────────────────
@gpu_task
def process_video(video_path: str, test_name: str, side: str, model_key: str,
layers: list[str], session_state):
"""Analyse one clip and accumulate it into the screening session.
Decorated with @spaces.GPU on ZeroGPU: the whole pipeline (pose, optional 3D,
Qwen3-VL judge) runs inside one GPU window. The decorator is a no-op off-Space.
"""
if not video_path:
return (
session_state, _render_empty_state(), "Upload a video to begin analysis.",
"", "", None, "", _render_session_table(session_state),
gr.update(visible=False), gr.update(visible=False),
)
if session_state is None:
session_state = session_mod.new_session()
director = Director()
state = director.run(video_path, test_name=test_name, side=side, model_key=model_key)
score_html = _render_empty_state()
score_details = ""
if state.features:
result = score_test(state.features)
judge = state.judge
if judge and judge.score is not None:
score_html = _render_score_card(judge.score, judge.confidence, judge.needs_human)
score_details = _render_score_details_judge(judge, result, state.features)
elif judge and judge.needs_human:
score_html = _render_score_card(0, 0, True)
score_details = f"### Needs Clinician Review\n{judge.rationale}"
else:
score_html = _render_score_card(result.score, result.confidence, result.needs_human)
score_details = _render_score_details(result, state.features)
# Accumulate into the session (only when we have a real analysis)
if state.ingest and state.pose2d and state.judge:
draw_trails = "trails" in {lbl.lower().replace(" ", "_") for lbl in (layers or [])}
try:
session_mod.add_analysis(
session_state, ingest=state.ingest, pose2d=state.pose2d,
features=state.features, judge=state.judge,
test_name=test_name, side=side, draw_trails=draw_trails,
)
except Exception as e:
state.warnings.append(f"session accumulation failed: {e}")
pipeline_md = _render_pipeline_status(state)
alerts = _render_alerts(state)
overlay_path = None
vel_summary = ""
layer_set = {lbl.lower().replace(" ", "_") for lbl in (layers or [])}
if layer_set and state.ingest and state.pose2d:
try:
from formscout.agents.visualizer import PoseVisualizer, build_velocity_summary
vis = PoseVisualizer()
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
out_path = f.name
overlay_path = vis.render_video(state.ingest, state.pose2d, layer_set, out_path)
if overlay_path:
vel_summary = build_velocity_summary(state.pose2d.keypoints, vis.last_velocities)
except Exception as e:
alerts = (alerts or "") + f"\n⚠️ Visualizer error: {e}"
has_entries = bool(session_state and session_state.entries)
return (
session_state, score_html, pipeline_md, score_details, alerts,
overlay_path, vel_summary, _render_session_table(session_state),
gr.update(visible=has_entries), gr.update(visible=has_entries),
)
def _render_score_card(score: int, confidence: float, needs_human: bool) -> str:
"""Render the score dial as HTML."""
if needs_human:
return """
⚠️ Needs Clinician Review
Pain or clearing test detected — cannot auto-score
"""
conf_pct = int(confidence * 100)
conf_color = "#2b8a8a" if confidence >= 0.7 else "#cf922a" if confidence >= 0.4 else "#d9534f"
return f"""
{score}/3
{SCORE_DESCRIPTIONS.get(score, '')}
"""
def _render_empty_state() -> str:
"""Render placeholder when no video processed yet."""
return """
🏔️
Upload a video to begin
"""
def _render_score_details(result, features) -> str:
"""Render the rubric breakdown."""
parts = [f"### Rationale\n{result.rationale}\n"]
if features.angles:
parts.append("### Measurements")
for key, val in features.angles.items():
label = key.replace("_", " ").title()
parts.append(f"- **{label}:** {val:.1f}°")
if features.alignments:
parts.append("\n### Alignment Checks")
for key, val in features.alignments.items():
label = key.replace("_", " ").title()
icon = "✓" if val else "✗"
parts.append(f"- {icon} {label}")
if features.view == "2d":
parts.append(
"\n> ⚠️ *2D estimate — angles are camera-angle dependent. "
"For best accuracy, film from the side at hip height.*"
)
return "\n".join(parts)
def _render_score_details_judge(judge, rubric, features) -> str:
"""Render judge + rubric combined breakdown."""
parts = [f"### Judge Rationale\n{judge.rationale}\n"]
if judge.compensation_tags:
parts.append(f"**Compensations:** {', '.join(judge.compensation_tags)}")
if judge.corrective_hint:
parts.append(f"**Corrective:** {judge.corrective_hint}")
parts.append(f"\n### Rubric Score: {rubric.score}/3")
parts.append(f"*{rubric.rationale}*")
if features.angles:
parts.append("\n### Measurements")
for key, val in features.angles.items():
label = key.replace("_", " ").title()
parts.append(f"- **{label}:** {val:.1f}°" if isinstance(val, float) else f"- **{label}:** {val}")
if features.symmetry_delta is not None:
parts.append(f"\n### Asymmetry\n- **L/R Delta:** {features.symmetry_delta:.1f}°")
if features.view == "2d":
parts.append(
"\n> ⚠️ *2D estimate — angles are camera-angle dependent.*"
)
return "\n".join(parts)
def _render_pipeline_status(state) -> str:
"""Render pipeline step summary."""
parts = []
if state.ingest:
parts.append(
f"📹 **Ingest:** {len(state.ingest.frames)} frames · "
f"{state.ingest.fps:.0f}fps · {state.ingest.duration:.1f}s · "
f"{state.ingest.width}×{state.ingest.height}"
)
if state.pose2d:
n = sum(1 for kps in state.pose2d.keypoints if kps)
parts.append(
f"🦴 **Pose2D:** {n}/{len(state.pose2d.keypoints)} frames detected · "
f"conf={state.pose2d.confidence:.0%}"
)
if state.body3d:
if state.body3d.used:
parts.append(f"🧊 **Body3D:** active · conf={state.body3d.confidence:.0%}")
else:
parts.append("🧊 **Body3D:** 2D-only path (normal)")
if state.features:
parts.append(
f"📐 **Biomechanics:** view={state.features.view} · "
f"conf={state.features.confidence:.0%}"
)
return "\n\n".join(parts) if parts else "*Processing...*"
def _render_alerts(state) -> str:
"""Render errors and warnings."""
parts = []
if state.errors:
for e in state.errors:
parts.append(f"🚨 {e}")
if state.warnings:
for w in state.warnings:
parts.append(f"⚠️ {w}")
return "\n\n".join(parts)
def _render_session_table(session_state) -> str:
"""Render the accumulated 'Session so far' table as markdown."""
if not session_state or not session_state.entries:
return "*No clips analysed yet.*"
lines = ["| Test | Side | Score | Status |", "|---|---|---|---|"]
for e in session_state.entries:
test = e.test_name.replace("_", " ").title()
side = e.side if e.side in ("left", "right") else "—"
if e.needs_human:
score, status = "—", "⚠️ Clinician review"
else:
score, status = f"{e.score}/3", "✓ scored"
lines.append(f"| {test} | {side} | {score} | {status} |")
return "\n".join(lines)
def _finish_session(session_state):
"""Build the composite report + PDF for the whole session."""
if not session_state or not session_state.entries:
return ("⚠️ No clips analysed yet — analyse at least one clip first.",
None, None, None)
report, pdf_path = session_mod.finish_session(session_state)
if report is None:
return ("⚠️ Nothing to report.", None, None, None)
if report.composite is not None:
summary = [f"## Composite: {report.composite} / 21"]
else:
n = len(session_state.entries)
summary = [f"## Composite: Incomplete — {n}/7 tests scored",
"*(One or more tests need clinician review or were unscored.)*"]
if report.asymmetries:
summary.append("\n### Asymmetries")
for a in report.asymmetries:
test = a["test"].replace("_", " ").title()
summary.append(f"- **{test}:** L={a['left_score']} R={a['right_score']} (Δ {a['delta']})")
flags = list(report.low_confidence_flags) + list(report.disagreement_flags)
if flags:
summary.append("\n### Flags")
for fl in flags:
summary.append(f"- {fl}")
md_path = os.path.join(session_state.session_dir, "analysis.md")
md_out = md_path if os.path.exists(md_path) else None
return "\n".join(summary), pdf_path, md_out, report.scoresheet_path
# ─── App Builder ─────────────────────────────────────────────────────────────
def build_app() -> gr.Blocks:
"""Build the FormScout Gradio app with custom scout/trail theme."""
with gr.Blocks(title="FormScout — FMS Screening Aid") as app:
session_state = gr.State(None)
# Header
gr.HTML("""
""")
# Safety banner (always visible — non-negotiable)
gr.HTML(f'{DISCLAIMER}
')
with gr.Row(equal_height=False):
# Left column: Input
with gr.Column(scale=2):
gr.Markdown("### 📹 Input")
video_input = gr.Video(label="Upload FMS Video")
with gr.Row():
test_dropdown = gr.Dropdown(
choices=[name for name, _ in FMS_TESTS],
value="Deep Squat",
label="FMS Test",
scale=2,
)
side_dropdown = gr.Dropdown(
choices=["N/A", "Left", "Right"],
value="N/A",
label="Side",
scale=1,
)
movement_guide = gr.Image(
value=_screening_image("Deep Squat"),
label="Movement guide",
interactive=False,
buttons=[], # no download/share/fullscreen overlay
height=240,
elem_id="movement-guide",
)
_available_models = config.available_pose_models() or config.POSE_MODELS
_default_model = (
config.DEFAULT_POSE_MODEL
if config.DEFAULT_POSE_MODEL in _available_models
else list(_available_models.keys())[0]
)
pose_model_dropdown = gr.Dropdown(
choices=list(_available_models.keys()),
value=_default_model,
label="Pose Model",
)
overlay_layers = gr.CheckboxGroup(
choices=["Skeleton", "Trails", "Velocity arrows"],
value=["Skeleton", "Trails", "Velocity arrows"],
label="Overlay Layers",
)
submit_btn = gr.Button(
"🎯 Score Movement",
variant="primary",
size="lg",
)
with gr.Row():
new_clip_btn = gr.Button("➕ Analyse new clip", visible=False)
finish_btn = gr.Button("✅ Finish & generate PDF",
variant="primary", visible=False)
gr.Markdown(
"*Tip: Film from the side at hip height for best accuracy. "
"One athlete, one rep per clip.*",
elem_classes=["topo-accent"],
)
# Right column: Results
with gr.Column(scale=3):
gr.Markdown("### 📊 Results")
# Score display
score_html = gr.HTML(value=_render_empty_state())
# Tabs for details
with gr.Tabs():
with gr.TabItem("📐 Rubric Breakdown"):
score_details = gr.Markdown("")
with gr.TabItem("🔧 Pipeline"):
pipeline_md = gr.Markdown("*Waiting for video...*")
with gr.TabItem("⚠️ Alerts"):
alerts_md = gr.Markdown("")
with gr.TabItem("🎬 Overlay Video"):
overlay_video = gr.Video(label="Annotated Movement")
velocity_md = gr.Markdown("")
with gr.TabItem("🗂️ Session"):
session_table = gr.Markdown("*No clips analysed yet.*")
finish_summary = gr.Markdown("")
scoresheet_file = gr.File(label="FMS Scoring Sheet (PDF)", visible=True)
pdf_file = gr.File(label="Detailed Screening Report (PDF)", visible=True)
md_file = gr.File(label="Analysis Log (Markdown)", visible=True)
# Footer safety banner
gr.HTML(f'{DISCLAIMER}
')
gr.Markdown(
""
"FormScout · ~18B params · Off the Grid · "
"Silas Therapy · Build Small Hackathon"
""
)
# ─── Event wiring ────────────────────────────────────────────────────
def _map_inputs(video, test_display_name, side_display, pose_model_key, overlay_layers, sess):
"""Map UI display values to internal values and accumulate into the session."""
test_map = {name: val for name, val in FMS_TESTS}
test_name = test_map.get(test_display_name, "deep_squat")
side = {"N/A": "na", "Left": "left", "Right": "right"}.get(side_display, "na")
return process_video(video, test_name, side, pose_model_key, overlay_layers, sess)
# Swap the movement-guide illustration when the selected test changes.
test_dropdown.change(
fn=_screening_image, inputs=[test_dropdown], outputs=[movement_guide],
)
submit_btn.click(
fn=_map_inputs,
inputs=[video_input, test_dropdown, side_dropdown, pose_model_dropdown,
overlay_layers, session_state],
outputs=[session_state, score_html, pipeline_md, score_details, alerts_md,
overlay_video, velocity_md, session_table, new_clip_btn, finish_btn],
)
def _new_clip():
"""Clear inputs for the next clip; keep the session intact."""
return None, _render_empty_state(), ""
new_clip_btn.click(
fn=_new_clip,
inputs=[],
outputs=[video_input, score_html, score_details],
)
finish_btn.click(
fn=_finish_session,
inputs=[session_state],
outputs=[finish_summary, pdf_file, md_file, scoresheet_file],
)
return app
if __name__ == "__main__":
app = build_app()
app.launch(theme=formscout_theme(), css=FORMSCOUT_CSS,
allowed_paths=[_ASSET_DIR])