fix(ui): browser-playable overlay video (H.264) + light all dropdown option lists

#16
.gitattributes CHANGED
@@ -35,3 +35,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  docs/FormScout-FMS-Spec.md.pdf filter=lfs diff=lfs merge=lfs -text
37
  docs/plans/FormScout-Build-Prompt.md.pdf filter=lfs diff=lfs merge=lfs -text
 
 
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  docs/FormScout-FMS-Spec.md.pdf filter=lfs diff=lfs merge=lfs -text
37
  docs/plans/FormScout-Build-Prompt.md.pdf filter=lfs diff=lfs merge=lfs -text
38
+ assets/screenings/*.png filter=lfs diff=lfs merge=lfs -text
CLAUDE.md CHANGED
@@ -170,7 +170,7 @@ Track the running sum in `MODEL_BUDGET.md`. The two Qwen3-VL-8B models share a b
170
 
171
  The UI uses **Gradio `gr.Blocks`** with custom CSS/theme (`formscout/ui/theme.py`). Custom Svelte components for score dial, asymmetry bars, rubric drawer are planned for Phase 4. Use `gradio-svelte-expert` agent for Svelte component work.
172
 
173
- - ZeroGPU: wrap heavy inference (`Pose2DAgent.run`, `Body3DAgent.run`) in `@spaces.GPU` before deploying to Spaces.
174
  - Verify Gradio APIs against current docs before use — pin exact versions in `requirements.txt`.
175
 
176
  ## Build phases
 
170
 
171
  The UI uses **Gradio `gr.Blocks`** with custom CSS/theme (`formscout/ui/theme.py`). Custom Svelte components for score dial, asymmetry bars, rubric drawer are planned for Phase 4. Use `gradio-svelte-expert` agent for Svelte component work.
172
 
173
+ - ZeroGPU: `app.py`'s `process_video` (the Start Analysis handler) is decorated with `@spaces.GPU` (via the `gpu_task` shim, no-op off-Space) so one GPU window wraps the whole pipeline — pose, optional 3D, and the judge. **ZeroGPU aborts startup with "No @spaces.GPU function detected" unless a decorated function exists at import time**, so the decorator must stay at module level on a top-level function, not buried behind a lazy import. Window length is `config.ZEROGPU_DURATION` (default 120s, `FORMSCOUT_ZEROGPU_DURATION`).
174
  - Verify Gradio APIs against current docs before use — pin exact versions in `requirements.txt`.
175
 
176
  ## Build phases
app.py CHANGED
@@ -20,6 +20,23 @@ from formscout import config
20
  from formscout import session as session_mod
21
  from formscout.startup import ensure_checkpoints
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  ensure_checkpoints()
24
 
25
 
@@ -47,12 +64,39 @@ SCORE_DESCRIPTIONS = {
47
  0: "Pain reported — clinician referral required",
48
  }
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  # ─── Processing ──────────────────────────────────────────────────────────────
52
 
 
53
  def process_video(video_path: str, test_name: str, side: str, model_key: str,
54
  layers: list[str], session_state):
55
- """Analyse one clip and accumulate it into the screening session."""
 
 
 
 
56
  if not video_path:
57
  return (
58
  session_state, _render_empty_state(), "Upload a video to begin analysis.",
@@ -277,11 +321,11 @@ def _finish_session(session_state):
277
  """Build the composite report + PDF for the whole session."""
278
  if not session_state or not session_state.entries:
279
  return ("⚠️ No clips analysed yet — analyse at least one clip first.",
280
- None, None)
281
 
282
  report, pdf_path = session_mod.finish_session(session_state)
283
  if report is None:
284
- return ("⚠️ Nothing to report.", None, None)
285
 
286
  if report.composite is not None:
287
  summary = [f"## Composite: {report.composite} / 21"]
@@ -304,7 +348,7 @@ def _finish_session(session_state):
304
 
305
  md_path = os.path.join(session_state.session_dir, "analysis.md")
306
  md_out = md_path if os.path.exists(md_path) else None
307
- return "\n".join(summary), pdf_path, md_out
308
 
309
 
310
  # ─── App Builder ─────────────────────────────────────────────────────────────
@@ -348,6 +392,15 @@ def build_app() -> gr.Blocks:
348
  scale=1,
349
  )
350
 
 
 
 
 
 
 
 
 
 
351
  _available_models = config.available_pose_models() or config.POSE_MODELS
352
  _default_model = (
353
  config.DEFAULT_POSE_MODEL
@@ -407,7 +460,8 @@ def build_app() -> gr.Blocks:
407
  with gr.TabItem("🗂️ Session"):
408
  session_table = gr.Markdown("*No clips analysed yet.*")
409
  finish_summary = gr.Markdown("")
410
- pdf_file = gr.File(label="Screening Report (PDF)", visible=True)
 
411
  md_file = gr.File(label="Analysis Log (Markdown)", visible=True)
412
 
413
  # Footer safety banner
@@ -429,6 +483,11 @@ def build_app() -> gr.Blocks:
429
  side = {"N/A": "na", "Left": "left", "Right": "right"}.get(side_display, "na")
430
  return process_video(video, test_name, side, pose_model_key, overlay_layers, sess)
431
 
 
 
 
 
 
432
  submit_btn.click(
433
  fn=_map_inputs,
434
  inputs=[video_input, test_dropdown, side_dropdown, pose_model_dropdown,
@@ -450,7 +509,7 @@ def build_app() -> gr.Blocks:
450
  finish_btn.click(
451
  fn=_finish_session,
452
  inputs=[session_state],
453
- outputs=[finish_summary, pdf_file, md_file],
454
  )
455
 
456
  return app
@@ -458,4 +517,5 @@ def build_app() -> gr.Blocks:
458
 
459
  if __name__ == "__main__":
460
  app = build_app()
461
- app.launch(theme=formscout_theme(), css=FORMSCOUT_CSS)
 
 
20
  from formscout import session as session_mod
21
  from formscout.startup import ensure_checkpoints
22
 
23
+ # ─── ZeroGPU ──────────────────────────────────────────────────────────────────
24
+ # On an HF Spaces ZeroGPU runtime the heavy analysis MUST run inside an
25
+ # @spaces.GPU function, and that function must already exist at import time:
26
+ # ZeroGPU scans for one during startup and aborts the Space with
27
+ # "No @spaces.GPU function detected during startup" if none is registered.
28
+ # We decorate process_video (the Start Analysis handler) so a single GPU window
29
+ # covers the whole pipeline — pose, optional 3D, and the Qwen3-VL judge. Off a
30
+ # ZeroGPU Space the `spaces` package is absent (or its decorator is effect-free),
31
+ # so local runs and CPU Spaces are unaffected.
32
+ try:
33
+ import spaces
34
+
35
+ gpu_task = spaces.GPU(duration=config.ZEROGPU_DURATION)
36
+ except Exception: # local dev / non-ZeroGPU — decorate as a no-op
37
+ def gpu_task(fn):
38
+ return fn
39
+
40
  ensure_checkpoints()
41
 
42
 
 
64
  0: "Pain reported — clinician referral required",
65
  }
66
 
67
+ # Per-test illustration shown as a movement guide (test_key -> filename).
68
+ _ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "screenings")
69
+ SCREENING_ASSETS = {
70
+ "deep_squat": "deep-squat.png",
71
+ "hurdle_step": "hurdle-step.png",
72
+ "inline_lunge": "inline-lunge.png",
73
+ "shoulder_mobility": "shoulder-mobility.png",
74
+ "active_slr": "active-straight-leg-raise.png",
75
+ "trunk_stability_pushup": "trunk-stability-push-up.png",
76
+ "rotary_stability": "rotary-stability.png",
77
+ }
78
+
79
+
80
+ def _screening_image(test_display_name: str) -> str | None:
81
+ """Resolve the movement-guide image path for a dropdown display name."""
82
+ key = {name: val for name, val in FMS_TESTS}.get(test_display_name)
83
+ fname = SCREENING_ASSETS.get(key)
84
+ if not fname:
85
+ return None
86
+ path = os.path.join(_ASSET_DIR, fname)
87
+ return path if os.path.exists(path) else None
88
+
89
 
90
  # ─── Processing ──────────────────────────────────────────────────────────────
91
 
92
+ @gpu_task
93
  def process_video(video_path: str, test_name: str, side: str, model_key: str,
94
  layers: list[str], session_state):
95
+ """Analyse one clip and accumulate it into the screening session.
96
+
97
+ Decorated with @spaces.GPU on ZeroGPU: the whole pipeline (pose, optional 3D,
98
+ Qwen3-VL judge) runs inside one GPU window. The decorator is a no-op off-Space.
99
+ """
100
  if not video_path:
101
  return (
102
  session_state, _render_empty_state(), "Upload a video to begin analysis.",
 
321
  """Build the composite report + PDF for the whole session."""
322
  if not session_state or not session_state.entries:
323
  return ("⚠️ No clips analysed yet — analyse at least one clip first.",
324
+ None, None, None)
325
 
326
  report, pdf_path = session_mod.finish_session(session_state)
327
  if report is None:
328
+ return ("⚠️ Nothing to report.", None, None, None)
329
 
330
  if report.composite is not None:
331
  summary = [f"## Composite: {report.composite} / 21"]
 
348
 
349
  md_path = os.path.join(session_state.session_dir, "analysis.md")
350
  md_out = md_path if os.path.exists(md_path) else None
351
+ return "\n".join(summary), pdf_path, md_out, report.scoresheet_path
352
 
353
 
354
  # ─── App Builder ─────────────────────────────────────────────────────────────
 
392
  scale=1,
393
  )
394
 
395
+ movement_guide = gr.Image(
396
+ value=_screening_image("Deep Squat"),
397
+ label="Movement guide",
398
+ interactive=False,
399
+ buttons=[], # no download/share/fullscreen overlay
400
+ height=240,
401
+ elem_id="movement-guide",
402
+ )
403
+
404
  _available_models = config.available_pose_models() or config.POSE_MODELS
405
  _default_model = (
406
  config.DEFAULT_POSE_MODEL
 
460
  with gr.TabItem("🗂️ Session"):
461
  session_table = gr.Markdown("*No clips analysed yet.*")
462
  finish_summary = gr.Markdown("")
463
+ scoresheet_file = gr.File(label="FMS Scoring Sheet (PDF)", visible=True)
464
+ pdf_file = gr.File(label="Detailed Screening Report (PDF)", visible=True)
465
  md_file = gr.File(label="Analysis Log (Markdown)", visible=True)
466
 
467
  # Footer safety banner
 
483
  side = {"N/A": "na", "Left": "left", "Right": "right"}.get(side_display, "na")
484
  return process_video(video, test_name, side, pose_model_key, overlay_layers, sess)
485
 
486
+ # Swap the movement-guide illustration when the selected test changes.
487
+ test_dropdown.change(
488
+ fn=_screening_image, inputs=[test_dropdown], outputs=[movement_guide],
489
+ )
490
+
491
  submit_btn.click(
492
  fn=_map_inputs,
493
  inputs=[video_input, test_dropdown, side_dropdown, pose_model_dropdown,
 
509
  finish_btn.click(
510
  fn=_finish_session,
511
  inputs=[session_state],
512
+ outputs=[finish_summary, pdf_file, md_file, scoresheet_file],
513
  )
514
 
515
  return app
 
517
 
518
  if __name__ == "__main__":
519
  app = build_app()
520
+ app.launch(theme=formscout_theme(), css=FORMSCOUT_CSS,
521
+ allowed_paths=[_ASSET_DIR])
assets/screenings/active-straight-leg-raise.png ADDED

Git LFS Details

  • SHA256: ff1c9bd59b925d8cdee50c26569d47df10b1492e87c97fca54fe70e5f986657c
  • Pointer size: 131 Bytes
  • Size of remote file: 907 kB
assets/screenings/deep-squat.png ADDED

Git LFS Details

  • SHA256: c88a8e4be38bdf0225bdafcff54501314cab251525e4a0d64f54e96904387219
  • Pointer size: 131 Bytes
  • Size of remote file: 933 kB
assets/screenings/hurdle-step.png ADDED

Git LFS Details

  • SHA256: f0ae14712b910507ca135d0f5e8c630d6bb836e7dbfb0933aff307e6a6245ad5
  • Pointer size: 132 Bytes
  • Size of remote file: 1.53 MB
assets/screenings/inline-lunge.png ADDED

Git LFS Details

  • SHA256: 97213a46f9d34ce15ce88366e9955bc215346b125f7f4ac9a8b5b57a30621f3c
  • Pointer size: 131 Bytes
  • Size of remote file: 895 kB
assets/screenings/rotary-stability.png ADDED

Git LFS Details

  • SHA256: 6383d753060411151b4d93c7b9b3301094bf8353c96f3fee32698fded4cdc283
  • Pointer size: 131 Bytes
  • Size of remote file: 901 kB
assets/screenings/shoulder-mobility.png ADDED

Git LFS Details

  • SHA256: 555593ae4d722428ec47f16d6080916c7bc57df12d3b22d5e86999d01c3b0fad
  • Pointer size: 131 Bytes
  • Size of remote file: 929 kB
assets/screenings/trunk-stability-push-up.png ADDED

Git LFS Details

  • SHA256: 404fe27f03e717bcb2528dc384368ac0c0b7fd656d69116c5076bb61bb9e12fa
  • Pointer size: 131 Bytes
  • Size of remote file: 881 kB
formscout/agents/prompts/c2_judge.md CHANGED
@@ -14,13 +14,14 @@ FMS scoring scale (apply per side; the test score is the LOWER side):
14
  - 1: the person cannot perform the movement pattern even with the allowed regression.
15
  - 0: PAIN. You CANNOT see pain. Never assign 0 yourself.
16
 
17
- Per-test criteria to weigh (use the features as primary evidence):
18
- - deep_squat (3): femur below horizontal, torso roughly parallel to the tibia, knees tracking over the feet, dowel staying aligned over the feet, heels flat. (2): the same achieved only with heels elevated. (1): criteria unmet even with heels elevated.
19
- - hurdle_step / inline_lunge: minimal sway/loss of balance, knee/hip/ankle alignment maintained, no contact with the hurdle, dowel/posture stable. Compensation -> 2; failure to complete -> 1. Report L/R asymmetry.
20
- - shoulder_mobility: judge by the normalized inter-fist distance bands (per side). Report asymmetry.
21
- - active_slr: judge the raised-leg hip-flexion angle relative to the standard band; the down leg stays flat.
22
- - trunk_stability_pushup: the body must move as one rigid unit (low segment-angle variance through the press); sag/lag or needing the easier hand position -> 2.
23
- - rotary_stability: smooth contralateral (or the allowed unilateral) coordination with a stable trunk; loss of coordination/balance -> lower.
 
24
 
25
  Hard safety rules:
26
  - If there is any clearing-test context, visible pain, grimacing, or an aborted rep, set needs_human=true and score=null. Do not score it.
 
14
  - 1: the person cannot perform the movement pattern even with the allowed regression.
15
  - 0: PAIN. You CANNOT see pain. Never assign 0 yourself.
16
 
17
+ Per-test criteria to weigh (official FMS grading; use the features as primary evidence):
18
+ - deep_squat. (3): upper torso parallel with the tibia or toward vertical, femur below horizontal, knees aligned over the feet, dowel aligned over the feet, heels flat. (2): all of the above achieved only with the heels elevated (on a board). (1): tibia and torso not parallel, femur not below horizontal, knees not aligned over the feet, or lumbar flexion noted.
19
+ - hurdle_step (bilateral, score each side). (3): hips/knees/ankles stay aligned in the sagittal plane, minimal lumbar-spine movement, dowel and hurdle remain parallel. (2): alignment lost between hips/knees/ankles, lumbar movement, or dowel and hurdle not parallel. (1): contact between foot and hurdle (cord not cleared) or any loss of balance. Report L/R asymmetry.
20
+ - inline_lunge (bilateral, score each side). (3): minimal-to-no torso movement, feet remain in the sagittal plane of the board, dowel stays vertical and in contact, rear knee touches behind the heel of the front foot. (2): torso movement, feet/dowel leave the sagittal plane, or rear knee does not touch behind the front heel. (1): loss of balance (steps off the board) or inability to complete the pattern. Report L/R asymmetry.
21
+ - shoulder_mobility (bilateral, score each side). (3): fists within one hand length. (2): fists within one-and-a-half hand lengths. (1): fists greater than one-and-a-half hand lengths apart. Judge by the normalized inter-fist distance bands; report asymmetry.
22
+ - active_slr (bilateral, score each side). (3): the raised-ankle malleolus reaches between mid-thigh and the ASIS of the down leg. (2): the malleolus reaches between mid-thigh and the joint line/mid-patella. (1): the malleolus stays below the joint line. The non-moving (down) limb stays flat in neutral. Judge by the raised-leg hip-flexion angle relative to the standard band.
23
+ - trunk_stability_pushup. The body must lift as one rigid unit with no lag/sag in the spine (low segment-angle variance through the press). (3): one clean rep from the hardest start hand position (men: thumbs at forehead; women: thumbs at chin). (2): one clean rep only from the easier position (men: chin; women: clavicle). (1): unable to perform a rep even from the easier position. If sex is unknown, judge primarily on the rigid-unit press and note the assumption.
24
+ - rotary_stability (score each side). (3): a correct UNILATERAL (same-side) rep — same-side elbow and knee touch over the board without touching down, trunk parallel to the board. (2): a correct DIAGONAL (contralateral, opposite elbow/knee) rep over the board. (1): inability to perform a diagonal repetition. Note: a clean unilateral rep outranks a diagonal one.
25
 
26
  Hard safety rules:
27
  - If there is any clearing-test context, visible pain, grimacing, or an aborted rep, set needs_human=true and score=null. Do not score it.
formscout/agents/scoresheet.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ScoreSheetAgent — renders a one-page FMS scoring sheet from a ReportResult.
3
+
4
+ A FormScout-styled scorecard (NOT a reproduction of the copyrighted FMS chart):
5
+ all seven screens in their canonical order, each with its 0–3 score, L/R columns
6
+ for the bilateral tests, the composite out of 21, and the standard FMS risk
7
+ interpretation (>=14 reduced injury risk; <=13 increased risk). It is the
8
+ single-page companion download to the detailed PdfReportAgent report.
9
+
10
+ Input: ReportResult, session_dir (str), optional meta dict (name/date/etc.)
11
+ Output: path to the written PDF (str), or None on failure (e.g. reportlab missing).
12
+ Failure: returns None, never raises.
13
+ Params: 0 (pure rendering — no model).
14
+ License: n/a.
15
+ Gated: no.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import datetime as _dt
20
+ import logging
21
+ import os
22
+
23
+ from formscout.types import ReportResult
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ DISCLAIMER = "Screening aid — not a diagnosis. Pain or clearing tests require a clinician."
28
+
29
+ # Canonical FMS order; (key, display label, is_bilateral). Post-MVP additions
30
+ # (clearing tests, adjunct measures) slot in here without touching the renderer.
31
+ CANONICAL_TESTS = [
32
+ ("deep_squat", "1. Deep Squat", False),
33
+ ("hurdle_step", "2. Hurdle Step", True),
34
+ ("inline_lunge", "3. In-Line Lunge", True),
35
+ ("shoulder_mobility", "4. Shoulder Mobility", True),
36
+ ("active_slr", "5. Active Straight-Leg Raise", True),
37
+ ("trunk_stability_pushup", "6. Trunk Stability Push-Up", False),
38
+ ("rotary_stability", "7. Rotary Stability", False),
39
+ ]
40
+
41
+
42
+ def _cell(score, needs_human: bool) -> str:
43
+ """Render one score cell: a number, a review flag, or a blank placeholder."""
44
+ if needs_human:
45
+ return "Review"
46
+ if score is None:
47
+ return "—"
48
+ return str(score)
49
+
50
+
51
+ class ScoreSheetAgent:
52
+ """Assembles the single-page FMS scoring sheet via ReportLab."""
53
+
54
+ def run(self, report: ReportResult, session_dir: str,
55
+ meta: dict | None = None) -> str | None:
56
+ try:
57
+ from reportlab.lib import colors
58
+ from reportlab.lib.pagesizes import LETTER
59
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
60
+ from reportlab.lib.units import inch
61
+ from reportlab.platypus import (
62
+ Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
63
+ )
64
+ except Exception as e:
65
+ logger.warning("reportlab unavailable: %s", e)
66
+ return None
67
+
68
+ out_path = os.path.join(session_dir, "formscout_fms_scoresheet.pdf")
69
+ try:
70
+ styles = getSampleStyleSheet()
71
+ ink = colors.HexColor("#243a34")
72
+ banner = ParagraphStyle(
73
+ "banner", parent=styles["Normal"], fontSize=9, textColor=colors.white,
74
+ backColor=colors.HexColor("#cf922a"), alignment=1, borderPadding=6,
75
+ spaceAfter=12,
76
+ )
77
+ small = ParagraphStyle("small", parent=styles["Normal"], fontSize=8,
78
+ textColor=ink)
79
+
80
+ # Index report data by test name.
81
+ by_test = {t["test_name"]: t for t in (report.per_test or [])}
82
+ asym = {a["test"]: a for a in (report.asymmetries or [])}
83
+
84
+ story = []
85
+ story.append(Paragraph(f"<b>&#9888; {DISCLAIMER}</b>", banner))
86
+ story.append(Paragraph("FormScout — FMS Scoring Sheet", styles["Title"]))
87
+
88
+ # Optional athlete/meta header (auto-filled date by default).
89
+ meta = dict(meta or {})
90
+ meta.setdefault("date", _dt.date.today().isoformat())
91
+ meta_bits = [f"<b>{k.title()}:</b> {v}" for k, v in meta.items() if v]
92
+ if meta_bits:
93
+ story.append(Paragraph(" &nbsp;&nbsp; ".join(meta_bits), small))
94
+ story.append(Spacer(1, 0.15 * inch))
95
+
96
+ # Scores table: # / Test, Left, Right, Score, Status.
97
+ header = ["Test", "Left", "Right", "Score", "Status"]
98
+ rows = [header]
99
+ for key, label, bilateral in CANONICAL_TESTS:
100
+ t = by_test.get(key)
101
+ needs_human = bool(t and t.get("needs_human"))
102
+ score = t.get("score") if t else None
103
+ if not t:
104
+ status = "Not screened"
105
+ elif needs_human:
106
+ status = "Clinician review"
107
+ elif score is None:
108
+ status = "Unscored"
109
+ else:
110
+ status = "Scored"
111
+
112
+ if bilateral and key in asym:
113
+ left = _cell(asym[key]["left_score"], needs_human)
114
+ right = _cell(asym[key]["right_score"], needs_human)
115
+ elif bilateral:
116
+ # Only one side (or none) was screened — no asymmetry pair.
117
+ left = right = _cell(score, needs_human) if t else "—"
118
+ else:
119
+ left = right = "" # n/a for non-bilateral tests
120
+ rows.append([label, left, right, _cell(score, needs_human), status])
121
+
122
+ comp = (f"{report.composite}" if report.composite is not None else "—")
123
+ rows.append(["Total Score (Tests 1–7)", "", "", f"{comp} / 21",
124
+ "Complete" if report.composite is not None else "Incomplete"])
125
+
126
+ tbl = Table(rows, colWidths=[3.0 * inch, 0.8 * inch, 0.8 * inch,
127
+ 0.9 * inch, 1.4 * inch])
128
+ tbl.setStyle(TableStyle([
129
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
130
+ ("TEXTCOLOR", (0, 0), (-1, -1), ink),
131
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1f6e6e")),
132
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
133
+ ("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#f7eedd")),
134
+ ("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
135
+ ("ALIGN", (1, 0), (-1, -1), "CENTER"),
136
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#c9d6cf")),
137
+ ("ROWBACKGROUNDS", (0, 1), (-1, -2),
138
+ [colors.white, colors.HexColor("#f3f7f5")]),
139
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
140
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
141
+ ]))
142
+ story.append(tbl)
143
+ story.append(Spacer(1, 0.18 * inch))
144
+
145
+ # Risk interpretation — only meaningful when the screen is complete.
146
+ if report.composite is not None:
147
+ if report.composite >= 14:
148
+ risk = (f"Composite {report.composite}/21 &#8805; 14 — associated in the "
149
+ "FMS literature with <b>reduced</b> injury risk during physical "
150
+ "activity.")
151
+ else:
152
+ risk = (f"Composite {report.composite}/21 &#8804; 13 — associated in the "
153
+ "FMS literature with <b>increased</b> injury risk during physical "
154
+ "activity.")
155
+ else:
156
+ risk = ("Composite is incomplete — all seven screens must be scored before the "
157
+ "14-point risk threshold applies.")
158
+ story.append(Paragraph(risk, small))
159
+
160
+ if report.asymmetries:
161
+ story.append(Spacer(1, 0.1 * inch))
162
+ bits = ", ".join(
163
+ f"{a['test'].replace('_', ' ').title()} (L{a['left_score']}/R{a['right_score']},"
164
+ f" &#916;{a['delta']})" for a in report.asymmetries)
165
+ story.append(Paragraph(f"<b>Left/right asymmetries:</b> {bits}", small))
166
+
167
+ story.append(Spacer(1, 0.25 * inch))
168
+ story.append(Paragraph(f"<b>&#9888; {DISCLAIMER}</b>", banner))
169
+
170
+ doc = SimpleDocTemplate(out_path, pagesize=LETTER,
171
+ topMargin=0.6 * inch, bottomMargin=0.6 * inch)
172
+ doc.build(story)
173
+ return out_path
174
+ except Exception as e:
175
+ logger.warning("scoresheet build failed: %s", e)
176
+ return None
formscout/agents/visualizer.py CHANGED
@@ -268,17 +268,21 @@ class PoseVisualizer:
268
 
269
  scaled_keypoints = [_scale_kps(k) for k in pose2d.keypoints]
270
 
271
- # Write raw mp4v to a temp file, then remux with ffmpeg faststart
272
- import subprocess
273
- import tempfile as _tf
274
- tmp = _tf.NamedTemporaryFile(suffix="_raw.mp4", delete=False)
275
- tmp_path = tmp.name
276
- tmp.close()
277
-
278
- fourcc = cv2.VideoWriter_fourcc(*"mp4v")
279
- writer = cv2.VideoWriter(tmp_path, fourcc, fps, (out_w, out_h))
280
- if not writer.isOpened():
281
- logger.warning("VideoWriter failed to open: %s", tmp_path)
 
 
 
 
282
  return None
283
 
284
  trail_history: dict[int, deque] = {j: deque(maxlen=TRAIL_LENGTH) for j in range(17)}
@@ -309,19 +313,29 @@ class PoseVisualizer:
309
 
310
  writer.release()
311
 
312
- # Remux with faststart so browsers can seek without downloading the whole file
313
- try:
314
- subprocess.run(
315
- ["ffmpeg", "-y", "-i", tmp_path, "-c", "copy",
316
- "-movflags", "+faststart", output_path],
317
- check=True, capture_output=True,
318
- )
319
  import os
320
- os.unlink(tmp_path)
321
- except Exception as ffmpeg_err:
322
- logger.warning("ffmpeg remux failed (%s) — using raw mp4v", ffmpeg_err)
323
  import shutil
324
- shutil.move(tmp_path, output_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
326
  return output_path
327
 
 
268
 
269
  scaled_keypoints = [_scale_kps(k) for k in pose2d.keypoints]
270
 
271
+ # Prefer H.264 (avc1): browser-playable via OpenCV's bundled FFmpeg, no
272
+ # external ffmpeg needed. Fall back to mp4v only if avc1 can't open
273
+ # (older OpenCV builds) — that case is transcoded after the write.
274
+ writer = None
275
+ used_fourcc = None
276
+ for _tag in ("avc1", "mp4v"):
277
+ candidate = cv2.VideoWriter(
278
+ output_path, cv2.VideoWriter_fourcc(*_tag), fps, (out_w, out_h)
279
+ )
280
+ if candidate.isOpened():
281
+ writer, used_fourcc = candidate, _tag
282
+ break
283
+ candidate.release()
284
+ if writer is None:
285
+ logger.warning("VideoWriter failed to open: %s", output_path)
286
  return None
287
 
288
  trail_history: dict[int, deque] = {j: deque(maxlen=TRAIL_LENGTH) for j in range(17)}
 
313
 
314
  writer.release()
315
 
316
+ # mp4v is NOT browser-playable; if we had to fall back to it, transcode
317
+ # to H.264 with ffmpeg when available (best effort).
318
+ if used_fourcc != "avc1":
 
 
 
 
319
  import os
 
 
 
320
  import shutil
321
+ import subprocess
322
+ if shutil.which("ffmpeg"):
323
+ raw = output_path + ".raw.mp4"
324
+ try:
325
+ os.replace(output_path, raw)
326
+ subprocess.run(
327
+ ["ffmpeg", "-y", "-i", raw, "-c:v", "libx264",
328
+ "-pix_fmt", "yuv420p", "-movflags", "+faststart", output_path],
329
+ check=True, capture_output=True,
330
+ )
331
+ os.unlink(raw)
332
+ except Exception as e:
333
+ logger.warning("ffmpeg transcode failed (%s) — leaving mp4v", e)
334
+ if os.path.exists(raw):
335
+ os.replace(raw, output_path)
336
+ else:
337
+ logger.warning("wrote mp4v but no avc1/ffmpeg — overlay may not "
338
+ "play in-browser")
339
 
340
  return output_path
341
 
formscout/config.py CHANGED
@@ -147,14 +147,40 @@ LLAMA_CPP_PORT_EMBED = 8081
147
  # ─── Judge backend selection ────────────────────────────────────────────────
148
  # "llama_cpp" — local llama-server (default for local dev; works perfectly)
149
  # "transformers"— in-process Qwen3-VL via transformers, GPU on HF Spaces (ZeroGPU)
150
- # "auto" — transformers on a Space (SPACE_ID set), llama_cpp locally
151
  JUDGE_BACKEND = os.environ.get("FORMSCOUT_JUDGE_BACKEND", "auto")
152
  JUDGE_HF_MODEL = os.environ.get("FORMSCOUT_JUDGE_HF_MODEL", "Qwen/Qwen3-VL-8B-Instruct")
153
  ON_HF_SPACE = bool(os.environ.get("SPACE_ID"))
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
  def resolve_judge_backend() -> str:
157
- """Resolve the effective judge backend from JUDGE_BACKEND + environment."""
 
 
 
 
 
 
158
  if JUDGE_BACKEND in ("llama_cpp", "transformers"):
159
  return JUDGE_BACKEND
160
- return "transformers" if ON_HF_SPACE else "llama_cpp"
 
147
  # ─── Judge backend selection ────────────────────────────────────────────────
148
  # "llama_cpp" — local llama-server (default for local dev; works perfectly)
149
  # "transformers"— in-process Qwen3-VL via transformers, GPU on HF Spaces (ZeroGPU)
150
+ # "auto" — transformers ONLY on a GPU/ZeroGPU Space, else llama_cpp
151
  JUDGE_BACKEND = os.environ.get("FORMSCOUT_JUDGE_BACKEND", "auto")
152
  JUDGE_HF_MODEL = os.environ.get("FORMSCOUT_JUDGE_HF_MODEL", "Qwen/Qwen3-VL-8B-Instruct")
153
  ON_HF_SPACE = bool(os.environ.get("SPACE_ID"))
154
 
155
+ # Seconds the ZeroGPU window stays allocated per analysis. One window wraps the
156
+ # whole pipeline (pose, optional 3D, Qwen3-VL judge), so size it for the slowest
157
+ # clip; raise via env for long videos. Only effective on a ZeroGPU Space.
158
+ ZEROGPU_DURATION = int(os.environ.get("FORMSCOUT_ZEROGPU_DURATION", "120"))
159
+
160
+
161
+ def has_gpu() -> bool:
162
+ """True on a ZeroGPU Space (env flag) or when CUDA is actually present.
163
+
164
+ ZeroGPU exposes no CUDA outside @spaces.GPU, so it is detected via the
165
+ SPACES_ZERO_GPU env flag; ordinary GPU Spaces report via torch.cuda.
166
+ """
167
+ if os.environ.get("SPACES_ZERO_GPU") or os.environ.get("ZERO_GPU"):
168
+ return True
169
+ try:
170
+ import torch
171
+ return bool(torch.cuda.is_available())
172
+ except Exception:
173
+ return False
174
+
175
 
176
  def resolve_judge_backend() -> str:
177
+ """Resolve the effective judge backend from JUDGE_BACKEND + environment.
178
+
179
+ `auto` only engages the heavy in-process transformers model when a GPU is
180
+ actually available — a CPU-only Space stays on llama_cpp (which is then
181
+ unreachable, so the Judge falls back to the fast rubric instead of trying to
182
+ run a 17 GB model on CPU).
183
+ """
184
  if JUDGE_BACKEND in ("llama_cpp", "transformers"):
185
  return JUDGE_BACKEND
186
+ return "transformers" if (ON_HF_SPACE and has_gpu()) else "llama_cpp"
formscout/serving/transformers_vlm.py CHANGED
@@ -39,19 +39,27 @@ except Exception: # pragma: no cover
39
  return fn
40
 
41
 
42
- @_gpu
43
- def _generate(model_id: str, prompt: str, pil_images: list, max_tokens: int,
44
- temperature: float) -> str: # pragma: no cover - needs GPU + model
45
- """Load (cached) and run Qwen3-VL; returns the raw decoded string."""
46
- import torch
47
- from transformers import AutoModelForImageTextToText, AutoProcessor
48
-
49
  if "model" not in _CACHE:
 
 
50
  _CACHE["processor"] = AutoProcessor.from_pretrained(model_id)
51
  _CACHE["model"] = AutoModelForImageTextToText.from_pretrained(
52
- model_id, torch_dtype="auto", device_map="auto",
53
  )
 
 
 
 
 
 
 
 
 
54
  processor, model = _CACHE["processor"], _CACHE["model"]
 
55
 
56
  content = [{"type": "image", "image": im} for im in pil_images]
57
  content.append({"type": "text", "text": prompt})
@@ -60,7 +68,7 @@ def _generate(model_id: str, prompt: str, pil_images: list, max_tokens: int,
60
  inputs = processor.apply_chat_template(
61
  messages, tokenize=True, add_generation_prompt=True,
62
  return_tensors="pt", return_dict=True,
63
- ).to(model.device)
64
 
65
  with torch.no_grad():
66
  out = model.generate(
@@ -90,7 +98,8 @@ class TransformersVLMClient:
90
  stop: list[str] | None = None) -> dict:
91
  try:
92
  pil_images = self._decode_images(images)
93
- text = _generate(self.model_id, prompt, pil_images, max_tokens, temperature)
 
94
  return LlamaCppClient._parse_json_reply(text)
95
  except Exception as e: # pragma: no cover - needs GPU + model
96
  logger.warning("transformers VLM failed (%s) — falling back to rubric", e)
 
39
  return fn
40
 
41
 
42
+ def _ensure_loaded(model_id: str): # pragma: no cover - downloads ~16 GB
43
+ """Load processor + model to CPU once (cached). Kept OUT of the GPU window so
44
+ the 17 GB download/load does not eat ZeroGPU time."""
 
 
 
 
45
  if "model" not in _CACHE:
46
+ import torch
47
+ from transformers import AutoModelForImageTextToText, AutoProcessor
48
  _CACHE["processor"] = AutoProcessor.from_pretrained(model_id)
49
  _CACHE["model"] = AutoModelForImageTextToText.from_pretrained(
50
+ model_id, torch_dtype=torch.bfloat16,
51
  )
52
+ return _CACHE["processor"], _CACHE["model"]
53
+
54
+
55
+ @_gpu
56
+ def _generate(prompt: str, pil_images: list, max_tokens: int,
57
+ temperature: float) -> str: # pragma: no cover - needs GPU + model
58
+ """Move the cached model to CUDA and run Qwen3-VL (ZeroGPU window)."""
59
+ import torch
60
+
61
  processor, model = _CACHE["processor"], _CACHE["model"]
62
+ model.to("cuda")
63
 
64
  content = [{"type": "image", "image": im} for im in pil_images]
65
  content.append({"type": "text", "text": prompt})
 
68
  inputs = processor.apply_chat_template(
69
  messages, tokenize=True, add_generation_prompt=True,
70
  return_tensors="pt", return_dict=True,
71
+ ).to("cuda")
72
 
73
  with torch.no_grad():
74
  out = model.generate(
 
98
  stop: list[str] | None = None) -> dict:
99
  try:
100
  pil_images = self._decode_images(images)
101
+ _ensure_loaded(self.model_id) # CPU load (no GPU time)
102
+ text = _generate(prompt, pil_images, max_tokens, temperature)
103
  return LlamaCppClient._parse_json_reply(text)
104
  except Exception as e: # pragma: no cover - needs GPU + model
105
  logger.warning("transformers VLM failed (%s) — falling back to rubric", e)
formscout/session.py CHANGED
@@ -179,7 +179,14 @@ def finish_session(session) -> tuple[ReportResult | None, str | None]:
179
  except Exception as e:
180
  logger.warning("pdf generation failed: %s", e)
181
 
182
- report = replace(report, pdf_path=pdf_path)
 
 
 
 
 
 
 
183
  return report, pdf_path
184
 
185
 
 
179
  except Exception as e:
180
  logger.warning("pdf generation failed: %s", e)
181
 
182
+ scoresheet_path = None
183
+ try:
184
+ from formscout.agents.scoresheet import ScoreSheetAgent
185
+ scoresheet_path = ScoreSheetAgent().run(report, session.session_dir)
186
+ except Exception as e:
187
+ logger.warning("scoresheet generation failed: %s", e)
188
+
189
+ report = replace(report, pdf_path=pdf_path, scoresheet_path=scoresheet_path)
190
  return report, pdf_path
191
 
192
 
formscout/types.py CHANGED
@@ -6,7 +6,6 @@ Validate at every boundary — never accept raw dicts across agent boundaries.
6
  from __future__ import annotations
7
 
8
  from dataclasses import dataclass, field
9
- from typing import Any
10
 
11
 
12
  @dataclass(frozen=True)
@@ -139,6 +138,7 @@ class ReportResult:
139
  pdf_path: str | None
140
  low_confidence_flags: list
141
  disagreement_flags: list
 
142
  notes: str = ""
143
 
144
 
 
6
  from __future__ import annotations
7
 
8
  from dataclasses import dataclass, field
 
9
 
10
 
11
  @dataclass(frozen=True)
 
138
  pdf_path: str | None
139
  low_confidence_flags: list
140
  disagreement_flags: list
141
+ scoresheet_path: str | None = None # one-page FMS scoring sheet (separate download)
142
  notes: str = ""
143
 
144
 
formscout/ui/theme.py CHANGED
@@ -16,6 +16,8 @@ SAGE_DEEP = "#9cbcad"
16
  TEAL = "#2b8a8a"
17
  TEAL_HOVER = "#1f6e6e"
18
  TEAL_DEEP = "#175757"
 
 
19
  GOLD = "#e0a43b"
20
  GOLD_DEEP = "#cf922a"
21
  INK = "#243a34"
@@ -59,15 +61,20 @@ def formscout_theme() -> gr.Theme:
59
  button_secondary_background_fill="rgba(156, 188, 173, 0.55)",
60
  button_secondary_text_color=INK,
61
  # Inputs
62
- input_background_fill="rgba(255, 255, 255, 0.85)",
63
- input_background_fill_dark="rgba(255, 255, 255, 0.85)",
 
64
  input_border_color="rgba(43, 138, 138, 0.30)",
65
  input_border_color_focus="rgba(43, 138, 138, 0.75)",
 
 
 
 
 
66
  # Text
67
  body_text_color=INK,
68
  body_text_color_dark=INK,
69
  block_title_text_color=TEAL_DEEP,
70
- block_label_text_color=INK_MUTED,
71
  # Spacing
72
  block_padding="16px",
73
  layout_gap="16px",
@@ -111,6 +118,32 @@ FORMSCOUT_CSS = f"""
111
  color: {GOLD_DEEP};
112
  }}
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  /* Score display */
115
  .score-card {{
116
  background: rgba(43, 138, 138, 0.08);
 
16
  TEAL = "#2b8a8a"
17
  TEAL_HOVER = "#1f6e6e"
18
  TEAL_DEEP = "#175757"
19
+ TEAL_LIGHT = "#d6ecec"
20
+ TEAL_LIGHT_HOVER = "#bfe3e3"
21
  GOLD = "#e0a43b"
22
  GOLD_DEEP = "#cf922a"
23
  INK = "#243a34"
 
61
  button_secondary_background_fill="rgba(156, 188, 173, 0.55)",
62
  button_secondary_text_color=INK,
63
  # Inputs
64
+ input_background_fill="rgba(255, 255, 255, 0.92)",
65
+ input_background_fill_dark="rgba(255, 255, 255, 0.92)",
66
+ input_background_fill_focus="rgba(255, 255, 255, 1.0)",
67
  input_border_color="rgba(43, 138, 138, 0.30)",
68
  input_border_color_focus="rgba(43, 138, 138, 0.75)",
69
+ # Labels — pin light in both modes so no dark dropdown header appears
70
+ block_label_background_fill="rgba(188, 211, 200, 0.55)",
71
+ block_label_background_fill_dark="rgba(188, 211, 200, 0.55)",
72
+ block_label_text_color=INK,
73
+ block_label_text_color_dark=INK,
74
  # Text
75
  body_text_color=INK,
76
  body_text_color_dark=INK,
77
  block_title_text_color=TEAL_DEEP,
 
78
  # Spacing
79
  block_padding="16px",
80
  layout_gap="16px",
 
118
  color: {GOLD_DEEP};
119
  }}
