pre merge cleanup commit
This commit is contained in:
Regular → Executable
+153
-20
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user