ajakab's picture
feat: Phase 2 β€” all 7 FMS tests, judge, classifier, report agents
ea64ca0 verified
Raw
History Blame
12.3 kB
"""
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 """
<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)
# ─── 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("""
<div class="formscout-header">
<h1>πŸ”οΈ FormScout</h1>
<p style="color: #94a3b8; font-size: 0.95em;">
Functional Movement Screen Β· Automated Scoring Aid
</p>
</div>
""")
# Safety banner (always visible β€” non-negotiable)
gr.HTML(f'<div class="safety-banner">{DISCLAIMER}</div>')
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'<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>"
)
# ─── 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()