120
 
121
+ /* Dropdown option lists — pin light-teal cells in both modes (Gradio's default
122
+ --background-fill-primary resolves dark here, same issue as the labels). */
123
+ .gradio-container ul.options {{
124
+ background: {TEAL_LIGHT} !important;
125
+ border: 1px solid rgba(43, 138, 138, 0.30) !important;
126
+ }}
127
+ .gradio-container ul.options li.item {{
128
+ background: {TEAL_LIGHT} !important;
129
+ color: {INK} !important;
130
+ }}
131
+ .gradio-container ul.options li.item:hover,
132
+ .gradio-container ul.options li.item.active {{
133
+ background: {TEAL_LIGHT_HOVER} !important;
134
+ color: {INK} !important;
135
+ }}
136
+
137
+ /* Movement-guide illustration — rounded card matching the test selection */
138
+ #movement-guide img {{
139
+ border-radius: 12px;
140
+ object-fit: contain;
141
+ }}
142
+ #movement-guide {{
143
+ border: 1px solid rgba(43, 138, 138, 0.22);
144
+ border-radius: 14px;
145
+ }}
146
+
147
  /* Score display */
148
  .score-card {{
149
  background: rgba(43, 138, 138, 0.08);
scripts/hf_upload.sh CHANGED
@@ -21,8 +21,11 @@ set -euo pipefail
21
 
22
  cd "$(dirname "$0")/.."
23
 
24
- MODEL_REPO="silas-therapy/small-functional-movement-screening"
25
- SPACE_REPO="spaces/silas-therapy/small-functional-movement-screening"
 
 
 
26
  MSG="${1:-$(git log -1 --pretty=%s)}"
27
  LARGE_THRESHOLD="${FORMSCOUT_HF_LARGE_THRESHOLD:-500}"
28
 
@@ -76,22 +79,62 @@ if (( N_FILES == 0 )); then
76
  exit 1
77
  fi
78
 
 
 
 
79
  upload_repo() {
80
  local repo="$1"
 
81
  if (( N_FILES > LARGE_THRESHOLD )); then
82
  echo "── $repo: $N_FILES files > $LARGE_THRESHOLD, using upload-large-folder"
83
  echo " (resumable; commits directly to main — no PR, no custom message)"
84
  hf upload-large-folder "$repo" . "${EXCLUDES[@]}"
 
 
 
85
  else
86
- echo "── uploading to: $repo"
87
- hf upload "$repo" . . \
88
- "${EXCLUDES[@]}" \
89
- --create-pr \
90
- --commit-message="$MSG"
91
  fi
92
  }
93
 
94
- upload_repo "$MODEL_REPO"
95
- upload_repo "$SPACE_REPO"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
- echo "✓ done"
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  cd "$(dirname "$0")/.."
23
 
24
+ REPO_NAME="small-functional-movement-screening"
25
+ BLADE_OWNER="${FORMSCOUT_HF_BLADE_OWNER:-BladeSzaSza}"
26
+ MODEL_REPO="silas-therapy/$REPO_NAME"
27
+ SPACE_REPO="spaces/silas-therapy/$REPO_NAME"
28
+ SPACE_BLADESZASZA_REPO="spaces/$BLADE_OWNER/$REPO_NAME"
29
  MSG="${1:-$(git log -1 --pretty=%s)}"
30
  LARGE_THRESHOLD="${FORMSCOUT_HF_LARGE_THRESHOLD:-500}"
31
 
 
79
  exit 1
80
  fi
81
 
82
+ # upload_repo <repo> [pr|direct]
83
+ # pr — open a PR (shared org repos; review before merge)
84
+ # direct — commit straight to main (repos you own; deploys immediately)
85
  upload_repo() {
86
  local repo="$1"
87
+ local mode="${2:-pr}"
88
  if (( N_FILES > LARGE_THRESHOLD )); then
89
  echo "── $repo: $N_FILES files > $LARGE_THRESHOLD, using upload-large-folder"
90
  echo " (resumable; commits directly to main — no PR, no custom message)"
91
  hf upload-large-folder "$repo" . "${EXCLUDES[@]}"
92
+ elif [[ "$mode" == "direct" ]]; then
93
+ echo "── uploading (direct → main) to: $repo"
94
+ hf upload "$repo" . . "${EXCLUDES[@]}" --commit-message="$MSG"
95
  else
96
+ echo "── uploading (PR) to: $repo"
97
+ hf upload "$repo" . . "${EXCLUDES[@]}" --create-pr --commit-message="$MSG"
 
 
 
98
  fi
99
  }
100
 
101
+ # Ensure the personal ZeroGPU Space exists. Tries zero-a10g (needs Pro/ZeroGPU);
102
+ # falls back to cpu-basic so the upload still has a target (set ZeroGPU in
103
+ # Settings afterward). Idempotent via --exist-ok.
104
+ ensure_blade_space() {
105
+ local id="$BLADE_OWNER/$REPO_NAME"
106
+ if hf repos create "$id" --type space --space-sdk gradio --flavor zero-a10g --exist-ok 2>/dev/null; then
107
+ echo "── Space ready (ZeroGPU / zero-a10g): $id"; return 0
108
+ fi
109
+ if hf repos create "$id" --type space --space-sdk gradio --exist-ok 2>/dev/null; then
110
+ echo "── Space created cpu-basic (set ZeroGPU in Settings → Hardware): $id"; return 0
111
+ fi
112
+ return 1
113
+ }
114
+
115
+ blade_help() {
116
+ cat >&2 <<EOF
117
+ ── ⚠ Could not create/deploy to $SPACE_BLADESZASZA_REPO
118
+ Your active HF token can push to silas-therapy but not create repos under
119
+ "$BLADE_OWNER". To deploy your own ZeroGPU Space:
120
+ 1) In the HF UI create a Space: $BLADE_OWNER/$REPO_NAME
121
+ SDK = Gradio, Hardware = ZeroGPU (Nvidia A10G).
122
+ 2) Re-auth with a token that can write there:
123
+ hf auth login (token with 'Write' role, or fine-grained with
124
+ write access to $BLADE_OWNER)
125
+ 3) Re-run ./scripts/hf_upload.sh
126
+ EOF
127
+ }
128
 
