BladeSzaSza's picture
fix: define REPO_NAME in hf_upload.sh (ensure_blade_space referenced it)
4c4cb91 verified
|
Raw
History Blame Contribute Delete
13.8 kB

A newer version of the Gradio SDK is available: 6.20.0

Upgrade

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project overview

FormScout is a Gradio app (Hugging Face Space) that scores Functional Movement Screen (FMS) videos 0–3 per test with a written rationale and an annotated overlay. It is a screening aid β€” not a diagnosis, not an injury predictor. Built for the Build Small Hackathon (Backyard AI track). Full product spec is in docs/FormScout-FMS-Spec.md; the engineering contract is in docs/plans/FormScout-Build-Prompt.md.

Current status: Phase 2 complete. All 7 FMS test rubric scorers, JudgeAgent, MovementClassifierAgent, ReportAgent, PoseVisualizer (overlay video), and a user-selectable pose-model registry are implemented and tested (86/87 passing). Phase 3 is next (ST-GCN fine-tune + RAG retrieval).

Common commands

# Run the Gradio app locally
python3 app.py

# Headless pipeline test (no Gradio)
python3 -m formscout.run sample.mp4

# Run all tests
pytest tests/

# Run a single test file or test
pytest tests/test_phase2.py
pytest tests/test_biomechanics.py::TestBiomechanicsAgent::test_deep_squat_score

# Lint / format
ruff check . && ruff format .

# Start the local VLM judge server (llama.cpp, port 8080)
./scripts/serve_judge.sh

# Push source tree to the HF model repo + Space (PRs; message from last commit)
./scripts/hf_upload.sh

# Run Svelte component tests (when frontend work is added)
npx vitest run

Architecture

The pipeline is a sequence of typed specialist agents. Each agent accepts and returns a frozen dataclass from formscout/types.py. The Director in formscout/pipeline.py orchestrates them as a deterministic state machine (not an LLM).

Agent pipeline

IngestAgent β†’ Pose2DAgent β†’ [Body3DAgent β€” optional]
β†’ MovementClassifierAgent β†’ BiomechanicsAgent
β†’ rubric/score_test() β†’ JudgeAgent β†’ ReportAgent

The Director (pipeline.py) owns the flow. app.py creates one Director() instance and calls director.run(video_path, test_name, side, model_key) per submission. The Gradio UI passes test_name directly (from dropdown), bypassing the classifier; model_key selects the pose backend from config.POSE_MODELS.

PoseVisualizer (formscout/agents/visualizer.py) renders the annotated overlay video (skeleton, trails, velocity arrows) from IngestResult + Pose2DResult. It is called from app.py after the pipeline run β€” it is a UI-layer component, not a Director stage. It returns None on failure, never raises.

The tiering rule (most important invariant)

The 2D path is the default and must stand alone as a complete, functional pipeline. Body3DAgent is only activated when config.ENABLE_3D == True AND the checkpoint loads successfully. If 3D is off or fails, Body3DResult(used=False, ...) is returned β€” this is a normal success path, not an error. BiomechFeatures.view is "2d" or "3d" so the JudgeAgent can caveat its rationale appropriately. Never put Body3DAgent on the critical path.

Feature flags in config.py and their current state

Flag Default Meaning
ENABLE_JUDGE True Judge/Classifier call Qwen3-VL via llama-server; graceful rubric fallback when the server is down
ENABLE_3D False When False, Body3DAgent returns used=False immediately
ENABLE_STGCN False Phase 3 β€” ST-GCN learned scoring head
ENABLE_RAG False Phase 3 β€” RetrievalAgent exemplar lookup

All model IDs, thresholds, k-values, and feature flags live in config.py β€” never scattered literals.

Judge backend selection (local vs Space)

config.resolve_judge_backend() picks the VLM backend via FORMSCOUT_JUDGE_BACKEND (llama_cpp | transformers | auto). auto (default) uses llama-server locally and the in-process transformers backend on a Space (detected via SPACE_ID). JudgeAgent gets its client from serving.get_vlm_client().

  • llama_cpp β€” LlamaCppClient β†’ llama-server at 127.0.0.1:8080 (start with scripts/serve_judge.sh). The local path; works perfectly.
  • transformers β€” TransformersVLMClient loads Qwen3-VL-8B via transformers, GPU-wrapped with spaces.GPU (ZeroGPU). Lazy model load, cached per process. On any load/inference failure it returns {"fallback": True} and the Judge falls back to the rubric. Needs validation on real ZeroGPU hardware β€” not exercised in CPU tests.

Fallback chain (important for local dev and Spaces)

  1. ENABLE_JUDGE=False β†’ JudgeAgent returns rubric score wrapped as JudgeResult (no VLM needed)
  2. ENABLE_JUDGE=True + selected backend unavailable / transformers load fails β†’ same rubric fallback, logs a warning
  3. ENABLE_JUDGE=True + backend available β†’ calls Qwen3-VL-8B-Instruct (llama-server locally, transformers/ZeroGPU on a Space)

Start the VLM server with scripts/serve_judge.sh (downloads live in checkpoints/qwen3-vl/, gitignored). To use a fine-tuned GGUF, set FORMSCOUT_JUDGE_GGUF (and FORMSCOUT_JUDGE_MMPROJ if it ships its own projector) β€” no code change needed. Multimodal requests go through the OpenAI-compatible /v1/chat/completions endpoint (the legacy /completion + image_data path does not work with modern llama-server).

This means the app is fully functional without any GPU or llama.cpp β€” rubric scoring is pure Python.

Rubric scorers

Each FMS test has a pure-function scorer in formscout/rubric/:

score_deep_squat / score_hurdle_step / score_inline_lunge /
score_shoulder_mobility / score_active_slr /
score_trunk_stability_pushup / score_rotary_stability

All accept BiomechFeatures and return ScoreResult. Dispatch via rubric.score_test(features). Rubric functions must remain pure β€” no model calls, no I/O.

Bilateral tests

hurdle_step, inline_lunge, shoulder_mobility, active_slr are bilateral. ReportAgent groups them by test name, takes the lower score, and always emits the asymmetry delta even when scores are equal. composite is None when any test is unscored.

Types contract

Every agent I/O is a frozen dataclass from formscout/types.py. Key types:

  • IngestResult β€” decoded frames (np.ndarray list), fps, duration, dimensions
  • Pose2DResult β€” per-frame keypoints as dict[int, {x, y, conf}] (COCO 17 joints)
  • Body3DResult β€” optional 3D joints, always has used: bool
  • MovementResult β€” test_name (validated enum), side ("left"|"right"|"na")
  • BiomechFeatures β€” angles: dict, alignments: dict, view: "2d"|"3d", symmetry_delta
  • ScoreResult β€” score: int (0–3), rationale, needs_human
  • JudgeResult β€” same as ScoreResult + compensation_tags, corrective_hint; score=None when needs_human=True
  • PipelineState β€” mutable accumulator threaded through the Director

MovementResult and JudgeResult validate their fields in __post_init__ β€” passing invalid values raises immediately.

Pose model selection and checkpoints

config.POSE_MODELS is a registry of pose backends: MediaPipe (CPU-friendly), five YOLO26 sizes (n/s/m/l/x), and Sapiens2 variants (Phase 3, need the custom sapiens repo installed). config.DEFAULT_POSE_MODEL is YOLO26n. The Gradio UI exposes a dropdown built from config.available_pose_models() (filters to checkpoints actually present) and passes the chosen model_key through Director.run to Pose2DAgent. config.YOLO_POSE_MODEL is a backward-compat alias only.

Checkpoints are not committed (checkpoints/ is gitignored). formscout/startup.py:ensure_checkpoints() downloads missing YOLO26/MediaPipe files from the silas-therapy/formscout-checkpoints HF repo once at app startup. Models load once per process and are cached β€” never inside the inference hot path.

llama.cpp serving

formscout/serving/llama_cpp.py provides LlamaCppClient (VLM, port 8080) and EmbeddingClient (embeddings, port 8081). Both check /health before use and return safe error dicts when unavailable. Only active when the corresponding ENABLE_* flag is True.

Deploying to Hugging Face

The repo deploys to both silas-therapy/small-functional-movement-screening (model repo) and the Space of the same name (README frontmatter is the Space config). Use ./scripts/hf_upload.sh β€” never raw hf upload .: the hf CLI does not read .hfignore, so a raw upload hashes the entire .venv (~44k files) and pushes torch binaries. The script parses .hfignore into --exclude globs, preflights the file count, creates PRs on both repos, and auto-switches to hf upload-large-folder (resumable, but no PR / no commit message) above 500 files.

Key constraints and invariants

  • No cloud model APIs. All inference runs on-Space (ZeroGPU). No OpenAI/Anthropic/Gemini calls.
  • Pain is never auto-scored. Any clearing test or visible distress sets needs_human=True β€” enforced in rubric functions and JudgeAgent. JudgeResult.score must be None when needs_human=True.
  • Quality gates (Director, never silently skip):
    • Any agent confidence < config.MIN_CONFIDENCE (0.6) β†’ warn or stop
    • |rubric.score - judge.score| >= 1 β†’ flag disagreement
    • MovementResult.test_name == "unknown" β†’ stop pipeline, surface manual override
    • JudgeAgent.needs_human == True β†’ no numeric score emitted
  • Composite is null when any test is unscored. Never show a partial 0–21 as complete.
  • Pipeline runs headless. No Gradio imports in any agent file.
  • Safety banner ("Screening aid β€” not a diagnosis…") must always be visible in the UI β€” appears at top and bottom of app.py.

Engineering standards

  • Every agent: one public entrypoint, typed dataclass I/O from types.py, confidence: float and notes: str on every result.
  • Models load once at module/instance init β€” never inside the inference hot path.
  • Every agent module docstring states: purpose, inputs, outputs, failure behavior, model param count, license, and gated status.
  • tracing.py records structured per-agent I/O for any run; one full run gets exported to the Hub.
  • Every agent ships with a pytest in tests/ that runs without model downloads and asserts the typed contract.

Model stack (~17.6B total β€” stay under 32B)

Component Model Params Status
2D pose (primary) YOLO26-Pose n/s/m/l/x (default: n) 0.0007–0.058B Ready (auto-downloaded at startup)
2D pose (CPU alt) MediaPipe Pose Landmarker (full) ~0.004B Ready (auto-downloaded at startup)
2D pose (HQ alt) facebook/sapiens2-pose-0.4b/0.8b/1b/5b 0.4–5B Phase 3 β€” needs custom sapiens repo
Segmentation SAM 3.1 base ~0.85B Access accepted
3D biomechanics facebook/sam-3d-body-dinov3 ~0.84B Access ACCEPTED Jun 4 2026
Learned scoring ST-GCN (pyskl) ~0.03B Phase 3
Judge + Classifier Qwen3-VL-8B-Instruct (llama.cpp) 8B Online β€” scripts/serve_judge.sh, ENABLE_JUDGE=True
Retrieval Qwen3-VL-Embedding-8B (llama.cpp) 8B Phase 3

Track the running sum in MODEL_BUDGET.md. The two Qwen3-VL-8B models share a backbone.

Gradio + Svelte UI guidance

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.

  • 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).
  • Verify Gradio APIs against current docs before use β€” pin exact versions in requirements.txt.

Build phases

  1. Phase 0 β€” Recon: βœ… Complete. See RECON.md.
  2. Phase 1 β€” Spine: βœ… Complete. Deep Squat end-to-end.
  3. Phase 2 β€” All 7 tests: βœ… Complete. Classifier, Judge, Report agents; all rubric scorers; Gradio UI.
  4. Phase 3 β€” Learned scoring + retrieval: ST-GCN fine-tune on physio clips, publish to Hub. RetrievalAgent with embedding index.
  5. Phase 4 β€” Polish + ship: Custom Svelte UI components, agent trace to Hub, blog post. (Overlay video done via PoseVisualizer; full 7-test session + PDF export done via formscout/session.py + PdfReportAgent.)

Known issues

  • tests/test_biomechanics.py::TestBiomechanicsAgent::test_unimplemented_test_returns_low_confidence fails: expects "not yet implemented" in result.notes but biomechanics returns empty string. Minor β€” low priority.

Badge checklist (definition of done)

  • Space runs green; upload β†’ scorecard works on real clips
  • Param sum verified ≀ 32B in MODEL_BUDGET.md
  • πŸ”Œ Off the Grid β€” no cloud model APIs anywhere in the pipeline
  • 🎯 Well-Tuned β€” fine-tuned ST-GCN head published to Hub with honest model card
  • 🎨 Off-Brand β€” custom, non-default Gradio UI (scout/trail theme)
  • πŸ¦™ Llama Champion β€” VLM + embedder served via llama.cpp (GGUF)
  • πŸ“‘ Sharing is Caring β€” one full agent trace (all I/O) published to Hub
  • πŸ““ Field Notes β€” blog post written, honesty section (FMS limitations) front-and-center
  • Demo video + social post recorded
  • Safety banner present; pain/clearing never auto-scored; low-confidence flagged