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-235Camera.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 ophydCpt(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 allKind.configsignals of the device together in one message whenever any of them changes.getting_camera_status(x_ray_eye.py:886-892) readsdata["signals"]["cam_xeye_live_mode_enabled"]["value"]and updates the toggle via theblockSignals(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 undertests/forx_ray_eye.py— check before implementing) should covergetting_camera_statusupdating the three new widgets from a synthetic message dict, andexposure_time_submitted/auto_*_enabled_changedcalling 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) callsexposure_time.put(...), and the value round-trips back throughgetting_camera_statusinto 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 exceptionfrom the device server. Root cause:Camera.set_auto_gain()/set_auto_shutter()(base_integration/camera.py) passedueye.c_int()foris_SetAutoParameter'spval1/pval2, which the SDK defines asdouble *(8 bytes) — the driver read 4 bytes past ac_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 passingueye.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 fromIDSCameraitself 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) andget_pixel_clock()/get_pixel_clock_range()/set_pixel_clock()(IS_PIXELCLOCK_CMD_GET/_GET_RANGE/_SET) tobase_integration/camera.py, exposed onIDSCameraasget_exposure_time_range()/get_pixel_clock()/get_pixel_clock_range()/set_pixel_clock()(USER_ACCESS). Newexposure_time_min/exposure_time_maxKind.configsignals are seeded inon_connected()and re-seeded byset_pixel_clock(). - Widget: replaced the exposure-time spinbox with a
QSliderbounded toexposure_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_enableddefault toTrue, but unlikelive_mode_enabled/exposure_time,on_connected()never actually applied that default to hardware — it only left the Signal's cached value atTruewithout ever callingset_auto_shutter()/set_auto_gain().is_ResetToDefault()(called on every connect, insideIDSCameraObject.__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 inon_connected()(self.auto_exposure_enabled.put(bool(self.auto_exposure_enabled.get()))and the same forauto_gain_enabled), same idiom asexposure_timealready 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, andround()can round a small-but-nonzero min down to0. 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 topixel_clock_min/max(also newKind.configsignals, 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 pulseauto_gain_enabledon 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 likeauto_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 fromOMNY_XRayEyeentirely (_init_ui,getting_camera_status,_queue_guarded_toggles,auto_gain_enabled_changed) —auto_gain_enabledis now purely an internal connect-time mechanism (still onIDSCamera.USER_ACCESSviaset_auto_gain_enabled()for scripted use), not a persistent GUI control. - Bug: the pixel-clock slider raised
failed to set pixel_clock=<N> on hardwarefor 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, butIS_PIXELCLOCK_CMD_SETdoesn't actually accept every value in it — many uEye sensors only support a short discrete list of pixel clocks. AddedCamera.get_pixel_clock_list()(IS_PIXELCLOCK_CMD_GET_NUMBER+IS_PIXELCLOCK_CMD_GET_LIST, the SDK's authoritative source for what's actually settable) andIDSCamera.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 nextdevice_read_configurationmessage, 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_XRayEyefetchesget_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 intoself._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_maxsignals are unchanged onIDSCamera(still informational, still seeded on connect) but are no longer read by the widget, which no longer needs a numeric range at all.