LucasLooTan Claude Opus 4.7 (1M context) commited on
Commit
62443b6
·
1 Parent(s): 37b4f5b

fix: webcam capture — global cache, .change() event, drop share=True

Browse files

Previous attempts at frame capture all failed because gradio 4.44.1
silently drops handlers whose signatures it can't introspect. Adding
a `gr.Request` parameter to a stream/change handler in particular
appears to trigger this. Also `outputs=None` may not wire the event.

This commit:
1. Replaces session-keyed `_frame_cache: dict[str, ndarray]` with a
single global `_latest_frame: ndarray | None`. Single-user demo;
per-session keying is over-engineering and bypasses the gr.Request
injection bug entirely.
2. Switches `webcam.stream()` → `webcam.change()` (canonical per-frame
handler in gradio 4.x for streaming components) and uses
`outputs=[]` instead of `None`.
3. Adds `_stash_count` debug counter + periodic log line so HF run
logs confirm the handler is actually firing.
4. Drops `share=True` from app.py — HF Spaces warns it's unsupported
and SYSTEM=spaces env var (in Dockerfile) is sufficient to bypass
gradio's _check_localhost pre-flight.

Local verified: _stash_frame populates _latest_frame; _capture_sign
reads it and returns "couldn't recognise" (not "no frame yet") on a
black test frame. Counter increments correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +2 -7
  2. signbridge/space.py +37 -27
app.py CHANGED
@@ -20,18 +20,13 @@ def main() -> None:
20
  # Docker the loopback connect-back occasionally races the bind and trips
21
  # the "When localhost is not accessible" guard. Setting SYSTEM=spaces
22
  # mirrors what the gradio-SDK runtime sets and is the documented escape
23
- # hatch.
 
24
  os.environ.setdefault("SYSTEM", "spaces")
25
  demo = build_demo()
26
- # Gradio 4.44.1's _check_localhost pre-flight tries to connect to
27
- # 127.0.0.1:7860 from inside the container and fails on HF's Docker
28
- # SDK seccomp. Setting share=True is the documented bypass that skips
29
- # the check; the FRP tunnel it would normally create is suppressed
30
- # because HF detects the Space environment and serves directly.
31
  demo.queue().launch(
32
  server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
33
  server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
34
- share=True,
35
  show_error=True,
36
  )
37
 
 
20
  # Docker the loopback connect-back occasionally races the bind and trips
21
  # the "When localhost is not accessible" guard. Setting SYSTEM=spaces
22
  # mirrors what the gradio-SDK runtime sets and is the documented escape
23
+ # hatch. share=True was used earlier as a backup but HF Spaces warns it
24
+ # is unsupported; SYSTEM=spaces alone is now sufficient.
25
  os.environ.setdefault("SYSTEM", "spaces")
26
  demo = build_demo()
 
 
 
 
 
27
  demo.queue().launch(
28
  server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
29
  server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
 
30
  show_error=True,
31
  )
32
 
signbridge/space.py CHANGED
@@ -82,13 +82,14 @@ class _SessionState:
82
  last_audio_path: str | None = None
83
 
84
 
85
- # Module-level frame cache, keyed by gradio session_hash. The webcam
86
- # `.stream()` handler writes here on every frame; the Take-image click
87
- # handler reads from here. We use a global dict instead of gr.State
88
- # because gradio 4.44.1 deep-copies state between handlers (so a
89
- # stream-handler mutation doesn't show up in the click-handler view).
90
- _frame_cache: dict[str, np.ndarray] = {}
91
- _frame_cache_lock = threading.Lock()
 
92
 
93
 
94
  def _new_session() -> _SessionState:
@@ -145,26 +146,35 @@ def _shared_extractor() -> LandmarkExtractor:
145
  return _extractor_singleton
146
 
147
 