129
+ # Shared org repos → PRs; personal ZeroGPU Space → created + direct deploy.
130
+ upload_repo "$MODEL_REPO" pr
131
+ upload_repo "$SPACE_REPO" pr
132
+
133
+ set +e
134
+ if ensure_blade_space; then
135
+ upload_repo "$SPACE_BLADESZASZA_REPO" direct || blade_help
136
+ else
137
+ blade_help
138
+ fi
139
+ set -e
140
+ echo "✓ done (silas-therapy PRs created; see any notes above for the personal Space)"
tests/test_judge_backend.py CHANGED
@@ -21,9 +21,19 @@ def test_resolve_backend_default_local(monkeypatch):
21
  assert cfg.resolve_judge_backend() == "llama_cpp"
22
 
23
 
24
- def test_resolve_backend_auto_on_space(monkeypatch):
25
- cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto", SPACE_ID="me/space")
 
26
  assert cfg.resolve_judge_backend() == "transformers"
 
 
 
 
 
 
 
 
 
27
 
28
 
29
  def test_resolve_backend_explicit(monkeypatch):
 
21
  assert cfg.resolve_judge_backend() == "llama_cpp"
22
 
23
 
24
+ def test_resolve_backend_auto_on_zero_gpu_space(monkeypatch):
25
+ cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto",
26
+ SPACE_ID="me/space", SPACES_ZERO_GPU="true")
27
  assert cfg.resolve_judge_backend() == "transformers"
