Skip to content

Plan: Manual exposure / auto-gain control for IDSCamera + xrayeye widget knobs

Status: done — implemented and merged into main, after four rounds of real-hardware verification (see the addenda below for the bugs found and fixed each round: startup auto-exposure/gain never actually engaging, a zero-ms exposure floor, auto-gain being more useful as a one-time startup pulse than a persistent toggle, and the pixel-clock slider needing to be index-based over the camera's real discrete list instead of a ranged value).

Context

IDSCamera (the cSAXS ophyd device wrapping IDS uEye cameras via the vendor's pyueye SDK) currently gives operators no way to leave auto-exposure/auto-gain and drive the camera manually. The underlying driver already has almost everything needed — it's just not wired up or exposed to BEC clients:

  • Camera.exposure_time (get/set property) — base_integration/camera.py:220-235
  • Camera.set_auto_shutter(enable) — base_integration/camera.py:248-257 (auto-exposure toggle)
  • Camera.set_auto_gain(enable) — base_integration/camera.py:237-246 (auto-gain toggle)

None of these three are called anywhere, and none are on IDSCamera.USER_ACCESS, so a BEC client can't reach them today.

OMNY_XRayEye (csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py) is the GUI panel for the flomni/LamNI/OMNY X-ray-eye alignment camera (cam_xeye, an IDSCamera device). It should get exposure/auto-gain control knobs too, using the same cached/event-driven pattern it already uses for its "Camera running" toggle, rather than polling the device with RPC calls.

Per user decisions (2026-09-11): cover exposure control + the auto-gain enable/disable toggle only (no manual gain value — the driver has no SDK call for that yet); leave connect-time default behavior untouched (no new constructor/config kwarg — the camera keeps whatever state is_ResetToDefault() leaves it in unless a client/GUI explicitly calls the new methods); and be careful to use cached readings wherever possible rather than polling the hardware for GUI display.

Design: Cpt(Signal, kind=Kind.config), not plain methods only

OMNY_XRayEye's existing "Camera running" toggle is the template for a control knob that both writes device state and reflects it back without polling:

  • Write: self.dev.get(CAMERA[0]).live_mode_enabled.put(enabled) (x_ray_eye.py:882) — RPC .put() on an ophyd Cpt(Signal, kind=Kind.config) on the device (ids_camera.py:59-65).
  • Read (cached/event-driven, no polling): the widget subscribes once, at construction, to MessageEndpoints.device_read_configuration(CAMERA[0]) (x_ray_eye.py:293-295) — BEC pushes all Kind.config signals of the device together in one message whenever any of them changes. getting_camera_status (x_ray_eye.py:886-892) reads data["signals"]["cam_xeye_live_mode_enabled"]["value"] and updates the toggle via the blockSignals(True)/.checked = .../blockSignals(False) idiom (to avoid re-triggering the write handler).

This matches the repo's established convention for Signal-backed device state (confirmed against slit_control.py, sample_storage.py, tomo_params.py: read(cached=True) for polled numeric readbacks, and bec_dispatcher.connect_slot(..., device_read_configuration(...)) for Kind.config signals, which is cheaper still — zero RPC round-trips, pure pub/sub). So exposure_time and the two auto-enable flags should be Cpt(Signal, kind=Kind.config) on IDSCamera (not plain methods only), with thin USER_ACCESS wrapper methods kept on top for ipython/scripted use — mirroring how start_live_mode()/stop_live_mode() are just wrappers around live_mode_enabled.put(). No code should poll get_exposure_time() / get_gain() RPCs from a QTimer loop.

Changes

1. csaxs_bec/devices/ids_cameras/ids_camera.py

Add three new components, next to live_mode_enabled (ids_camera.py:59-65):

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,
)

In __init__, alongside the existing live_mode_enabled.subscribe(...) (ids_camera.py:127):

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)

New callbacks + thin USER_ACCESS wrappers, in a new ############## Exposure / Gain ############## section (placed after the live-mode/ROI methods and before ############## User Interface Methods ##############):

def _on_exposure_time_changed(self, *args, value, **kwargs):
    self.cam.exposure_time = value

def _on_auto_exposure_enabled_changed(self, *args, value, **kwargs):
    self.cam.set_auto_shutter(bool(value))

def _on_auto_gain_enabled_changed(self, *args, value, **kwargs):
    self.cam.set_auto_gain(bool(value))

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))

