| """
|
| 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
|
|
|
|
|
|
|
|
|
| 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:
|
| 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_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_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 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()
|
|
|