Files
dora-webcam/dora_webcam/main.py
T
cristhian 3c0e0f327d
validate / validate (push) Successful in 59m15s
add dora-webcam node
Captures frames from a V4L2 camera and publishes them as std/media/v1/Image:
an Arrow struct of width, height, encoding and data, so a receiver needs
nothing from the message metadata. For a compressed encoding the recorded
dimensions are the image's, not the length of the payload.

Based on opencv-video-capture from dora-hub, with three changes. The output is
the typed image struct rather than a flat UInt8 buffer plus metadata. Camera
selection by id reads /dev/v4l/by-id/ only; the upstream macOS system_profiler
and Windows PowerShell enumeration is not carried. CAPTURE_FRAMES bounds a run
so a test ends on its own, in place of upstream's ten-second CI cutoff -- a
timeout would have made the CI step exit non-zero.

CI=true keeps a failed capture from raising, publishing a labelled black frame
instead, which is what lets dataflow.yml run on a machine with no camera.

Two dataflows, run one at a time, as dora has no cross-dataflow wiring:
dataflow.yml is the camera alone and the CI gate; dataflow_view.yml wires it
into dora-image-view and is not run by CI, since it fetches that node over the
network and needs a tag to exist there.
2026-09-15 06:04:25 -03:00

114 lines
3.3 KiB
Python

"""A dora webcam source: captures frames and publishes them as std/media/v1/Image.
Each `tick` reads one frame, optionally flips and resizes it, encodes it, and
sends it. The node stops when the tick input closes, or after CAPTURE_FRAMES
frames if that is set.
"""
import argparse
import os
import cv2
from dora import Node
from dora_webcam import camera, encode, image
FLIPS = {
"VERTICAL": 0,
"HORIZONTAL": 1,
"BOTH": -1,
}
def main() -> None:
"""Open the camera and publish one frame per tick."""
args = _parse_args()
device = camera.resolve(os.getenv("CAMERA_ID"), os.getenv("CAPTURE_PATH", "0"))
capture = cv2.VideoCapture(device)
width = _int("IMAGE_WIDTH")
height = _int("IMAGE_HEIGHT")
# Ask the camera for the size rather than only resizing after the fact: a
# camera that can deliver it natively avoids a scale per frame.
if width and height:
capture.set(cv2.CAP_PROP_FRAME_WIDTH, width)
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
encoding = os.getenv("ENCODING", "bgr8").lower()
quality = _int("JPEG_QUALITY") or 95
flip = FLIPS.get(os.getenv("FLIP", "").upper())
ci = os.getenv("CI") == "true"
# 0 runs until the tick closes; a positive count is what lets CI run the
# dataflow to completion instead of killing it on a timeout.
limit = _int("CAPTURE_FRAMES") or 0
node = Node(args.name)
sent = 0
try:
for event in node:
if event["type"] == "ERROR":
raise RuntimeError(event["error"])
if event["type"] == "INPUT_CLOSED" and event.get("id") == "tick":
break
if event["type"] != "INPUT" or event["id"] != "tick":
continue
frame = camera.read(capture, device, ci)
if flip is not None:
frame = cv2.flip(frame, flip)
if width and height and (frame.shape[1], frame.shape[0]) != (width, height):
frame = cv2.resize(frame, (width, height))
# Taken before encoding: a compressed payload's byte count is not
# its pixel dimensions.
frame_height, frame_width = frame.shape[:2]
node.send_output(
"image",
image.pack(
encode.encode(frame, encoding, quality),
encoding,
frame_width,
frame_height,
),
{},
)
sent += 1
if limit and sent >= limit:
break
finally:
capture.release()
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Dora webcam source: publishes captured frames as std/media/v1/Image.",
)
parser.add_argument(
"--name",
type=str,
default="dora-webcam",
# Passing the name lets the node also run standalone as a dynamic node;
# when dora spawns it, the id from the dataflow wins.
help="The name of the node in the dataflow.",
)
return parser.parse_args()
def _int(name: str) -> int | None:
"""Read an integer environment variable, treating unset and empty as absent."""
value = os.getenv(name)
return int(value) if value else None
if __name__ == "__main__":
main()