fix(ui): browser-playable overlay video (H.264) + light all dropdown option lists
#16
by BladeSzaSza - opened
- .gitattributes +1 -0
- CLAUDE.md +1 -1
- app.py +67 -7
- assets/screenings/active-straight-leg-raise.png +3 -0
- assets/screenings/deep-squat.png +3 -0
- assets/screenings/hurdle-step.png +3 -0
- assets/screenings/inline-lunge.png +3 -0
- assets/screenings/rotary-stability.png +3 -0
- assets/screenings/shoulder-mobility.png +3 -0
- assets/screenings/trunk-stability-push-up.png +3 -0
- formscout/agents/prompts/c2_judge.md +8 -7
- formscout/agents/scoresheet.py +176 -0
- formscout/agents/visualizer.py +36 -22
- formscout/config.py +29 -3
- formscout/serving/transformers_vlm.py +19 -10
- formscout/session.py +8 -1
- formscout/types.py +1 -1
- formscout/ui/theme.py +36 -3
- scripts/hf_upload.sh +53 -10
- tests/test_judge_backend.py +12 -2
- tests/test_scoresheet.py +60 -0
.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:
|
| 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 |
-
|
|
|
|
| 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
|
assets/screenings/deep-squat.png
ADDED
|
Git LFS Details
|
assets/screenings/hurdle-step.png
ADDED
|
Git LFS Details
|
assets/screenings/inline-lunge.png
ADDED
|
Git LFS Details
|
assets/screenings/rotary-stability.png
ADDED
|
Git LFS Details
|
assets/screenings/shoulder-mobility.png
ADDED
|
Git LFS Details
|
assets/screenings/trunk-stability-push-up.png
ADDED
|
Git LFS Details
|
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):
|
| 19 |
-
- hurdle_step
|
| 20 |
-
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
|
|
|
| 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>⚠ {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(" ".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 ≥ 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 ≤ 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" Δ{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>⚠ {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 |
-
#
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 313 |
-
|
| 314 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 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=
|
| 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(
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 63 |
-
input_background_fill_dark="rgba(255, 255, 255, 0.
|
|
|
|
| 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 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 25 |
-
cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto",
|
|
|
|
| 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)
|