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 @@
|
||||
"""Dora node that captures webcam frames and publishes them as std/media/v1/Image."""
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Opening a capture device and reading frames from it."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
BY_ID = "/dev/v4l/by-id/"
|
||||
|
||||
# Size of the frame substituted when a read fails under CI.
|
||||
PLACEHOLDER = (480, 640)
|
||||
|
||||
|
||||
def resolve(camera_id: str | None, capture_path: str | int) -> str | int:
|
||||
"""Decide which device to open.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
camera_id : str or None
|
||||
Substring of an entry in ``/dev/v4l/by-id/``. A by-id name is stable
|
||||
across reboots and across which USB port a camera is in, unlike an
|
||||
index, so it takes precedence when set.
|
||||
capture_path : str or int
|
||||
Device path such as ``/dev/video1``, or an index. A numeric string is
|
||||
read as an index.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str or int
|
||||
The argument to hand to ``cv2.VideoCapture``.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If ``camera_id`` is set and matches no device.
|
||||
|
||||
"""
|
||||
if camera_id:
|
||||
index = _by_id(camera_id)
|
||||
|
||||
if index is None:
|
||||
raise RuntimeError(
|
||||
f"no camera matching id `{camera_id}`. "
|
||||
f"{_hint()}",
|
||||
)
|
||||
|
||||
return index
|
||||
|
||||
if isinstance(capture_path, str) and capture_path.isnumeric():
|
||||
return int(capture_path)
|
||||
|
||||
return capture_path
|
||||
|
||||
|
||||
def read(capture: cv2.VideoCapture, device: str | int, ci: bool) -> np.ndarray:
|
||||
"""Read one frame, or a placeholder when running without a camera.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
capture : cv2.VideoCapture
|
||||
An opened capture device.
|
||||
device : str or int
|
||||
What was opened, used only in messages.
|
||||
ci : bool
|
||||
When true, a failed read yields a labelled black frame instead of
|
||||
raising. This is what lets the dataflow run on a machine with no
|
||||
camera, which is every CI runner.
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
A BGR frame.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If the read fails and ``ci`` is false.
|
||||
|
||||
"""
|
||||
ok, frame = capture.read()
|
||||
|
||||
if ok:
|
||||
return frame
|
||||
|
||||
if not ci:
|
||||
raise RuntimeError(
|
||||
f"could not read a frame from `{device}`. To keep a dataflow "
|
||||
f"running across a camera disconnect, set `restart_policy: "
|
||||
f"on-failure` on this node.",
|
||||
)
|
||||
|
||||
return placeholder(f"no frame from {device}")
|
||||
|
||||
|
||||
def placeholder(message: str) -> np.ndarray:
|
||||
"""Build the black frame that stands in for a camera that did not read.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message : str
|
||||
Text drawn on the frame, so a recording shows why it is black.
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
A BGR frame of size ``PLACEHOLDER``.
|
||||
|
||||
"""
|
||||
frame = np.zeros((*PLACEHOLDER, 3), dtype=np.uint8)
|
||||
cv2.putText(
|
||||
frame,
|
||||
message,
|
||||
(30, 30),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.5,
|
||||
(255, 255, 255),
|
||||
1,
|
||||
)
|
||||
return frame
|
||||
|
||||
|
||||
def _by_id(camera_id: str) -> int | None:
|
||||
"""Find the /dev/videoN index whose by-id name contains `camera_id`."""
|
||||
if not os.path.isdir(BY_ID):
|
||||
return None
|
||||
|
||||
for entry in sorted(os.listdir(BY_ID)):
|
||||
if camera_id.lower() not in entry.lower():
|
||||
continue
|
||||
|
||||
target = os.path.realpath(os.path.join(BY_ID, entry))
|
||||
|
||||
if target.startswith("/dev/video"):
|
||||
return int(target.removeprefix("/dev/video"))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _hint() -> str:
|
||||
if platform.system() != "Linux":
|
||||
return (
|
||||
"Selecting a camera by id is implemented for Linux only; "
|
||||
"use CAPTURE_PATH with an index or device path instead."
|
||||
)
|
||||
|
||||
return f"Check {BY_ID} for the ids this machine offers."
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Turning a captured BGR frame into the bytes of a chosen encoding."""
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
# Compressed encodings, and the OpenCV file suffix each is written through.
|
||||
COMPRESSED = {
|
||||
"jpeg": ".jpeg",
|
||||
"jpg": ".jpg",
|
||||
"jpe": ".jpe",
|
||||
"png": ".png",
|
||||
"bmp": ".bmp",
|
||||
"webp": ".webp",
|
||||
}
|
||||
|
||||
RAW = ("bgr8", "rgb8", "yuv420")
|
||||
|
||||
SUPPORTED = (*RAW, *COMPRESSED)
|
||||
|
||||
|
||||
def encode(frame: np.ndarray, encoding: str, jpeg_quality: int) -> np.ndarray:
|
||||
"""Convert a BGR frame into the bytes of ``encoding``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
frame : np.ndarray
|
||||
Captured frame, BGR, as OpenCV reads it.
|
||||
encoding : str
|
||||
One of ``SUPPORTED``.
|
||||
jpeg_quality : int
|
||||
Quality 0-100, consulted only for the JPEG encodings.
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
The bytes to publish. Its shape is not the image shape for a
|
||||
compressed encoding, which is why the dimensions are recorded
|
||||
separately.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If ``encoding`` is unsupported, or the encoder fails.
|
||||
|
||||
"""
|
||||
if encoding == "bgr8":
|
||||
return frame
|
||||
|
||||
if encoding == "rgb8":
|
||||
return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
|
||||
if encoding == "yuv420":
|
||||
return cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420)
|
||||
|
||||
if encoding in COMPRESSED:
|
||||
params = (
|
||||
[cv2.IMWRITE_JPEG_QUALITY, jpeg_quality]
|
||||
if encoding in ("jpeg", "jpg", "jpe")
|
||||
else []
|
||||
)
|
||||
ok, encoded = cv2.imencode(COMPRESSED[encoding], frame, params)
|
||||
|
||||
if not ok:
|
||||
raise RuntimeError(f"could not encode the frame as {encoding}")
|
||||
|
||||
return encoded
|
||||
|
||||
raise RuntimeError(
|
||||
f"unsupported encoding: {encoding}; expected one of {', '.join(SUPPORTED)}",
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Packing a frame as std/media/v1/Image.
|
||||
|
||||
The struct is respelled here rather than imported from the viewer: node
|
||||
repositories share no source package, so the manifest and this schema are the
|
||||
whole contract between them.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
|
||||
IMAGE_TYPE = pa.struct([
|
||||
("width", pa.uint32()),
|
||||
("height", pa.uint32()),
|
||||
("encoding", pa.utf8()),
|
||||
("data", pa.large_binary()),
|
||||
])
|
||||
|
||||
|
||||
def pack(frame: np.ndarray, encoding: str, width: int, height: int) -> pa.Array:
|
||||
"""Pack encoded frame bytes as a single-row std/media/v1/Image array.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
frame : np.ndarray
|
||||
The bytes to send. Either a raw pixel buffer or the output of an
|
||||
encoder; either way only its bytes are read.
|
||||
encoding : str
|
||||
Name of the encoding the bytes are in, e.g. ``bgr8`` or ``jpeg``.
|
||||
width, height : int
|
||||
Dimensions of the *image*, which for a compressed encoding are not the
|
||||
shape of ``frame``. They are recorded so a receiver can size a buffer
|
||||
without decoding first.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pa.Array
|
||||
One-row struct array matching ``IMAGE_TYPE``.
|
||||
|
||||
"""
|
||||
return pa.array(
|
||||
[{
|
||||
"width": width,
|
||||
"height": height,
|
||||
"encoding": encoding,
|
||||
"data": frame.tobytes(),
|
||||
}],
|
||||
type=IMAGE_TYPE,
|
||||
)
|
||||
@@ -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