"""
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 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.startup import ensure_checkpoints
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",
}
# ─── Processing ──────────────────────────────────────────────────────────────
def process_video(video_path: str, test_name: str, side: str, model_key: str, layers: list[str]):
"""Process an uploaded video through the FormScout pipeline."""
if not video_path:
return (
_render_empty_state(),
"Upload a video to begin analysis.",
"",
"",
None,
"",
)
director = Director()
state = director.run(video_path, test_name=test_name, side=side, model_key=model_key)
# ─── Score card ───
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)
# ─── Pipeline info ───
pipeline_md = _render_pipeline_status(state)
# ─── Warnings/errors ───
alerts = _render_alerts(state)
# ─── Overlay video ───
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}"
return score_html, pipeline_md, score_details, alerts, overlay_path, vel_summary
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 = "#059669" if confidence >= 0.7 else "#f59e0b" if confidence >= 0.4 else "#ef4444"
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)
# ─── 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:
# 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,
)
_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"],
label="Overlay Layers",
)
submit_btn = gr.Button(
"🎯 Score Movement",
variant="primary",
size="lg",
)
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("")
# Footer safety banner
gr.HTML(f'{DISCLAIMER}
')
gr.Markdown(
""
"FormScout · ~18B params · Off the Grid · "
"Built for Build Small Hackathon"
""
)
# ─── Event wiring ────────────────────────────────────────────────────
def _map_inputs(video, test_display_name, side_display, pose_model_key, overlay_layers):
"""Map UI display values to internal values."""
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)
submit_btn.click(
fn=_map_inputs,
inputs=[video_input, test_dropdown, side_dropdown, pose_model_dropdown, overlay_layers],
outputs=[score_html, pipeline_md, score_details, alerts_md, overlay_video, velocity_md],
)
return app
if __name__ == "__main__":
app = build_app()
app.launch(theme=formscout_theme(), css=FORMSCOUT_CSS)