(Confirm the SDK's exposure unit — is_Exposure/IS_EXPOSURE_CMD_* is documented in milliseconds; adjust the docstring if inspection shows otherwise.)

Add the four wrapper names to USER_ACCESS (ids_camera.py:67-76): "get_exposure_time", "set_exposure_time", "set_auto_exposure_enabled", "set_auto_gain_enabled".

In on_connected() (ids_camera.py:283-288), after self.cam.on_connect(), seed the exposure_time signal from the real hardware value once, so the GUI shows a real number immediately on connect instead of the 0.0 placeholder:

self.exposure_time.put(self.cam.exposure_time)

(This round-trips through _on_exposure_time_changed, which writes the same value back to the driver — a harmless one-time no-op write, same idiom as the existing self.live_mode_enabled.put(bool(self._inputs.get("live_mode", False))) line right below it.) auto_exposure_enabled/auto_gain_enabled are not seeded from hardware — there is no SDK query for current auto-shutter/auto-gain state in this driver (only enable-setters), so, like live_mode_enabled, they start at their declared default (True, matching the uEye SDK's typical post-reset default) and only reflect reality once explicitly set through this API.

No changes needed in base_integration/camera.py — exposure_time, set_auto_shutter, set_auto_gain already exist there exactly as needed.

2. csaxs_bec/devices/sim/sim_cameras.py

SimIDSCamera replaces self.cam with _SimIDSBackend (line 211), which currently has no exposure_time attribute and no set_auto_shutter/set_auto_gain methods — on_connected()'s self.exposure_time.put(self.cam.exposure_time) and the new subscribe callbacks would raise AttributeError against SimIDSCamera otherwise. Add to _SimIDSBackend (mirroring _SimAlliedVisionBackend.exposure_time, lines 397-403):

def __init__(self, ...):
    ...
    self._exposure_time = 10000.0  # ms
    self._auto_exposure = True
    self._auto_gain = True

@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)

3. Tests — tests/tests_devices/test_ids_camera.py

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.on_connected()
    assert ids_camera.get_exposure_time() == 4200.0

Phase 2: GUI control knobs in OMNY_XRayEye

4. csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py

Imports: add QDoubleSpinBox to the qtpy.QtWidgets import block (x_ray_eye.py:15-29) — only QSpinBox is imported today.

New widgets in _init_ui, extending the existing switch_grid (x_ray_eye.py:368-404) with a 3rd row (same QGridLayout the shutter/camera-running and smear toggles already share, so columns keep lining up):

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)

self.auto_gain_label = QLabel("Auto gain", parent=self)
self.auto_gain_toggle = ToggleSwitch(parent=self)
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)

An exposure-time spinbox, added as its own small form near the step-size form (x_ray_eye.py:424-439), mirroring that form's QGridLayout structure:

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)

Using editingFinished (fires once, on Enter/focus-loss) rather than valueChanged (fires on every arrow-button click/scroll tick) is the throttling choice here — the "don't hammer the device" concern applied to a manual-entry control instead of a poll loop.

New handlers, alongside camera_running_enabled/opening_shutter (x_ray_eye.py:875-906):

@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_spin.setEnabled(not enabled)
    self.auto_exposure_toggle.blockSignals(False)

@SafeSlot(bool)
def auto_gain_enabled_changed(self, enabled: bool):
    if self._manual_toggle_blocked_by_queue():
        logger.warning("Ignoring auto-gain toggle while scan queue is busy.")
        return
    self.auto_gain_toggle.blockSignals(True)
    self.dev.get(CAMERA[0]).auto_gain_enabled.put(enabled)
    self.auto_gain_toggle.checked = enabled
    self.auto_gain_toggle.blockSignals(False)

def exposure_time_submitted(self):
    self.dev.get(CAMERA[0]).exposure_time.put(self.exposure_time_spin.value())

Extend the existing getting_camera_status (x_ray_eye.py:886-892) — no new Redis subscription needed, since exposure_time/auto_exposure_enabled/auto_gain_enabled are Kind.config signals on the same device, so they already arrive in the same device_read_configuration(CAMERA[0]) message live_mode_enabled uses:

@SafeSlot(dict, dict)
def getting_camera_status(self, data, meta):
    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_spin.setEnabled(not enabled)
        self.auto_exposure_toggle.blockSignals(False)

    auto_gain = signals.get(f"{CAMERA[0]}_auto_gain_enabled")
    if auto_gain is not None:
        self.auto_gain_toggle.blockSignals(True)
        self.auto_gain_toggle.checked = bool(auto_gain.get("value"))
        self.auto_gain_toggle.blockSignals(False)

    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)

The is not None guards are defensive (harmless if this ever runs against an older IDSCamera revision without these signals) and cost nothing.

Verification

  • Unit tests: pytest tests/tests_devices/test_ids_camera.py -v. GUI widget tests (if any exist under tests/ for x_ray_eye.py — check before implementing) should cover getting_camera_status updating the three new widgets from a synthetic message dict, and exposure_time_submitted/auto_*_enabled_changed calling the right RPC .put().
  • Manual/simulation check: run against SimIDSCamera (no real hardware needed) — confirm toggling "Auto exposure" off enables the spinbox, entering a value + Enter (or focus-loss) calls exposure_time.put(...), and the value round-trips back through getting_camera_status into the spinbox display without any added polling.
  • Manual/integration check on real hardware (during a non-live test session): open the 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.5s 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.