|
@@ -12,60 +12,69 @@ from nicegui import Client, app, core, run, ui
|
|
|
# In case you don't have a webcam, this will provide a black placeholder image.
|
|
|
black_1px = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII='
|
|
|
placeholder = Response(content=base64.b64decode(black_1px.encode('ascii')), media_type='image/png')
|
|
|
-# OpenCV is used to access the webcam.
|
|
|
-video_capture = cv2.VideoCapture(0)
|
|
|
|
|
|
|
|
|
def convert(frame: np.ndarray) -> bytes:
|
|
|
+ """Converts a frame from OpenCV to a JPEG image.
|
|
|
+
|
|
|
+ This is a free function (not in a class or inner-function),
|
|
|
+ to allow run.cpu_bound to pickle it and send it to a separate process.
|
|
|
+ """
|
|
|
_, imencode_image = cv2.imencode('.jpg', frame)
|
|
|
return imencode_image.tobytes()
|
|
|
|
|
|
|
|
|
-@app.get('/video/frame')
|
|
|
-# Thanks to FastAPI's `app.get`` it is easy to create a web route which always provides the latest image from OpenCV.
|
|
|
-async def grab_video_frame() -> Response:
|
|
|
- if not video_capture.isOpened():
|
|
|
- return placeholder
|
|
|
- # The `video_capture.read` call is a blocking function.
|
|
|
- # So we run it in a separate thread (default executor) to avoid blocking the event loop.
|
|
|
- _, frame = await run.io_bound(video_capture.read)
|
|
|
- if frame is None:
|
|
|
- return placeholder
|
|
|
- # `convert` is a CPU-intensive function, so we run it in a separate process to avoid blocking the event loop and GIL.
|
|
|
- jpeg = await run.cpu_bound(convert, frame)
|
|
|
- return Response(content=jpeg, media_type='image/jpeg')
|
|
|
-
|
|
|
-# For non-flickering image updates an interactive image is much better than `ui.image()`.
|
|
|
-video_image = ui.interactive_image().classes('w-full h-full')
|
|
|
-# A timer constantly updates the source of the image.
|
|
|
-# Because data from same paths are cached by the browser,
|
|
|
-# we must force an update by adding the current timestamp to the source.
|
|
|
-ui.timer(interval=0.1, callback=lambda: video_image.set_source(f'/video/frame?{time.time()}'))
|
|
|
-
|
|
|
-
|
|
|
-async def disconnect() -> None:
|
|
|
- """Disconnect all clients from current running server."""
|
|
|
- for client_id in Client.instances:
|
|
|
- await core.sio.disconnect(client_id)
|
|
|
-
|
|
|
-
|
|
|
-def handle_sigint(signum, frame) -> None:
|
|
|
- # `disconnect` is async, so it must be called from the event loop; we use `ui.timer` to do so.
|
|
|
- ui.timer(0.1, disconnect, once=True)
|
|
|
- # Delay the default handler to allow the disconnect to complete.
|
|
|
- ui.timer(1, lambda: signal.default_int_handler(signum, frame), once=True)
|
|
|
-
|
|
|
-
|
|
|
-async def cleanup() -> None:
|
|
|
- # This prevents ugly stack traces when auto-reloading on code change,
|
|
|
- # because otherwise disconnected clients try to reconnect to the newly started server.
|
|
|
- await disconnect()
|
|
|
- # Release the webcam hardware so it can be used by other applications again.
|
|
|
- video_capture.release()
|
|
|
-
|
|
|
-app.on_shutdown(cleanup)
|
|
|
-# We also need to disconnect clients when the app is stopped with Ctrl+C,
|
|
|
-# because otherwise they will keep requesting images which lead to unfinished subprocesses blocking the shutdown.
|
|
|
-signal.signal(signal.SIGINT, handle_sigint)
|
|
|
+def setup() -> None:
|
|
|
+ # OpenCV is used to access the webcam.
|
|
|
+ video_capture = cv2.VideoCapture(0)
|
|
|
+
|
|
|
+ @app.get('/video/frame')
|
|
|
+ # Thanks to FastAPI's `app.get` it is easy to create a web route which always provides the latest image from OpenCV.
|
|
|
+ async def grab_video_frame() -> Response:
|
|
|
+ if not video_capture.isOpened():
|
|
|
+ return placeholder
|
|
|
+ # The `video_capture.read` call is a blocking function.
|
|
|
+ # So we run it in a separate thread (default executor) to avoid blocking the event loop.
|
|
|
+ _, frame = await run.io_bound(video_capture.read)
|
|
|
+ if frame is None:
|
|
|
+ return placeholder
|
|
|
+ # `convert` is a CPU-intensive function, so we run it in a separate process to avoid blocking the event loop and GIL.
|
|
|
+ jpeg = await run.cpu_bound(convert, frame)
|
|
|
+ return Response(content=jpeg, media_type='image/jpeg')
|
|
|
+
|
|
|
+ # For non-flickering image updates and automatic bandwidth adaptation an interactive image is much better than `ui.image()`.
|
|
|
+ video_image = ui.interactive_image().classes('w-full h-full')
|
|
|
+ # A timer constantly updates the source of the image.
|
|
|
+ # Because data from same paths is cached by the browser,
|
|
|
+ # we must force an update by adding the current timestamp to the source.
|
|
|
+ ui.timer(interval=0.1, callback=lambda: video_image.set_source(f'/video/frame?{time.time()}'))
|
|
|
+
|
|
|
+ async def disconnect() -> None:
|
|
|
+ """Disconnect all clients from current running server."""
|
|
|
+ for client_id in Client.instances:
|
|
|
+ await core.sio.disconnect(client_id)
|
|
|
+
|
|
|
+ def handle_sigint(signum, frame) -> None:
|
|
|
+ # `disconnect` is async, so it must be called from the event loop; we use `ui.timer` to do so.
|
|
|
+ ui.timer(0.1, disconnect, once=True)
|
|
|
+ # Delay the default handler to allow the disconnect to complete.
|
|
|
+ ui.timer(1, lambda: signal.default_int_handler(signum, frame), once=True)
|
|
|
+
|
|
|
+ async def cleanup() -> None:
|
|
|
+ # This prevents ugly stack traces when auto-reloading on code change,
|
|
|
+ # because otherwise disconnected clients try to reconnect to the newly started server.
|
|
|
+ await disconnect()
|
|
|
+ # Release the webcam hardware so it can be used by other applications again.
|
|
|
+ video_capture.release()
|
|
|
+
|
|
|
+ app.on_shutdown(cleanup)
|
|
|
+ # We also need to disconnect clients when the app is stopped with Ctrl+C,
|
|
|
+ # because otherwise they will keep requesting images which lead to unfinished subprocesses blocking the shutdown.
|
|
|
+ signal.signal(signal.SIGINT, handle_sigint)
|
|
|
+
|
|
|
+
|
|
|
+# All the setup is only done when the server starts. This avoids the webcam being accessed
|
|
|
+# by the auto-reload main process (see https://github.com/zauberzeug/nicegui/discussions/2321).
|
|
|
+app.on_startup(setup)
|
|
|
|
|
|
ui.run()
|