""" 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 gradio as gr from formscout.pipeline import Director from formscout.rubric import score_test from formscout.ui.theme import formscout_theme, FORMSCOUT_CSS # ─── 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): """Process an uploaded video through the FormScout pipeline.""" if not video_path: return ( _render_empty_state(), "Upload a video to begin analysis.", "", "", ) director = Director() state = director.run(video_path, test_name=test_name, side=side) # ─── Score card ─── score_html = _render_empty_state() score_details = "" if state.features: result = score_test(state.features) # Use judge result if available, otherwise rubric 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) return score_html, pipeline_md, score_details, alerts 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, '')}
Confidence {conf_pct}%
""" 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", theme=formscout_theme(), css=FORMSCOUT_CSS, ) as app: # Header gr.HTML("""

🏔️ FormScout

Functional Movement Screen · Automated Scoring Aid

""") # 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, ) 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("") # 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): """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) submit_btn.click( fn=_map_inputs, inputs=[video_input, test_dropdown, side_dropdown], outputs=[score_html, pipeline_md, score_details, alerts_md], ) return app if __name__ == "__main__": app = build_app() app.launch()