validate / validate (push) Successful in 59m15s
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.
114 lines
3.3 KiB
Python
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()
|