fix: webcam capture — global cache, .change() event, drop share=True
Browse filesPrevious 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>
- app.py +2 -7
- signbridge/space.py +37 -27
|
@@ -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 |
|
|
@@ -82,13 +82,14 @@ class _SessionState:
|
|
| 82 |
last_audio_path: str | None = None
|
| 83 |
|
| 84 |
|
| 85 |
-
#
|
| 86 |
-
#
|
| 87 |
-
#
|
| 88 |
-
#
|
| 89 |
-
#
|
| 90 |
-
|
| 91 |
-
|
|
|
|
| 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
|
| 149 |
-
"""Webcam .
|
| 150 |
-
|
| 151 |
-
|
|
|
|
| 152 |
if frame is None:
|
| 153 |
return
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 335 |
-
#
|
| 336 |
-
#
|
| 337 |
-
webcam.
|
| 338 |
fn=_stash_frame,
|
| 339 |
inputs=[webcam],
|
| 340 |
-
outputs=
|
| 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(
|