fix: gate transformers judge on real GPU; ZeroGPU-correct loading; deploy to BladeSzaSza ZeroGPU Space

#8
by BladeSzaSza - opened
formscout/config.py CHANGED
@@ -147,14 +147,35 @@ 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
 
156
+ def has_gpu() -> bool:
157
+ """True on a ZeroGPU Space (env flag) or when CUDA is actually present.
158
+
159
+ ZeroGPU exposes no CUDA outside @spaces.GPU, so it is detected via the
160
+ SPACES_ZERO_GPU env flag; ordinary GPU Spaces report via torch.cuda.
161
+ """
162
+ if os.environ.get("SPACES_ZERO_GPU") or os.environ.get("ZERO_GPU"):
163
+ return True
164
+ try:
165
+ import torch
166
+ return bool(torch.cuda.is_available())
167
+ except Exception:
168
+ return False
169
+
170
+
171
  def resolve_judge_backend() -> str:
172
+ """Resolve the effective judge backend from JUDGE_BACKEND + environment.
173
+
174
+ `auto` only engages the heavy in-process transformers model when a GPU is
175
+ actually available β€” a CPU-only Space stays on llama_cpp (which is then
176
+ unreachable, so the Judge falls back to the fast rubric instead of trying to
177
+ run a 17 GB model on CPU).
178
+ """
179
  if JUDGE_BACKEND in ("llama_cpp", "transformers"):
180
  return JUDGE_BACKEND
181
+ 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)
scripts/hf_upload.sh CHANGED
@@ -23,6 +23,7 @@ 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 +77,41 @@ 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"
 
23
 
24
  MODEL_REPO="silas-therapy/small-functional-movement-screening"
25
  SPACE_REPO="spaces/silas-therapy/small-functional-movement-screening"
26
+ SPACE_BLADESZASZA_REPO="spaces/bladeszasza/small-functional-movement-screening"
27
  MSG="${1:-$(git log -1 --pretty=%s)}"
28
  LARGE_THRESHOLD="${FORMSCOUT_HF_LARGE_THRESHOLD:-500}"
29
 
 
77
  exit 1
78
  fi
79
 
80
+ # upload_repo <repo> [pr|direct]
81
+ # pr β€” open a PR (shared org repos; review before merge)
82
+ # direct β€” commit straight to main (repos you own; deploys immediately)
83
  upload_repo() {
84
  local repo="$1"
85
+ local mode="${2:-pr}"
86
  if (( N_FILES > LARGE_THRESHOLD )); then
87
  echo "── $repo: $N_FILES files > $LARGE_THRESHOLD, using upload-large-folder"
88
  echo " (resumable; commits directly to main β€” no PR, no custom message)"
89
  hf upload-large-folder "$repo" . "${EXCLUDES[@]}"
90
+ elif [[ "$mode" == "direct" ]]; then
91
+ echo "── uploading (direct β†’ main) to: $repo"
92
+ hf upload "$repo" . . "${EXCLUDES[@]}" --commit-message="$MSG"
93
  else
94
+ echo "── uploading (PR) to: $repo"
95
+ hf upload "$repo" . . "${EXCLUDES[@]}" --create-pr --commit-message="$MSG"
 
 
 
96
  fi
97
  }
98
 
99
+ # Ensure the personal ZeroGPU Space exists. Tries zero-a10g (needs Pro/ZeroGPU);
100
+ # falls back to cpu-basic so the upload still has a target (set ZeroGPU in
101
+ # Settings afterward). Idempotent via --exist-ok.
102
+ ensure_blade_space() {
103
+ local id="bladeszasza/$REPO_NAME"
104
+ if hf repos create "$id" --type space --space-sdk gradio --flavor zero-a10g --exist-ok 2>/dev/null; then
105
+ echo "── Space ready (ZeroGPU / zero-a10g): $id"
106
+ else
107
+ echo "── zero-a10g unavailable (Pro/ZeroGPU quota?) β€” creating cpu-basic; switch to ZeroGPU in Settings"
108
+ hf repos create "$id" --type space --space-sdk gradio --exist-ok || true
109
+ fi
110
+ }
111
 
112
+ # Shared org repos β†’ PRs; personal ZeroGPU Space β†’ created + direct deploy.
113
+ upload_repo "$MODEL_REPO" pr
114
+ upload_repo "$SPACE_REPO" pr
115
+ ensure_blade_space
116
+ upload_repo "$SPACE_BLADESZASZA_REPO" direct
117
  echo "βœ“ done"
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):