148
- def _stash_frame(frame: np.ndarray | None, request: gr.Request) -> None:
149
- """Webcam .stream() callback — writes every live frame to the global
150
- cache keyed by gradio session_hash. Returns nothing because no
151
- component output needs updating per frame."""
 
152
  if frame is None:
153
  return
154
- sid = getattr(request, "session_hash", "default") or "default"
155
- with _frame_cache_lock:
156
- _frame_cache[sid] = frame
 
 
 
 
 
 
 
157
 
158
 
159
- def _capture_sign(
160
- state: _SessionState, request: gr.Request
161
- ) -> tuple[str, str, _SessionState]:
162
  """Take-image button handler. Reads the latest live frame from the
163
- module-level cache (populated by the .stream() handler), runs
164
- recognition, appends to history."""
165
- sid = getattr(request, "session_hash", "default") or "default"
166
- with _frame_cache_lock:
167
- frame = _frame_cache.get(sid)
 
 
 
168
 
169
  if frame is None:
170
  return (
@@ -331,13 +341,13 @@ def build_demo() -> gr.Blocks:
331
  "Spell out a word letter-by-letter, then press Speak."
332
  )
333
 
334
- # Webcam streams continuously while the camera is live
335
- # _stash_frame writes each frame to the global session
336
- # cache. Click reads the latest cached frame.
337
- webcam.stream(
338
  fn=_stash_frame,
339
  inputs=[webcam],
340
- outputs=None,
341
  show_progress="hidden",
342
  )
343
  capture_btn.click(
 
82
  last_audio_path: str | None = None
83
 
84
 
85
+ # Single-user demo: one global latest-frame variable instead of a
86
+ # session-keyed dict. The previous session-keyed approach failed because
87
+ # adding `gr.Request` to a stream-handler signature appears to silently
88
+ # kill the handler in gradio 4.44.1 (TypeError swallowed by the queue
89
+ # worker). No request injection here = no failure surface.
90
+ _latest_frame: np.ndarray | None = None
91
+ _frame_lock = threading.Lock()
92
+ _stash_count = 0
93
 
94
 
95
  def _new_session() -> _SessionState:
 
146
  return _extractor_singleton
147
 
148
 
149
+ def _stash_frame(frame: np.ndarray | None) -> None:
150
+ """Webcam .change() callback — writes every live frame to the global
151
+ `_latest_frame`. Bare signature (no gr.Request, no extra params) so
152
+ gradio's signature inspection can't fail."""
153
+ global _latest_frame, _stash_count
154
  if frame is None:
155
  return
156
+ with _frame_lock:
157
+ _latest_frame = frame
158
+ _stash_count += 1
159
+ # Log every ~30 frames (~1/sec at 30 fps webcam) so HF run logs
160
+ # confirm the handler is actually firing.
161
+ if _stash_count == 1 or _stash_count % 30 == 0:
162
+ logger.info(
163
+ "_stash_frame fired %d times; last shape=%s dtype=%s",
164
+ _stash_count, frame.shape, frame.dtype,
165
+ )
166
 
167
 
168
+ def _capture_sign(state: _SessionState) -> tuple[str, str, _SessionState]:
 
 
169
  """Take-image button handler. Reads the latest live frame from the
170
+ global cache, runs recognition, appends to history."""
171
+ with _frame_lock:
172
+ frame = _latest_frame
173
+
174
+ logger.info(
175
+ "_capture_sign: frame_present=%s, stash_count=%d",
176
+ frame is not None, _stash_count,
177
+ )
178
 
179
  if frame is None:
180
  return (
 
341
  "Spell out a word letter-by-letter, then press Speak."
342
  )
343
 
344
+ # Use .change() (not .stream()) it fires on every value
345
+ # change which, for a streaming webcam, is every frame.
346
+ # outputs=[] is required (None doesn't wire the event).
347
+ webcam.change(
348
  fn=_stash_frame,
349
  inputs=[webcam],
350
+ outputs=[],
351
  show_progress="hidden",
352
  )
353
  capture_btn.click(