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
+57 -6
View File
@@ -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:
+1 -1
View File
@@ -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")