pre merge cleanup commit

This commit is contained in:
Thomas Ales
2026-07-28 09:20:58 -05:00
parent 1014533626
commit 2041fc8439
52 changed files with 58333 additions and 545 deletions
Regular → Executable
+153 -20
View File
@@ -4,6 +4,9 @@ Driver for IDS/Thorlabs uEye uC480 cameras using pyueye library.
Provides camera control, live streaming, and image capture capabilities.
"""
import glob
import os
import re
import time
import numpy as np
from pyueye import ueye
@@ -12,10 +15,71 @@ from PyQt6.QtGui import QImage
import logging
import threading
from contextlib import contextmanager
from typing import Optional, Tuple
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
IDS_USB_VENDOR_ID = "1409"
def find_camera_bus_conflicts() -> List[Tuple[str, str, str]]:
"""Return (tty_name, bus, product) for serial adapters sharing a USB host
controller with the IDS camera.
A uC480 USB2 camera at high pixel clock needs nearly the whole 480 Mbit/s
of its host controller. When a full-speed serial adapter (ESP32 CDC,
FTDI, …) on the same controller has its port *open*, the kernel's
periodic split transactions starve the camera's bulk stream — measured
on this rig: 22 fps with all ports closed, <1.5 fps with either the T3R
(ttyACM0) or BBD202 (ttyUSB1) port open, full recovery on close. The
only real fix is plugging the camera into a port on a different
controller (e.g. a USB-3/xHCI port); this check exists so the UI can
say that instead of silently dropping frames.
"""
cam_buses = set()
for vid_path in glob.glob("/sys/bus/usb/devices/*/idVendor"):
try:
with open(vid_path) as f:
if f.read().strip() != IDS_USB_VENDOR_ID:
continue
with open(os.path.join(os.path.dirname(vid_path), "busnum")) as f:
cam_buses.add(f.read().strip())
except OSError:
continue
conflicts: List[Tuple[str, str, str]] = []
if not cam_buses:
return conflicts
tty_paths = glob.glob("/sys/class/tty/ttyUSB*") + glob.glob("/sys/class/tty/ttyACM*")
for tty_path in sorted(tty_paths):
real = os.path.realpath(os.path.join(tty_path, "device"))
m = re.search(r"/usb(\d+)/", real)
if not m or m.group(1) not in cam_buses:
continue
# Walk up from the interface dir to the USB device dir for its name
product = ""
d = real
for _ in range(6):
d = os.path.dirname(d)
if os.path.exists(os.path.join(d, "busnum")):
try:
with open(os.path.join(d, "product")) as f:
product = f.read().strip()
except OSError:
pass
break
conflicts.append((os.path.basename(tty_path), m.group(1), product))
if conflicts:
devs = ", ".join(f"/dev/{n} ({p})" if p else f"/dev/{n}" for n, _, p in conflicts)
logger.warning(
f"Camera shares USB bus {sorted(cam_buses)} with serial adapters: {devs}. "
f"Opening any of these ports will collapse the camera frame rate — "
f"move the camera to a port on another USB controller."
)
return conflicts
class UC480Camera(QObject):
"""
@@ -60,7 +124,9 @@ class UC480Camera(QObject):
self.height = 0
self.bits_per_pixel = 24 # Default to 24-bit color
self.bytes_per_pixel = 3
self.color_mode = ueye.IS_CM_BGR8_PACKED
# RGB (not BGR) so get_frame() can hand the buffer straight to QImage
# without a per-frame channel-reversal copy.
self.color_mode = ueye.IS_CM_RGB8_PACKED
# Lock to serialize parameter changes that require stopping live video
self._settings_lock = threading.Lock()
@@ -159,10 +225,19 @@ class UC480Camera(QObject):
self.is_initialized = True
logger.info(f"Camera initialized: {self.width}x{self.height}, {self.bits_per_pixel}bpp")
# Set default settings
# Set default settings. Pixel clock caps the max sensor readout
# rate, which in turn caps achievable fps regardless of the
# requested framerate below — use the sensor's max rather than
# a hardcoded guess, since a too-low clock silently forces
# is_SetFrameRate to negotiate down to a much lower actual fps.
self.set_exposure(10.0) # 10ms default exposure
self.set_pixel_clock(30) # 30MHz default pixel clock
self.set_framerate(30.0) # 30fps default
clock_range = self.get_pixel_clock_range()
if clock_range is not None:
min_clock, max_clock, _ = clock_range
self.set_pixel_clock(max_clock)
else:
self.set_pixel_clock(30) # fallback if range query fails
self.set_framerate(30.0) # 30fps default (actual may be lower)
return True
@@ -210,10 +285,32 @@ class UC480Camera(QObject):
self.error_occurred.emit(f"Failed to start capture: {ret}")
return False
ret = ueye.is_EnableEvent(self.h_cam, ueye.IS_SET_EVENT_FRAME)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to enable frame event: {ret}")
self.is_capturing = True
logger.info("Video capture started")
return True
def wait_for_frame(self, timeout_ms: int = 200) -> bool:
"""
Block until the camera signals that a new frame has landed in
image memory (or until timeout_ms elapses).
Without this, a caller polling get_frame() in a tight loop just
re-reads the same still-unfinished/unchanged buffer as fast as the
GIL allows — burning CPU without raising the delivered frame rate,
and occasionally reading a frame mid-write (tearing).
Returns:
True if a new frame arrived, False on timeout/error.
"""
if not self.is_capturing:
return False
ret = ueye.is_WaitEvent(self.h_cam, ueye.IS_SET_EVENT_FRAME, timeout_ms)
return ret == ueye.IS_SUCCESS
def stop_capture(self) -> bool:
"""
Stop continuous video capture.
@@ -224,6 +321,7 @@ class UC480Camera(QObject):
if not self.is_capturing:
return True
ueye.is_DisableEvent(self.h_cam, ueye.IS_SET_EVENT_FRAME)
ret = ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT)
self.is_capturing = False # Always reset, even if the call fails
if ret != ueye.IS_SUCCESS:
@@ -278,25 +376,23 @@ class UC480Camera(QObject):
copy=True
)
# Reshape to image dimensions
# Reshape to image dimensions (view, no copy — array already owns
# its memory since get_data() was called with copy=True above)
frame = np.reshape(array, (self.height, self.width, self.bytes_per_pixel))
# Convert to QImage (BGR to RGB)
height, width, channel = frame.shape
bytes_per_line = self.bytes_per_pixel * width
# Convert BGR to RGB
rgb_frame = frame[:, :, ::-1].copy()
q_image = QImage(
rgb_frame.data,
frame.data,
width,
height,
bytes_per_line,
QImage.Format.Format_RGB888
)
# Make a copy since the numpy array will be deleted
# Must copy: this QImage crosses threads via a queued signal,
# which hands the slot a new Python wrapper around the same
# frame that gets delivered after `frame` may already be GC'd —
# confirmed by testing that skipping this copy corrupts pixels.
return q_image.copy()
except Exception as e:
@@ -355,6 +451,30 @@ class UC480Camera(QObject):
else:
return None
def get_pixel_clock_range(self) -> Optional[Tuple[int, int, int]]:
"""
Query the sensor's supported pixel clock range.
Returns:
(min_mhz, max_mhz, increment_mhz), or None if the query failed
"""
if not self.is_initialized:
return None
clock_range = (ueye.c_uint * 3)()
ret = ueye.is_PixelClock(
self.h_cam,
ueye.IS_PIXELCLOCK_CMD_GET_RANGE,
clock_range,
ueye.sizeof(clock_range)
)
if ret == ueye.IS_SUCCESS:
return clock_range[0].value, clock_range[1].value, clock_range[2].value
else:
logger.error(f"Failed to get pixel clock range: {ret}")
return None
def set_pixel_clock(self, pixel_clock_mhz: int) -> bool:
"""
Set camera pixel clock.
@@ -376,7 +496,7 @@ class UC480Camera(QObject):
)
if ret == ueye.IS_SUCCESS:
logger.debug(f"Pixel clock set to {pixel_clock_mhz}MHz")
logger.info(f"Pixel clock set to {pixel_clock_mhz}MHz")
return True
else:
logger.error(f"Failed to set pixel clock: {ret}")
@@ -384,7 +504,10 @@ class UC480Camera(QObject):
def set_framerate(self, fps: float) -> bool:
"""
Set camera framerate.
Set camera framerate. The SDK negotiates the requested value against
the current pixel clock/exposure/AOI and may return a lower actual
rate — that negotiated value is what's logged and returned, not the
request, since silently trusting the request hides the real cap.
Args:
fps: Frames per second
@@ -400,7 +523,13 @@ class UC480Camera(QObject):
ret = ueye.is_SetFrameRate(self.h_cam, new_fps, actual_fps)
if ret == ueye.IS_SUCCESS:
logger.debug(f"Framerate set to {fps}fps")
if actual_fps.value < fps * 0.9:
logger.warning(
f"Requested {fps}fps but camera negotiated only "
f"{actual_fps.value:.1f}fps (pixel clock/exposure/AOI-limited)"
)
else:
logger.info(f"Framerate set to {actual_fps.value:.1f}fps")
return True
else:
logger.error(f"Failed to set framerate: {ret}")
@@ -514,12 +643,16 @@ class CameraStreamThread(QThread):
return
while self.running:
# Block until the camera actually has a new frame ready, instead
# of re-reading (and re-copying/re-emitting) the same buffer as
# fast as possible. The short timeout just bounds how quickly a
# stop() request is noticed.
if not self.camera.wait_for_frame(200):
continue
frame = self.camera.get_frame()
if frame is not None:
self.frame_ready.emit(frame)
else:
# Small delay on error to prevent CPU spinning
self.msleep(10)
self.camera.stop_capture()