mgamsby-lattice commited on
Commit
92d6a80
·
verified ·
1 Parent(s): 6282517

Upload LSCC-Demo-HMI-Human-Machine-Interface-Edge-Vision-Engine with upload_repo.py

Browse files
README.md CHANGED
@@ -1,6 +1,7 @@
1
  ---
2
- title: HMI (Human-Machine Interface) - Edge Vision Engine
3
- short_description: Person, Face, Hand Detection and Face ID on CPU, runs on NXP
 
4
  emoji: 🏆
5
  colorFrom: yellow
6
  colorTo: gray
@@ -8,18 +9,24 @@ sdk: docker
8
  pinned: false
9
  ---
10
 
11
- Powered by the [Edge Vision Engine SDK](https://huggingface.co/mgamsby-lattice/gated-content-test-unknown-license).
12
-
13
- ### Get a Free 30-Day Trial License
14
- Request access to the [EVE SDK model repository](https://huggingface.co/mgamsby-lattice/gated-content-test-unknown-license)
15
- and receive a trial license key automatically via email.
16
-
17
- After activation, EVE can run offline for a limited number of days before needing
18
- to reconnect to the internet to re-verify your license.
19
-
20
- ### Need a Longer or Commercial License?
21
- - **Email**: [CONTACT_EMAIL_PLACEHOLDER]
22
- - **Web**: [CONTACT_URL_PLACEHOLDER]
 
 
 
 
 
 
23
 
24
  ## About
25
 
@@ -28,3 +35,7 @@ This demo is maintained by **Lattice Semiconductor** (LatticeSemi).
28
  ## License
29
 
30
  Proprietary - Lattice Semiconductor Corporation. All rights reserved.
 
 
 
 
 
1
  ---
2
+ title: Lattice sensAI Edge Vision Engine SDK
3
+ slug: HMI-Human-Machine-Interface-Edge-Vision-Engine
4
+ short_description: Best in class human sensing SDK for far edge
5
  emoji: 🏆
6
  colorFrom: yellow
7
  colorTo: gray
 
9
  pinned: false
10
  ---
11
 
12
+ The Lattice sensAI Edge Vision Engine SDK (or simply EVE!) provides an Extern-C interface and ready to use Python bindings to access retrieve the following from a camera image:
13
+
14
+ | Human Sensing Attribute | Description | Can be visualized in 🤗 space |
15
+ | --- | --- | :---: |
16
+ | 3D Head Pose | x, y, z, roll, pitch and yaw of user(s) | yes |
17
+ | Face Landmarks | 23 or 68 face landmarks | yes |
18
+ | Fatigue | Karolinska Sleepiness score | |
19
+ | Face ID | Provides identification of detected user (if user was registered) | yes |
20
+ | Gaze | Gaze vectors of main user | |
21
+ | ROI Selection | Indicates if gaze (or projection of face) of main user intersects custom ROI(s) | |
22
+ | Visual Speech Detection | Indicates if main user is speaking | |
23
+ | Personal protective equipment detection | Indicates if protective eyewear, hat and gloves are worn for detected user(s) | |
24
+ | Person Detection | Person bounding box(es) | yes |
25
+ | Depth | Depth of user(s) up to 5m | |
26
+ | Object Detection | Bounding box(es) and classification of 90 objects | |
27
+
28
+ Our models have a low computation footprint and are ideal for <.5 TOPS devices like FPGAs, small NPUs and SOCs.
29
+ EVE SDK packages are available for Windows, Linux and RPI.
30
 
31
  ## About
32
 
 
35
  ## License
36
 
37
  Proprietary - Lattice Semiconductor Corporation. All rights reserved.
38
+
39
+ ## Need a Longer or Commercial License?
40
+
41
+ - **Email**: senseaiAccessrequest@latticesemi.com
app.py CHANGED
@@ -15,9 +15,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent / "shared"))
15
  from env_utils import load_dotenv_if_present, require_secrets
16
  from eve_messages import FeatureFlags
17
  from eve_worker_pool import EveWorkerPool
18
- from frame_utils import draw_countdown_banner, draw_overlay, draw_session_timer
19
- from video_file_server import VideoFileServer
20
  from face_id_tab import FaceEntry, FaceIdTab
 
