Add camera selection diagnostics

resolve_device now prints each by-id candidate it scans, which entry
matched, and the resolved device path; the no-match error lists all
by-id entries with their targets. Camera startup logs the raw config
setting, resolved path, the node's sysfs name, the OpenCV version, and
the negotiated resolution. 'python3 camera.py' now dumps every detected
camera before opening, to troubleshoot machines where the wrong camera
gets used.
This commit is contained in:
2026-07-18 15:21:32 -04:00
parent 8af59ac11f
commit 13b210935f
2 changed files with 62 additions and 3 deletions

View File

@ -109,6 +109,12 @@ Test the camera on its own (uses the device from `config.toml`):
python3 camera.py python3 camera.py
``` ```
It first prints a pre-flight dump of every camera the system sees
(`/dev/v4l/by-id/` names, device nodes, and what each node identifies as),
then reports which device was matched, resolved, and opened, plus the OpenCV
version. If the booth ever uses the wrong camera, run this and read the
output — it shows exactly where the selection went sideways.
**If it reports black frames** (or the live preview shows a warning): the **If it reports black frames** (or the live preview shows a warning): the
Macally cam's chipset sometimes wedges and streams pure black until it is Macally cam's chipset sometimes wedges and streams pure black until it is
power-cycled. **Unplug the camera, plug it back in, and restart the power-cycled. **Unplug the camera, plug it back in, and restart the

View File

@ -14,6 +14,16 @@ import cv2
import numpy as np import numpy as np
def sysfs_name(device_path):
"""Human-readable name of a /dev/videoN node from sysfs, e.g.
'Macally USB2.0Camera: Macally U'. Empty string if unknown."""
try:
with open(f"/sys/class/video4linux/{os.path.basename(device_path)}/name") as f:
return f.read().strip()
except OSError:
return ""
def resolve_device(setting, wait_seconds=15): def resolve_device(setting, wait_seconds=15):
"""Turn the config's camera device setting into something cv2 accepts. """Turn the config's camera device setting into something cv2 accepts.
@ -33,16 +43,34 @@ def resolve_device(setting, wait_seconds=15):
needle = setting[len("auto-"):].lower() needle = setting[len("auto-"):].lower()
deadline = time.monotonic() + wait_seconds deadline = time.monotonic() + wait_seconds
waited = False waited = False
last_seen = None
while True: while True:
candidates = sorted(glob.glob("/dev/v4l/by-id/*-video-index0")) candidates = sorted(glob.glob("/dev/v4l/by-id/*-video-index0"))
# Re-print the scan only when the device set changes, so a slow
# USB camera showing up mid-wait doesn't spam the log.
if candidates != last_seen:
last_seen = candidates
if candidates:
print(f"Scanning /dev/v4l/by-id for '{needle}':")
for path in candidates:
print(f" {os.path.basename(path)} -> "
f"{os.path.realpath(path)}")
else:
print("Scanning /dev/v4l/by-id: no cameras found yet.")
for path in candidates: for path in candidates:
if needle in os.path.basename(path).lower(): if needle in os.path.basename(path).lower():
return os.path.realpath(path) device = os.path.realpath(path)
print(f"Matched '{needle}': "
f"{os.path.basename(path)} -> {device}")
return device
if time.monotonic() > deadline: if time.monotonic() > deadline:
names = [os.path.basename(p) for p in candidates] all_entries = [
f"{os.path.basename(p)} -> {os.path.realpath(p)}"
for p in sorted(glob.glob("/dev/v4l/by-id/*"))
]
raise RuntimeError( raise RuntimeError(
f"No camera matching '{needle}' found. " f"No camera matching '{needle}' found. "
f"Available cameras: {names or 'none'}" f"Available cameras: {all_entries or 'none'}"
) )
if not waited: if not waited:
waited = True waited = True
@ -68,6 +96,12 @@ def crop_4x3(frame):
class Camera: class Camera:
def __init__(self, device_setting, width, height): def __init__(self, device_setting, width, height):
device = resolve_device(device_setting) device = resolve_device(device_setting)
dev_path = device if isinstance(device, str) else f"/dev/video{device}"
name = sysfs_name(dev_path)
print(f"Camera: setting={device_setting!r} resolved={dev_path!r} "
f"cv2={cv2.__version__}")
if name:
print(f"Camera: {dev_path} identifies as '{name}' (sysfs)")
self.cap = cv2.VideoCapture(device, cv2.CAP_V4L2) self.cap = cv2.VideoCapture(device, cv2.CAP_V4L2)
if not self.cap.isOpened(): if not self.cap.isOpened():
raise RuntimeError(f"Could not open camera {device!r}") raise RuntimeError(f"Could not open camera {device!r}")
@ -75,6 +109,9 @@ class Camera:
self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
actual_w = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
actual_h = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"Camera: opened {dev_path}, negotiated {actual_w}x{actual_h}")
self._lock = threading.Lock() self._lock = threading.Lock()
self._frame = None self._frame = None
@ -140,6 +177,22 @@ if __name__ == "__main__":
import tomllib import tomllib
from pathlib import Path from pathlib import Path
print("== Camera pre-flight ==")
print(f"OpenCV version: {cv2.__version__}")
by_id = sorted(glob.glob("/dev/v4l/by-id/*"))
if by_id:
print("/dev/v4l/by-id entries:")
for p in by_id:
print(f" {os.path.basename(p)} -> {os.path.realpath(p)}")
else:
print("/dev/v4l/by-id: (empty or missing)")
nodes = sorted(glob.glob("/dev/video*"))
if nodes:
print("Video device nodes:")
for n in nodes:
print(f" {n} {sysfs_name(n)}")
print("=======================")
if len(sys.argv) > 1: if len(sys.argv) > 1:
device = sys.argv[1] device = sys.argv[1]
else: else: