Feat/ids camera manual exposure #318
@@ -14,6 +14,7 @@ logger = bec_logger.logger
|
||||
|
||||
_Widgets = {
|
||||
"DataViewer": "DataViewer",
|
||||
"IDSCameraSettings": "IDSCameraSettings",
|
||||
"OMNY_SampleStorage": "OMNY_SampleStorage",
|
||||
"OMNY_TomoParams": "OMNY_TomoParams",
|
||||
"OMNY_XRayEye": "OMNY_XRayEye",
|
||||
@@ -47,6 +48,24 @@ class DataViewer(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class IDSCameraSettings(RPCBase):
|
||||
"""Pick a configured IDS camera and adjust its exposure time / pixel clock."""
|
||||
|
||||
_IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings"
|
||||
|
||||
@rpc_call
|
||||
def selected_camera():
|
||||
"""
|
||||
Intermediate wrapper used so that the user can optionally chain .setter(...).
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_camera(self, name: "str"):
|
||||
"""
|
||||
Select a camera by device name, as if chosen from the dropdown.
|
||||
"""
|
||||
|
||||
|
||||
class OMNY_SampleStorage(RPCBase):
|
||||
"""View and correct the FlOMNI sample-storage records."""
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ from __future__ import annotations
|
||||
|
||||
designer_plugins = {
|
||||
"DataViewer": ("csaxs_bec.bec_widgets.widgets.data_viewer.data_viewer", "DataViewer"),
|
||||
"IDSCameraSettings": (
|
||||
"csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings",
|
||||
"IDSCameraSettings",
|
||||
),
|
||||
"OMNY_SampleStorage": (
|
||||
"csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage",
|
||||
"OMNY_SampleStorage",
|
||||
@@ -22,6 +26,7 @@ designer_plugins = {
|
||||
|
||||
widget_icons = {
|
||||
"DataViewer": "find_in_page",
|
||||
"IDSCameraSettings": "photo_camera",
|
||||
"OMNY_SampleStorage": "widgets",
|
||||
"OMNY_TomoParams": "widgets",
|
||||
"OMNY_XRayEye": "widgets",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings import IDSCameraSettings
|
||||
|
||||
__all__ = ["IDSCameraSettings"]
|
||||
@@ -0,0 +1,511 @@
|
||||
"""IDS camera settings widget for cSAXS -- pick a configured IDS camera from
|
||||
a dropdown and adjust its exposure time / pixel clock.
|
||||
|
||||
The exposure/pixel-clock control logic mirrors OMNY_XRayEye
|
||||
(bec_widgets/widgets/xray_eye/x_ray_eye.py), which implements the same
|
||||
controls but hardcoded to a single camera (module-level CAMERA constant).
|
||||
This widget generalizes that to whichever camera is selected, for beamlines
|
||||
that configure more than one IDS camera at once (e.g. ptycho_omny.yaml's
|
||||
cam200..cam203).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
DeviceComboBox,
|
||||
)
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QFrame, QGridLayout, QLabel, QSizePolicy, QSlider, QVBoxLayout, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class IDSCameraComboBox(DeviceComboBox):
|
||||
"""Dropdown listing configured IDS cameras.
|
||||
|
||||
DeviceComboBox's device_filter only distinguishes bec_lib.device base
|
||||
classes (Device/Positioner/Signal/ComputedSignal) and can't express
|
||||
"exactly IDSCamera", so this overrides update_devices_from_filters() to
|
||||
filter by each device's deviceClass config instead -- the same technique
|
||||
device_table.py uses to filter/display device classes client-side.
|
||||
|
||||
To support another camera class later (once it grows the same
|
||||
Kind.config exposure/pixel-clock signals this widget depends on), add
|
||||
its bare class name to CAMERA_DEVICE_CLASSES -- no other change should
|
||||
be required here.
|
||||
"""
|
||||
|
||||
PLUGIN = False
|
||||
RPC = False
|
||||
|
||||
CAMERA_DEVICE_CLASSES: tuple[str, ...] = ("IDSCamera",)
|
||||
|
||||
@staticmethod
|
||||
def _device_class_name(device) -> str:
|
||||
"""Bare class name from a device's deviceClass config, e.g.
|
||||
"csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera" -> "IDSCamera"."""
|
||||
device_class = (getattr(device, "_config", None) or {}).get("deviceClass") or ""
|
||||
return device_class.rsplit(".", 1)[-1] if device_class else ""
|
||||
|
||||
@SafeSlot()
|
||||
def update_devices_from_filters(self):
|
||||
if not self.apply_filter:
|
||||
return
|
||||
self.devices = sorted(
|
||||
device.name
|
||||
for device in self.dev.enabled_devices
|
||||
if self._device_class_name(device) in self.CAMERA_DEVICE_CLASSES
|
||||
)
|
||||
|
||||
|
||||
class IDSCameraSettings(BECWidget, QWidget):
|
||||
"""Pick a configured IDS camera and adjust its exposure time / pixel clock."""
|
||||
|
||||
ICON_NAME = "photo_camera"
|
||||
PLUGIN = True
|
||||
USER_ACCESS = ["selected_camera", "set_camera"]
|
||||
|
||||
# exposure_time_slider works in tenths of a ms internally (QSlider is
|
||||
# int-only); divide by this to get ms. Same idiom as OMNY_XRayEye.
|
||||
_EXPOSURE_SLIDER_SCALE = 10
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self._camera_name: str | None = None
|
||||
self._auto_exposure_enabled = True
|
||||
# pixel_clock_slider is index-based over this list (re-fetched once
|
||||
# per camera, see _init_pixel_clock_options()) rather than a raw MHz
|
||||
# range -- many uEye sensors only accept a short discrete list of
|
||||
# pixel clocks, not every value in [min, max] (see OMNY_XRayEye).
|
||||
self._pixel_clock_options: list[int] = []
|
||||
self._last_pixel_clock_mhz: int | None = None
|
||||
|
||||
self._queue_busy = False
|
||||
self._queue_idle_timer = QTimer(self)
|
||||
self._queue_idle_timer.setSingleShot(True)
|
||||
self._queue_idle_timer.setInterval(800)
|
||||
self._queue_idle_timer.timeout.connect(self._release_queue_busy)
|
||||
|
||||
self._init_ui()
|
||||
self._make_connections()
|
||||
self._reset_controls_to_placeholder()
|
||||
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_queue_status_update, MessageEndpoints.scan_queue_status()
|
||||
)
|
||||
QTimer.singleShot(0, self._init_queue_status)
|
||||
|
||||
def _init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
camera_row = QGridLayout()
|
||||
camera_row.setColumnStretch(1, 1)
|
||||
self.camera_label = QLabel("Camera", parent=self)
|
||||
self.camera_combo = IDSCameraComboBox(parent=self)
|
||||
self.camera_combo.set_first_element_as_empty = True
|
||||
camera_row.addWidget(self.camera_label, 0, 0)
|
||||
camera_row.addWidget(self.camera_combo, 0, 1)
|
||||
layout.addLayout(camera_row)
|
||||
|
||||
self.status_label = QLabel(parent=self)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
layout.addWidget(self._create_separator())
|
||||
|
||||
_right_vcenter = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||
|
||||
self.exposure_grid_widget = QWidget(parent=self)
|
||||
exposure_grid = QGridLayout(self.exposure_grid_widget)
|
||||
exposure_grid.setHorizontalSpacing(8)
|
||||
exposure_grid.setVerticalSpacing(4)
|
||||
exposure_grid.setColumnStretch(0, 1)
|
||||
|
||||
# No "Auto gain" control here -- see OMNY_XRayEye, which dropped it
|
||||
# after HW testing found continuous auto-gain isn't useful once
|
||||
# correctly exposed. IDSCamera pulses it on then off internally,
|
||||
# once, at connect; there is no persistent UI for it.
|
||||
self.auto_exposure_label = QLabel("Auto exposure", parent=self)
|
||||
self.auto_exposure_toggle = ToggleSwitch(parent=self)
|
||||
self.auto_exposure_toggle.checked = True
|
||||
self.auto_exposure_toggle.enabled.connect(self.auto_exposure_enabled_changed)
|
||||
exposure_grid.addWidget(self.auto_exposure_label, 0, 1, _right_vcenter)
|
||||
exposure_grid.addWidget(self.auto_exposure_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
# QSlider only takes ints, so exposure time is tracked in tenths of a
|
||||
# ms internally (_EXPOSURE_SLIDER_SCALE) for one decimal of
|
||||
# resolution on the display label.
|
||||
self.exposure_time_label = QLabel("Exposure time", parent=self)
|
||||
self.exposure_time_slider = QSlider(Qt.Orientation.Horizontal, parent=self)
|
||||
self.exposure_time_slider.setRange(1, 10000) # placeholder; reseeded per camera
|
||||
self.exposure_time_value_label = QLabel("-- ms", parent=self)
|
||||
self.exposure_time_value_label.setMinimumWidth(60)
|
||||
self.exposure_time_slider.valueChanged.connect(self._update_exposure_time_value_label)
|
||||
# sliderReleased (fires once, on mouse-up), not valueChanged (fires on
|
||||
# every tick while dragging) -- don't hammer the device.
|
||||
self.exposure_time_slider.sliderReleased.connect(self.exposure_time_submitted)
|
||||
exposure_grid.addWidget(self.exposure_time_label, 1, 1, _right_vcenter)
|
||||
exposure_grid.addWidget(self.exposure_time_slider, 1, 2, 1, 2)
|
||||
exposure_grid.addWidget(self.exposure_time_value_label, 1, 4, Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
# Pixel clock (MHz): lowering it raises the max achievable exposure
|
||||
# time, at the cost of frame rate. Many uEye sensors only accept a
|
||||
# short discrete list of pixel clocks, so this slider's *position* is
|
||||
# an index into self._pixel_clock_options, not a raw MHz value.
|
||||
self.pixel_clock_label = QLabel("Pixel clock", parent=self)
|
||||
self.pixel_clock_slider = QSlider(Qt.Orientation.Horizontal, parent=self)
|
||||
self.pixel_clock_slider.setRange(0, 0) # placeholder; see _set_pixel_clock_options()
|
||||
self.pixel_clock_value_label = QLabel("-- MHz", parent=self)
|
||||
self.pixel_clock_value_label.setMinimumWidth(60)
|
||||
self.pixel_clock_slider.valueChanged.connect(self._update_pixel_clock_value_label)
|
||||
self.pixel_clock_slider.sliderReleased.connect(self.pixel_clock_submitted)
|
||||
exposure_grid.addWidget(self.pixel_clock_label, 2, 1, _right_vcenter)
|
||||
exposure_grid.addWidget(self.pixel_clock_slider, 2, 2, 1, 2)
|
||||
exposure_grid.addWidget(self.pixel_clock_value_label, 2, 4, Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
layout.addWidget(self.exposure_grid_widget)
|
||||
layout.addStretch()
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
|
||||
def _make_connections(self):
|
||||
self.camera_combo.device_selected.connect(self._on_camera_selected)
|
||||
self.camera_combo.device_reset.connect(self._on_camera_reset)
|
||||
|
||||
def _create_separator(self):
|
||||
sep = QFrame(parent=self)
|
||||
sep.setFrameShape(QFrame.Shape.HLine)
|
||||
sep.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
sep.setLineWidth(1)
|
||||
return sep
|
||||
|
||||
################################################################################
|
||||
# Camera selection
|
||||
################################################################################
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_camera_selected(self, camera_name: str):
|
||||
if not camera_name or camera_name == self._camera_name:
|
||||
return
|
||||
self._switch_camera(camera_name)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_camera_reset(self):
|
||||
if self._camera_name is not None:
|
||||
self._switch_camera(None)
|
||||
|
||||
@SafeProperty(str)
|
||||
def selected_camera(self) -> str:
|
||||
"""Name of the currently selected IDS camera, or an empty string if none."""
|
||||
return self._camera_name or ""
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_camera(self, name: str):
|
||||
"""Select a camera by device name, as if chosen from the dropdown."""
|
||||
self.camera_combo.set_device(name)
|
||||
|
||||
def _switch_camera(self, camera_name: str | None):
|
||||
if self._camera_name is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.getting_camera_status,
|
||||
MessageEndpoints.device_read_configuration(self._camera_name),
|
||||
)
|
||||
self._camera_name = camera_name
|
||||
self._reset_controls_to_placeholder()
|
||||
|
||||
if camera_name is None:
|
||||
return
|
||||
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.getting_camera_status, MessageEndpoints.device_read_configuration(camera_name)
|
||||
)
|
||||
# device_read_configuration is retained in redis (set_and_publish), so
|
||||
# seed immediately from the last known value instead of waiting for
|
||||
# the camera to spontaneously republish -- connect_slot alone only
|
||||
# delivers messages published *after* it connects.
|
||||
try:
|
||||
msg = self.client.connector.get(
|
||||
MessageEndpoints.device_read_configuration(camera_name)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to fetch initial config for {camera_name}: {exc}")
|
||||
msg = None
|
||||
if msg is not None:
|
||||
self.getting_camera_status(msg.content, msg.metadata)
|
||||
|
||||
self._pixel_clock_options = []
|
||||
self._last_pixel_clock_mhz = None
|
||||
QTimer.singleShot(0, self._init_pixel_clock_options)
|
||||
|
||||
def _reset_controls_to_placeholder(self):
|
||||
self._auto_exposure_enabled = True
|
||||
self.auto_exposure_toggle.blockSignals(True)
|
||||
self.auto_exposure_toggle.checked = True
|
||||
self.auto_exposure_toggle.blockSignals(False)
|
||||
|
||||
self.exposure_time_value_label.setText("-- ms")
|
||||
|
||||
self._pixel_clock_options = []
|
||||
self._last_pixel_clock_mhz = None
|
||||
self.pixel_clock_slider.blockSignals(True)
|
||||
self.pixel_clock_slider.setRange(0, 0)
|
||||
self.pixel_clock_slider.blockSignals(False)
|
||||
self.pixel_clock_value_label.setText("-- MHz")
|
||||
|
||||
if self._camera_name is None:
|
||||
if self.camera_combo.devices:
|
||||
self.status_label.setText("No camera selected")
|
||||
else:
|
||||
self.status_label.setText("No IDS cameras configured")
|
||||
else:
|
||||
self.status_label.setText(f"Camera: {self._camera_name}")
|
||||
|
||||
self._refresh_control_availability()
|
||||
|
||||
def _refresh_control_availability(self):
|
||||
have_camera = self._camera_name is not None
|
||||
enabled = have_camera and not self._queue_busy
|
||||
tooltip = "Disabled while scan queue is busy." if have_camera and self._queue_busy else ""
|
||||
|
||||
self.auto_exposure_toggle.setEnabled(enabled)
|
||||
self.auto_exposure_toggle.setToolTip(tooltip)
|
||||
self.exposure_time_slider.setEnabled(enabled and not self._auto_exposure_enabled)
|
||||
self.exposure_time_slider.setToolTip(tooltip)
|
||||
self.pixel_clock_slider.setEnabled(enabled and bool(self._pixel_clock_options))
|
||||
self.pixel_clock_slider.setToolTip(tooltip)
|
||||
|
||||
################################################################################
|
||||
# Exposure / pixel clock
|
||||
################################################################################
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def getting_camera_status(self, data, meta):
|
||||
if self._camera_name is None:
|
||||
return
|
||||
signals = data.get("signals") or {}
|
||||
|
||||
auto_exp = signals.get(f"{self._camera_name}_auto_exposure_enabled")
|
||||
if auto_exp is not None:
|
||||
enabled = bool(auto_exp.get("value"))
|
||||
self.auto_exposure_toggle.blockSignals(True)
|
||||
self.auto_exposure_toggle.checked = enabled
|
||||
self.auto_exposure_toggle.blockSignals(False)
|
||||
self._auto_exposure_enabled = enabled
|
||||
self._refresh_control_availability()
|
||||
|
||||
# No auto_gain_enabled handling here -- see the comment above the
|
||||
# (omitted) toggle in _init_ui.
|
||||
|
||||
# Update the pixel-clock slider's position before the exposure ones
|
||||
# below -- changing the pixel clock changes the exposure range, and
|
||||
# both can arrive together in this same message.
|
||||
pixel_clock = signals.get(f"{self._camera_name}_pixel_clock")
|
||||
if pixel_clock is not None:
|
||||
self._set_pixel_clock_display(int(pixel_clock.get("value")))
|
||||
|
||||
# Reseed the exposure slider's bounds next, so a value arriving in
|
||||
# the same message is clamped against up-to-date min/max. Round the
|
||||
# min UP and the max DOWN (never the other way) -- ceil (clamped to
|
||||
# at least 1) keeps the bound physical instead of claiming 0 ms
|
||||
# exposure is settable when it isn't.
|
||||
exposure_min = signals.get(f"{self._camera_name}_exposure_time_min")
|
||||
exposure_max = signals.get(f"{self._camera_name}_exposure_time_max")
|
||||
if exposure_min is not None and exposure_max is not None:
|
||||
lo = max(1, math.ceil(float(exposure_min.get("value")) * self._EXPOSURE_SLIDER_SCALE))
|
||||
hi = math.floor(float(exposure_max.get("value")) * self._EXPOSURE_SLIDER_SCALE)
|
||||
if (lo, hi) != (self.exposure_time_slider.minimum(), self.exposure_time_slider.maximum()):
|
||||
self.exposure_time_slider.setRange(lo, hi)
|
||||
|
||||
exposure_time = signals.get(f"{self._camera_name}_exposure_time")
|
||||
if exposure_time is not None:
|
||||
self.exposure_time_slider.blockSignals(True)
|
||||
self.exposure_time_slider.setValue(
|
||||
round(float(exposure_time.get("value")) * self._EXPOSURE_SLIDER_SCALE)
|
||||
)
|
||||
self.exposure_time_slider.blockSignals(False)
|
||||
self._update_exposure_time_value_label(self.exposure_time_slider.value())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def auto_exposure_enabled_changed(self, enabled: bool):
|
||||
if self._camera_name is None or self._queue_busy:
|
||||
logger.warning("Ignoring auto-exposure toggle: no camera selected or queue busy.")
|
||||
return
|
||||
self.auto_exposure_toggle.blockSignals(True)
|
||||
self.dev.get(self._camera_name).auto_exposure_enabled.put(enabled)
|
||||
self.auto_exposure_toggle.checked = enabled
|
||||
self.auto_exposure_toggle.blockSignals(False)
|
||||
self._auto_exposure_enabled = enabled
|
||||
self._refresh_control_availability()
|
||||
|
||||
def _update_exposure_time_value_label(self, raw_value: int):
|
||||
self.exposure_time_value_label.setText(
|
||||
f"{raw_value / self._EXPOSURE_SLIDER_SCALE:.1f} ms"
|
||||
)
|
||||
|
||||
def exposure_time_submitted(self):
|
||||
if self._camera_name is None or self._queue_busy:
|
||||
return
|
||||
value_ms = self.exposure_time_slider.value() / self._EXPOSURE_SLIDER_SCALE
|
||||
self.dev.get(self._camera_name).exposure_time.put(value_ms)
|
||||
|
||||
def _init_pixel_clock_options(self):
|
||||
"""One-time-per-camera fetch of the actually-supported pixel clocks
|
||||
(see IDSCamera.get_pixel_clock_list()'s docstring for why this can't
|
||||
just be [min, max] from a Kind.config signal) -- not a polling loop,
|
||||
this list is static per camera, so a single RPC call per camera
|
||||
selection is the right cost/benefit trade-off."""
|
||||
camera_name = self._camera_name
|
||||
if camera_name is None:
|
||||
return
|
||||
try:
|
||||
options = self.dev.get(camera_name).get_pixel_clock_list()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to fetch pixel clock options for {camera_name}: {exc}")
|
||||
return
|
||||
if camera_name != self._camera_name:
|
||||
return # camera changed again while this RPC call was in flight
|
||||
self._set_pixel_clock_options(sorted(int(v) for v in options))
|
||||
|
||||
def _set_pixel_clock_options(self, options: list[int]):
|
||||
self._pixel_clock_options = options
|
||||
if not options:
|
||||
self._refresh_control_availability()
|
||||
return
|
||||
self.pixel_clock_slider.blockSignals(True)
|
||||
self.pixel_clock_slider.setRange(0, len(options) - 1)
|
||||
self.pixel_clock_slider.blockSignals(False)
|
||||
if self._last_pixel_clock_mhz is not None:
|
||||
# A status message already arrived before this RPC call
|
||||
# returned -- apply it now that we can actually resolve it to a
|
||||
# slider position.
|
||||
self._set_pixel_clock_display(self._last_pixel_clock_mhz)
|
||||
else:
|
||||
self._update_pixel_clock_value_label(self.pixel_clock_slider.value())
|
||||
self._refresh_control_availability()
|
||||
|
||||
def _set_pixel_clock_display(self, mhz: int):
|
||||
"""Move the slider to the option nearest `mhz` (from hardware, via
|
||||
getting_camera_status()) and update the value label -- never a raw
|
||||
MHz value on the slider itself, only a valid index."""
|
||||
self._last_pixel_clock_mhz = mhz
|
||||
if not self._pixel_clock_options:
|
||||
# Options not fetched yet -- _set_pixel_clock_options() will
|
||||
# call back into this once they arrive.
|
||||
self.pixel_clock_value_label.setText(f"{mhz} MHz")
|
||||
return
|
||||
index = min(
|
||||
range(len(self._pixel_clock_options)),
|
||||
key=lambda i: abs(self._pixel_clock_options[i] - mhz),
|
||||
)
|
||||
self.pixel_clock_slider.blockSignals(True)
|
||||
self.pixel_clock_slider.setValue(index)
|
||||
self.pixel_clock_slider.blockSignals(False)
|
||||
self._update_pixel_clock_value_label(index)
|
||||
|
||||
def _update_pixel_clock_value_label(self, index: int):
|
||||
if 0 <= index < len(self._pixel_clock_options):
|
||||
self.pixel_clock_value_label.setText(f"{self._pixel_clock_options[index]} MHz")
|
||||
else:
|
||||
self.pixel_clock_value_label.setText("-- MHz")
|
||||
|
||||
def pixel_clock_submitted(self):
|
||||
if self._camera_name is None or self._queue_busy:
|
||||
return
|
||||
index = self.pixel_clock_slider.value()
|
||||
if not 0 <= index < len(self._pixel_clock_options):
|
||||
return
|
||||
self.dev.get(self._camera_name).pixel_clock.put(self._pixel_clock_options[index])
|
||||
|
||||
################################################################################
|
||||
# Scan-queue guard
|
||||
################################################################################
|
||||
|
||||
def _update_queue_busy_state(self, busy: bool):
|
||||
if busy:
|
||||
self._queue_idle_timer.stop()
|
||||
self._set_queue_busy(True)
|
||||
return
|
||||
if self._queue_busy and not self._queue_idle_timer.isActive():
|
||||
self._queue_idle_timer.start()
|
||||
|
||||
def _set_queue_busy(self, busy: bool):
|
||||
if busy == self._queue_busy:
|
||||
return
|
||||
self._queue_busy = busy
|
||||
self._refresh_control_availability()
|
||||
|
||||
def _release_queue_busy(self):
|
||||
self._set_queue_busy(False)
|
||||
|
||||
def _init_queue_status(self):
|
||||
try:
|
||||
msg = self.client.connector.get(MessageEndpoints.scan_queue_status())
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to fetch initial scan queue status for IDSCameraSettings: {exc}")
|
||||
return
|
||||
if msg is None:
|
||||
return
|
||||
self._update_queue_busy_state(self._is_queue_busy(msg.content))
|
||||
|
||||
@staticmethod
|
||||
def _is_queue_busy(msg_content: dict) -> bool:
|
||||
queues = msg_content.get("queue", {}) if isinstance(msg_content, dict) else {}
|
||||
primary_queue = queues.get("primary") if isinstance(queues, dict) else None
|
||||
if primary_queue is None:
|
||||
return False
|
||||
|
||||
queue_info = getattr(primary_queue, "info", None)
|
||||
if queue_info is None and isinstance(primary_queue, dict):
|
||||
queue_info = primary_queue.get("info", [])
|
||||
if not queue_info:
|
||||
return False
|
||||
|
||||
idle_statuses = {"STOPPED", "COMPLETED", "IDLE"}
|
||||
for item in queue_info:
|
||||
status = getattr(item, "status", None)
|
||||
if status is None and isinstance(item, dict):
|
||||
status = item.get("status")
|
||||
if str(status).upper() not in idle_statuses:
|
||||
return True
|
||||
return False
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_queue_status_update(self, data, meta):
|
||||
_ = meta
|
||||
self._update_queue_busy_state(self._is_queue_busy(data))
|
||||
|
||||
def cleanup(self):
|
||||
self._queue_idle_timer.stop()
|
||||
if self._camera_name is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.getting_camera_status,
|
||||
MessageEndpoints.device_read_configuration(self._camera_name),
|
||||
)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_queue_status_update, MessageEndpoints.scan_queue_status()
|
||||
)
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
dispatcher = BECDispatcher(gui_id="ids_camera_settings")
|
||||
win = IDSCameraSettings()
|
||||
|
||||
win.resize(400, 250)
|
||||
win.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['ids_camera_settings.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings import IDSCameraSettings
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='IDSCameraSettings' name='ids_camera_settings'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class IDSCameraSettingsPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = IDSCameraSettings(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(IDSCameraSettings.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "ids_camera_settings"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "IDSCameraSettings"
|
||||
|
||||
def toolTip(self):
|
||||
return "Pick a configured IDS camera and adjust its exposure time / pixel clock."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings_plugin import IDSCameraSettingsPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(IDSCameraSettingsPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -20,6 +22,7 @@ from qtpy.QtWidgets import (
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
@@ -269,10 +272,20 @@ class OMNY_XRayEye(BECWidget, QWidget):
|
||||
PIXEL_CALIBRATION_USER_PARAM = "pixel_calibration"
|
||||
PIXEL_CALIBRATION_DEFAULT = 1.0
|
||||
|
||||
# exposure_time_slider works in tenths of a ms internally (QSlider is
|
||||
# int-only); divide by this to get ms.
|
||||
_EXPOSURE_SLIDER_SCALE = 10
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._live_view_signal = CAMERA[1]
|
||||
self._last_smear_composite = None
|
||||
# pixel_clock_slider is index-based over this list (populated once,
|
||||
# see _init_pixel_clock_options()) rather than a raw MHz range --
|
||||
# many uEye sensors only accept a short discrete list of pixel
|
||||
# clocks, not every value in [min, max] (confirmed on hardware).
|
||||
self._pixel_clock_options: list[int] = []
|
||||
self._last_pixel_clock_mhz: int | None = None
|
||||
self._connected_motor = None
|
||||
self._dap_params_forwarding_connected = False
|
||||
self._queue_busy = False
|
||||
@@ -315,6 +328,7 @@ class OMNY_XRayEye(BECWidget, QWidget):
|
||||
self.resize(800, 600)
|
||||
QTimer.singleShot(0, self._init_queue_status)
|
||||
QTimer.singleShot(0, self._init_gui_trigger)
|
||||
QTimer.singleShot(0, self._init_pixel_clock_options)
|
||||
|
||||
def _init_ui(self):
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
@@ -401,9 +415,90 @@ class OMNY_XRayEye(BECWidget, QWidget):
|
||||
switch_grid.addWidget(self.smear_active_toggle, 1, 2, Qt.AlignmentFlag.AlignVCenter)
|
||||
switch_grid.addWidget(self.smear_preview_label, 1, 3, _right_vcenter)
|
||||
switch_grid.addWidget(self.smear_preview_toggle, 1, 4, Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
self.control_panel_layout.addWidget(self.switch_grid_widget)
|
||||
|
||||
# separator
|
||||
# separator: below shutter/camera-running/smear switches, above the
|
||||
# exposure/gain section
|
||||
self.control_panel_layout.addWidget(self._create_separator())
|
||||
|
||||
# Exposure/gain section: auto-exposure and auto-gain enable toggles
|
||||
# (row 0, same layout idiom as switch_grid above), plus a manual
|
||||
# exposure-time slider (row 1) that's only enabled while
|
||||
# auto-exposure is off. Its range is a placeholder until seeded from
|
||||
# the hardware-reported (min, max) at connect time -- see
|
||||
# getting_camera_status().
|
||||
self.exposure_grid_widget = QWidget(parent=self)
|
||||
exposure_grid = QGridLayout(self.exposure_grid_widget)
|
||||
exposure_grid.setContentsMargins(0, 0, 0, 0)
|
||||
exposure_grid.setHorizontalSpacing(8)
|
||||
exposure_grid.setVerticalSpacing(4)
|
||||
exposure_grid.setColumnStretch(0, 1)
|
||||
|
||||
# No "Auto gain" control here -- HW testing found continuous
|
||||
# auto-gain isn't useful once correctly exposed, and takes gain out
|
||||
# of manual control. IDSCamera now pulses it on then off internally,
|
||||
# once, at connect (see its on_connected()); there is no persistent
|
||||
# UI for it.
|
||||
self.auto_exposure_label = QLabel("Auto exposure", parent=self)
|
||||
self.auto_exposure_toggle = ToggleSwitch(parent=self)
|
||||
self.auto_exposure_toggle.checked = True
|
||||
self.auto_exposure_toggle.enabled.connect(self.auto_exposure_enabled_changed)
|
||||
|
||||
exposure_grid.addWidget(self.auto_exposure_label, 0, 1, _right_vcenter)
|
||||
exposure_grid.addWidget(self.auto_exposure_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
# QSlider only takes ints, so exposure time is tracked in tenths of a
|
||||
# ms internally (_EXPOSURE_SLIDER_SCALE) for one decimal of
|
||||
# resolution on the display label.
|
||||
self.exposure_time_label = QLabel("Exposure time", parent=self)
|
||||
self.exposure_time_slider = QSlider(Qt.Orientation.Horizontal, parent=self)
|
||||
# Placeholder until reseeded from hardware on connect (see
|
||||
# getting_camera_status()) -- minimum is 1 (0.1 ms), never 0, since
|
||||
# 0 ms exposure isn't physical.
|
||||
self.exposure_time_slider.setRange(1, 10000)
|
||||
self.exposure_time_slider.setEnabled(False) # auto-exposure starts enabled
|
||||
self.exposure_time_value_label = QLabel("-- ms", parent=self)
|
||||
self.exposure_time_value_label.setMinimumWidth(60)
|
||||
self.exposure_time_slider.valueChanged.connect(self._update_exposure_time_value_label)
|
||||
# sliderReleased (fires once, on mouse-up), not valueChanged (fires on
|
||||
# every tick while dragging) -- the throttling choice here, same
|
||||
# "don't hammer the device" concern as editingFinished was for the
|
||||
# spinbox this replaces.
|
||||
self.exposure_time_slider.sliderReleased.connect(self.exposure_time_submitted)
|
||||
|
||||
exposure_grid.addWidget(self.exposure_time_label, 1, 1, _right_vcenter)
|
||||
exposure_grid.addWidget(self.exposure_time_slider, 1, 2, 1, 2)
|
||||
exposure_grid.addWidget(self.exposure_time_value_label, 1, 4, Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
# Pixel clock (MHz): lowering it raises the max achievable exposure
|
||||
# time (at the cost of frame rate) -- exposed here since operators
|
||||
# hitting the exposure slider's ceiling need this knob to go further.
|
||||
# Many uEye sensors only accept a short discrete list of pixel
|
||||
# clocks (confirmed on hardware -- most values in the driver's own
|
||||
# reported min/max range were rejected), so this slider's *position*
|
||||
# is an index into self._pixel_clock_options, not a raw MHz value --
|
||||
# every position it can land on is therefore one the hardware
|
||||
# actually accepts. The real list is fetched once, over RPC, in
|
||||
# _init_pixel_clock_options() (it's static per camera, not something
|
||||
# that needs a live subscription); disabled until then.
|
||||
self.pixel_clock_label = QLabel("Pixel clock", parent=self)
|
||||
self.pixel_clock_slider = QSlider(Qt.Orientation.Horizontal, parent=self)
|
||||
self.pixel_clock_slider.setRange(0, 0) # placeholder; see _set_pixel_clock_options()
|
||||
self.pixel_clock_slider.setEnabled(False)
|
||||
self.pixel_clock_value_label = QLabel("-- MHz", parent=self)
|
||||
self.pixel_clock_value_label.setMinimumWidth(60)
|
||||
self.pixel_clock_slider.valueChanged.connect(self._update_pixel_clock_value_label)
|
||||
self.pixel_clock_slider.sliderReleased.connect(self.pixel_clock_submitted)
|
||||
|
||||
exposure_grid.addWidget(self.pixel_clock_label, 2, 1, _right_vcenter)
|
||||
exposure_grid.addWidget(self.pixel_clock_slider, 2, 2, 1, 2)
|
||||
exposure_grid.addWidget(self.pixel_clock_value_label, 2, 4, Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
self.control_panel_layout.addWidget(self.exposure_grid_widget)
|
||||
|
||||
# separator: below the exposure/gain section, above the alignment
|
||||
# values (2D positioner + zoom)
|
||||
self.control_panel_layout.addWidget(self._create_separator())
|
||||
|
||||
# 2D Positioner (fixed size)
|
||||
@@ -648,8 +743,13 @@ class OMNY_XRayEye(BECWidget, QWidget):
|
||||
def enable_move_buttons(self, enabled: bool):
|
||||
self.motor_control_2d.setEnabled(enabled)
|
||||
|
||||
def _queue_guarded_toggles(self) -> tuple[ToggleSwitch, ToggleSwitch, ToggleSwitch]:
|
||||
return (self.live_preview_toggle, self.shutter_toggle, self.camera_running_toggle)
|
||||
def _queue_guarded_toggles(self) -> tuple[ToggleSwitch, ...]:
|
||||
return (
|
||||
self.live_preview_toggle,
|
||||
self.shutter_toggle,
|
||||
self.camera_running_toggle,
|
||||
self.auto_exposure_toggle,
|
||||
)
|
||||
|
||||
def _set_queue_toggles_blocked(self, blocked: bool):
|
||||
if blocked == self._queue_busy:
|
||||
@@ -886,11 +986,142 @@ class OMNY_XRayEye(BECWidget, QWidget):
|
||||
@SafeSlot(dict, dict)
|
||||
def getting_camera_status(self, data, meta):
|
||||
print(f"msg:{data}")
|
||||
live_mode_enabled = data.get("signals").get(f"{CAMERA[0]}_live_mode_enabled").get("value")
|
||||
signals = data.get("signals")
|
||||
live_mode_enabled = signals.get(f"{CAMERA[0]}_live_mode_enabled").get("value")
|
||||
self.camera_running_toggle.blockSignals(True)
|
||||
self.camera_running_toggle.checked = live_mode_enabled
|
||||
self.camera_running_toggle.blockSignals(False)
|
||||
|
||||
auto_exp = signals.get(f"{CAMERA[0]}_auto_exposure_enabled")
|
||||
if auto_exp is not None:
|
||||
enabled = bool(auto_exp.get("value"))
|
||||
self.auto_exposure_toggle.blockSignals(True)
|
||||
self.auto_exposure_toggle.checked = enabled
|
||||
self.exposure_time_slider.setEnabled(not enabled)
|
||||
self.auto_exposure_toggle.blockSignals(False)
|
||||
|
||||
# No auto_gain_enabled handling here -- see the comment above the
|
||||
# (removed) toggle in _init_ui.
|
||||
|
||||
# Update the pixel-clock slider's position before the exposure ones
|
||||
# below -- changing the pixel clock changes the exposure range, and
|
||||
# both arrive together in this same message. (No range/bounds to
|
||||
# reseed here -- pixel_clock_slider's range is fixed once
|
||||
# self._pixel_clock_options is populated; see _init_pixel_clock_
|
||||
# options()/_set_pixel_clock_options(). pixel_clock_min/max still
|
||||
# exist on the device as informational metadata, just unused here.)
|
||||
pixel_clock = signals.get(f"{CAMERA[0]}_pixel_clock")
|
||||
if pixel_clock is not None:
|
||||
self._set_pixel_clock_display(int(pixel_clock.get("value")))
|
||||
|
||||
# Reseed the exposure slider's bounds next (rare -- only changes if
|
||||
# the pixel clock changed), so a value arriving in the same message
|
||||
# is clamped against up-to-date min/max rather than a stale range.
|
||||
# Round the min UP and the max DOWN (never the other way) -- e.g. a
|
||||
# hardware min of 0.04 ms rounds to 0 in tenths-of-ms units, which
|
||||
# would let the slider claim 0 ms exposure is settable when it isn't;
|
||||
# ceil (clamped to at least 1) keeps the bound physical instead.
|
||||
exposure_min = signals.get(f"{CAMERA[0]}_exposure_time_min")
|
||||
exposure_max = signals.get(f"{CAMERA[0]}_exposure_time_max")
|
||||
if exposure_min is not None and exposure_max is not None:
|
||||
lo = max(1, math.ceil(float(exposure_min.get("value")) * self._EXPOSURE_SLIDER_SCALE))
|
||||
hi = math.floor(float(exposure_max.get("value")) * self._EXPOSURE_SLIDER_SCALE)
|
||||
if (lo, hi) != (self.exposure_time_slider.minimum(), self.exposure_time_slider.maximum()):
|
||||
self.exposure_time_slider.setRange(lo, hi)
|
||||
|
||||
exposure_time = signals.get(f"{CAMERA[0]}_exposure_time")
|
||||
if exposure_time is not None:
|
||||
self.exposure_time_slider.blockSignals(True)
|
||||
self.exposure_time_slider.setValue(
|
||||
round(float(exposure_time.get("value")) * self._EXPOSURE_SLIDER_SCALE)
|
||||
)
|
||||
self.exposure_time_slider.blockSignals(False)
|
||||
self._update_exposure_time_value_label(self.exposure_time_slider.value())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def auto_exposure_enabled_changed(self, enabled: bool):
|
||||
if self._manual_toggle_blocked_by_queue():
|
||||
logger.warning("Ignoring auto-exposure toggle while scan queue is busy.")
|
||||
return
|
||||
self.auto_exposure_toggle.blockSignals(True)
|
||||
self.dev.get(CAMERA[0]).auto_exposure_enabled.put(enabled)
|
||||
self.auto_exposure_toggle.checked = enabled
|
||||
self.exposure_time_slider.setEnabled(not enabled)
|
||||
self.auto_exposure_toggle.blockSignals(False)
|
||||
|
||||
def _update_exposure_time_value_label(self, raw_value: int):
|
||||
self.exposure_time_value_label.setText(
|
||||
f"{raw_value / self._EXPOSURE_SLIDER_SCALE:.1f} ms"
|
||||
)
|
||||
|
||||
def exposure_time_submitted(self):
|
||||
value_ms = self.exposure_time_slider.value() / self._EXPOSURE_SLIDER_SCALE
|
||||
self.dev.get(CAMERA[0]).exposure_time.put(value_ms)
|
||||
|
||||
def _init_pixel_clock_options(self):
|
||||
"""One-time fetch of the camera's actually-supported pixel clocks
|
||||
(see get_pixel_clock_list()'s docstring for why this can't just be
|
||||
[min, max] from a Kind.config signal) -- not a polling loop, this
|
||||
list is static per camera, so a single RPC call at widget startup is
|
||||
the right cost/benefit trade-off versus adding a whole signal path
|
||||
for something that never changes at runtime."""
|
||||
try:
|
||||
options = self.dev.get(CAMERA[0]).get_pixel_clock_list()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to fetch pixel clock options for OMNY_XRayEye: {exc}")
|
||||
return
|
||||
self._set_pixel_clock_options(sorted(int(v) for v in options))
|
||||
|
||||
def _set_pixel_clock_options(self, options: list[int]):
|
||||
self._pixel_clock_options = options
|
||||
if not options:
|
||||
self.pixel_clock_slider.setEnabled(False)
|
||||
return
|
||||
self.pixel_clock_slider.blockSignals(True)
|
||||
self.pixel_clock_slider.setRange(0, len(options) - 1)
|
||||
self.pixel_clock_slider.blockSignals(False)
|
||||
self.pixel_clock_slider.setEnabled(True)
|
||||
if self._last_pixel_clock_mhz is not None:
|
||||
# A status message already arrived before this RPC call
|
||||
# returned -- apply it now that we can actually resolve it to a
|
||||
# slider position.
|
||||
self._set_pixel_clock_display(self._last_pixel_clock_mhz)
|
||||
else:
|
||||
# No status message yet either -- keep the label in sync with
|
||||
# the slider's default position (index 0, the lowest option).
|
||||
self._update_pixel_clock_value_label(self.pixel_clock_slider.value())
|
||||
|
||||
def _set_pixel_clock_display(self, mhz: int):
|
||||
"""Move the slider to the option nearest `mhz` (from hardware, e.g.
|
||||
via getting_camera_status()) and update the value label -- never a
|
||||
raw MHz value on the slider itself, only a valid index."""
|
||||
self._last_pixel_clock_mhz = mhz
|
||||
if not self._pixel_clock_options:
|
||||
# Options not fetched yet -- _set_pixel_clock_options() will
|
||||
# call back into this once they arrive.
|
||||
self.pixel_clock_value_label.setText(f"{mhz} MHz")
|
||||
return
|
||||
index = min(
|
||||
range(len(self._pixel_clock_options)),
|
||||
key=lambda i: abs(self._pixel_clock_options[i] - mhz),
|
||||
)
|
||||
self.pixel_clock_slider.blockSignals(True)
|
||||
self.pixel_clock_slider.setValue(index)
|
||||
self.pixel_clock_slider.blockSignals(False)
|
||||
self._update_pixel_clock_value_label(index)
|
||||
|
||||
def _update_pixel_clock_value_label(self, index: int):
|
||||
if 0 <= index < len(self._pixel_clock_options):
|
||||
self.pixel_clock_value_label.setText(f"{self._pixel_clock_options[index]} MHz")
|
||||
else:
|
||||
self.pixel_clock_value_label.setText("-- MHz")
|
||||
|
||||
def pixel_clock_submitted(self):
|
||||
index = self.pixel_clock_slider.value()
|
||||
if not 0 <= index < len(self._pixel_clock_options):
|
||||
return
|
||||
self.dev.get(CAMERA[0]).pixel_clock.put(self._pixel_clock_options[index])
|
||||
|
||||
@SafeSlot(bool)
|
||||
def opening_shutter(self, enabled: bool):
|
||||
if self._manual_toggle_blocked_by_queue():
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# TEMPORARY test config: a single IDS color camera (camera_id 41), used to
|
||||
# manually verify the exposure/auto-gain controls added in
|
||||
# docs/plans/ids-camera-manual-exposure.md against real hardware, without
|
||||
# reconfiguring a production beamline. Named "cam_xeye" so the OMNY_XRayEye
|
||||
# widget (which hardcodes that device name) can be pointed at it directly.
|
||||
#
|
||||
# Delete this file once real-hardware verification of the exposure/gain
|
||||
# feature is done -- it is not meant to be loaded in a production session.
|
||||
cam_xeye:
|
||||
description: Test IDS color camera (ID 41) for exposure/auto-gain widget verification
|
||||
deviceClass: csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera
|
||||
deviceConfig:
|
||||
camera_id: 41
|
||||
bits_per_pixel: 24
|
||||
num_rotation_90: 0
|
||||
transpose: false
|
||||
force_monochrome: false
|
||||
m_n_colormode: 1
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: async
|
||||
userParameter:
|
||||
pixel_calibration: 1.0
|
||||
deviceTags:
|
||||
- test_config
|
||||
@@ -235,23 +235,130 @@ class Camera:
|
||||
)
|
||||
|
||||
def set_auto_gain(self, enable: bool):
|
||||
"""Enable or disable auto gain."""
|
||||
enable = ueye.c_int(1) if enable else ueye.c_int(0)
|
||||
value_to_return = ueye.c_double()
|
||||
"""Enable or disable auto gain.
|
||||
|
||||
is_SetAutoParameter's pval1/pval2 are `double *` (the SDK reads/writes
|
||||
8 bytes through them), not `int *` -- passing a ueye.c_int() here (as
|
||||
this used to do) hands the driver a 4-byte buffer to read a double
|
||||
out of, so it reads 4 bytes of adjacent memory as the rest of the
|
||||
mantissa/exponent. The resulting garbage value is essentially never
|
||||
exactly 0.0/1.0, so the driver rejects it -- this is what raised
|
||||
UEyeException here instead of actually toggling auto gain.
|
||||
"""
|
||||
enable_value = ueye.c_double(1.0) if enable else ueye.c_double(0.0)
|
||||
check_error(
|
||||
self.ueye.is_SetAutoParameter(
|
||||
self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_GAIN, enable, value_to_return
|
||||
self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_GAIN, enable_value, enable_value
|
||||
),
|
||||
"IDSCameraObject",
|
||||
)
|
||||
|
||||
def set_auto_shutter(self, enable: bool):
|
||||
"""Enable or disable auto exposure."""
|
||||
enable = ueye.c_int(1) if enable else ueye.c_int(0)
|
||||
value_to_return = ueye.c_double()
|
||||
"""Enable or disable auto exposure. See set_auto_gain() for why
|
||||
pval1/pval2 must be c_double, not c_int."""
|
||||
enable_value = ueye.c_double(1.0) if enable else ueye.c_double(0.0)
|
||||
check_error(
|
||||
self.ueye.is_SetAutoParameter(
|
||||
self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_SHUTTER, enable, value_to_return
|
||||
self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_SHUTTER, enable_value, enable_value
|
||||
),
|
||||
"IDSCameraObject",
|
||||
)
|
||||
|
||||
def get_exposure_range(self) -> tuple[float, float, float]:
|
||||
"""Get the (min, max, increment) exposure time range (ms) at the
|
||||
camera's current pixel clock. Lowering the pixel clock (see
|
||||
set_pixel_clock()) raises the achievable max exposure time, at the
|
||||
cost of frame rate.
|
||||
"""
|
||||
param = (ueye.c_double * 3)()
|
||||
check_error(
|
||||
self.ueye.is_Exposure(
|
||||
self.cam.h_cam,
|
||||
ueye.IS_EXPOSURE_CMD_GET_EXPOSURE_RANGE,
|
||||
param,
|
||||
self.ueye.sizeof(param),
|
||||
),
|
||||
"IDSCameraObject",
|
||||
)
|
||||
return float(param[0]), float(param[1]), float(param[2])
|
||||
|
||||
def get_pixel_clock(self) -> int:
|
||||
"""Get the camera's current pixel clock (MHz)."""
|
||||
value = ueye.UINT()
|
||||
check_error(
|
||||
self.ueye.is_PixelClock(
|
||||
self.cam.h_cam, ueye.IS_PIXELCLOCK_CMD_GET, value, self.ueye.sizeof(value)
|
||||
),
|
||||
"IDSCameraObject",
|
||||
)
|
||||
return int(value.value)
|
||||
|
||||
def get_pixel_clock_range(self) -> tuple[int, int, int]:
|
||||
"""Get the (min, max, increment) pixel clock range (MHz).
|
||||
|
||||
Confirmed on hardware: this reports a *linear* range/increment, but
|
||||
not every value in it is actually accepted by is_PixelClock's SET
|
||||
command -- many uEye sensors only support a short discrete list of
|
||||
clocks (e.g. setting 49, 60, 73 MHz all failed on camera 41, even
|
||||
though they fell inside this range). Use get_pixel_clock_list() to
|
||||
find out what's actually settable; treat this range as informational
|
||||
only.
|
||||
"""
|
||||
param = (ueye.UINT * 3)()
|
||||
check_error(
|
||||
self.ueye.is_PixelClock(
|
||||
self.cam.h_cam,
|
||||
ueye.IS_PIXELCLOCK_CMD_GET_RANGE,
|
||||
param,
|
||||
self.ueye.sizeof(param),
|
||||
),
|
||||
"IDSCameraObject",
|
||||
)
|
||||
return int(param[0]), int(param[1]), int(param[2])
|
||||
|
||||
def get_pixel_clock_list(self) -> list[int]:
|
||||
"""Get the actual list of pixel clock values (MHz) this camera
|
||||
accepts -- see get_pixel_clock_range()'s docstring for why this,
|
||||
not that range, is what callers should validate/snap against."""
|
||||
count = ueye.UINT()
|
||||
check_error(
|
||||
self.ueye.is_PixelClock(
|
||||
self.cam.h_cam,
|
||||
ueye.IS_PIXELCLOCK_CMD_GET_NUMBER,
|
||||
count,
|
||||
self.ueye.sizeof(count),
|
||||
),
|
||||
"IDSCameraObject",
|
||||
)
|
||||
n = int(count.value)
|
||||
if n <= 0:
|
||||
return []
|
||||
values = (ueye.UINT * n)()
|
||||
check_error(
|
||||
self.ueye.is_PixelClock(
|
||||
self.cam.h_cam,
|
||||
ueye.IS_PIXELCLOCK_CMD_GET_LIST,
|
||||
values,
|
||||
self.ueye.sizeof(values),
|
||||
),
|
||||
"IDSCameraObject",
|
||||
)
|
||||
return sorted(int(v) for v in values)
|
||||
|
||||
def set_pixel_clock(self, value: int) -> None:
|
||||
"""Set the camera's pixel clock (MHz). Lowering it raises the max
|
||||
achievable exposure time (see get_exposure_range()), at the cost of
|
||||
frame rate. Does not itself validate value against
|
||||
get_pixel_clock_list() -- callers (IDSCamera.set_pixel_clock()) are
|
||||
expected to snap to a supported value first; passing an unsupported
|
||||
one here raises UEyeException."""
|
||||
pixel_clock = ueye.UINT(value)
|
||||
check_error(
|
||||
self.ueye.is_PixelClock(
|
||||
self.cam.h_cam,
|
||||
ueye.IS_PIXELCLOCK_CMD_SET,
|
||||
pixel_clock,
|
||||
self.ueye.sizeof(pixel_clock),
|
||||
),
|
||||
"IDSCameraObject",
|
||||
)
|
||||
|
||||
@@ -63,6 +63,57 @@ class IDSCamera(PSIDeviceBase):
|
||||
doc="Enable or disable live mode.",
|
||||
kind=Kind.config,
|
||||
)
|
||||
exposure_time = Cpt(
|
||||
Signal,
|
||||
name="exposure_time",
|
||||
value=0.0,
|
||||
doc="Camera exposure time (ms).",
|
||||
kind=Kind.config,
|
||||
)
|
||||
auto_exposure_enabled = Cpt(
|
||||
Signal,
|
||||
name="auto_exposure_enabled",
|
||||
value=True,
|
||||
doc="Enable/disable auto-exposure (auto-shutter).",
|
||||
kind=Kind.config,
|
||||
)
|
||||
auto_gain_enabled = Cpt(
|
||||
Signal,
|
||||
name="auto_gain_enabled",
|
||||
value=True,
|
||||
doc="Enable/disable auto-gain.",
|
||||
kind=Kind.config,
|
||||
)
|
||||
exposure_time_min = Cpt(
|
||||
Signal,
|
||||
name="exposure_time_min",
|
||||
value=0.0,
|
||||
doc="Minimum exposure time (ms) at the camera's current pixel clock.",
|
||||
kind=Kind.config,
|
||||
)
|
||||
exposure_time_max = Cpt(
|
||||
Signal,
|
||||
name="exposure_time_max",
|
||||
value=1000.0,
|
||||
doc="Maximum exposure time (ms) at the camera's current pixel clock.",
|
||||
kind=Kind.config,
|
||||
)
|
||||
pixel_clock = Cpt(
|
||||
Signal,
|
||||
name="pixel_clock",
|
||||
value=0,
|
||||
doc=(
|
||||
"Camera pixel clock (MHz). Lowering it raises the max achievable "
|
||||
"exposure time (see exposure_time_max), at the cost of frame rate."
|
||||
),
|
||||
kind=Kind.config,
|
||||
)
|
||||
pixel_clock_min = Cpt(
|
||||
Signal, name="pixel_clock_min", value=0, doc="Minimum pixel clock (MHz).", kind=Kind.config
|
||||
)
|
||||
pixel_clock_max = Cpt(
|
||||
Signal, name="pixel_clock_max", value=0, doc="Maximum pixel clock (MHz).", kind=Kind.config
|
||||
)
|
||||
|
||||
USER_ACCESS = [
|
||||
"start_live_mode",
|
||||
@@ -73,8 +124,22 @@ class IDSCamera(PSIDeviceBase):
|
||||
"push_preview_image",
|
||||
"push_smear_preview",
|
||||
"get_live_fps",
|
||||
"get_exposure_time",
|
||||
"set_exposure_time",
|
||||
"set_auto_exposure_enabled",
|
||||
"set_auto_gain_enabled",
|
||||
"get_exposure_time_range",
|
||||
"get_pixel_clock",
|
||||
"get_pixel_clock_range",
|
||||
"get_pixel_clock_list",
|
||||
"set_pixel_clock",
|
||||
]
|
||||
|
||||
# How long to hold auto_gain_enabled on during the connect-time pulse
|
||||
# (see on_connected()) before turning it back off -- long enough for a
|
||||
# few frames from the continuously-running capture to be processed.
|
||||
_AUTO_GAIN_SETTLE_S = 0.5
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -125,6 +190,10 @@ class IDSCamera(PSIDeviceBase):
|
||||
self.image.transpose = transpose
|
||||
self._force_monochrome = force_monochrome
|
||||
self.live_mode_enabled.subscribe(self._on_live_mode_enabled_changed, run=False)
|
||||
self.exposure_time.subscribe(self._on_exposure_time_changed, run=False)
|
||||
self.auto_exposure_enabled.subscribe(self._on_auto_exposure_enabled_changed, run=False)
|
||||
self.auto_gain_enabled.subscribe(self._on_auto_gain_enabled_changed, run=False)
|
||||
self.pixel_clock.subscribe(self._on_pixel_clock_changed, run=False)
|
||||
self.live_mode_enabled.put(bool(live_mode))
|
||||
|
||||
############## Live Mode Methods ##############
|
||||
@@ -278,6 +347,110 @@ class IDSCamera(PSIDeviceBase):
|
||||
"""
|
||||
self.smear_preview.put(data)
|
||||
|
||||
############## Exposure / Gain ##############
|
||||
|
||||
def _on_exposure_time_changed(self, *args, value, **kwargs):
|
||||
try:
|
||||
self.cam.exposure_time = value
|
||||
except Exception:
|
||||
# Caught (not re-raised) so this doesn't surface as ophyd's generic
|
||||
# "Subscription value callback exception" -- which logs the same
|
||||
# traceback but without this context. The Signal itself already
|
||||
# holds `value` regardless (ophyd updates the cache before running
|
||||
# subscribers), so it now reflects the *requested*, not confirmed,
|
||||
# exposure time if the hardware write failed -- re-set it (e.g. via
|
||||
# set_exposure_time()) once the underlying issue is resolved.
|
||||
logger.exception(
|
||||
f"{self.name}: failed to set exposure_time={value} on hardware."
|
||||
)
|
||||
|
||||
def _on_auto_exposure_enabled_changed(self, *args, value, **kwargs):
|
||||
try:
|
||||
self.cam.set_auto_shutter(bool(value))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"{self.name}: failed to set auto_exposure_enabled={bool(value)} on hardware."
|
||||
)
|
||||
|
||||
def _on_auto_gain_enabled_changed(self, *args, value, **kwargs):
|
||||
try:
|
||||
self.cam.set_auto_gain(bool(value))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"{self.name}: failed to set auto_gain_enabled={bool(value)} on hardware."
|
||||
)
|
||||
|
||||
def _on_pixel_clock_changed(self, *args, value, **kwargs):
|
||||
try:
|
||||
self.cam.set_pixel_clock(int(value))
|
||||
# The exposure range is pixel-clock-dependent -- re-derive it from
|
||||
# hardware immediately so exposure_time_min/max (and the GUI
|
||||
# slider bound to them) never lag behind the new pixel clock.
|
||||
exp_min, exp_max, _inc = self.cam.get_exposure_range()
|
||||
self.exposure_time_min.put(exp_min)
|
||||
self.exposure_time_max.put(exp_max)
|
||||
except Exception:
|
||||
logger.exception(f"{self.name}: failed to set pixel_clock={int(value)} on hardware.")
|
||||
|
||||
def get_exposure_time(self) -> float:
|
||||
"""Get the current exposure time (ms), from the cached Signal value."""
|
||||
return float(self.exposure_time.get())
|
||||
|
||||
def set_exposure_time(self, value: float) -> None:
|
||||
"""Set the exposure time (ms). Does not itself disable auto-exposure --
|
||||
call set_auto_exposure_enabled(False) first, or the driver will keep
|
||||
overriding it."""
|
||||
self.exposure_time.put(value)
|
||||
|
||||
def set_auto_exposure_enabled(self, enable: bool) -> None:
|
||||
self.auto_exposure_enabled.put(bool(enable))
|
||||
|
||||
def set_auto_gain_enabled(self, enable: bool) -> None:
|
||||
self.auto_gain_enabled.put(bool(enable))
|
||||
|
||||
def get_exposure_time_range(self) -> tuple[float, float]:
|
||||
"""Get the (min, max) exposure time (ms) at the camera's current
|
||||
pixel clock, from the cached Signal values (seeded on connect; see
|
||||
on_connected())."""
|
||||
return float(self.exposure_time_min.get()), float(self.exposure_time_max.get())
|
||||
|
||||
def get_pixel_clock(self) -> int:
|
||||
"""Get the camera's current pixel clock (MHz), from the cached Signal
|
||||
value."""
|
||||
return int(self.pixel_clock.get())
|
||||
|
||||
def get_pixel_clock_range(self) -> tuple[int, int]:
|
||||
"""Get the (min, max) pixel clock (MHz), from the cached Signal
|
||||
values (seeded on connect; see on_connected()). Informational only --
|
||||
see get_pixel_clock_list() for what's actually settable."""
|
||||
return int(self.pixel_clock_min.get()), int(self.pixel_clock_max.get())
|
||||
|
||||
def get_pixel_clock_list(self) -> list[int]:
|
||||
"""Get the list of pixel clock values (MHz) this camera actually
|
||||
accepts. Confirmed on hardware: not every value between
|
||||
get_pixel_clock_range()'s min/max is valid -- many uEye sensors only
|
||||
support a short discrete list; set_pixel_clock() snaps to the
|
||||
nearest one from this list rather than passing an arbitrary value
|
||||
straight to the driver."""
|
||||
return self.cam.get_pixel_clock_list()
|
||||
|
||||
def set_pixel_clock(self, value: int) -> None:
|
||||
"""Set the pixel clock (MHz). Lowering it raises the max achievable
|
||||
exposure time (see get_exposure_time_range()), at the cost of frame
|
||||
rate. Snaps to the nearest value in get_pixel_clock_list() -- passing
|
||||
an arbitrary MHz value from that (min, max) range is not guaranteed
|
||||
to be accepted by the driver."""
|
||||
target = int(value)
|
||||
options = self.cam.get_pixel_clock_list()
|
||||
if options and target not in options:
|
||||
nearest = min(options, key=lambda v: abs(v - target))
|
||||
logger.info(
|
||||
f"{self.name}: pixel_clock={target} is not one of this camera's supported "
|
||||
f"values {options}; using nearest supported value {nearest} instead."
|
||||
)
|
||||
target = nearest
|
||||
self.pixel_clock.put(target)
|
||||
|
||||
############## User Interface Methods ##############
|
||||
|
||||
def on_connected(self):
|
||||
@@ -285,6 +458,53 @@ class IDSCamera(PSIDeviceBase):
|
||||
self.cam.force_monochrome = self._force_monochrome
|
||||
self.cam.on_connect()
|
||||
self.live_mode_enabled.put(bool(self._inputs.get("live_mode", False)))
|
||||
# auto_exposure_enabled defaults to True, but there is no SDK query
|
||||
# for the camera's *current* auto-shutter state (only an
|
||||
# enable-setter) -- so unlike live_mode_enabled/exposure_time, this
|
||||
# used to just leave the Signal at its declared default without ever
|
||||
# telling the hardware. is_ResetToDefault() (called during
|
||||
# IDSCameraObject init, i.e. every connect) leaves the sensor with
|
||||
# auto-shutter off, so a fresh session started under-exposed until an
|
||||
# operator happened to toggle the GUI switch off and back on -- which
|
||||
# is what actually issued the enable call for the first time.
|
||||
# Round-trip through its subscribe callback now (same idiom as
|
||||
# exposure_time below) so the declared default is actually enforced
|
||||
# on hardware on every connect.
|
||||
self.auto_exposure_enabled.put(bool(self.auto_exposure_enabled.get()))
|
||||
# auto_gain_enabled: HW testing found continuous auto-gain isn't
|
||||
# useful here -- once correctly exposed it has nothing further to
|
||||
# adjust, and leaving it on takes gain out of the operator's manual
|
||||
# control. But *some* one-time gain correction at connect is still
|
||||
# needed (same under-exposed-at-start symptom as above, confirmed to
|
||||
# be specifically about gain, not just auto-exposure). So pulse it on
|
||||
# briefly, then back off -- "in the background": one settle period
|
||||
# to let the driver correct the gain from a few live frames, then
|
||||
# leave gain fixed at whatever it converged to, under manual control
|
||||
# from then on. This is the only place auto_gain_enabled is toggled;
|
||||
# there is no persistent GUI control for it (see OMNY_XRayEye).
|
||||
self.auto_gain_enabled.put(True)
|
||||
time.sleep(self._AUTO_GAIN_SETTLE_S)
|
||||
self.auto_gain_enabled.put(False)
|
||||
# Seed exposure_time from the real hardware value once, so the GUI shows
|
||||
# a real number immediately on connect instead of the 0.0 placeholder.
|
||||
# This round-trips through _on_exposure_time_changed, which writes the
|
||||
# same value back to the driver -- a harmless one-time no-op write.
|
||||
self.exposure_time.put(self.cam.exposure_time)
|
||||
# Seed the exposure time range (depends on the current pixel clock) so
|
||||
# the GUI can bound its slider to values the hardware will actually
|
||||
# accept, instead of a generic placeholder.
|
||||
exp_min, exp_max, _inc = self.cam.get_exposure_range()
|
||||
self.exposure_time_min.put(exp_min)
|
||||
self.exposure_time_max.put(exp_max)
|
||||
# Seed the pixel clock and its range the same way. This round-trips
|
||||
# through _on_pixel_clock_changed, which re-derives exposure_time_min/
|
||||
# max from hardware again -- redundant with the lines just above on
|
||||
# this first call, but it's what keeps them correct after any later
|
||||
# pixel-clock change from the GUI.
|
||||
pc_min, pc_max, _pc_inc = self.cam.get_pixel_clock_range()
|
||||
self.pixel_clock_min.put(pc_min)
|
||||
self.pixel_clock_max.put(pc_max)
|
||||
self.pixel_clock.put(self.cam.get_pixel_clock())
|
||||
self.set_rect_roi(0, 0, self.cam.cam.width.value, self.cam.cam.height.value)
|
||||
|
||||
def on_destroy(self):
|
||||
|
||||
@@ -238,6 +238,14 @@ class _SimIDSBackend:
|
||||
self._connected = False
|
||||
self._rgb = rgb
|
||||
self._noise_std = float(noise_std)
|
||||
self._exposure_time = 10000.0 # ms
|
||||
self._auto_exposure = True
|
||||
self._auto_gain = True
|
||||
self._pixel_clock = 20 # MHz
|
||||
self._pixel_clock_range = (5, 40, 1) # (min, max, increment) MHz -- informational only
|
||||
# Mimics real uEye hardware only accepting a short discrete list, not
|
||||
# every value in the range above (see get_pixel_clock_list()).
|
||||
self._pixel_clock_options = [5, 10, 20, 30, 40]
|
||||
self._width = width
|
||||
self._height = height
|
||||
self._rotation_coupling = rotation_coupling
|
||||
@@ -282,6 +290,38 @@ class _SimIDSBackend:
|
||||
def on_disconnect(self):
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def exposure_time(self) -> float:
|
||||
return self._exposure_time
|
||||
|
||||
@exposure_time.setter
|
||||
def exposure_time(self, value: float):
|
||||
self._exposure_time = value
|
||||
|
||||
def set_auto_shutter(self, enable: bool):
|
||||
self._auto_exposure = bool(enable)
|
||||
|
||||
def set_auto_gain(self, enable: bool):
|
||||
self._auto_gain = bool(enable)
|
||||
|
||||
def get_exposure_range(self) -> tuple[float, float, float]:
|
||||
# Loosely mimics the real driver's pixel-clock-dependent ceiling: a
|
||||
# higher pixel clock -> shorter max frame time -> lower max exposure.
|
||||
max_exposure = 1_000_000.0 / max(self._pixel_clock, 1)
|
||||
return 0.1, max_exposure, 0.1
|
||||
|
||||
def get_pixel_clock(self) -> int:
|
||||
return self._pixel_clock
|
||||
|
||||
def get_pixel_clock_range(self) -> tuple[int, int, int]:
|
||||
return self._pixel_clock_range
|
||||
|
||||
def get_pixel_clock_list(self) -> list[int]:
|
||||
return list(self._pixel_clock_options)
|
||||
|
||||
def set_pixel_clock(self, value: int):
|
||||
self._pixel_clock = int(value)
|
||||
|
||||
def _current_angle_deg(self) -> float:
|
||||
coupling = self._rotation_coupling
|
||||
galil = SimStateRegistry.get(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Plan: Manual exposure / auto-gain control for IDSCamera + xrayeye widget knobs
|
||||
|
||||
Status: **planned, not yet implemented** (`IDSCamera` and the `OMNY_XRayEye` GUI widget
|
||||
are both used in production during beamtimes; implementation should happen as its own
|
||||
change, reviewed and tested outside a live beamtime).
|
||||
Status: **implemented on `feat/ids-camera-manual-exposure`, pending real-hardware
|
||||
verification** (`IDSCamera` and the `OMNY_XRayEye` GUI widget are both used in
|
||||
production during beamtimes; this change should be reviewed and verified against
|
||||
real hardware outside a live beamtime -- see `csaxs_bec/device_configs/test_ids_camera_41.yaml`
|
||||
for a single-camera (ID 41, color) config for that purpose -- before merging).
|
||||
|
||||
## Context
|
||||
|
||||
@@ -328,3 +330,124 @@ The `is not None` guards are defensive (harmless if this ever runs against an ol
|
||||
xrayeye widget against a connected `cam_xeye`, toggle auto-exposure off, set an
|
||||
exposure time, and confirm the live image brightness responds and stays stable
|
||||
(doesn't drift back, confirming auto-exposure is actually off).
|
||||
|
||||
## Addendum: HW-testing findings, round 1 (2026-09-14)
|
||||
|
||||
Real-hardware testing against `test_ids_camera_41.yaml` surfaced one bug and two
|
||||
follow-up improvements, all now implemented:
|
||||
|
||||
- **Bug**: toggling "Auto gain" raised `ophyd.ophydobj | [ERROR] | Subscription value
|
||||
callback exception` from the device server. Root cause: `Camera.set_auto_gain()` /
|
||||
`set_auto_shutter()` (`base_integration/camera.py`) passed `ueye.c_int()` for
|
||||
`is_SetAutoParameter`'s `pval1`/`pval2`, which the SDK defines as `double *` (8 bytes)
|
||||
— the driver read 4 bytes past a `c_int` (4 bytes) as the rest of the double, got a
|
||||
near-never-exactly-0.0/1.0 garbage value, and rejected it. Fixed by passing
|
||||
`ueye.c_double(1.0/0.0)` instead, matching upstream pyueye examples. Also wrapped all
|
||||
three hardware-write callbacks (`_on_exposure_time_changed`,
|
||||
`_on_auto_exposure_enabled_changed`, `_on_auto_gain_enabled_changed`) in try/except so
|
||||
a *future* driver-level failure logs clearly from `IDSCamera` itself instead of
|
||||
surfacing only as ophyd's generic subscription-exception message.
|
||||
- **Exposure ceiling**: the max settable exposure time is bounded by the camera's
|
||||
current pixel clock (lower pixel clock -> longer max exposure, less frame rate).
|
||||
Added `Camera.get_exposure_range()` (`IS_EXPOSURE_CMD_GET_EXPOSURE_RANGE`) and
|
||||
`get_pixel_clock()`/`get_pixel_clock_range()`/`set_pixel_clock()`
|
||||
(`IS_PIXELCLOCK_CMD_GET`/`_GET_RANGE`/`_SET`) to `base_integration/camera.py`, exposed
|
||||
on `IDSCamera` as `get_exposure_time_range()`/`get_pixel_clock()`/
|
||||
`get_pixel_clock_range()`/`set_pixel_clock()` (`USER_ACCESS`). New
|
||||
`exposure_time_min`/`exposure_time_max` `Kind.config` signals are seeded in
|
||||
`on_connected()` and re-seeded by `set_pixel_clock()`.
|
||||
- **Widget**: replaced the exposure-time spinbox with a `QSlider` bounded to
|
||||
`exposure_time_min`/`max` (seeded from the device_read_configuration message, same as
|
||||
everything else — still no polling), and reorganized the control panel: a horizontal
|
||||
separator below the shutter/camera-running/smear switches, then the exposure/gain
|
||||
section (auto-exposure toggle, auto-gain toggle, exposure slider), then another
|
||||
separator, then the alignment values (2D positioner + zoom) below.
|
||||
|
||||
## Addendum: HW-testing findings, round 2 (2026-09-14)
|
||||
|
||||
Follow-up round after the auto-gain fix confirmed working on hardware:
|
||||
|
||||
- **Bug**: after a fresh device-server start the image was noticeably under-exposed,
|
||||
fixed by toggling "Auto gain" off then on. Root cause: `auto_exposure_enabled`/
|
||||
`auto_gain_enabled` default to `True`, but unlike `live_mode_enabled`/`exposure_time`,
|
||||
`on_connected()` never actually *applied* that default to hardware — it only left the
|
||||
Signal's cached value at `True` without ever calling `set_auto_shutter()`/
|
||||
`set_auto_gain()`. `is_ResetToDefault()` (called on every connect, inside
|
||||
`IDSCameraObject.__init__`) leaves the sensor's auto-shutter/auto-gain off, so the
|
||||
camera actually ran with both off until an operator happened to re-toggle the GUI
|
||||
switch — which is what issued the enable call for the first time. Fixed by
|
||||
round-tripping both through their subscribe callbacks in `on_connected()` (`
|
||||
self.auto_exposure_enabled.put(bool(self.auto_exposure_enabled.get()))` and the same
|
||||
for `auto_gain_enabled`), same idiom as `exposure_time` already used. (This also fully
|
||||
explains "auto gain seems to adjust once, then stay fixed" — it wasn't a one-shot
|
||||
algorithm limitation, it just was never actually enabled until manually toggled; once
|
||||
actually engaged it converges and correctly stays put for an unchanging scene.)
|
||||
- **Bug**: the exposure-time slider allowed a literal 0 ms, which isn't physical.
|
||||
Root cause: `getting_camera_status()` rounded the hardware-reported min (e.g. some
|
||||
sub-0.1 ms value) to the nearest tenth of a ms for the slider's integer units, and
|
||||
`round()` can round a small-but-nonzero min down to `0`. Fixed by rounding the min UP
|
||||
(`math.ceil`, clamped to at least 1) and the max DOWN (`math.floor`) instead of nearest
|
||||
— the slider must never claim a bound the hardware won't actually accept.
|
||||
- **Feature**: added a "Pixel clock" slider to the widget (was previously script-only
|
||||
via `set_pixel_clock()`), directly below the exposure-time slider in the exposure/gain
|
||||
section, bounded to `pixel_clock_min`/`max` (also new `Kind.config` signals, seeded on
|
||||
connect the same way as the exposure ones) — since operators hitting the exposure
|
||||
slider's ceiling need this knob to raise it further, without dropping to a script.
|
||||
|
||||
## Addendum: HW-testing findings, round 3 (2026-09-14)
|
||||
|
||||
Follow-up after confirming the round-1/2 fixes on hardware (startup exposure now good).
|
||||
Two more findings:
|
||||
|
||||
- **"Auto gain is kind of useless" (design change, not a bug)**: continuous auto-gain
|
||||
has nothing further to adjust once the image is correctly exposed, and leaving it
|
||||
enabled takes gain out of the operator's manual control. Changed `on_connected()` to
|
||||
pulse `auto_gain_enabled` on then off (`_AUTO_GAIN_SETTLE_S = 0.5`s in between, long
|
||||
enough for a few frames from the already-running continuous capture to be processed),
|
||||
instead of leaving it enabled like `auto_exposure_enabled`. This still fixes the
|
||||
under-exposed-at-start symptom (a one-time gain correction) while leaving gain fixed
|
||||
under manual control afterward. Removed the "Auto gain" toggle from `OMNY_XRayEye`
|
||||
entirely (`_init_ui`, `getting_camera_status`, `_queue_guarded_toggles`,
|
||||
`auto_gain_enabled_changed`) — `auto_gain_enabled` is now purely an internal
|
||||
connect-time mechanism (still on `IDSCamera.USER_ACCESS` via `set_auto_gain_enabled()`
|
||||
for scripted use), not a persistent GUI control.
|
||||
- **Bug**: the pixel-clock slider raised `failed to set pixel_clock=<N> on hardware`
|
||||
for essentially every value dragged to (49, 60, 73 MHz all failed on camera 41). Root
|
||||
cause: `get_pixel_clock_range()`'s (min, max, increment) describes a *linear* range,
|
||||
but `IS_PIXELCLOCK_CMD_SET` doesn't actually accept every value in it — many uEye
|
||||
sensors only support a short discrete list of pixel clocks. Added
|
||||
`Camera.get_pixel_clock_list()` (`IS_PIXELCLOCK_CMD_GET_NUMBER` +
|
||||
`IS_PIXELCLOCK_CMD_GET_LIST`, the SDK's authoritative source for what's actually
|
||||
settable) and `IDSCamera.get_pixel_clock_list()`. `IDSCamera.set_pixel_clock()` now
|
||||
snaps to the nearest value in that list before writing, instead of passing the raw
|
||||
slider value straight to the driver — so the widget doesn't need to know about the
|
||||
discrete list itself; it just gets a corrected value back on the next
|
||||
`device_read_configuration` message, same round-trip pattern as everything else here.
|
||||
|
||||
## Addendum: HW-testing findings, round 4 (2026-09-14)
|
||||
|
||||
Round 3's server-side snap-to-nearest fixed what actually got *written*, but the
|
||||
slider itself still let an operator drag to (and briefly display) any integer in
|
||||
[min, max] before self-correcting on the next status message — reported as "the
|
||||
pixel clock slider still allows for any integer setting and not the specific ones".
|
||||
That's a worse interaction than just rejecting bad values: the operator sees the
|
||||
slider land somewhere it can't actually stay.
|
||||
|
||||
Fixed by making `pixel_clock_slider` index-based over the real discrete list instead
|
||||
of ranged over `[pixel_clock_min, pixel_clock_max]`:
|
||||
|
||||
- `OMNY_XRayEye` fetches `get_pixel_clock_list()` once over RPC at widget startup
|
||||
(`_init_pixel_clock_options()`, `QTimer.singleShot(0, ...)` alongside the widget's
|
||||
other one-time init calls) — this list is static per camera, so a one-time call is
|
||||
the right trade-off versus adding a whole new signal/subscription path for something
|
||||
that never changes at runtime. The slider is disabled until this arrives.
|
||||
- The slider's range becomes `[0, len(options)-1]`; its *position* is an index into
|
||||
`self._pixel_clock_options`, so every position it can physically be dragged to
|
||||
(`pixel_clock_submitted()`) is one the hardware has already confirmed it accepts —
|
||||
no more relying on a post-hoc correction the operator has to notice.
|
||||
- `getting_camera_status()`'s pixel_clock handling now maps the hardware-reported MHz
|
||||
value to the *nearest* option's index (`_set_pixel_clock_display()`) rather than
|
||||
setting the slider to a raw MHz value directly.
|
||||
- `pixel_clock_min`/`pixel_clock_max` signals are unchanged on `IDSCamera` (still
|
||||
informational, still seeded on connect) but are no longer read by the widget, which
|
||||
no longer needs a numeric range at all.
|
||||
|
||||
@@ -27,6 +27,14 @@ def ids_camera():
|
||||
camera.cam.cam = mock.Mock()
|
||||
camera.cam.cam.width.value = 2
|
||||
camera.cam.cam.height.value = 2
|
||||
camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 1000.0, 0.1))
|
||||
camera.cam.get_pixel_clock = mock.Mock(return_value=20)
|
||||
camera.cam.get_pixel_clock_range = mock.Mock(return_value=(5, 40, 1))
|
||||
camera.cam.get_pixel_clock_list = mock.Mock(return_value=[5, 10, 20, 40])
|
||||
# on_connected() pulses auto_gain_enabled on then off with a real sleep
|
||||
# in between (see IDSCamera._AUTO_GAIN_SETTLE_S) -- skip the wait in
|
||||
# tests, only hardware needs the settle time.
|
||||
camera._AUTO_GAIN_SETTLE_S = 0
|
||||
yield camera
|
||||
camera.stop_live_mode()
|
||||
|
||||
@@ -148,6 +156,106 @@ def test_push_preview_image_compensates_rotation_and_transpose():
|
||||
assert np.array_equal(result, display_oriented)
|
||||
|
||||
|
||||
def test_get_set_exposure_time(ids_camera):
|
||||
ids_camera.set_exposure_time(1234.5)
|
||||
assert ids_camera.cam.exposure_time == 1234.5
|
||||
assert ids_camera.get_exposure_time() == 1234.5
|
||||
|
||||
|
||||
def test_set_auto_exposure_enabled(ids_camera):
|
||||
ids_camera.set_auto_exposure_enabled(False)
|
||||
ids_camera.cam.set_auto_shutter.assert_called_once_with(False)
|
||||
|
||||
|
||||
def test_set_auto_gain_enabled(ids_camera):
|
||||
ids_camera.set_auto_gain_enabled(False)
|
||||
ids_camera.cam.set_auto_gain.assert_called_once_with(False)
|
||||
|
||||
|
||||
def test_on_connected_seeds_exposure_time(ids_camera):
|
||||
ids_camera.cam.on_connect = mock.Mock()
|
||||
ids_camera.cam.exposure_time = 4200.0
|
||||
ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 1000.0, 0.1))
|
||||
ids_camera.on_connected()
|
||||
assert ids_camera.get_exposure_time() == 4200.0
|
||||
|
||||
|
||||
def test_on_connected_seeds_exposure_time_range(ids_camera):
|
||||
ids_camera.cam.on_connect = mock.Mock()
|
||||
ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.05, 500.0, 0.05))
|
||||
ids_camera.on_connected()
|
||||
assert ids_camera.get_exposure_time_range() == (0.05, 500.0)
|
||||
|
||||
|
||||
def test_auto_gain_hardware_failure_is_logged_not_raised(ids_camera):
|
||||
"""A driver-level failure inside the subscribe callback must not
|
||||
propagate -- see _on_auto_gain_enabled_changed()'s docstring. This also
|
||||
covers the case that motivated it: the old set_auto_gain() passed a
|
||||
c_int where the SDK expects c_double, which the real driver rejected."""
|
||||
ids_camera.cam.set_auto_gain = mock.Mock(side_effect=RuntimeError("driver rejected value"))
|
||||
ids_camera.set_auto_gain_enabled(False) # must not raise
|
||||
ids_camera.cam.set_auto_gain.assert_called_once_with(False)
|
||||
|
||||
|
||||
def test_auto_exposure_hardware_failure_is_logged_not_raised(ids_camera):
|
||||
ids_camera.cam.set_auto_shutter = mock.Mock(side_effect=RuntimeError("driver rejected value"))
|
||||
ids_camera.set_auto_exposure_enabled(False) # must not raise
|
||||
ids_camera.cam.set_auto_shutter.assert_called_once_with(False)
|
||||
|
||||
|
||||
def test_on_connected_applies_auto_exposure_default_to_hardware(ids_camera):
|
||||
"""auto_exposure_enabled defaults to True but, unlike
|
||||
live_mode_enabled/exposure_time, was never actually applied to hardware
|
||||
at connect -- meaning a fresh session left the camera in whatever
|
||||
is_ResetToDefault() leaves it in (observed: auto off) regardless of the
|
||||
Signal's declared default. on_connected() must now round-trip it through
|
||||
its subscribe callback so the real SDK call is issued."""
|
||||
ids_camera.cam.on_connect = mock.Mock()
|
||||
ids_camera.on_connected()
|
||||
ids_camera.cam.set_auto_shutter.assert_called_once_with(True)
|
||||
|
||||
|
||||
def test_on_connected_pulses_auto_gain_on_then_off(ids_camera):
|
||||
"""auto_gain_enabled gets pulsed on then off at connect (a one-time gain
|
||||
correction), unlike auto_exposure_enabled which stays on -- HW testing
|
||||
found continuous auto-gain has nothing useful left to do once correctly
|
||||
exposed, and it should end up under the operator's manual control."""
|
||||
ids_camera.cam.on_connect = mock.Mock()
|
||||
ids_camera.on_connected()
|
||||
ids_camera.cam.set_auto_gain.assert_has_calls([mock.call(True), mock.call(False)])
|
||||
assert ids_camera.auto_gain_enabled.get() is False
|
||||
|
||||
|
||||
def test_on_connected_seeds_pixel_clock(ids_camera):
|
||||
ids_camera.cam.on_connect = mock.Mock()
|
||||
ids_camera.cam.get_pixel_clock = mock.Mock(return_value=30)
|
||||
ids_camera.cam.get_pixel_clock_range = mock.Mock(return_value=(5, 45, 1))
|
||||
ids_camera.on_connected()
|
||||
assert ids_camera.get_pixel_clock() == 30
|
||||
assert ids_camera.get_pixel_clock_range() == (5, 45)
|
||||
|
||||
|
||||
def test_set_pixel_clock_reseeds_exposure_range(ids_camera):
|
||||
ids_camera.cam.set_pixel_clock = mock.Mock()
|
||||
ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.2, 200.0, 0.1))
|
||||
ids_camera.set_pixel_clock(10)
|
||||
ids_camera.cam.set_pixel_clock.assert_called_once_with(10)
|
||||
assert ids_camera.get_exposure_time_range() == (0.2, 200.0)
|
||||
|
||||
|
||||
def test_set_pixel_clock_snaps_to_nearest_supported_value(ids_camera):
|
||||
"""Confirmed on hardware: not every value between get_pixel_clock_range()'s
|
||||
min/max is actually accepted (49, 60, 73 MHz were all rejected on camera
|
||||
41) -- set_pixel_clock() must snap to the nearest value in
|
||||
get_pixel_clock_list() instead of passing the raw slider value through."""
|
||||
ids_camera.cam.get_pixel_clock_list = mock.Mock(return_value=[5, 10, 20, 40])
|
||||
ids_camera.cam.set_pixel_clock = mock.Mock()
|
||||
ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 100.0, 0.1))
|
||||
ids_camera.set_pixel_clock(37) # nearest supported value is 40
|
||||
ids_camera.cam.set_pixel_clock.assert_called_once_with(40)
|
||||
assert ids_camera.get_pixel_clock() == 40
|
||||
|
||||
|
||||
def test_push_smear_preview_no_rotation_compensation(ids_camera):
|
||||
"""smear_preview has no rotation_90/transpose configured, so pushed data
|
||||
passes straight through unmodified -- unlike push_preview_image, no
|
||||
|
||||
Reference in New Issue
Block a user