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,148 @@
|
||||
"""Tests that need neither a camera nor a dataflow."""
|
||||
|
||||
import os
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
|
||||
from dora_webcam import camera, encode, image
|
||||
|
||||
WIDTH, HEIGHT = 12, 8
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def frame() -> np.ndarray:
|
||||
rng = np.random.default_rng(seed=0)
|
||||
return rng.integers(0, 255, (HEIGHT, WIDTH, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def test_a_numeric_capture_path_is_read_as_an_index():
|
||||
assert camera.resolve(None, "1") == 1
|
||||
|
||||
|
||||
def test_a_device_path_is_passed_through():
|
||||
assert camera.resolve(None, "/dev/video2") == "/dev/video2"
|
||||
|
||||
|
||||
def test_an_unmatched_camera_id_is_an_error():
|
||||
with pytest.raises(RuntimeError, match="no camera matching id"):
|
||||
camera.resolve("nothing-matches-this", "0")
|
||||
|
||||
|
||||
def test_camera_id_resolves_through_by_id(tmp_path, monkeypatch):
|
||||
# A by-id directory of one symlink, standing in for /dev/v4l/by-id.
|
||||
by_id = tmp_path / "by-id"
|
||||
by_id.mkdir()
|
||||
(by_id / "usb-Acme_Webcam_1234-video-index0").symlink_to("/dev/video3")
|
||||
monkeypatch.setattr(camera, "BY_ID", str(by_id) + os.sep)
|
||||
|
||||
assert camera.resolve("acme_webcam", "0") == 3
|
||||
|
||||
|
||||
def test_camera_id_wins_over_capture_path(tmp_path, monkeypatch):
|
||||
by_id = tmp_path / "by-id"
|
||||
by_id.mkdir()
|
||||
(by_id / "usb-Acme_Webcam-video-index0").symlink_to("/dev/video5")
|
||||
monkeypatch.setattr(camera, "BY_ID", str(by_id) + os.sep)
|
||||
|
||||
assert camera.resolve("acme", "/dev/video9") == 5
|
||||
|
||||
|
||||
def test_bgr8_is_passed_through_unchanged(frame):
|
||||
np.testing.assert_array_equal(encode.encode(frame, "bgr8", 95), frame)
|
||||
|
||||
|
||||
def test_rgb8_swaps_the_channels(frame):
|
||||
encoded = encode.encode(frame, "rgb8", 95)
|
||||
|
||||
np.testing.assert_array_equal(cv2.cvtColor(encoded, cv2.COLOR_RGB2BGR), frame)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["jpeg", "png", "bmp", "webp"])
|
||||
def test_compressed_encodings_round_trip(frame, encoding):
|
||||
encoded = encode.encode(frame, encoding, 100)
|
||||
|
||||
decoded = cv2.imdecode(np.frombuffer(encoded.tobytes(), np.uint8), cv2.IMREAD_COLOR)
|
||||
|
||||
assert decoded.shape == frame.shape
|
||||
|
||||
|
||||
def test_yuv420_is_one_and_a_half_bytes_per_pixel(frame):
|
||||
encoded = encode.encode(frame, "yuv420", 95)
|
||||
|
||||
assert encoded.size == WIDTH * HEIGHT * 3 // 2
|
||||
|
||||
|
||||
def test_an_unsupported_encoding_is_rejected(frame):
|
||||
with pytest.raises(RuntimeError, match="unsupported encoding"):
|
||||
encode.encode(frame, "tiff", 95)
|
||||
|
||||
|
||||
def test_packing_produces_the_image_struct(frame):
|
||||
packed = image.pack(frame, "bgr8", WIDTH, HEIGHT)
|
||||
|
||||
assert packed.type == image.IMAGE_TYPE
|
||||
row = packed[0].as_py()
|
||||
assert (row["width"], row["height"], row["encoding"]) == (WIDTH, HEIGHT, "bgr8")
|
||||
np.testing.assert_array_equal(
|
||||
np.frombuffer(row["data"], np.uint8).reshape(HEIGHT, WIDTH, 3),
|
||||
frame,
|
||||
)
|
||||
|
||||
|
||||
def test_packing_records_image_size_not_payload_size(frame):
|
||||
# A compressed payload is a 1-D byte run; the receiver still needs the
|
||||
# image's dimensions, so they are recorded rather than inferred.
|
||||
encoded = encode.encode(frame, "jpeg", 95)
|
||||
|
||||
row = image.pack(encoded, "jpeg", WIDTH, HEIGHT)[0].as_py()
|
||||
|
||||
assert (row["width"], row["height"]) == (WIDTH, HEIGHT)
|
||||
assert len(row["data"]) != WIDTH * HEIGHT * 3
|
||||
|
||||
|
||||
def test_the_placeholder_frame_is_labelled():
|
||||
frame = camera.placeholder("no frame from 0")
|
||||
|
||||
assert frame.shape == (*camera.PLACEHOLDER, 3)
|
||||
assert frame.any(), "the message should have been drawn"
|
||||
|
||||
|
||||
class FakeCapture:
|
||||
"""Stands in for cv2.VideoCapture."""
|
||||
|
||||
def __init__(self, ok: bool) -> None:
|
||||
self._ok = ok
|
||||
|
||||
def read(self):
|
||||
return self._ok, np.zeros((4, 4, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def test_a_failed_read_raises_outside_ci():
|
||||
with pytest.raises(RuntimeError, match="could not read a frame"):
|
||||
camera.read(FakeCapture(ok=False), 0, ci=False)
|
||||
|
||||
|
||||
def test_a_failed_read_yields_a_placeholder_under_ci():
|
||||
frame = camera.read(FakeCapture(ok=False), 0, ci=True)
|
||||
|
||||
assert frame.shape == (*camera.PLACEHOLDER, 3)
|
||||
|
||||
|
||||
def test_a_good_read_is_returned_as_is():
|
||||
frame = camera.read(FakeCapture(ok=True), 0, ci=False)
|
||||
|
||||
assert frame.shape == (4, 4, 3)
|
||||
|
||||
|
||||
def test_the_struct_matches_what_the_viewer_expects():
|
||||
# The viewer respells this schema in its own repository; they share no
|
||||
# package, so the field names and types are the entire contract.
|
||||
assert image.IMAGE_TYPE == pa.struct([
|
||||
("width", pa.uint32()),
|
||||
("height", pa.uint32()),
|
||||
("encoding", pa.utf8()),
|
||||
("data", pa.large_binary()),
|
||||
])
|
||||
Reference in New Issue
Block a user