1
0

main.py 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. #!/usr/bin/env python3
  2. import base64
  3. import signal
  4. import time
  5. import cv2
  6. import numpy as np
  7. from fastapi import Response
  8. from nicegui import Client, app, core, run, ui
  9. # In case you don't have a webcam, this will provide a black placeholder image.
  10. black_1px = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII='
  11. placeholder = Response(content=base64.b64decode(black_1px.encode('ascii')), media_type='image/png')
  12. def convert(frame: np.ndarray) -> bytes:
  13. """Converts a frame from OpenCV to a JPEG image.
  14. This is a free function (not in a class or inner-function),
  15. to allow run.cpu_bound to pickle it and send it to a separate process.
  16. """
  17. _, imencode_image = cv2.imencode('.jpg', frame)
  18. return imencode_image.tobytes()
  19. def setup() -> None:
  20. # OpenCV is used to access the webcam.
  21. video_capture = cv2.VideoCapture(0)
  22. @app.get('/video/frame')
  23. # Thanks to FastAPI's `app.get` it is easy to create a web route which always provides the latest image from OpenCV.
  24. async def grab_video_frame() -> Response:
  25. if not video_capture.isOpened():
  26. return placeholder
  27. # The `video_capture.read` call is a blocking function.
  28. # So we run it in a separate thread (default executor) to avoid blocking the event loop.
  29. _, frame = await run.io_bound(video_capture.read)
  30. if frame is None:
  31. return placeholder
  32. # `convert` is a CPU-intensive function, so we run it in a separate process to avoid blocking the event loop and GIL.
  33. jpeg = await run.cpu_bound(convert, frame)
  34. return Response(content=jpeg, media_type='image/jpeg')
  35. # For non-flickering image updates and automatic bandwidth adaptation an interactive image is much better than `ui.image()`.
  36. video_image = ui.interactive_image().classes('w-full h-full')
  37. # A timer constantly updates the source of the image.
  38. # Because data from same paths is cached by the browser,
  39. # we must force an update by adding the current timestamp to the source.
  40. ui.timer(interval=0.1, callback=lambda: video_image.set_source(f'/video/frame?{time.time()}'))
  41. async def disconnect() -> None:
  42. """Disconnect all clients from current running server."""
  43. for client_id in Client.instances:
  44. await core.sio.disconnect(client_id)
  45. def handle_sigint(signum, frame) -> None:
  46. # `disconnect` is async, so it must be called from the event loop; we use `ui.timer` to do so.
  47. ui.timer(0.1, disconnect, once=True)
  48. # Delay the default handler to allow the disconnect to complete.
  49. ui.timer(1, lambda: signal.default_int_handler(signum, frame), once=True)
  50. async def cleanup() -> None:
  51. # This prevents ugly stack traces when auto-reloading on code change,
  52. # because otherwise disconnected clients try to reconnect to the newly started server.
  53. await disconnect()
  54. # Release the webcam hardware so it can be used by other applications again.
  55. video_capture.release()
  56. app.on_shutdown(cleanup)
  57. # We also need to disconnect clients when the app is stopped with Ctrl+C,
  58. # because otherwise they will keep requesting images which lead to unfinished subprocesses blocking the shutdown.
  59. signal.signal(signal.SIGINT, handle_sigint)
  60. # All the setup is only done when the server starts. This avoids the webcam being accessed
  61. # by the auto-reload main process (see https://github.com/zauberzeug/nicegui/discussions/2321).
  62. app.on_startup(setup)
  63. ui.run()