discover a camera when none is configured
validate / validate (push) Successful in 59m14s

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:
2026-09-15 06:16:14 -03:00
parent dc700106c5
commit 7628a8515b
5 changed files with 117 additions and 12 deletions
+9 -3
View File
@@ -74,7 +74,7 @@ compressed encoding, `width` and `height` are the image's dimensions, not the le
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `CAMERA_ID` | unset | Substring of a `/dev/v4l/by-id/` entry. Wins over `CAPTURE_PATH`. | | `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_WIDTH` | native | Requested capture width; frames are also resized to it. |
| `IMAGE_HEIGHT` | native | Requested capture height; 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`. | | `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. 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 With neither `CAMERA_ID` nor `CAPTURE_PATH` set, the node opens the first `/dev/video*`
across which USB port the camera is in, and an index is not. 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 `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 is what lets the dataflow run on a machine with no camera. It is not a configuration
+5 -2
View File
@@ -42,8 +42,11 @@ env:
which USB port the camera is in, unlike an index, so it takes precedence which USB port the camera is in, unlike an index, so it takes precedence
over CAPTURE_PATH. Linux only. over CAPTURE_PATH. Linux only.
CAPTURE_PATH: CAPTURE_PATH:
default: "0" description: >-
description: Device path such as /dev/video1, or an index. A numeric string is read as an index. 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: IMAGE_WIDTH:
type: int type: int
description: Requested capture width; frames are also resized to it. Defaults to the camera's native width. description: Requested capture width; frames are also resized to it. Defaults to the camera's native width.
+57 -6
View File
@@ -1,18 +1,25 @@
"""Opening a capture device and reading frames from it.""" """Opening a capture device and reading frames from it."""
import glob
import os import os
import platform import platform
import re
import cv2 import cv2
import numpy as np import numpy as np
BY_ID = "/dev/v4l/by-id/" 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. # Size of the frame substituted when a read fails under CI.
PLACEHOLDER = (480, 640) 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. """Decide which device to open.
Parameters 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 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 across reboots and across which USB port a camera is in, unlike an
index, so it takes precedence when set. 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 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 Returns
------- -------
@@ -47,10 +54,54 @@ def resolve(camera_id: str | None, capture_path: str | int) -> str | int:
return index return index
if isinstance(capture_path, str) and capture_path.isnumeric(): if capture_path is not None and capture_path != "":
return int(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: def read(capture: cv2.VideoCapture, device: str | int, ci: bool) -> np.ndarray:
+1 -1
View File
@@ -24,7 +24,7 @@ def main() -> None:
"""Open the camera and publish one frame per tick.""" """Open the camera and publish one frame per tick."""
args = _parse_args() 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) capture = cv2.VideoCapture(device)
width = _int("IMAGE_WIDTH") width = _int("IMAGE_WIDTH")
+45
View File
@@ -18,6 +18,51 @@ def frame() -> np.ndarray:
return rng.integers(0, 255, (HEIGHT, WIDTH, 3), dtype=np.uint8) 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(): def test_a_numeric_capture_path_is_read_as_an_index():
assert camera.resolve(None, "1") == 1 assert camera.resolve(None, "1") == 1