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.
217 lines
7.8 KiB
Python
217 lines
7.8 KiB
Python
"""Threaded webcam capture with a consistent 4:3 center crop.
|
|
|
|
The reader thread continuously grabs frames so the UI never blocks on the
|
|
camera. Every frame is center-cropped to 4:3 before anyone sees it, so the
|
|
live preview and the saved photos are guaranteed to show the same framing.
|
|
"""
|
|
|
|
import glob
|
|
import os
|
|
import threading
|
|
import time
|
|
|
|
import cv2
|
|
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):
|
|
"""Turn the config's camera device setting into something cv2 accepts.
|
|
|
|
Accepts an integer index, an explicit "/dev/videoN" path, or
|
|
"auto-<name>" which scans /dev/v4l/by-id/ for a device whose stable
|
|
name contains <name> (case-insensitive), e.g. "auto-macally".
|
|
|
|
A freshly plugged-in USB camera takes a few seconds to enumerate, so
|
|
the auto- form keeps rescanning for up to wait_seconds before failing.
|
|
"""
|
|
if isinstance(setting, int):
|
|
return setting
|
|
setting = str(setting)
|
|
if setting.startswith("/dev/"):
|
|
return setting
|
|
if setting.startswith("auto-"):
|
|
needle = setting[len("auto-"):].lower()
|
|
deadline = time.monotonic() + wait_seconds
|
|
waited = False
|
|
last_seen = None
|
|
while True:
|
|
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:
|
|
if needle in os.path.basename(path).lower():
|
|
device = os.path.realpath(path)
|
|
print(f"Matched '{needle}': "
|
|
f"{os.path.basename(path)} -> {device}")
|
|
return device
|
|
if time.monotonic() > deadline:
|
|
all_entries = [
|
|
f"{os.path.basename(p)} -> {os.path.realpath(p)}"
|
|
for p in sorted(glob.glob("/dev/v4l/by-id/*"))
|
|
]
|
|
raise RuntimeError(
|
|
f"No camera matching '{needle}' found. "
|
|
f"Available cameras: {all_entries or 'none'}"
|
|
)
|
|
if not waited:
|
|
waited = True
|
|
print(f"Waiting for '{needle}' camera to show up "
|
|
f"(just plugged in? give it a moment)...")
|
|
time.sleep(1)
|
|
return int(setting)
|
|
|
|
|
|
def crop_4x3(frame):
|
|
"""Center-crop a BGR frame to 4:3."""
|
|
h, w = frame.shape[:2]
|
|
if w * 3 > h * 4: # too wide: trim sides
|
|
new_w = h * 4 // 3
|
|
x = (w - new_w) // 2
|
|
return frame[:, x:x + new_w]
|
|
else: # too tall: trim top/bottom
|
|
new_h = w * 3 // 4
|
|
y = (h - new_h) // 2
|
|
return frame[y:y + new_h, :]
|
|
|
|
|
|
class Camera:
|
|
def __init__(self, device_setting, width, height):
|
|
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)
|
|
if not self.cap.isOpened():
|
|
raise RuntimeError(f"Could not open camera {device!r}")
|
|
# MJPG lets most USB cams deliver full frame rates at higher resolutions
|
|
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_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._frame = None
|
|
self._brightness = 0.0
|
|
self._running = True
|
|
self._thread = threading.Thread(target=self._reader, daemon=True)
|
|
self._thread.start()
|
|
|
|
def _reader(self):
|
|
while self._running:
|
|
ok, frame = self.cap.read()
|
|
if not ok:
|
|
continue
|
|
frame = crop_4x3(frame)
|
|
with self._lock:
|
|
self._frame = frame
|
|
self._brightness = float(frame.mean())
|
|
|
|
def looks_black(self):
|
|
"""True when the camera is delivering pure black frames.
|
|
|
|
The Macally cam (Z-Star chip) sometimes wedges and streams all-zero
|
|
frames until it is physically unplugged and replugged; this lets the
|
|
UI warn the operator instead of silently taking black photos.
|
|
"""
|
|
with self._lock:
|
|
return self._frame is not None and self._brightness < 0.5
|
|
|
|
def get_frame(self):
|
|
"""Latest 4:3 BGR frame, or None if the camera hasn't warmed up yet."""
|
|
with self._lock:
|
|
return self._frame
|
|
|
|
def save_photo(self, path):
|
|
"""Write the latest frame as a JPEG. Returns True on success."""
|
|
frame = self.get_frame()
|
|
if frame is None:
|
|
return False
|
|
return cv2.imwrite(path, frame, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
|
|
|
def close(self):
|
|
self._running = False
|
|
self._thread.join(timeout=2)
|
|
self.cap.release()
|
|
|
|
|
|
def frame_to_surface(frame, size, mirror=False):
|
|
"""Convert a BGR frame to a pygame surface scaled to `size` (w, h)."""
|
|
import pygame
|
|
|
|
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
if mirror:
|
|
rgb = np.ascontiguousarray(rgb[:, ::-1])
|
|
surf = pygame.image.frombuffer(rgb.tobytes(), (rgb.shape[1], rgb.shape[0]), "RGB")
|
|
return pygame.transform.smoothscale(surf, size)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Quick standalone check: python3 camera.py [device]
|
|
# Grabs a frame from the configured camera and reports on it.
|
|
import sys
|
|
import time
|
|
import tomllib
|
|
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:
|
|
device = sys.argv[1]
|
|
else:
|
|
with open(Path(__file__).parent / "config.toml", "rb") as f:
|
|
device = tomllib.load(f)["camera"]["device"]
|
|
|
|
print(f"Opening camera {device!r}...")
|
|
c = Camera(device, 640, 480)
|
|
time.sleep(2)
|
|
frame = c.get_frame()
|
|
if frame is None:
|
|
print("PROBLEM: no frames arriving from the camera.")
|
|
elif c.looks_black():
|
|
print("PROBLEM: camera is streaming black frames. "
|
|
"Unplug it, plug it back in, and try again.")
|
|
else:
|
|
h, w = frame.shape[:2]
|
|
out = "/tmp/camera_test.jpg"
|
|
c.save_photo(out)
|
|
print(f"OK: {w}x{h} frames, test photo saved to {out}")
|
|
c.close()
|