28
+ importlib.reload(config)
29
+
30
+
31
+ def test_resolve_backend_auto_on_cpu_space_stays_llama(monkeypatch):
32
+ # A CPU-only Space must NOT load the 17 GB transformers model.
33
+ cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto",
34
+ SPACE_ID="me/space", SPACES_ZERO_GPU=None, ZERO_GPU=None)
35
+ assert cfg.resolve_judge_backend() == "llama_cpp"
36
+ importlib.reload(config)
37
 
38
 
39
  def test_resolve_backend_explicit(monkeypatch):
tests/test_scoresheet.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for ScoreSheetAgent — the one-page FMS scoring sheet. No GPU/model downloads."""
2
+ import os
3
+
4
+ from formscout.types import ReportResult
5
+
6
+
7
+ def _report(composite=14, per_test=None, asymmetries=None):
8
+ return ReportResult(
9
+ per_test=per_test if per_test is not None else [],
10
+ composite=composite,
11
+ asymmetries=asymmetries or [],
12
+ overlay_video_path=None, pdf_path=None,
13
+ low_confidence_flags=[], disagreement_flags=[],
14
+ )
15
+
16
+
17
+ def _pt(test_name, score, needs_human=False):
18
+ return {"test_name": test_name, "score": score, "judge": None,
19
+ "features": None, "needs_human": needs_human}
20
+
21
+
22
+ def test_scoresheet_is_created(tmp_path):
23
+ from formscout.agents.scoresheet import ScoreSheetAgent
24
+ report = _report(
25
+ composite=15,
26
+ per_test=[
27
+ _pt("deep_squat", 2),
28
+ _pt("hurdle_step", 2),
29
+ _pt("trunk_stability_pushup", 3),
30
+ ],
31
+ asymmetries=[{"test": "hurdle_step", "left_score": 2, "right_score": 3, "delta": 1}],
32
+ )
33
+ path = ScoreSheetAgent().run(report, str(tmp_path))
34
+ assert path is not None
35
+ assert os.path.exists(path)
36
+ assert os.path.getsize(path) > 1000
37
+ with open(path, "rb") as f:
38
+ assert f.read(5) == b"%PDF-"
39
+
40
+
41
+ def test_scoresheet_incomplete_composite(tmp_path):
42
+ """A session with unscored/needs-human tests must still render."""
43
+ from formscout.agents.scoresheet import ScoreSheetAgent
44
+ report = _report(
45
+ composite=None,
46
+ per_test=[_pt("deep_squat", None, needs_human=True)],
47
+ )
48
+ path = ScoreSheetAgent().run(report, str(tmp_path))
49
+ assert path is not None and os.path.exists(path)
50
+
51
+
52
+ def test_scoresheet_accepts_meta(tmp_path):
53
+ """Optional athlete/meta fields are accepted without breaking rendering."""
54
+ from formscout.agents.scoresheet import ScoreSheetAgent
55
+ path = ScoreSheetAgent().run(
56
+ _report(14, per_test=[_pt("deep_squat", 2)]),
57
+ str(tmp_path),
58
+ meta={"name": "Test Athlete", "date": "2026-06-14"},
59
+ )
60
+ assert path is not None and os.path.exists(path)