diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index a9c57ed7..7d44b807 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -13,7 +13,6 @@ from bec_widgets.widgets.plots.roi.image_roi import BaseROI, CircularROI, Rectan from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( - QDoubleSpinBox, QFrame, QGridLayout, QHBoxLayout, @@ -21,6 +20,7 @@ from qtpy.QtWidgets import ( QLineEdit, QPushButton, QSizePolicy, + QSlider, QSpinBox, QToolButton, QVBoxLayout, @@ -270,6 +270,10 @@ 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] @@ -403,8 +407,25 @@ class OMNY_XRayEye(BECWidget, QWidget): switch_grid.addWidget(self.smear_preview_label, 1, 3, _right_vcenter) switch_grid.addWidget(self.smear_preview_toggle, 1, 4, Qt.AlignmentFlag.AlignVCenter) - # Exposure/gain control knobs (row 2): auto-exposure and auto-gain - # enable toggles, mirroring the shutter/camera-running row above. + self.control_panel_layout.addWidget(self.switch_grid_widget) + + # 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) + self.auto_exposure_label = QLabel("Auto exposure", parent=self) self.auto_exposure_toggle = ToggleSwitch(parent=self) self.auto_exposure_toggle.checked = True @@ -415,14 +436,35 @@ class OMNY_XRayEye(BECWidget, QWidget): self.auto_gain_toggle.checked = True self.auto_gain_toggle.enabled.connect(self.auto_gain_enabled_changed) - switch_grid.addWidget(self.auto_exposure_label, 2, 1, _right_vcenter) - switch_grid.addWidget(self.auto_exposure_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter) - switch_grid.addWidget(self.auto_gain_label, 2, 3, _right_vcenter) - switch_grid.addWidget(self.auto_gain_toggle, 2, 4, Qt.AlignmentFlag.AlignVCenter) + exposure_grid.addWidget(self.auto_exposure_label, 0, 1, _right_vcenter) + exposure_grid.addWidget(self.auto_exposure_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) + exposure_grid.addWidget(self.auto_gain_label, 0, 3, _right_vcenter) + exposure_grid.addWidget(self.auto_gain_toggle, 0, 4, Qt.AlignmentFlag.AlignVCenter) - self.control_panel_layout.addWidget(self.switch_grid_widget) + # 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(0, 10000) # placeholder; reseeded on connect + 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) - # separator + 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) + + 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) @@ -457,18 +499,6 @@ class OMNY_XRayEye(BECWidget, QWidget): # Add form to control panel self.control_panel_layout.addLayout(step_size_form) - # Exposure time (manual entry, only usable while auto-exposure is off) - exposure_form = QGridLayout() - self.exposure_time_spin = QDoubleSpinBox(parent=self) - self.exposure_time_spin.setRange(0.01, 1000.0) # ms; confirm real driver range - self.exposure_time_spin.setDecimals(2) - self.exposure_time_spin.setSuffix(" ms") - self.exposure_time_spin.setEnabled(False) # auto-exposure starts enabled - self.exposure_time_spin.editingFinished.connect(self.exposure_time_submitted) - exposure_form.addWidget(QLabel("Exposure time", parent=self), 0, 0) - exposure_form.addWidget(self.exposure_time_spin, 0, 1) - self.control_panel_layout.addLayout(exposure_form) - # Push form to bottom self.control_panel_layout.addStretch() @@ -934,7 +964,7 @@ class OMNY_XRayEye(BECWidget, QWidget): enabled = bool(auto_exp.get("value")) self.auto_exposure_toggle.blockSignals(True) self.auto_exposure_toggle.checked = enabled - self.exposure_time_spin.setEnabled(not enabled) + self.exposure_time_slider.setEnabled(not enabled) self.auto_exposure_toggle.blockSignals(False) auto_gain = signals.get(f"{CAMERA[0]}_auto_gain_enabled") @@ -943,11 +973,25 @@ class OMNY_XRayEye(BECWidget, QWidget): self.auto_gain_toggle.checked = bool(auto_gain.get("value")) self.auto_gain_toggle.blockSignals(False) + # Reseed the slider's bounds first (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. + 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 = round(float(exposure_min.get("value")) * self._EXPOSURE_SLIDER_SCALE) + hi = round(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_spin.blockSignals(True) - self.exposure_time_spin.setValue(float(exposure_time.get("value"))) - self.exposure_time_spin.blockSignals(False) + 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): @@ -957,7 +1001,7 @@ class OMNY_XRayEye(BECWidget, QWidget): 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_spin.setEnabled(not enabled) + self.exposure_time_slider.setEnabled(not enabled) self.auto_exposure_toggle.blockSignals(False) @SafeSlot(bool) @@ -970,8 +1014,14 @@ class OMNY_XRayEye(BECWidget, QWidget): self.auto_gain_toggle.checked = enabled self.auto_gain_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): - self.dev.get(CAMERA[0]).exposure_time.put(self.exposure_time_spin.value()) + value_ms = self.exposure_time_slider.value() / self._EXPOSURE_SLIDER_SCALE + self.dev.get(CAMERA[0]).exposure_time.put(value_ms) @SafeSlot(bool) def opening_shutter(self, enabled: bool):