Experimental/rotation center smear #272

Merged
holler merged 11 commits from experimental/rotation_center_smear into main 2026-07-24 16:17:01 +02:00
Member
No description provided.
bec_bl_push-gitea added 11 commits 2026-07-24 16:08:59 +02:00
Adds an EXPERIMENTAL, operator-only alternative to the extended-sample
rotation-center click: instead of judging the center from a single
instant of live rotation, accumulate a max-projection composite while
lsamrot rotates continuously through a (tunable, default 360 deg)
sweep. Off-axis features smear into circular arcs; the common center
of curvature is the rotation axis, an easier target to click than one
live frame. No automatic circle fitting -- purely a visual aid feeding
the same _collect_click mechanism the production feature already uses.

Rotation is issued non-blocking via scans.mv() (new local wrapper,
sibling of this file's existing blocking umv()) and frames are grabbed
via get_last_image() in a single-threaded loop polling ScanReport.status
-- the same thing ScanReport.wait() does internally -- so no background
thread or device-server change is needed to grab frames while the motor
is actively moving. The composite is pushed to the GUI through the
camera's existing PreviewSignal (dev.cam_xeye.image), duty-cycling
live_mode_enabled off/on around each push so the background 5 Hz live
thread can't immediately overwrite it.

New entry point: lamni.xrayeye_rotation_center_calibration_smear_experimental().
Purely additive -- does not modify find_rotation_center()/_live_sweep()
or the two production entry points.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dev.cam_xeye.image.put(composite) failed on real hardware --
PreviewSignal is a BECMessageSignal subclass, and BEC hardcodes
rpc_access=False for that signal_info, so the client-side device proxy
never exposes `image` as an attribute at all (confirmed: this is a
structural BEC constraint, not specific to this device -- no existing
RPC surface, device or widget, offers a raw-array setter either).

Add IDSCamera.push_preview_image(data), a small device-server-side
method (alongside the existing get_last_image()) that does
self.image.put(data) from within the device-server process, where
PreviewSignal is a normal ophyd attribute. _push_smear_composite now
calls dev.cam_xeye.push_preview_image(composite) instead of writing to
the signal directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The simulated cam_xeye (SimIDSCamera/_SimIDSBackend) served a single
static Gaussian blob, cached at construction -- rotating lsamrot in a
simulated session produced no visible motion, so the smear/composite
rotation-center calibration aid had nothing to smear into an arc.

Add an opt-in sim_rotation_coupling config (mirrors the existing
sim_coarse_coupling idiom in sim_lamni.py's measured_positions()): when
set, _SimIDSBackend regenerates a fresh frame on every get_image_data()
call with an eccentric blob (make_rotating_test_pattern) tracking the
coupled axis' live simulated Galil position instead of serving the
cached static frame. Wired into LamNI's cam_xeye sim config only
(host/port/axis matching lsamrot's own sim config); flomni's cam_xeye
and any other SimIDSCamera user are unaffected since the new params
default to None (byte-identical output to before, confirmed by test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Real-HW-adjacent (sim) testing surfaced three issues with the smear
calibration aid:

1. The composite reverted to the plain rotating live view right before
   the click prompt. find_rotation_center_smear_experimental() called
   update_frame() after _smear_sweep(), which grabs a fresh raw live
   frame -- overwriting the composite it just built. Also,
   _push_smear_composite always resumed live mode, so even without that
   bug the composite would only stay visible for hold_display_s.
   Fixed: _push_smear_composite gained resume_live=False for the final
   push; _smear_sweep now takes keep_shutter_open and closes the
   shutter itself at the end, so the composite (not a fresh grab) stays
   frozen on screen for the click. The stray update_frame() call is
   removed.

2. The composite appeared rotated relative to the live view.
   get_last_image() frames already have num_rotation_90/transpose
   applied (PreviewSignal.put() applies it to whatever it stores), so
   composites built from those frames got the transform applied AGAIN
   by push_preview_image()'s own self.image.put() call. Fixed:
   push_preview_image() now undoes the transform first (reverse order,
   since transpose is applied last forward) so the net effect is a
   single transform, matching a live frame.

3. Feature request: the sim camera's rotation-coupled pattern was a
   single eccentric gaussian blob -- a better analog for the isolated/
   point-particle case than the extended/textured case this feature
   actually targets. Added generate_speckle_grains/
   make_speckle_test_pattern (sim_cameras.py): a fixed, seeded field of
   small gaussian "grains" at a range of radii, rotating rigidly
   together as the live angle changes -- producing several concentric
   arcs in the composite instead of one, much closer to a real textured
   sample. New sim_speckle_count/sim_speckle_seed config, defaults
   requiring no yaml changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The "camera running" toggle (IDSCamera.live_mode_enabled) was flapping
on/off during the smear sweep, and on real hardware that's genuinely
slow (starting/stopping the live acquisition thread has real driver
overhead, unlike the simulator). Root cause: composite pushes shared
the same device_preview channel the live thread continuously publishes
to, so live_mode_enabled had to be toggled off around every push to
keep the composite from being instantly overwritten.

Give the composite its own channel instead:
- IDSCamera gains a smear_preview PreviewSignal (no rotation_90/
  transpose configured -- composite data is already display-oriented)
  and push_smear_preview(), mirroring push_preview_image() but fully
  decoupled from the live channel.
- XRayEye gains set_live_view_signal() to switch which camera signal
  the live Image view displays, defaulting to the normal channel for
  every fresh GUI instance; on_live_view_enabled() now uses whichever
  signal is currently selected instead of a hardcoded constant.
- _smear_sweep() switches the GUI to "smear_preview" for the sweep and
  pushes composite updates there -- live_mode_enabled is never touched
  at all (just ensured on at the start, exactly like _live_sweep()).
  The GUI is deliberately left on the composite after a successful
  sweep until the caller has collected the operator's click, then
  switched back to "image"; on KeyboardInterrupt it's switched back
  immediately.

This removes the resume_live/hold_display_s toggle-avoidance mechanism
added in def9bf4, which is no longer needed with a dedicated channel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The simulated speckle pattern rotated exactly around image center --
the same point the FZP crosshair sits at -- so the calibration was
trivially already correct in sim, never exercising the actual
find-and-correct-a-miscalibration path.

Add an opt-in axis_offset_range_px/axis_offset_seed: when set, the
"true" rotation axis the speckle field orbits is offset from image
center by a random amount (fresh each device construction unless a
seed is given, so each sim session gets a new miscalibration to find).
Wired into LamNI's cam_xeye sim config (sim_axis_offset_range_px: 40,
no seed -- random each session); default (0.0) preserves prior
behavior exactly for any other SimIDSCamera user.

Also drop the synthetic crosshair lines baked into
make_speckle_test_pattern's pixel data -- a real camera image has no
such marking; the GUI already draws its own crosshair overlay
(TargetCrosshair) on top of the live image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
csaxs_bec/bec_widgets/widgets/client.py is a checked-in generated file
(via `bw-generate-cli --target csaxs_bec`) containing the client-side
RPC stub for each plugin widget's USER_ACCESS surface -- BECGuiClient
resolves widget classes by name against this exact file, not via a
dynamic import of the real widget class, so it must be regenerated
whenever a plugin widget's USER_ACCESS changes.

Picks up XRayEye.set_live_view_signal (added for the LamNI smear
calibration aid) plus other plugin widgets that had drifted out of
sync already (SAXSWidget.move_to_selected_row, a SampleStorageWidget/
TomoParamsWidget docstring update) -- unrelated to this session's work
but caught by the same regeneration pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add docs/developer/lamni_smear_architecture.md covering the three-tier
device/ipython-client/GUI architecture this feature exercises: the
camera's two PreviewSignal channels (image vs smear_preview) and why
composite pushes needed their own channel, XRayEye.set_live_view_signal
and exactly when the caller must switch it back, the sweep algorithm
(non-blocking mv() + ScanReport.status polling + max-projection), and
the bw-generate-cli regeneration step required whenever a plugin
widget's USER_ACCESS changes (the cause of this session's confusing
AttributeError even after a full client/GUI restart).

Also expand the user-facing description in lamni.md with a note on the
live-updating composite display and a cross-reference to the new
developer page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A Ctrl-C interrupting an in-flight RPC call (e.g. mid
set_live_view_signal) could leave the XRayEye widget stuck showing the
smear-preview channel afterward -- and since lamnigui_show_xeyealign()
reuses the existing xeyegui widget across calls rather than recreating
it, that stale state would silently carry into the *next* alignment
run too (including production align()/find_rotation_center(), since
on_live_view_enabled() now respects whichever channel is selected).

Fix: lamnigui_show_xeyealign() unconditionally resets both
set_live_view_signal("image") and set_smear_active(False) every time
it's called -- both are cheap no-ops if already in the default state,
and this is the single shared entry point for every alignment
procedure, so one fix covers all of them.

Also add two GUI-facing controls to XRayEye, in a new row below the
shutter/camera-running toggles:
- "Smear preview channel" -- an interactive toggle mirroring
  set_live_view_signal(), so the operator has a GUI-only recovery path
  independent of the ipython client (e.g. if it's hung/disconnected).
- "Smear integrating" -- a read-only status indicator (disabled
  ToggleSwitch) reflecting whether _smear_sweep() is currently
  accumulating a composite, driven entirely by the automation.

set_live_view_signal() keeps the interactive toggle's visual state in
sync regardless of who changed the channel (automation or operator) --
single source of truth. _smear_sweep() brackets accumulation with
set_smear_active(True)/(False), turning it off as soon as the sweep
ends (independent of the channel switch, which stays on the composite
until the operator's click is collected). Both new XRayEye methods
required regenerating csaxs_bec/bec_widgets/widgets/client.py via
bw-generate-cli --target csaxs_bec.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix(LamNI): show cached composite on manual channel switch, align toggle grid, archive smear runs
CI for csaxs_bec / test (pull_request) Successful in 2m42s
Read the Docs Deploy Trigger / trigger-rtd-webhook (push) Successful in 1s
CI for csaxs_bec / test (push) Successful in 1m45s
83f9a6bb59
Three follow-ups from real-hardware testing of the smear aid:

1. Switching the "Smear preview channel" toggle off then back on showed
   the live camera feed, not the previous composite. Root cause:
   Image.image()'s connect_slot subscription only delivers stream
   entries published *after* connecting (bec_lib's default
   register(from_start=False, newest_only=False) bookmarks the
   then-current last entry and only forwards newer ones) -- so nothing
   re-renders on reconnect until the next sweep pushes a new frame.
   Added a permanent, independent subscription (newest_only=True) that
   keeps the last smear_preview payload cached regardless of which
   channel is currently displayed; set_live_view_signal() now force-
   renders that cache via main_image.set_data() when switching back to
   "smear_preview", instead of waiting for a push that may never come.
   Naturally "resets" once a new sweep starts pushing fresh composites.

2. The new switch row wasn't column-aligned with the existing
   shutter/camera-running row (each used an independent QHBoxLayout
   sized by its own labels' widths). Merged both rows into one
   QGridLayout with right-aligned label columns, so all four toggles
   line up regardless of label text length. Shutter now pairs with
   Smear integrating, Camera running with Smear preview channel.

3. find_rotation_center_smear_experimental() now archives each run to
   a timestamped HDF5 file (~/data/raw/logs/xrayeye_smear_calibration/),
   mirroring align()'s existing _save_alignment_data() pattern: the FZP
   reference, one composite image + one clicked centre per iteration,
   and the final result -- collecting data towards eventually
   automating center detection from the composite offline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
bec_bl_push-gitea force-pushed experimental/rotation_center_smear from 9299d813ad to 83f9a6bb59 2026-07-24 16:08:59 +02:00 Compare
holler merged commit 83f9a6bb59 into main 2026-07-24 16:17:01 +02:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bec/csaxs_bec#272