21
  from live_inference import (
22
  RtcConfigProvider,
23
  build_webrtc_stream,
@@ -25,12 +24,14 @@ from live_inference import (
25
  )
26
  from live_stream_manager import LiveStreamManager
27
  from log_utils import setup_logger
 
28
  from video_processing import (
29
  VideoLimits,
30
  build_video_constraints_accordion,
31
  get_example_videos,
32
  wire_video_upload,
33
  )
 
34
  # --- Handlers ---
35
 
36
 
@@ -124,9 +125,7 @@ def run_eve_inference(
124
  total_frames=total_frames,
125
  progress=progress,
126
  )
127
- logger.info(
128
- f"[{session}] run_eve_inference: done, {frames_processed} frames processed"
129
- )
130
  finally:
131
  pool.release(worker)
132
  logger.info(f"[{session}] run_eve_inference: worker {worker.worker_id} released")
@@ -231,12 +230,12 @@ def _build_video_processing_tab() -> tuple[
231
  Tuple of (video_tab, input_video, output_video, cb_face, cb_person,
232
  cb_face_id, cb_hand_gesture, process_btn, example_dataset).
233
  """
234
- with gr.TabItem("Video Processing") as video_tab:
235
  with gr.Accordion("Instructions", open=False):
236
  gr.Markdown(
237
  "1. Select the features that will be processed on the video\n"
238
  "2. Select a video (or upload your own in the Input Video frame)\n"
239
- "3. Press the **Process Video** button\n"
240
  "Once the video has been processed, you can play the video in the "
241
  "Output Video frame"
242
  )
@@ -304,7 +303,6 @@ def _build_live_tab(
304
  return tab, webrtc_stream, cb_face, cb_person, cb_face_id, cb_hand_gesture
305
 
306
 
307
-
308
  def _process_live_frame(
309
  frame: np.ndarray,
310
  face_detection: bool,
@@ -341,12 +339,8 @@ def _process_live_frame(
341
  mins, secs = divmod(int(eta), 60)
342
  eta_text = f"\nest. wait ~{mins}:{secs:02d}"
343
  if total > 1:
344
- return draw_overlay(
345
- frame, f"In queue (position {pos}/{total}){eta_text}"
346
- )
347
- return draw_overlay(
348
- frame, f"Waiting for available worker...{eta_text}"
349
- )
350
  # Terminal reason (session expired, pressure timeout, etc.)
351
  # — close the WebRTC stream so the UI resets to "Start Inference".
352
  from fastrtc import CloseStream
@@ -401,7 +395,7 @@ if __name__ == "__main__":
401
  max_workers = int(os.environ.get("MAX_WORKERS", os.cpu_count()))
402
  max_ram_gb = float(os.environ.get("MAX_RAM_GB", 32))
403
  pool = EveWorkerPool(max_workers=max_workers, max_ram_gb=max_ram_gb, ram_headroom_gb=2.0)
404
- stream_manager = LiveStreamManager(pool, session_lifetime_seconds=60*4)
405
 
406
  # Separate HTTP server for video output — bypasses Chrome's per-origin
407
  # connection limit that blocks /file= requests while SSE connections are open.
@@ -427,17 +421,34 @@ if __name__ == "__main__":
427
  )
428
 
429
  with gr.Blocks(title="Eve HMI Demo") as demo:
430
- gr.Markdown("# Edge Vision Engine Demo")
431
  gr.Markdown(
432
- "Powered by the [Edge Vision Engine SDK]"
433
- "(https://huggingface.co/mgamsby-lattice/gated-content-test-unknown-license) "
434
- "[Request access](https://huggingface.co/mgamsby-lattice/"
435
- "gated-content-test-unknown-license) to get started."
 
 
 
 
 
 
 
 
436
  )
437
 
438
  session_registry = gr.State(value={})
439
 
440
  with gr.Tabs():
 
 
 
 
 
 
 
 
 
441
  (
442
  video_tab,
443
  input_video,
@@ -452,15 +463,6 @@ if __name__ == "__main__":
452
 
453
  face_id_tab.build()
454
 
455
- (
456
- _live_tab,
457
- webrtc_stream,
458
- live_cb_face,
459
- live_cb_person,
460
- live_cb_face_id,
461
- live_cb_hand_gesture,
462
- ) = _build_live_tab(rtc_config_provider.get(), face_id_tab)
463
-
464
  # --- Video Processing wiring ---
465
 
466
  feature_controls = [
 
15
  from env_utils import load_dotenv_if_present, require_secrets
16
  from eve_messages import FeatureFlags
17
  from eve_worker_pool import EveWorkerPool
 
 
18
  from face_id_tab import FaceEntry, FaceIdTab
19
+ from frame_utils import draw_countdown_banner, draw_overlay, draw_session_timer
20
  from live_inference import (
21
  RtcConfigProvider,
22
  build_webrtc_stream,
 
24
  )
25
  from live_stream_manager import LiveStreamManager
26
  from log_utils import setup_logger
27
+ from video_file_server import VideoFileServer
28
  from video_processing import (
29
  VideoLimits,
30
  build_video_constraints_accordion,
31
  get_example_videos,
32
  wire_video_upload,
33
  )
34
+
35
  # --- Handlers ---
36
 
37
 
 
125
  total_frames=total_frames,
126
  progress=progress,
127
  )
128
+ logger.info(f"[{session}] run_eve_inference: done, {frames_processed} frames processed")
 
 
129
  finally:
130
  pool.release(worker)
131
  logger.info(f"[{session}] run_eve_inference: worker {worker.worker_id} released")
 
230
  Tuple of (video_tab, input_video, output_video, cb_face, cb_person,
231
  cb_face_id, cb_hand_gesture, process_btn, example_dataset).
232
  """
233
+ with gr.TabItem("Offline Inference") as video_tab:
234
  with gr.Accordion("Instructions", open=False):
235
  gr.Markdown(
236
  "1. Select the features that will be processed on the video\n"
237
  "2. Select a video (or upload your own in the Input Video frame)\n"
238
+ "3. Press the **Process Video** button\n\n"
239
  "Once the video has been processed, you can play the video in the "
240
  "Output Video frame"
241
  )
 
303
  return tab, webrtc_stream, cb_face, cb_person, cb_face_id, cb_hand_gesture
304
 
305
 
 
306
  def _process_live_frame(
307
  frame: np.ndarray,
308
  face_detection: bool,
 
339
  mins, secs = divmod(int(eta), 60)
340
  eta_text = f"\nest. wait ~{mins}:{secs:02d}"
341
  if total > 1:
342
+ return draw_overlay(frame, f"In queue (position {pos}/{total}){eta_text}")
343
+ return draw_overlay(frame, f"Waiting for available worker...{eta_text}")
 
 
 
 
344
  # Terminal reason (session expired, pressure timeout, etc.)
345
  # — close the WebRTC stream so the UI resets to "Start Inference".
346
  from fastrtc import CloseStream
 
395
  max_workers = int(os.environ.get("MAX_WORKERS", os.cpu_count()))
396
  max_ram_gb = float(os.environ.get("MAX_RAM_GB", 32))
397
  pool = EveWorkerPool(max_workers=max_workers, max_ram_gb=max_ram_gb, ram_headroom_gb=2.0)
398
+ stream_manager = LiveStreamManager(pool, session_lifetime_seconds=60 * 4)
399
 
400
  # Separate HTTP server for video output — bypasses Chrome's per-origin
401
  # connection limit that blocks /file= requests while SSE connections are open.
 
421
  )
422
 
423
  with gr.Blocks(title="Eve HMI Demo") as demo:
424
+ gr.Markdown("# Lattice sensAI Edge Vision Engine SDK")
425
  gr.Markdown(
426
+ "Our SDK solves the human sensing challenges by outputting ready-to-use data."
427
+ " Our models have a low computation footprint and are ideal for <.5 TOPS devices"
428
+ " like FPGAs, small NPUs and SOCs.\n\n"
429
+ # TODO: Insert performance summary table
430
+ "EVE SDK packages are available for Windows, Linux and RPI.\n\n"
431
+ "Follow the instructions [here](https://huggingface.co/LatticeSemi/LSCC-SDK-HMI-Human-Machine-Interface-Edge-Vision-Engine)"
432
+ " to download and start using the EVE SDK within minutes.\n\n"
433
+ "You can also preview the EVE SDK with the following tabs:\n\n"
434
+ "- **Live Inference** to run it live from your webcam\n"
435
+ "- **Offline Inference** to test it with videos you can upload\n"
436
+ "- Use the **Face ID Registration** tab to register face(s) you can use in either Live or Offline Inference to test the Face ID model.\n"
437
+ "\n\n"
438
  )
439
 
440
  session_registry = gr.State(value={})
441
 
442
  with gr.Tabs():
443
+ (
444
+ _live_tab,
445
+ webrtc_stream,
446
+ live_cb_face,
447
+ live_cb_person,
448
+ live_cb_face_id,
449
+ live_cb_hand_gesture,
450
+ ) = _build_live_tab(rtc_config_provider.get(), face_id_tab)
451
+
452
  (
453
  video_tab,
454
  input_video,
 
463
 
464
  face_id_tab.build()
465
 
 
 
 
 
 
 
 
 
 
466
  # --- Video Processing wiring ---
467
 
468
  feature_controls = [
install_eve.py CHANGED
@@ -21,7 +21,7 @@ import sys
21
  from huggingface_hub import hf_hub_download
22
 
23
  EVE_REPO = "LatticeSemi/PRIVATE-Edge-Vision-Engine-EVE-v7.0"
24
- EVE_DEB = "LINUX_X86-571-dev-eve-huggingface_7.0.571~git20260331.f4bdc1d_amd64.deb"
25
  EVE_LICENSE_REPO = "LatticeSemi/PRIVATE-Edge-Vision-Engine-EVE-v7.0-License"
26
  EVE_LICENSE = "libEveDevLicense.so"
27
  SECRET_PATH = "/run/secrets/MODEL_ACCESS_TOKEN"
@@ -88,10 +88,7 @@ def main():
88
  subprocess.run(["apt-get", "install", "-y", deb_path], check=True)
89
 
90
  license_path = hf_hub_download(
91
- repo_id=EVE_LICENSE_REPO,
92
- filename=EVE_LICENSE,
93
- local_dir=DOWNLOAD_DIR,
94
- token=token
95
  )
96
 
97
  destination_path = get_license_destination_path()
 
21
  from huggingface_hub import hf_hub_download
22
 
23
  EVE_REPO = "LatticeSemi/PRIVATE-Edge-Vision-Engine-EVE-v7.0"
24
+ EVE_DEB = "LINUX_X86-5-7.0-eve-huggingface_7.0.5~git20260402.50d837a_amd64.deb"
25
  EVE_LICENSE_REPO = "LatticeSemi/PRIVATE-Edge-Vision-Engine-EVE-v7.0-License"
26
  EVE_LICENSE = "libEveDevLicense.so"
27
  SECRET_PATH = "/run/secrets/MODEL_ACCESS_TOKEN"
 
88
  subprocess.run(["apt-get", "install", "-y", deb_path], check=True)
89
 
90
  license_path = hf_hub_download(
91
+ repo_id=EVE_LICENSE_REPO, filename=EVE_LICENSE, local_dir=DOWNLOAD_DIR, token=token
 
 
 
92
  )
93
 
94
  destination_path = get_license_destination_path()
shared/env_utils.py CHANGED
@@ -1,6 +1,5 @@
1
  import os
2
  import sys
3
-
4
  from pathlib import Path
5
 
6
  from dotenv import load_dotenv # type: ignore
@@ -44,8 +43,7 @@ def require_secrets(*names: str) -> None:
44
 
45
  settings_url = f"https://huggingface.co/spaces/{space_id}/settings"
46
  print(
47
- f"ERROR: Missing required secrets: {', '.join(missing)}\n"
48
- f"Set them at: {settings_url}",
49
  file=sys.stderr,
50
  )
51
  sys.exit(1)
 
1
  import os
2
  import sys
 
3
  from pathlib import Path
4
 
5
  from dotenv import load_dotenv # type: ignore
 
43
 
44
  settings_url = f"https://huggingface.co/spaces/{space_id}/settings"
45
  print(
46
+ f"ERROR: Missing required secrets: {', '.join(missing)}\n" f"Set them at: {settings_url}",
 
47
  file=sys.stderr,
48
  )
49
  sys.exit(1)
shared/eve_messages.py CHANGED
@@ -6,7 +6,6 @@ main Gradio process and Eve SDK worker processes.
6
 
7
  from dataclasses import dataclass
8
 
9
-
10
  # ---------------------------------------------------------------------------
11
  # Shared data types
12
  # ---------------------------------------------------------------------------
 
6
 
7
  from dataclasses import dataclass
8
 
 
9
  # ---------------------------------------------------------------------------
10
  # Shared data types
11
  # ---------------------------------------------------------------------------
shared/eve_worker_pool.py CHANGED
@@ -22,12 +22,11 @@ import multiprocessing as mp
22
  import os
23
  import threading
24
  import time
 
25
  from dataclasses import dataclass
26
  from multiprocessing.connection import Connection
27
- from collections.abc import Callable
28
 
29
  import numpy as np
30
-
31
  from eve_messages import (
32
  CalibrateNewUserCmd,
33
  CalibrateOkResponse,
@@ -62,7 +61,9 @@ logger = setup_logger("EveWorkerPool")
62
  # (required on Windows; avoids CDLL sharing on Linux/fork).
63
  _mp_ctx = mp.get_context("spawn")
64
 
65
- DO_PROBE_WORKER = False # Set to False to skip the RAM probe and just use an estimate of the RAM used
 
 
66
 
67
 
68
  # ---------------------------------------------------------------------------
@@ -127,9 +128,7 @@ def _eve_worker_main(
127
 
128
  try:
129
  if isinstance(cmd, InferenceCmd):
130
- frame = np.frombuffer(cmd.frame_bytes, dtype=cmd.dtype).reshape(
131
- cmd.shape
132
- )
133
  features = cmd.features
134
  del cmd # free recv'd bytes early; numpy holds its own ref
135
 
@@ -167,9 +166,7 @@ def _eve_worker_main(
167
  result = eve.calibrate_new_user(frames)
168
  conn.send(
169
  CalibrateOkResponse(
170
- result=CalibrationResultMsg(
171
- result.success, result.user_id, result.message
172
- ),
173
  )
174
  )
175
 
@@ -178,16 +175,13 @@ def _eve_worker_main(
178
  conn.send(RemoveUsersOkResponse(result=ok))
179
 
180
  elif isinstance(cmd, RestoreGalleryCmd):
181
- frames_per_user = [
182
- _deserialize_frames(fd) for fd in cmd.frames_per_user_data
183
- ]
184
  eve.remove_all_users()
185
  results = eve.restore_gallery(frames_per_user)
186
  conn.send(
187
  RestoreGalleryOkResponse(
188
  results=[
189
- CalibrationResultMsg(r.success, r.user_id, r.message)
190
- for r in results
191
  ],
192
  )
193
  )
@@ -209,14 +203,11 @@ def _eve_worker_main(
209
  if cmd.remove_all_users:
210
  eve.remove_all_users()
211
  if cmd.gallery_paths:
212
- frames_per_user = [
213
- load_media_frames_raw(p) for p in cmd.gallery_paths
214
- ]
215
  raw_results = eve.restore_gallery(frames_per_user)
216
  del frames_per_user
217
  gallery_results = [
218
- CalibrationResultMsg(r.success, r.user_id, r.message)
219
- for r in raw_results
220
  ]
221
  del raw_results
222
  conn.send(GalleryRestoredResponse(results=gallery_results))
@@ -233,9 +224,7 @@ def _eve_worker_main(
233
 
234
  # Video processing loop
235
  cap = cv2.VideoCapture(cmd.input_path)
236
- container = av.open(
237
- cmd.output_path, mode="w", options={"movflags": "faststart"}
238
- )
239
  stream = container.add_stream("libx264", rate=round(cmd.fps))
240
  stream.width = cmd.width
241
  stream.height = cmd.height
@@ -274,7 +263,11 @@ def _eve_worker_main(
274
  finally:
275
  cap.release()
276
  container.close()
277
- del cap, container, stream,
 
 
 
 
278
 
279
  job_count += 1
280
  conn.send(
@@ -491,14 +484,10 @@ class EveWorker:
491
  return resp.frames_processed, gallery_results
492
 
493
  elif isinstance(resp, ErrorResponse):
494
- raise RuntimeError(
495
- f"Worker {self.worker_id} process_video error: {resp.error}"
496
- )
497
 
498
  else:
499
- raise RuntimeError(
500
- f"Worker {self.worker_id} unexpected response: {resp}"
501
- )
502
 
503
  def send_shutdown(self) -> None:
504
  try:
@@ -760,7 +749,7 @@ class EveWorkerPool:
760
  headroom_mb = self._cfg.ram_headroom_gb * 1024
761
 
762
  # Spawn a probe worker to measure init RSS
763
-
764
  if DO_PROBE_WORKER:
765
  probe = self._spawn_worker(probe=True)
766
  try:
@@ -780,7 +769,7 @@ class EveWorkerPool:
780
  # +1 because the probe worker already consumed memory
781
  max_by_ram = max(1, int(usable_mb / estimated_peak_mb) + 1)
782
  if DO_PROBE_WORKER:
783
- actual = min(self._cfg.max_workers-1, max_by_ram)
784
  else:
785
  actual = min(self._cfg.max_workers, max_by_ram)
786
 
@@ -807,15 +796,19 @@ class EveWorkerPool:
807
  parent_conn, child_conn = _mp_ctx.Pipe()
808
  proc = _mp_ctx.Process(
809
  target=_eve_worker_main,
810
- args=(child_conn, self._eve_bin_path, self._eve_lib_path, wid, self._cfg.max_jobs_per_worker),
 
 
 
 
 
 
811
  daemon=True,
812
  )
813
  proc.start()
814
  return wid, proc, parent_conn
815
 
816
- def _wait_for_ready(
817
- self, wid: int, proc: mp.Process, parent_conn: Connection
818
- ) -> EveWorker:
819
  """Block until a launched worker sends its "ready" message.
820
 
821
  Raises:
@@ -889,9 +882,7 @@ class EveWorkerPool:
889
  # Detect crashed / zombie processes
890
  if not w.process.is_alive():
891
  if w.pending_recycle:
892
- logger.info(
893
- f"Worker {w.worker_id} (pid={w.process.pid}) recycled cleanly"
894
- )
895
  else:
896
  logger.error(
897
  f"Worker {w.worker_id} (pid={w.process.pid}) died unexpectedly"
@@ -914,4 +905,3 @@ class EveWorkerPool:
914
  f"Worker {w.worker_id} busy for {elapsed:.0f}s "
915
  f"(threshold={self._cfg.stuck_timeout_s}s)"
916
  )
917
-
 
22
  import os
23
  import threading
24
  import time
25
+ from collections.abc import Callable
26
  from dataclasses import dataclass
27
  from multiprocessing.connection import Connection
 
28
 
29
  import numpy as np
 
30
  from eve_messages import (
31
  CalibrateNewUserCmd,
32
  CalibrateOkResponse,
 
61
  # (required on Windows; avoids CDLL sharing on Linux/fork).
62
  _mp_ctx = mp.get_context("spawn")
63
 
64
+ DO_PROBE_WORKER = (
65
+ False # Set to False to skip the RAM probe and just use an estimate of the RAM used
66
+ )
67
 
68
 
69
  # ---------------------------------------------------------------------------
 
128
 
129
  try:
130
  if isinstance(cmd, InferenceCmd):
131
+ frame = np.frombuffer(cmd.frame_bytes, dtype=cmd.dtype).reshape(cmd.shape)
 
 
132
  features = cmd.features
133
  del cmd # free recv'd bytes early; numpy holds its own ref
134
 
 
166
  result = eve.calibrate_new_user(frames)
167
  conn.send(
168
  CalibrateOkResponse(
169
+ result=CalibrationResultMsg(result.success, result.user_id, result.message),
 
 
170
  )
171
  )
172
 
 
175
  conn.send(RemoveUsersOkResponse(result=ok))
176
 
177
  elif isinstance(cmd, RestoreGalleryCmd):
178
+ frames_per_user = [_deserialize_frames(fd) for fd in cmd.frames_per_user_data]
 
 
179
  eve.remove_all_users()
180
  results = eve.restore_gallery(frames_per_user)
181
  conn.send(
182
  RestoreGalleryOkResponse(
183
  results=[
184
+ CalibrationResultMsg(r.success, r.user_id, r.message) for r in results
 
185
  ],
186
  )
187
  )
 
203
  if cmd.remove_all_users:
204
  eve.remove_all_users()
205
  if cmd.gallery_paths:
206
+ frames_per_user = [load_media_frames_raw(p) for p in cmd.gallery_paths]
 
 
207
  raw_results = eve.restore_gallery(frames_per_user)
208
  del frames_per_user
209
  gallery_results = [
210
+ CalibrationResultMsg(r.success, r.user_id, r.message) for r in raw_results
 
211
  ]
212
  del raw_results
213
  conn.send(GalleryRestoredResponse(results=gallery_results))
 
224
 
225
  # Video processing loop
226
  cap = cv2.VideoCapture(cmd.input_path)
227
+ container = av.open(cmd.output_path, mode="w", options={"movflags": "faststart"})
 
 
228
  stream = container.add_stream("libx264", rate=round(cmd.fps))
229
  stream.width = cmd.width
230
  stream.height = cmd.height
 
263
  finally:
264
  cap.release()
265
  container.close()
266
+ del (
267
+ cap,
268
+ container,
269
+ stream,
270
+ )
271
 
272
  job_count += 1
273
  conn.send(
 
484
  return resp.frames_processed, gallery_results
485
 
486
  elif isinstance(resp, ErrorResponse):
487
+ raise RuntimeError(f"Worker {self.worker_id} process_video error: {resp.error}")
 
 
488
 
489
  else:
490
+ raise RuntimeError(f"Worker {self.worker_id} unexpected response: {resp}")
 
 
491
 
492
  def send_shutdown(self) -> None:
493
  try:
 
749
  headroom_mb = self._cfg.ram_headroom_gb * 1024
750
 
751
  # Spawn a probe worker to measure init RSS
752
+
753
  if DO_PROBE_WORKER:
754
  probe = self._spawn_worker(probe=True)
755
  try:
 
769
  # +1 because the probe worker already consumed memory
770
  max_by_ram = max(1, int(usable_mb / estimated_peak_mb) + 1)
771
  if DO_PROBE_WORKER:
772
+ actual = min(self._cfg.max_workers - 1, max_by_ram)
773
  else:
774
  actual = min(self._cfg.max_workers, max_by_ram)
775
 
 
796
  parent_conn, child_conn = _mp_ctx.Pipe()
797
  proc = _mp_ctx.Process(
798
  target=_eve_worker_main,
799
+ args=(
800
+ child_conn,
801
+ self._eve_bin_path,
802
+ self._eve_lib_path,
803
+ wid,
804
+ self._cfg.max_jobs_per_worker,
805
+ ),
806
  daemon=True,
807
  )
808
  proc.start()
809
  return wid, proc, parent_conn
810
 
811
+ def _wait_for_ready(self, wid: int, proc: mp.Process, parent_conn: Connection) -> EveWorker:
 
 
812
  """Block until a launched worker sends its "ready" message.
813
 
814
  Raises:
 
882
  # Detect crashed / zombie processes
883
  if not w.process.is_alive():
884
  if w.pending_recycle:
885
+ logger.info(f"Worker {w.worker_id} (pid={w.process.pid}) recycled cleanly")
 
 
886
  else:
887
  logger.error(
888
  f"Worker {w.worker_id} (pid={w.process.pid}) died unexpectedly"
 
905
  f"Worker {w.worker_id} busy for {elapsed:.0f}s "
906
  f"(threshold={self._cfg.stuck_timeout_s}s)"
907
  )
 
shared/eve_wrapper.py CHANGED
@@ -3,12 +3,10 @@ import glob
3
  import os
4
  import platform
5
  import sys
6
-
7
  from dataclasses import dataclass
8
 
9
  import cv2
10
  import numpy as np
11
-
12
  from eve_python import eve_sdk as sdk
13
  from eve_python.structs.CFaceIdStructs import (
14
  EveFaceIdCommand,
@@ -170,8 +168,7 @@ class EveWrapper:
170
  if enabled
171
  else sdk.structs.EveOptionEnabled.EVE_OPTION_DISABLED
172
  )
173
- options = sdk.structs.EveFaceIdOptions(enabled=eve_enabled,
174
- threshold=threshold)
175
  if enabled:
176
  options.calibrationPoses = (
177
  sdk.structs.EveFaceIdCalibrationPoseMode.EVE_FACEID_CALIBRATION_FRONTAL_ONLY
 
3
  import os
4
  import platform
5
  import sys
 
6
  from dataclasses import dataclass
7
 
8
  import cv2
9
  import numpy as np
 
10
  from eve_python import eve_sdk as sdk
11
  from eve_python.structs.CFaceIdStructs import (
12
  EveFaceIdCommand,
 
168
  if enabled
169
  else sdk.structs.EveOptionEnabled.EVE_OPTION_DISABLED
170
  )
171
+ options = sdk.structs.EveFaceIdOptions(enabled=eve_enabled, threshold=threshold)
 
172
  if enabled:
173
  options.calibrationPoses = (
174
  sdk.structs.EveFaceIdCalibrationPoseMode.EVE_FACEID_CALIBRATION_FRONTAL_ONLY
shared/face_id_tab.py CHANGED
@@ -133,8 +133,7 @@ class FaceIdTab:
133
  with gr.Accordion("Instructions", open=False):
134
  gr.Markdown(
135
  (
136
- "1. Choose between registering a face from an **Image** or a "
137
- "**Video**\n"
138
  if self._accept_video
139
  else "1. Select an example image or upload your own\n"
140
  )
@@ -158,7 +157,9 @@ class FaceIdTab:
158
  with gr.Column(scale=3):
159
  if self._accept_video:
160
  # Image section (expanded by default)
161
- with gr.Accordion("Input from an Image", open=True) as self._image_accordion:
 
 
162
  if self._image_examples:
163
  with gr.Accordion("Examples", open=True):
164
  self._image_example_dataset = gr.Dataset(
@@ -173,7 +174,9 @@ class FaceIdTab:
173
  )
174
 
175
  # Video section (collapsed by default)
176
- with gr.Accordion("Input from a Video", open=False) as self._video_accordion:
 
 
177
  if self._video_examples:
178
  with gr.Accordion("Examples", open=True):
179
  self._video_example_dataset = gr.Dataset(
@@ -244,14 +247,10 @@ class FaceIdTab:
244
  column = gr.Column(scale=scale, min_width=100, visible=False)
245
  with column:
246
  gr.Markdown("**Registered Faces**")
247
- hint = gr.Markdown(
248
- "_Go to the **Face ID Registration** tab to register faces._"
249
- )
250
  for _ in range(self._max_users):
251
  imgs.append(gr.HTML(value="", visible=False))
252
- self._summaries.append(
253
- {"column": column, "hint": hint, "imgs": imgs, "height": height}
254
- )
255
 
256
  # ------------------------------------------------------------------
257
  # Event wiring
@@ -429,18 +428,13 @@ class FaceIdTab:
429
  media_source = image_path if image_path is not None else video_path
430
  ext = os.path.splitext(media_source)[1]
431
  session_tmp = _session_dir(request.session_hash)
432
- stored_path = os.path.join(
433
- session_tmp, f"face_id_{uuid.uuid4().hex[:8]}{ext}"
434
- )
435
  shutil.copy2(media_source, stored_path)
436
 
437
  next_key = max(registry.keys(), default=0) + 1
438
  registry = {**registry, next_key: FaceEntry(path=stored_path)}
439
 
440
- # Restore the full gallery so every entry gets a consistent sdk_id
441
- all_frames = [
442
- load_media_frames(entry.path) for entry in registry.values()
443
- ]
444
  restore_results = worker.send_restore_gallery(all_frames)
445
  for entry, r in zip(registry.values(), restore_results):
446
  entry.sdk_id = r.user_id if r.success else None
@@ -463,9 +457,11 @@ class FaceIdTab:
463
  return (
464
  *self._slot_updates(registry),
465
  *self._summary_updates(registry),
466
- f"Successfully registered (Face ID: {registry[next_key].sdk_id})."
467
- if registry[next_key].sdk_id is not None
468
- else f"Successfully registered as User {next_key}.",
 
 
469
  None,
470
  None,
471
  registry,
@@ -504,13 +500,9 @@ class FaceIdTab:
504
  frames = load_media_frames(entry.path)
505
  result = worker.send_calibrate_new_user(frames)
506
  if result.success:
507
- new_registry[u] = FaceEntry(
508
- path=entry.path, sdk_id=result.user_id
509
- )
510
  else:
511
- logger.warning(
512
- f"Failed to re-register user {u}: {result.message}"
513
- )
514
  if os.path.exists(entry.path):
515
  os.remove(entry.path)
516
  registry = new_registry
@@ -593,9 +585,7 @@ class FaceIdTab:
593
  updates.append(gr.update(interactive=False))
594
  return tuple(updates)
595
 
596
- def _summary_updates_single(
597
- self, registry: dict[int, FaceEntry], summary: dict
598
- ) -> list:
599
  """Build updates for one summary group (column + hint + images)."""
600
  updates: list = []
601
  user_ids = sorted(registry.keys())
 
133
  with gr.Accordion("Instructions", open=False):
134
  gr.Markdown(
135
  (
136
+ "1. Choose between registering a face from an **Image** or a " "**Video**\n"
 
137
  if self._accept_video
138
  else "1. Select an example image or upload your own\n"
139
  )
 
157
  with gr.Column(scale=3):
158
  if self._accept_video:
159
  # Image section (expanded by default)
160
+ with gr.Accordion(
161
+ "Input from an Image", open=True
162
+ ) as self._image_accordion:
163
  if self._image_examples:
164
  with gr.Accordion("Examples", open=True):
165
  self._image_example_dataset = gr.Dataset(
 
174
  )
175
 
176
  # Video section (collapsed by default)
177
+ with gr.Accordion(
178
+ "Input from a Video", open=False
179
+ ) as self._video_accordion:
180
  if self._video_examples:
181
  with gr.Accordion("Examples", open=True):
182
  self._video_example_dataset = gr.Dataset(
 
247
  column = gr.Column(scale=scale, min_width=100, visible=False)
248
  with column:
249
  gr.Markdown("**Registered Faces**")
250
+ hint = gr.Markdown("_Go to the **Face ID Registration** tab to register faces._")
 
 
251
  for _ in range(self._max_users):
252
  imgs.append(gr.HTML(value="", visible=False))
253
+ self._summaries.append({"column": column, "hint": hint, "imgs": imgs, "height": height})
 
 
254
 
255
  # ------------------------------------------------------------------
256
  # Event wiring
 
428
  media_source = image_path if image_path is not None else video_path
429
  ext = os.path.splitext(media_source)[1]
430
  session_tmp = _session_dir(request.session_hash)
431
+ stored_path = os.path.join(session_tmp, f"face_id_{uuid.uuid4().hex[:8]}{ext}")
 
 
432
  shutil.copy2(media_source, stored_path)
433
 
434
  next_key = max(registry.keys(), default=0) + 1
435
  registry = {**registry, next_key: FaceEntry(path=stored_path)}
436
 
437
+ all_frames = [load_media_frames(entry.path) for entry in registry.values()]
 
 
 
438
  restore_results = worker.send_restore_gallery(all_frames)
439
  for entry, r in zip(registry.values(), restore_results):
440
  entry.sdk_id = r.user_id if r.success else None
 
457
  return (
458
  *self._slot_updates(registry),
459
  *self._summary_updates(registry),
460
+ (
461
+ f"Successfully registered (Face ID: {registry[next_key].sdk_id})."
462
+ if registry[next_key].sdk_id is not None
463
+ else f"Successfully registered as User {next_key}."
464
+ ),
465
  None,
466
  None,
467
  registry,
 
500
  frames = load_media_frames(entry.path)
501
  result = worker.send_calibrate_new_user(frames)
502
  if result.success:
503
+ new_registry[u] = FaceEntry(path=entry.path, sdk_id=result.user_id)
 
 
504
  else:
505
+ logger.warning(f"Failed to re-register user {u}: {result.message}")
 
 
506
  if os.path.exists(entry.path):
507
  os.remove(entry.path)
508
  registry = new_registry
 
585
  updates.append(gr.update(interactive=False))
586
  return tuple(updates)
587
 
588
+ def _summary_updates_single(self, registry: dict[int, FaceEntry], summary: dict) -> list:
 
 
589
  """Build updates for one summary group (column + hint + images)."""
590
  updates: list = []
591
  user_ids = sorted(registry.keys())
shared/frame_utils.py CHANGED
@@ -49,9 +49,7 @@ def load_media_frames_raw(media_path: str) -> list[np.ndarray]:
49
  return [img]
50
 
51
 
52
- def extract_frames(
53
- image_path: str | None, video_path: str | None
54
- ) -> list[np.ndarray]:
55
  """Extract BGR frames from an image or video.
56
 
57
  Args:
 
49
  return [img]
50
 
51
 
52
+ def extract_frames(image_path: str | None, video_path: str | None) -> list[np.ndarray]:
 
 
53
  """Extract BGR frames from an image or video.
54
 
55
  Args:
shared/live_inference.py CHANGED
@@ -14,7 +14,6 @@ from typing import Any, Callable
14
  import cv2
15
  import gradio as gr
16
  import numpy as np
17
-
18
  from log_utils import setup_logger
19
 
20
  InferenceFn = Callable[[np.ndarray], np.ndarray]
@@ -180,9 +179,10 @@ def patch_fastrtc_frame_queue() -> None:
180
  Must be called **before** any ``WebRTC`` component is created
181
  (typically at the top of ``__main__``).
182
  """
 
 
183
  import fastrtc.tracks as frt
184
  from aiortc.mediastreams import MediaStreamError
185
- from typing import cast as _cast
186
 
187
  _orig_init = frt.VideoCallback.__init__
188
 
 
14
  import cv2
15
  import gradio as gr
16
  import numpy as np
 
17
  from log_utils import setup_logger
18
 
19
  InferenceFn = Callable[[np.ndarray], np.ndarray]
 
179
  Must be called **before** any ``WebRTC`` component is created
180
  (typically at the top of ``__main__``).
181
  """
182
+ from typing import cast as _cast
183
+
184
  import fastrtc.tracks as frt
185
  from aiortc.mediastreams import MediaStreamError
 
186
 
187
  _orig_init = frt.VideoCallback.__init__
188
 
shared/live_stream_manager.py CHANGED
@@ -134,15 +134,12 @@ class LiveStreamManager:
134
  self._release_entry(connection_id)
135
  with self._lock:
136
  self._expired.add(connection_id)
137
- return None, (
138
- "Session ended\nclick `Start Inference` to restart."
139
- )
140
 
141
  # Pressure-based countdown (only when enabled)
142
  if self._timeout_s is not None:
143
- under_pressure = (
144
- self._pool.idle_count == 0
145
- and (self._pool.waiting_count > 0 or len(self._waiting) > 0)
146
  )
147
  if under_pressure:
148
  if entry.pressure_start is None:
@@ -279,8 +276,7 @@ class LiveStreamManager:
279
  with self._lock:
280
  entries = list(self._streams.values())
281
  known = sorted(
282
- max(0.0, self._session_lifetime_s - (now - e.start_time))
283
- for e in entries
284
  )
285
 
286
  # Workers doing video processing (busy, not live-stream) have
@@ -351,12 +347,9 @@ class LiveStreamManager:
351
  with self._lock:
352
  self._waiting.pop(connection_id, None)
353
  logger.info(
354
- f"Live stream ({connection_id}) acquired worker {worker.worker_id} "
355
- f"after waiting"
356
- )
357
- return self._setup_stream(
358
- connection_id, waiting.session_hash, worker, waiting.registry
359
  )
 
360
 
361
  def _release_entry(self, connection_id: str) -> None:
362
  with self._lock:
 
134
  self._release_entry(connection_id)
135
  with self._lock:
136
  self._expired.add(connection_id)
137
+ return None, ("Session ended\nclick `Start Inference` to restart.")
 
 
138
 
139
  # Pressure-based countdown (only when enabled)
140
  if self._timeout_s is not None:
141
+ under_pressure = self._pool.idle_count == 0 and (
142
+ self._pool.waiting_count > 0 or len(self._waiting) > 0
 
143
  )
144
  if under_pressure:
145
  if entry.pressure_start is None:
 
276
  with self._lock:
277
  entries = list(self._streams.values())
278
  known = sorted(
279
+ max(0.0, self._session_lifetime_s - (now - e.start_time)) for e in entries
 
280
  )
281
 
282
  # Workers doing video processing (busy, not live-stream) have
 
347
  with self._lock:
348
  self._waiting.pop(connection_id, None)
349
  logger.info(
350
+ f"Live stream ({connection_id}) acquired worker {worker.worker_id} " f"after waiting"
 
 
 
 
351
  )
352
+ return self._setup_stream(connection_id, waiting.session_hash, worker, waiting.registry)
353
 
354
  def _release_entry(self, connection_id: str) -> None:
355
  with self._lock:
shared/log_utils.py CHANGED
@@ -11,8 +11,10 @@ def setup_logger(name: str = "hf_demos", level: str | None = None) -> logging.Lo
11
  logger.setLevel(getattr(logging, lvl, logging.INFO))
12
 
13
  ch = logging.StreamHandler()
14
- fmt = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
 
 
15
  ch.setFormatter(fmt)
16
  logger.addHandler(ch)
17
  logger.propagate = False
18
- return logger
 
11
  logger.setLevel(getattr(logging, lvl, logging.INFO))
12
 
13
  ch = logging.StreamHandler()
14
+ fmt = logging.Formatter(
15
+ "%(asctime)s [%(levelname)s] [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
16
+ )
17
  ch.setFormatter(fmt)
18
  logger.addHandler(ch)
19
  logger.propagate = False
20
+ return logger
shared/memory_monitor.py CHANGED
@@ -13,6 +13,7 @@ from log_utils import setup_logger
13
 
14
  logger = setup_logger("MemoryMonitor")
15
 
 
16
  class _HasWorkerInfo(Protocol):
17
  """Minimal interface a worker object must expose for memory reporting."""
18
 
@@ -25,7 +26,7 @@ def start_memory_reporter(
25
  get_workers: callable,
26
  lock: threading.Condition,
27
  shutdown_flag: callable,
28
- interval_s: float = 60*60,
29
  max_ram_gb: float = 0,
30
  ) -> threading.Thread:
31
  """Launch a daemon thread that logs memory usage periodically.
@@ -117,9 +118,7 @@ def _log_report(
117
  workers = list(get_workers())
118
  for w in workers:
119
  if w.status == "dead" or not w.process.is_alive():
120
- lines.append(
121
- f" worker {w.worker_id} (pid={w.process.pid}): dead"
122
- )
123
  continue
124
  try:
125
  w_rss = psutil.Process(w.process.pid).memory_info().rss / (1024 * 1024)
@@ -129,10 +128,7 @@ def _log_report(
129
  f"RSS={w_rss:.0f} MB, status={w.status}"
130
  )
131
  except (psutil.NoSuchProcess, psutil.AccessDenied):
132
- lines.append(
133
- f" worker {w.worker_id} (pid={w.process.pid}): "
134
- f"not accessible"
135
- )
136
 
137
  lines.append(
138
  f" total: main={main_rss:.0f} MB + "
 
13
 
14
  logger = setup_logger("MemoryMonitor")
15
 
16
+
17
  class _HasWorkerInfo(Protocol):
18
  """Minimal interface a worker object must expose for memory reporting."""
19
 
 
26
  get_workers: callable,
27
  lock: threading.Condition,
28
  shutdown_flag: callable,
29
+ interval_s: float = 60 * 60,
30
  max_ram_gb: float = 0,
31
  ) -> threading.Thread:
32
  """Launch a daemon thread that logs memory usage periodically.
 
118
  workers = list(get_workers())
119
  for w in workers:
120
  if w.status == "dead" or not w.process.is_alive():
121
+ lines.append(f" worker {w.worker_id} (pid={w.process.pid}): dead")
 
 
122
  continue
123
  try:
124
  w_rss = psutil.Process(w.process.pid).memory_info().rss / (1024 * 1024)
 
128
  f"RSS={w_rss:.0f} MB, status={w.status}"
129
  )
130
  except (psutil.NoSuchProcess, psutil.AccessDenied):
131
+ lines.append(f" worker {w.worker_id} (pid={w.process.pid}): " f"not accessible")
 
 
 
132
 
133
  lines.append(
134
  f" total: main={main_rss:.0f} MB + "
shared/video_file_server.py CHANGED
@@ -56,7 +56,9 @@ class _VideoHandler(http.server.BaseHTTPRequestHandler):
56
  self.send_header("Access-Control-Allow-Origin", "*")
57
  self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
58
  self.send_header("Access-Control-Allow-Headers", "Range")
59
- self.send_header("Access-Control-Expose-Headers", "Content-Range, Content-Length, Accept-Ranges")
 
 
60
 
61
  # ------------------------------------------------------------------
62
  # HTTP methods
@@ -151,7 +153,7 @@ class _VideoHandler(http.server.BaseHTTPRequestHandler):
151
  """Parse ``Range: bytes=start-end`` and return (start, end) inclusive."""
152
  if not header.startswith("bytes="):
153
  raise ValueError(header)
154
- spec = header[len("bytes="):]
155
  parts = spec.split("-", 1)
156
  if len(parts) != 2:
157
  raise ValueError(header)
 
56
  self.send_header("Access-Control-Allow-Origin", "*")
57
  self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
58
  self.send_header("Access-Control-Allow-Headers", "Range")
59
+ self.send_header(
60
+ "Access-Control-Expose-Headers", "Content-Range, Content-Length, Accept-Ranges"
61
+ )
62
 
63
  # ------------------------------------------------------------------
64
  # HTTP methods
 
153
  """Parse ``Range: bytes=start-end`` and return (start, end) inclusive."""
154
  if not header.startswith("bytes="):
155
  raise ValueError(header)
156
+ spec = header[len("bytes=") :]
157
  parts = spec.split("-", 1)
158
  if len(parts) != 2:
159
  raise ValueError(header)
shared/video_processing.py CHANGED
@@ -4,17 +4,17 @@ Provides validation, frame-by-frame inference processing, and Gradio UI helpers
4
  that are common across all video-based demos.
5
  """
6
 
7
- import av
8
- import cv2
9
- import gradio as gr
10
- import numpy as np
11
  import os
12
  import tempfile
13
-
14
  from dataclasses import dataclass
15
  from pathlib import Path
16
  from typing import Any, Callable
17
 
 
 
 
 
 
18
  InferenceFn = Callable[[np.ndarray], np.ndarray]
19
 
20
 
@@ -115,7 +115,8 @@ def validate_video(video_path: str, limits: VideoLimits = DEFAULT_LIMITS) -> Non
115
 
116
  if duration > limits.max_duration_seconds:
117
  raise gr.Error(
118
- f"Video duration ({duration:.1f}s) exceeds the maximum allowed length of {limits.max_duration_seconds} seconds.")
 
119
  finally:
120
  video_capture.release()
121
 
@@ -184,7 +185,7 @@ def process_video(
184
 
185
  fps = video_capture.get(cv2.CAP_PROP_FPS)
186
  width = int(video_capture.get(cv2.CAP_PROP_FRAME_WIDTH))
187
- height = int(video_capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
188
  total_frames = int(video_capture.get(cv2.CAP_PROP_FRAME_COUNT))
189
 
190
  # Webcam-recorded videos (especially WebM from browsers) can report bogus
@@ -279,9 +280,7 @@ def wire_video_upload(
279
  )
280
 
281
  input_video.stop_recording(
282
- fn=validate_and_update,
283
- inputs=input_video,
284
- outputs=[input_video, process_btn]
285
  )
286
 
287
  input_video.clear(
 
4
  that are common across all video-based demos.
5
  """
6
 
 
 
 
 
7
  import os
8
  import tempfile
 
9
  from dataclasses import dataclass
10
  from pathlib import Path
11
  from typing import Any, Callable
12
 
13
+ import av
14
+ import cv2
15
+ import gradio as gr
16
+ import numpy as np
17
+
18
  InferenceFn = Callable[[np.ndarray], np.ndarray]
19
 
20
 
 
115
 
116
  if duration > limits.max_duration_seconds:
117
  raise gr.Error(
118
+ f"Video duration ({duration:.1f}s) exceeds the maximum allowed length of {limits.max_duration_seconds} seconds."
119
+ )
120
  finally:
121
  video_capture.release()
122
 
 
185
 
186
  fps = video_capture.get(cv2.CAP_PROP_FPS)
187
  width = int(video_capture.get(cv2.CAP_PROP_FRAME_WIDTH))
188
+ height = int(video_capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
189
  total_frames = int(video_capture.get(cv2.CAP_PROP_FRAME_COUNT))
190
 
191
  # Webcam-recorded videos (especially WebM from browsers) can report bogus
 
280
  )
281
 
282
  input_video.stop_recording(
283
+ fn=validate_and_update, inputs=input_video, outputs=[input_video, process_btn]
 
 
284
  )
285
 
286
  input_video.clear(