File size: 21,631 Bytes
4948993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c4cb91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4948993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49fadc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4948993
 
 
4c4cb91
4948993
 
4c4cb91
 
 
 
 
4948993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3732b46
4948993
 
 
3732b46
4948993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3732b46
4948993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49fadc3
 
 
 
 
 
 
 
 
4948993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3732b46
 
4948993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49fadc3
 
 
 
 
4948993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3732b46
4948993
 
 
 
 
 
 
49fadc3
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
"""

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 os
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 import session as session_mod
from formscout.startup import ensure_checkpoints

# ─── ZeroGPU ──────────────────────────────────────────────────────────────────
# On an HF Spaces ZeroGPU runtime the heavy analysis MUST run inside an
# @spaces.GPU function, and that function must already exist at import time:
# ZeroGPU scans for one during startup and aborts the Space with
# "No @spaces.GPU function detected during startup" if none is registered.
# We decorate process_video (the Start Analysis handler) so a single GPU window
# covers the whole pipeline β€” pose, optional 3D, and the Qwen3-VL judge. Off a
# ZeroGPU Space the `spaces` package is absent (or its decorator is effect-free),
# so local runs and CPU Spaces are unaffected.
try:
    import spaces

    gpu_task = spaces.GPU(duration=config.ZEROGPU_DURATION)
except Exception:  # local dev / non-ZeroGPU β€” decorate as a no-op
    def gpu_task(fn):
        return fn

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",
}

# Per-test illustration shown as a movement guide (test_key -> filename).
_ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "screenings")
SCREENING_ASSETS = {
    "deep_squat": "deep-squat.png",
    "hurdle_step": "hurdle-step.png",
    "inline_lunge": "inline-lunge.png",
    "shoulder_mobility": "shoulder-mobility.png",
    "active_slr": "active-straight-leg-raise.png",
    "trunk_stability_pushup": "trunk-stability-push-up.png",
    "rotary_stability": "rotary-stability.png",
}


def _screening_image(test_display_name: str) -> str | None:
    """Resolve the movement-guide image path for a dropdown display name."""
    key = {name: val for name, val in FMS_TESTS}.get(test_display_name)
    fname = SCREENING_ASSETS.get(key)
    if not fname:
        return None
    path = os.path.join(_ASSET_DIR, fname)
    return path if os.path.exists(path) else None


# ─── Processing ──────────────────────────────────────────────────────────────

@gpu_task
def process_video(video_path: str, test_name: str, side: str, model_key: str,

                  layers: list[str], session_state):
    """Analyse one clip and accumulate it into the screening session.



    Decorated with @spaces.GPU on ZeroGPU: the whole pipeline (pose, optional 3D,

    Qwen3-VL judge) runs inside one GPU window. The decorator is a no-op off-Space.

    """
    if not video_path:
        return (
            session_state, _render_empty_state(), "Upload a video to begin analysis.",
            "", "", None, "", _render_session_table(session_state),
            gr.update(visible=False), gr.update(visible=False),
        )

    if session_state is None:
        session_state = session_mod.new_session()

    director = Director()
    state = director.run(video_path, test_name=test_name, side=side, model_key=model_key)

    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)

        # Accumulate into the session (only when we have a real analysis)
        if state.ingest and state.pose2d and state.judge:
            draw_trails = "trails" in {lbl.lower().replace(" ", "_") for lbl in (layers or [])}
            try:
                session_mod.add_analysis(
                    session_state, ingest=state.ingest, pose2d=state.pose2d,
                    features=state.features, judge=state.judge,
                    test_name=test_name, side=side, draw_trails=draw_trails,
                )
            except Exception as e:
                state.warnings.append(f"session accumulation failed: {e}")

    pipeline_md = _render_pipeline_status(state)
    alerts = _render_alerts(state)

    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}"

    has_entries = bool(session_state and session_state.entries)
    return (
        session_state, score_html, pipeline_md, score_details, alerts,
        overlay_path, vel_summary, _render_session_table(session_state),
        gr.update(visible=has_entries), gr.update(visible=has_entries),
    )


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: #cf922a; margin-bottom: 8px;">⚠️ Needs Clinician Review</div>

            <div style="font-size: 0.9em; color: #4a5f57;">Pain or clearing test detected β€” cannot auto-score</div>

        </div>

        """

    conf_pct = int(confidence * 100)
    conf_color = "#2b8a8a" if confidence >= 0.7 else "#cf922a" if confidence >= 0.4 else "#d9534f"

    return f"""

    <div class="score-card">

        <div class="score-value">{score}/3</div>

        <div style="font-size: 0.95em; color: #4a5f57; 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: #6b7d75;">

                <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.6;">

        <div style="font-size: 2em; margin-bottom: 8px;">πŸ”οΈ</div>

        <div style="color: #6b7d75;">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 _render_session_table(session_state) -> str:
    """Render the accumulated 'Session so far' table as markdown."""
    if not session_state or not session_state.entries:
        return "*No clips analysed yet.*"
    lines = ["| Test | Side | Score | Status |", "|---|---|---|---|"]
    for e in session_state.entries:
        test = e.test_name.replace("_", " ").title()
        side = e.side if e.side in ("left", "right") else "β€”"
        if e.needs_human:
            score, status = "β€”", "⚠️ Clinician review"
        else:
            score, status = f"{e.score}/3", "βœ“ scored"
        lines.append(f"| {test} | {side} | {score} | {status} |")
    return "\n".join(lines)


def _finish_session(session_state):
    """Build the composite report + PDF for the whole session."""
    if not session_state or not session_state.entries:
        return ("⚠️ No clips analysed yet β€” analyse at least one clip first.",
                None, None, None)

    report, pdf_path = session_mod.finish_session(session_state)
    if report is None:
        return ("⚠️ Nothing to report.", None, None, None)

    if report.composite is not None:
        summary = [f"## Composite: {report.composite} / 21"]
    else:
        n = len(session_state.entries)
        summary = [f"## Composite: Incomplete β€” {n}/7 tests scored",
                   "*(One or more tests need clinician review or were unscored.)*"]

    if report.asymmetries:
        summary.append("\n### Asymmetries")
        for a in report.asymmetries:
            test = a["test"].replace("_", " ").title()
            summary.append(f"- **{test}:** L={a['left_score']} R={a['right_score']} (Ξ” {a['delta']})")

    flags = list(report.low_confidence_flags) + list(report.disagreement_flags)
    if flags:
        summary.append("\n### Flags")
        for fl in flags:
            summary.append(f"- {fl}")

    md_path = os.path.join(session_state.session_dir, "analysis.md")
    md_out = md_path if os.path.exists(md_path) else None
    return "\n".join(summary), pdf_path, md_out, report.scoresheet_path


# ─── 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:

        session_state = gr.State(None)

        # Header
        gr.HTML("""

        <div class="formscout-header">

            <h1>πŸ”οΈ FormScout</h1>

            <p style="color: #4a5f57; 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,
                    )

                movement_guide = gr.Image(
                    value=_screening_image("Deep Squat"),
                    label="Movement guide",
                    interactive=False,
                    buttons=[],  # no download/share/fullscreen overlay
                    height=240,
                    elem_id="movement-guide",
                )

                _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", "Velocity arrows"],
                    label="Overlay Layers",
                )

                submit_btn = gr.Button(
                    "🎯 Score Movement",
                    variant="primary",
                    size="lg",
                )
                with gr.Row():
                    new_clip_btn = gr.Button("βž• Analyse new clip", visible=False)
                    finish_btn = gr.Button("βœ… Finish & generate PDF",
                                           variant="primary", visible=False)

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

                    with gr.TabItem("πŸ—‚οΈ Session"):
                        session_table = gr.Markdown("*No clips analysed yet.*")
                        finish_summary = gr.Markdown("")
                        scoresheet_file = gr.File(label="FMS Scoring Sheet (PDF)", visible=True)
                        pdf_file = gr.File(label="Detailed Screening Report (PDF)", visible=True)
                        md_file = gr.File(label="Analysis Log (Markdown)", visible=True)

        # Footer safety banner
        gr.HTML(f'<div class="safety-banner" style="margin-top: 20px;">{DISCLAIMER}</div>')

        gr.Markdown(
            "<center style='color: #6b7d75; font-size: 0.8em; margin-top: 12px;'>"
            "FormScout Β· ~18B params Β· Off the Grid Β· "
            "<a href='https://silastherapy.sk' style='color: #1f6e6e;'>Silas Therapy Β· Build Small Hackathon</a>"
            "</center>"
        )

        # ─── Event wiring ────────────────────────────────────────────────────

        def _map_inputs(video, test_display_name, side_display, pose_model_key, overlay_layers, sess):
            """Map UI display values to internal values and accumulate into the session."""
            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, sess)

        # Swap the movement-guide illustration when the selected test changes.
        test_dropdown.change(
            fn=_screening_image, inputs=[test_dropdown], outputs=[movement_guide],
        )

        submit_btn.click(
            fn=_map_inputs,
            inputs=[video_input, test_dropdown, side_dropdown, pose_model_dropdown,
                    overlay_layers, session_state],
            outputs=[session_state, score_html, pipeline_md, score_details, alerts_md,
                     overlay_video, velocity_md, session_table, new_clip_btn, finish_btn],
        )

        def _new_clip():
            """Clear inputs for the next clip; keep the session intact."""
            return None, _render_empty_state(), ""

        new_clip_btn.click(
            fn=_new_clip,
            inputs=[],
            outputs=[video_input, score_html, score_details],
        )

        finish_btn.click(
            fn=_finish_session,
            inputs=[session_state],
            outputs=[finish_summary, pdf_file, md_file, scoresheet_file],
        )

    return app


if __name__ == "__main__":
    app = build_app()
    app.launch(theme=formscout_theme(), css=FORMSCOUT_CSS,
               allowed_paths=[_ASSET_DIR])