| """ |
| 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.deep_squat import score_deep_squat |
| from formscout.ui.theme import formscout_theme, FORMSCOUT_CSS |
|
|
|
|
| |
|
|
| 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", |
| } |
|
|
|
|
| |
|
|
| 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_html = _render_empty_state() |
| score_details = "" |
|
|
| if state.features and test_name == "deep_squat": |
| result = score_deep_squat(state.features) |
| score_html = _render_score_card(result.score, result.confidence, result.needs_human) |
| score_details = _render_score_details(result, state.features) |
|
|
| |
| pipeline_md = _render_pipeline_status(state) |
|
|
| |
| 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 """ |
| <div class="score-card needs-review"> |
| <div style="font-size: 1.2em; color: #fbbf24; margin-bottom: 8px;">β οΈ Needs Clinician Review</div> |
| <div style="font-size: 0.9em; color: #94a3b8;">Pain or clearing test detected β cannot auto-score</div> |
| </div> |
| """ |
|
|
| conf_pct = int(confidence * 100) |
| conf_color = "#059669" if confidence >= 0.7 else "#f59e0b" if confidence >= 0.4 else "#ef4444" |
|
|
| return f""" |
| <div class="score-card"> |
| <div class="score-value">{score}/3</div> |
| <div style="font-size: 0.95em; color: #94a3b8; margin-top: 4px;"> |
| {SCORE_DESCRIPTIONS.get(score, '')} |
| </div> |
| <div style="margin-top: 12px;"> |
| <div style="display: flex; justify-content: space-between; font-size: 0.8em; color: #64748b;"> |
| <span>Confidence</span> |
| <span style="color: {conf_color};">{conf_pct}%</span> |
| </div> |
| <div class="confidence-bar"> |
| <div class="confidence-fill" style="width: {conf_pct}%;"></div> |
| </div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def _render_empty_state() -> str: |
| """Render placeholder when no video processed yet.""" |
| return """ |
| <div class="score-card" style="opacity: 0.5;"> |
| <div style="font-size: 2em; margin-bottom: 8px;">ποΈ</div> |
| <div style="color: #64748b;">Upload a video to begin</div> |
| </div> |
| """ |
|
|
|
|
| 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_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 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: |
|
|
| |
| gr.HTML(""" |
| <div class="formscout-header"> |
| <h1>ποΈ FormScout</h1> |
| <p style="color: #94a3b8; font-size: 0.95em;"> |
| Functional Movement Screen Β· Automated Scoring Aid |
| </p> |
| </div> |
| """) |
|
|
| |
| gr.HTML(f'<div class="safety-banner">{DISCLAIMER}</div>') |
|
|
| with gr.Row(equal_height=False): |
| |
| 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"], |
| ) |
|
|
| |
| with gr.Column(scale=3): |
| gr.Markdown("### π Results") |
|
|
| |
| score_html = gr.HTML(value=_render_empty_state()) |
|
|
| |
| 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("") |
|
|
| |
| gr.HTML(f'<div class="safety-banner" style="margin-top: 20px;">{DISCLAIMER}</div>') |
|
|
| gr.Markdown( |
| "<center style='color: #64748b; font-size: 0.8em; margin-top: 12px;'>" |
| "FormScout Β· ~18B params Β· Off the Grid Β· " |
| "<a href='https://github.com/' style='color: #86efac;'>Built for Build Small Hackathon</a>" |
| "</center>" |
| ) |
|
|
| |
|
|
| 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() |
|
|