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.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user