CAPTURE_PATH defaulted to index 0, which is a coin flip on a machine with one camera. A UVC camera exposes a second device node for its metadata stream -- /dev/video1 beside /dev/video0 -- and that node opens happily and never yields an image, so the lowest device number is wrong about as often as it is right. With neither CAMERA_ID nor CAPTURE_PATH set, the node now opens each /dev/video* in numeric order and keeps the first that actually delivers a frame. Reading one is the test, because opening is not evidence. Discovery finding nothing falls back to index 0 rather than raising, which keeps the failure in one place: the first read, where CI publishes a placeholder and a normal run raises with a message naming the device. CAMERA_ID is still worth preferring on a robot. Neither an index nor discovery order is stable across reboots or across which port a camera is in; a by-id name is.
This commit is contained in:
@@ -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
|
||||
|
||||
+5
-2
@@ -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.
|
||||
|
||||
+54
-3
@@ -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,11 +54,55 @@ def resolve(camera_id: str | None, capture_path: str | int) -> str | int:
|
||||
|
||||
return index
|
||||
|
||||
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 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:
|
||||
"""Read one frame, or a placeholder when running without a camera.
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user