diff --git a/README.md b/README.md index c6eae67..e8c13ac 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ compressed encoding, `width` and `height` are the image's dimensions, not the le | Variable | Default | Meaning | |---|---|---| | `CAMERA_ID` | unset | Substring of a `/dev/v4l/by-id/` entry. Wins over `CAPTURE_PATH`. | -| `CAPTURE_PATH` | `0` | Device path (`/dev/video1`) or index. A numeric string is an index. | +| `CAPTURE_PATH` | auto | Device path (`/dev/video1`) or index. A numeric string is an index. | | `IMAGE_WIDTH` | native | Requested capture width; frames are also resized to it. | | `IMAGE_HEIGHT` | native | Requested capture height; frames are also resized to it. | | `ENCODING` | `bgr8` | One of `bgr8`, `rgb8`, `yuv420`, `jpeg`/`jpg`/`jpe`, `png`, `bmp`, `webp`. | @@ -85,8 +85,14 @@ compressed encoding, `width` and `height` are the image's dimensions, not the le Resizing only happens when both `IMAGE_WIDTH` and `IMAGE_HEIGHT` are set. -`CAMERA_ID` is worth preferring on a robot: a by-id name is stable across reboots and -across which USB port the camera is in, and an index is not. +With neither `CAMERA_ID` nor `CAPTURE_PATH` set, the node opens the first `/dev/video*` +that actually delivers a frame. Merely opening is not enough to go on: a UVC camera +exposes a second node for its metadata stream — `/dev/video1` beside `/dev/video0` on a +one-camera machine — which opens happily and never yields an image, so the lowest device +number is wrong about as often as it is right. + +`CAMERA_ID` is worth preferring on a robot anyway: a by-id name is stable across reboots +and across which USB port the camera is in, and neither an index nor discovery order is. `CI=true` makes a failed read publish a labelled black frame instead of raising, which is what lets the dataflow run on a machine with no camera. It is not a configuration diff --git a/dora-node.yml b/dora-node.yml index 7819196..a8b73eb 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -42,8 +42,11 @@ env: which USB port the camera is in, unlike an index, so it takes precedence over CAPTURE_PATH. Linux only. CAPTURE_PATH: - default: "0" - description: Device path such as /dev/video1, or an index. A numeric string is read as an index. + description: >- + Device path such as /dev/video1, or an index. A numeric string is read as + an index. When unset, the first /dev/video* that actually delivers a frame + is used -- a UVC camera also exposes a metadata node that opens and never + captures, so the lowest device number is not a safe default. IMAGE_WIDTH: type: int description: Requested capture width; frames are also resized to it. Defaults to the camera's native width. diff --git a/dora_webcam/camera.py b/dora_webcam/camera.py index af91a17..f270ace 100644 --- a/dora_webcam/camera.py +++ b/dora_webcam/camera.py @@ -1,18 +1,25 @@ """Opening a capture device and reading frames from it.""" +import glob import os import platform +import re import cv2 import numpy as np BY_ID = "/dev/v4l/by-id/" +DEVICES = "/dev/video*" + +# Where discovery gives up, so the failure is reported by the first read with +# the same message an explicitly chosen device would produce. +FALLBACK = 0 # 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: +def resolve(camera_id: str | None, capture_path: str | int | None) -> str | int: """Decide which device to open. Parameters @@ -21,9 +28,9 @@ def resolve(camera_id: str | None, capture_path: str | int) -> str | int: 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 + capture_path : str or int or None Device path such as ``/dev/video1``, or an index. A numeric string is - read as an index. + read as an index. When this is unset too, a camera is discovered. Returns ------- @@ -47,10 +54,54 @@ def resolve(camera_id: str | None, capture_path: str | int) -> str | int: return index - if isinstance(capture_path, str) and capture_path.isnumeric(): - return int(capture_path) + if capture_path is not None and capture_path != "": + if isinstance(capture_path, str) and capture_path.isnumeric(): + return int(capture_path) - return capture_path + return capture_path + + return discover() or FALLBACK + + +def discover() -> str | None: + """Find the first video device that actually delivers a frame. + + Opening is not enough to go on. A UVC camera exposes a second node for its + metadata stream -- /dev/video1 beside /dev/video0 on a one-camera machine -- + which opens happily and never yields an image, so taking the lowest device + number picks the wrong one as often as not. + + Returns + ------- + str or None + Device path, or None when nothing on this machine captures. + + """ + for path in _devices(): + if _yields_frame(path): + return path + + return None + + +def _devices() -> list[str]: + """List /dev/video* in numeric order, so video2 does not sort before video10.""" + return sorted(glob.glob(DEVICES), key=_device_number) + + +def _device_number(path: str) -> int: + match = re.search(r"(\d+)$", path) + return int(match.group(1)) if match else 0 + + +def _yields_frame(path: str) -> bool: + """Whether a device both opens and reads one frame.""" + capture = cv2.VideoCapture(path) + + try: + return bool(capture.isOpened() and capture.read()[0]) + finally: + capture.release() def read(capture: cv2.VideoCapture, device: str | int, ci: bool) -> np.ndarray: diff --git a/dora_webcam/main.py b/dora_webcam/main.py index 6f2b837..848b6db 100644 --- a/dora_webcam/main.py +++ b/dora_webcam/main.py @@ -24,7 +24,7 @@ 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")) + device = camera.resolve(os.getenv("CAMERA_ID"), os.getenv("CAPTURE_PATH")) capture = cv2.VideoCapture(device) width = _int("IMAGE_WIDTH") diff --git a/tests/test_dora_webcam.py b/tests/test_dora_webcam.py index 13f372e..5e47de4 100644 --- a/tests/test_dora_webcam.py +++ b/tests/test_dora_webcam.py @@ -18,6 +18,51 @@ def frame() -> np.ndarray: return rng.integers(0, 255, (HEIGHT, WIDTH, 3), dtype=np.uint8) +def test_discovery_skips_a_device_that_opens_but_never_yields_a_frame(monkeypatch): + # A UVC camera exposes a metadata node beside the capture node; it opens + # and reads nothing, so the lowest device number is the wrong one to take. + monkeypatch.setattr(camera, "_devices", lambda: ["/dev/video0", "/dev/video1"]) + monkeypatch.setattr(camera, "_yields_frame", lambda path: path == "/dev/video1") + + assert camera.discover() == "/dev/video1" + + +def test_discovery_returns_none_when_nothing_captures(monkeypatch): + monkeypatch.setattr(camera, "_devices", lambda: ["/dev/video0"]) + monkeypatch.setattr(camera, "_yields_frame", lambda path: False) + + assert camera.discover() is None + + +def test_devices_are_ordered_numerically(monkeypatch): + monkeypatch.setattr( + camera.glob, "glob", + lambda pattern: ["/dev/video10", "/dev/video2", "/dev/video1"], + ) + + assert camera._devices() == ["/dev/video1", "/dev/video2", "/dev/video10"] + + +def test_an_unset_device_is_discovered(monkeypatch): + monkeypatch.setattr(camera, "discover", lambda: "/dev/video3") + + assert camera.resolve(None, None) == "/dev/video3" + + +def test_an_unset_device_falls_back_when_nothing_is_found(monkeypatch): + # Falling back rather than raising keeps the failure in one place: the + # first read, which under CI publishes a placeholder instead of raising. + monkeypatch.setattr(camera, "discover", lambda: None) + + assert camera.resolve(None, None) == camera.FALLBACK + + +def test_an_explicit_device_is_not_overridden_by_discovery(monkeypatch): + monkeypatch.setattr(camera, "discover", lambda: "/dev/video9") + + assert camera.resolve(None, "/dev/video4") == "/dev/video4" + + def test_a_numeric_capture_path_is_read_as_an_index(): assert camera.resolve(None, "1") == 1