Skip to content

Commit

Permalink
Ensure cv2.VideoCapture is only called once (#3619)
Browse files Browse the repository at this point in the history
* ensure cv2.VideoCapture is only called once (see #2321)

* review

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
  • Loading branch information
rodja and falkoschindler committed Aug 28, 2024
1 parent 43121d3 commit 53cedb1
Showing 1 changed file with 57 additions and 48 deletions.
105 changes: 57 additions & 48 deletions examples/opencv_webcam/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,60 +12,69 @@
# 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()

0 comments on commit 53cedb1

Please sign in to comment.