Feat/ellipse selector #328

Merged
holler merged 3 commits from feat/ellipse_selector into main 2026-10-13 10:54:53 +02:00
11 changed files with 869 additions and 90 deletions

No files matched your search

@@ -46,6 +46,8 @@ class LamniWebpageGenerator(WebpageGeneratorBase):
sample name, and measurement settings.
"""
CLIENT_NAME = "lamni"
# LamNI's tomo-queue jobs (added via TomoQueueMixin, see
# OMNY_shared/tomo_queue_mixin.py) are persisted under the same
# "tomo_queue" global var key flomni uses, so the shared "Tomo queue"
@@ -17,6 +17,7 @@ from __future__ import annotations
import builtins
import os
import h5py
import numpy as np
from bec_lib import bec_logger
from typeguard import typechecked
@@ -25,6 +26,27 @@ logger = bec_logger.logger
dev = builtins.__dict__.get("dev")
# Raw per-angle offsets of the most recent X-ray eye alignment, as written by
# XrayEyeAlign.write_output(): [angles_deg, x_offsets_um, y_offsets_um].
XEYE_FIT_DATA_VAR = "lamni_xeye_fit_data"
def fit_xray_eye_sine(angles_deg, values):
"""Least-squares fit of ``A * sin(radians(angle) + B) + C`` to *values*.
Same model as the XRayEye GUI's DAP fit (SineModel with the frequency
fixed to 1 cycle/360 deg, plus a constant) and the same convention as
``tomo_fit_xray_eye``. Written as ``a*sin + b*cos + C`` the model is
linear, so the fit is exact and needs no start values.
Returns:
tuple: ``(A, B, C)`` with ``A >= 0`` and ``B`` in (-pi, pi].
"""
theta = np.radians(np.asarray(angles_deg, dtype=float))
design = np.column_stack([np.sin(theta), np.cos(theta), np.ones_like(theta)])
(a, b, c), *_ = np.linalg.lstsq(design, np.asarray(values, dtype=float), rcond=None)
return float(np.hypot(a, b)), float(np.arctan2(b, a)), float(c)
class LamNIAlignmentMixin:
"""Correction infrastructure for LamNI laminography.
@@ -54,8 +76,10 @@ class LamNIAlignmentMixin:
self.corr_angle_2 = []
def reset_xray_eye_correction(self):
"""Delete the X-ray eye sinusoidal fit from the BEC global variable store."""
"""Delete the X-ray eye sinusoidal fit, and the alignment points it was
fitted to, from the BEC global variable store."""
self.client.delete_global_var("tomo_fit_xray_eye")
self.client.delete_global_var(XEYE_FIT_DATA_VAR)
# ------------------------------------------------------------------
# Additional lookup-table correction properties -- global-var-backed so
@@ -241,6 +265,125 @@ class LamNIAlignmentMixin:
print("New alignment parameters loaded from XRayEye GUI fit:")
self._print_xeye_fit(tomo_fit_xray_eye)
def xrayeye_refit(self, exclude_angles=None, file: str | None = None, confirm: bool = True):
"""Redo the X-ray eye sinusoidal fit, optionally leaving out angles.
Use this after ``xrayeye_alignment_start()`` when one click is clearly
off the curve (sample barely visible at that angle, misclick, ...).
The clicks of the last alignment are refitted without the excluded
angles, a table of all points with their deviation from the new fit
is printed, and after confirmation the result replaces
``tomo_fit_xray_eye``. Nothing is moved and no new frame is taken.
Call it without arguments first to see the deviations of all points
from the full fit and spot the outlier. Calling it again with an
empty exclusion list restores the fit over all points.
Args:
exclude_angles: angle or list of angles in deg (of 0, 45, ...,
315) whose point is left out of the fit in both x and y.
file: refit an archived alignment instead of the last one, e.g.
~/data/raw/logs/xrayeye_alignmentvalues/xrayeye_alignmentvalues_<timestamp>.h5
confirm: ask before replacing the stored fit.
Example:
lamni.xrayeye_refit() # inspect all points
lamni.xrayeye_refit([135]) # drop the 135 deg click
lamni.xrayeye_refit([135, 270])
"""
data = self._load_xeye_fit_data(file)
angles = np.asarray(data[0], dtype=float)
offsets = {"x": np.asarray(data[1], dtype=float), "y": np.asarray(data[2], dtype=float)}
if exclude_angles is None:
exclude_angles = []
elif np.isscalar(exclude_angles):
exclude_angles = [exclude_angles]
excluded = np.zeros(len(angles), dtype=bool)
for angle in exclude_angles:
match = np.isclose(angles, float(angle) % 360)
if not match.any():
raise ValueError(
f"No alignment point at {angle} deg. "
f"Available angles: {', '.join(f'{a:g}' for a in angles)}."
)
excluded |= match
kept = ~excluded
# 3 free parameters per axis; with only 3 points any set of clicks
# fits exactly, so a bad click could no longer be noticed.
if kept.sum() < 4:
raise ValueError(
f"Only {kept.sum()} of {len(angles)} points left -- at least 4 are "
"needed for a meaningful fit."
)
fit = np.array([fit_xray_eye_sine(angles[kept], offsets[ax][kept]) for ax in ("x", "y")])
residuals = {}
for i, ax in enumerate(("x", "y")):
amp, shift, const = fit[i]
residuals[ax] = offsets[ax] - (amp * np.sin(np.radians(angles) + shift) + const)
print("X-ray eye points (offsets and deviation from the new fit, in um):")
print(" angle x_off x_dev y_off y_dev")
for j, angle in enumerate(angles):
flag = " excluded" if excluded[j] else ""
print(
f" {angle:5.0f} {offsets['x'][j]:9.2f} {residuals['x'][j]:8.2f}"
f" {offsets['y'][j]:9.2f} {residuals['y'][j]:8.2f}{flag}"
)
for ax in ("x", "y"):
rms = np.sqrt(np.mean(residuals[ax][kept] ** 2))
print(f" rms deviation of the fitted points, {ax}: {rms:.2f} um")
print("New fit:")
self._print_xeye_fit(fit)
old_fit = self.client.get_global_var("tomo_fit_xray_eye")
if old_fit is not None:
print("Currently stored fit:")
self._print_xeye_fit(old_fit)
self._show_xeye_refit_in_gui(angles[kept], offsets["x"][kept], offsets["y"][kept])
if confirm and not self.OMNYTools.yesno("Replace the stored X-ray eye fit?", "y"):
print("Stored fit left unchanged.")
return
self.client.set_global_var("tomo_fit_xray_eye", fit.tolist())
if excluded.any():
print(
"New X-ray eye fit stored (excluded: "
+ ", ".join(f"{a:g}" for a in angles[excluded])
+ " deg)."
)
else:
print("New X-ray eye fit stored (all points).")
def _load_xeye_fit_data(self, file: str | None = None):
"""Return the ``[angles, x_offsets, y_offsets]`` of the last alignment,
or of the archived alignment in *file*."""
if file is not None:
with h5py.File(os.path.expanduser(file), "r") as f:
return f["alignment_fit"][()].tolist()
data = self.client.get_global_var(XEYE_FIT_DATA_VAR)
if data is None:
raise RuntimeError(
"No X-ray eye alignment points stored -- run lamni.xrayeye_alignment_start(), "
"or pass the archived alignment as file=... (see "
"~/data/raw/logs/xrayeye_alignmentvalues/)."
)
return data
def _show_xeye_refit_in_gui(self, angles, x_offsets, y_offsets):
"""Show the points used in the refit on the XRayEye Fit tab.
Display only: DAP parameter forwarding stays off, so the GUI's own fit
of these points does not touch the stored correction.
"""
try:
self.lamnigui_show_xeyealign_fittab()
self.xeyegui.submit_fit_array(np.array([angles, x_offsets, y_offsets]))
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Could not show the refit points in the XRayEye GUI: {exc}")
@staticmethod
def _print_xeye_fit(fit):
"""Pretty-print the 2×3 X-ray eye fit array."""
@@ -3,6 +3,7 @@ from __future__ import annotations
import builtins
import os
import time
from contextlib import contextmanager
from typing import TYPE_CHECKING
import h5py
@@ -10,6 +11,8 @@ import numpy as np
from bec_lib import bec_logger
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_alignment_mixin import XEYE_FIT_DATA_VAR
logger = bec_logger.logger
bec = builtins.__dict__.get("bec")
@@ -601,6 +604,11 @@ class XrayEyeAlign:
# ------------------------------------------------------------------
# All submits received - compute offsets and push to GUI fit tab
# ------------------------------------------------------------------
# Clear the previous run's fit parameters first: they'd otherwise
# satisfy _wait_for_dap_fit() immediately and be read back as this
# run's fit before DAP has fitted the new points.
dev.omny_xray_gui.fit_params_x.set({})
dev.omny_xray_gui.fit_params_y.set({})
self.write_output()
fovx = self._xray_fov_xy[0] * self.pixel_calibration * 1000 / 2
@@ -730,6 +738,8 @@ class XrayEyeAlign:
timestamp = time.strftime("%Y%m%d_%H%M%S")
file_h5 = f"~/data/raw/logs/xrayeye_alignmentvalues/xrayeye_alignmentvalues_{timestamp}.h5"
self._save_alignment_data(file_h5, fit_data=data)
# Raw offsets for lamni.xrayeye_refit(), which can leave out bad clicks
self.client.set_global_var(XEYE_FIT_DATA_VAR, data.tolist())
# Push to XRayEye widget: feeds waveform_x (row 1) and waveform_y (row 2)
self.gui.submit_fit_array(data)
@@ -793,6 +803,21 @@ class XrayEyeAlign:
dev.omny_xray_gui.mvy.set(0)
self.update_frame(keep_shutter_open)
@contextmanager
def _ellipse_roi_enabled(self):
"""Offer the rotatable elliptical ROI in the GUI for the enclosed clicks.
Only for the extended-sample rotation-centre calibration, and only entered after
the FZP click (step 0), so the FZP and isolated-particle clicks keep the
rectangle/circle tools. Always switched off again (which also removes the
ellipse and releases its angle lock).
"""
self.gui.enable_ellipse_roi(True)
try:
yield
finally:
self.gui.enable_ellipse_roi(False)
def _collect_click(
self,
k: int,
@@ -1084,56 +1109,57 @@ class XrayEyeAlign:
self.gui.set_crosshair_position(fzp_x / self.pixel_calibration, fzp_y / self.pixel_calibration)
self.gui.show_crosshair()
iteration = 0
cumulative_shift_x, cumulative_shift_y = 0.0, 0.0
while True:
iteration += 1
print(f"[rotation-center][smear] === iteration {iteration} ===")
self.send_message(
"Watch the sample rotate and locate the stationary rotation centre..."
)
composite = self._smear_sweep(
sweep_deg=sweep_deg,
keep_shutter_open=keep_shutter_open,
projection=projection,
opaque_level=opaque_level,
)
self.movement_buttons_enabled(True, True)
center_x, center_y = self._collect_click(
1,
"Submit the rotation centre.",
label=f"rotation centre, iter {iteration}",
)
self.gui.set_live_view_signal("image")
self.smear_composites.append(composite)
self.smear_clicks.append((center_x, center_y))
delta_x, delta_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y)
cumulative_shift_x += delta_x
cumulative_shift_y += delta_y
print(
f"[rotation-center][smear] cumulative shift from originally-configured center: "
f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm"
)
self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y)
self.send_message("Verifying alignment...")
# No 45 deg detour here (unlike the non-smear path below): the
# _smear_sweep above already rotated continuously through the
# full range with the operator watching, so a stop at 45 deg
# would just re-tread ground already covered -- go straight back
# to 0 deg (still a full ~360 deg move since lsamrot doesn't
# wrap) with live view running.
self._live_sweep([0])
self.update_frame(keep_shutter_open)
if self.lamni.OMNYTools.yesno(
"Alignment acceptable -- stop here? (n = run another iteration)", "y"
):
print(
f"[rotation-center][smear] operator accepted alignment after "
f"{iteration} iteration(s)"
with self._ellipse_roi_enabled():
iteration = 0
cumulative_shift_x, cumulative_shift_y = 0.0, 0.0
while True:
iteration += 1
print(f"[rotation-center][smear] === iteration {iteration} ===")
self.send_message(
"Watch the sample rotate and locate the stationary rotation centre..."
)
break
print("[rotation-center][smear] operator requested another iteration.")
composite = self._smear_sweep(
sweep_deg=sweep_deg,
keep_shutter_open=keep_shutter_open,
projection=projection,
opaque_level=opaque_level,
)
self.movement_buttons_enabled(True, True)
center_x, center_y = self._collect_click(
1,
"Submit the rotation centre.",
label=f"rotation centre, iter {iteration}",
)
self.gui.set_live_view_signal("image")
self.smear_composites.append(composite)
self.smear_clicks.append((center_x, center_y))
delta_x, delta_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y)
cumulative_shift_x += delta_x
cumulative_shift_y += delta_y
print(
f"[rotation-center][smear] cumulative shift from originally-configured center: "
f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm"
)
self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y)
self.send_message("Verifying alignment...")
# No 45 deg detour here (unlike the non-smear path below): the
# _smear_sweep above already rotated continuously through the
# full range with the operator watching, so a stop at 45 deg
# would just re-tread ground already covered -- go straight back
# to 0 deg (still a full ~360 deg move since lsamrot doesn't
# wrap) with live view running.
self._live_sweep([0])
self.update_frame(keep_shutter_open)
if self.lamni.OMNYTools.yesno(
"Alignment acceptable -- stop here? (n = run another iteration)", "y"
):
print(
f"[rotation-center][smear] operator accepted alignment after "
f"{iteration} iteration(s)"
)
break
print("[rotation-center][smear] operator requested another iteration.")
self.gui.hide_crosshair()
new_lsamx = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"]
@@ -1457,42 +1483,43 @@ class XrayEyeAlign:
target_x, target_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y)
self._apply_rotation_center_shift(target_x, target_y)
else:
iteration = 0
cumulative_shift_x, cumulative_shift_y = 0.0, 0.0
while True:
iteration += 1
print(f"[rotation-center] === iteration {iteration} ===")
self.send_message(
"Watch the sample rotate and locate the stationary rotation centre..."
)
self._live_sweep([180, 0])
self.update_frame(keep_shutter_open)
self.movement_buttons_enabled(False, False)
center_x, center_y = self._collect_click(
1,
"Submit the rotation centre.",
label=f"rotation centre, iter {iteration}",
)
delta_x, delta_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y)
cumulative_shift_x += delta_x
cumulative_shift_y += delta_y
print(
f"[rotation-center] cumulative shift from originally-configured center: "
f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm"
)
self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y)
self.send_message("Verifying alignment...")
self._live_sweep([45, 0])
self.update_frame(keep_shutter_open)
if self.lamni.OMNYTools.yesno(
"Alignment acceptable -- stop here? (n = run another iteration)", "y"
):
print(
f"[rotation-center] operator accepted alignment after {iteration} iteration(s)"
with self._ellipse_roi_enabled():
iteration = 0
cumulative_shift_x, cumulative_shift_y = 0.0, 0.0
while True:
iteration += 1
print(f"[rotation-center] === iteration {iteration} ===")
self.send_message(
"Watch the sample rotate and locate the stationary rotation centre..."
)
break
print("[rotation-center] operator requested another iteration.")
self._live_sweep([180, 0])
self.update_frame(keep_shutter_open)
self.movement_buttons_enabled(False, False)
center_x, center_y = self._collect_click(
1,
"Submit the rotation centre.",
label=f"rotation centre, iter {iteration}",
)
delta_x, delta_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y)
cumulative_shift_x += delta_x
cumulative_shift_y += delta_y
print(
f"[rotation-center] cumulative shift from originally-configured center: "
f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm"
)
self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y)
self.send_message("Verifying alignment...")
self._live_sweep([45, 0])
self.update_frame(keep_shutter_open)
if self.lamni.OMNYTools.yesno(
"Alignment acceptable -- stop here? (n = run another iteration)", "y"
):
print(
f"[rotation-center] operator accepted alignment after {iteration} iteration(s)"
)
break
print("[rotation-center] operator requested another iteration.")
self.gui.hide_crosshair()
new_lsamx = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"]
@@ -443,6 +443,10 @@ class WebpageGeneratorBase:
whole shared output dir, EPS files included.
"""
# Name of the setup's object in the BEC client namespace (e.g. "lamni"),
# used in console hints such as the set_web_password() one.
CLIENT_NAME: str | None = None
HAS_TOMO_QUEUE = True
HAS_EPS_STATUS = True
TOMO_TYPES = {
@@ -816,7 +820,8 @@ class WebpageGeneratorBase:
_BOLD_RED = "\033[1;31m"
_RESET = "\033[0m"
print(f"{_BOLD_RED}***\nWebpageGenerator: '{current_account}' has no web authentication yet.{_RESET}")
print(f"{_BOLD_RED} To set a password run: flomni.set_web_password(\"your_password\")\n***{_RESET}")
prefix = f"{self.CLIENT_NAME}." if self.CLIENT_NAME else ""
print(f"{_BOLD_RED} To set a password run: {prefix}set_web_password(\"your_password\")\n***{_RESET}")
def _clear_session_htpasswd(self) -> None:
"""Write an empty session.htpasswd locally and upload it."""
@@ -835,7 +840,7 @@ class WebpageGeneratorBase:
The username is taken from bec.active_account (e.g. 'p23092').
Example::
flomni.webpage_gen.set_web_password("my_secret_password")
flomni.set_web_password("my_secret_password")
"""
if self._set_password_url is None:
print("set_web_password: no upload URL configured — cannot reach server")
@@ -54,6 +54,8 @@ class FlomniWebpageGenerator(WebpageGeneratorBase):
Logo: flOMNI.png from the same directory as this file.
"""
CLIENT_NAME = "flomni"
# Global-var names of the tomo parameters to read live for the
# 'Current measurement' section. Kept in the same order as, and matching,
# the tomo queue's TQ_PARAM_DISPLAY keys so the two render identically.
+38
View File
@@ -295,6 +295,44 @@ class OMNY_XRayEye(RPCBase):
Stop auto-snapping ROIs' vertical center -- see ``lock_vertical_center()``.
"""
@rpc_timeout(20)
@rpc_call
def enable_ellipse_roi(self, enable: "bool"):
"""
Show/hide the elliptical ROI tool and its angle controls.
Hidden by default; the LamNI client enables it for the rotation-centre
calibration modes after the FZP centre has been submitted. Disabling removes
an existing ellipse and releases the angle lock.
Args:
enable(bool): True to offer the ellipse tool.
"""
@rpc_timeout(20)
@rpc_call
def lock_roi_angle(self, angle: "float | None" = None):
"""
Fix the rotation of elliptical ROIs, now and for ellipses drawn later.
Args:
angle(float | None): Rotation in degrees. If None, the active ellipse's
current rotation (or, without one, the angle in the spin box) is used.
"""
@rpc_timeout(20)
@rpc_call
def unlock_roi_angle(self):
"""
Let elliptical ROIs rotate freely again -- see ``lock_roi_angle()``.
"""
@rpc_call
def roi_angle(self) -> "float | None":
"""
Rotation in degrees of the active elliptical ROI, or None if there is none.
"""
@property
@rpc_call
def crosshair_visible(self) -> "bool":
@@ -11,13 +11,19 @@ from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.roi.image_roi import BaseROI, CircularROI, RectangularROI
from bec_widgets.widgets.plots.roi.image_roi import (
BaseROI,
CircularROI,
EllipticalROI,
RectangularROI,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import (
QFrame,
QGridLayout,
QHBoxLayout,
QDoubleSpinBox,
QLabel,
QLineEdit,
QPushButton,
@@ -55,6 +61,33 @@ SINE_FIT_DAP_PARAMETERS = [
]
def ellipse_geometry(roi) -> dict:
"""Centre, axes and rotation of an (optionally rotated) ``EllipticalROI``.
``EllipticalROI.get_coordinates()`` ignores the rotation (the ROI rotates about
its ``pos()``, not its centre) and sorts the axes by size. Here the centre is
mapped through the ROI transform, ``width``/``height`` are the axes along the
ROI's local x/y, and ``angle`` (degrees, pyqtgraph convention, normalised to
(-90, 90]; an ellipse is symmetric under 180 deg) is the rotation of the local
x axis in data coordinates.
"""
size_x, size_y = roi.state["size"]
centre = roi.mapToParent(pg.Point(size_x / 2, size_y / 2))
return {
"center_x": centre.x(),
"center_y": centre.y(),
"width": abs(size_x),
"height": abs(size_y),
"angle": _normalize_angle(roi.angle()),
}
def _normalize_angle(angle: float) -> float:
"""Map an angle in degrees onto (-90, 90]."""
angle = (angle + 90.0) % 180.0 - 90.0
return 90.0 if math.isclose(angle, -90.0, abs_tol=1e-9) else angle
class TargetCrosshair:
"""
Fixed, RPC-positionable crosshair overlay for an image plot item.
@@ -274,6 +307,10 @@ class OMNY_XRayEye(BECWidget, QWidget):
"hide_crosshair",
"lock_vertical_center",
"unlock_vertical_center",
"enable_ellipse_roi",
"lock_roi_angle",
"unlock_roi_angle",
"roi_angle",
"crosshair_visible",
"crosshair_visible.setter",
"set_crosshair_position",
@@ -322,7 +359,12 @@ class OMNY_XRayEye(BECWidget, QWidget):
self.target_crosshair = TargetCrosshair(self.image.plot_item)
# See lock_vertical_center()/unlock_vertical_center()/_snap_roi_vertical()
self._vertical_lock_enabled = False
# Elliptical ROI (LamNI centre calibration only, see enable_ellipse_roi());
# _roi_angle_locked is the fixed rotation in degrees, or None if free.
self._ellipse_enabled = False
self._roi_angle_locked = None
self._make_connections()
self.enable_ellipse_roi(False)
# Connection to redis endpoints
self.bec_dispatcher.connect_slot(
@@ -456,6 +498,20 @@ class OMNY_XRayEye(BECWidget, QWidget):
self.vertical_lock_toggle.checked = False
self.vertical_lock_toggle.enabled.connect(self._on_vertical_lock_toggle_changed)
# Angle fix for the elliptical ROI (see lock_roi_angle()); only visible
# while the ellipse tool is enabled (enable_ellipse_roi()).
self.roi_angle_lock_label = QLabel("ROI angle lock", parent=self)
self.roi_angle_lock_toggle = ToggleSwitch(parent=self)
self.roi_angle_lock_toggle.checked = False
self.roi_angle_lock_toggle.enabled.connect(self._on_roi_angle_lock_toggle_changed)
self.roi_angle_spinbox = QDoubleSpinBox(parent=self)
self.roi_angle_spinbox.setRange(-90.0, 90.0)
self.roi_angle_spinbox.setDecimals(1)
self.roi_angle_spinbox.setSingleStep(1.0)
self.roi_angle_spinbox.setSuffix(" deg")
self.roi_angle_spinbox.setToolTip("Rotation of the elliptical ROI")
self.roi_angle_spinbox.valueChanged.connect(self._on_roi_angle_spinbox_changed)
_right_vcenter = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
switch_grid.addWidget(self.shutter_label, 0, 1, _right_vcenter)
switch_grid.addWidget(self.shutter_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter)
@@ -469,6 +525,9 @@ class OMNY_XRayEye(BECWidget, QWidget):
switch_grid.addWidget(self.smear_preview_toggle, 2, 4, Qt.AlignmentFlag.AlignVCenter)
switch_grid.addWidget(self.vertical_lock_label, 3, 1, _right_vcenter)
switch_grid.addWidget(self.vertical_lock_toggle, 3, 2, Qt.AlignmentFlag.AlignVCenter)
switch_grid.addWidget(self.roi_angle_lock_label, 4, 1, _right_vcenter)
switch_grid.addWidget(self.roi_angle_lock_toggle, 4, 2, Qt.AlignmentFlag.AlignVCenter)
switch_grid.addWidget(self.roi_angle_spinbox, 4, 3, 1, 2)
self.control_panel_layout.addWidget(self.switch_grid_widget)
@@ -689,6 +748,8 @@ class OMNY_XRayEye(BECWidget, QWidget):
def _style_new_roi(self, roi):
"""Force a thinner outline on newly drawn ROIs (color is set via compact_color)."""
roi.line_width = self.ROI_LINE_WIDTH
if isinstance(roi, EllipticalROI):
self._setup_ellipse(roi)
# Live-update the microns readout while this ROI is drawn/resized.
roi.sigRegionChanged.connect(lambda r=roi: self._update_roi_size_readout(r))
self._update_roi_size_readout(roi)
@@ -708,6 +769,13 @@ class OMNY_XRayEye(BECWidget, QWidget):
if not self._vertical_lock_enabled or not self.target_crosshair.is_visible():
return
_, fzp_y = self.crosshair_position()
if isinstance(roi, EllipticalROI):
# The ellipse rotates about pos(), so move it by the centre's offset.
geo = ellipse_geometry(roi)
dy = fzp_y - geo["center_y"]
if not math.isclose(dy, 0.0, abs_tol=1e-9):
roi.setPos(roi.pos().x(), roi.pos().y() + dy)
return
if isinstance(roi, RectangularROI):
height = roi.get_coordinates(typed=True)["height"]
elif isinstance(roi, CircularROI):
@@ -764,6 +832,14 @@ class OMNY_XRayEye(BECWidget, QWidget):
# Read the raw x/y size so a non-circular (ellipse) state is reflected
# correctly; for an aspect-locked circle both values are equal.
size_x, size_y = roi.state["size"]
elif isinstance(roi, EllipticalROI):
geo = ellipse_geometry(roi)
scale, unit = self._microns_per_pixel()
self.roi_size_line_edit.setText(
f"a = {geo['width'] * scale:.1f} {unit}, b = {geo['height'] * scale:.1f} {unit}, "
f"angle = {geo['angle']:.1f} deg"
)
return
else:
self.roi_size_line_edit.setText("")
return
@@ -967,6 +1043,142 @@ class OMNY_XRayEye(BECWidget, QWidget):
else:
self.unlock_vertical_center()
# ------------------------------------------------------------------
# Elliptical ROI (LamNI rotation-centre calibration)
# ------------------------------------------------------------------
def _setup_ellipse(self, roi: EllipticalROI):
"""Add independent axis scale handles and the angle bookkeeping to a new ellipse.
pyqtgraph's ``EllipseROI`` only has a rotate handle and one uniform scale handle,
so the aspect ratio could not be changed after drawing.
"""
roi.addScaleHandle([0.0, 0.5], [1.0, 0.5]) # width, anchored at the opposite side
roi.addScaleHandle([0.5, 1.0], [0.5, 0.0]) # height
roi.sigRegionChanged.connect(lambda r=roi: self._on_ellipse_changed(r))
if self._roi_angle_locked is not None:
self._apply_locked_angle(roi)
self._set_rotate_handle_visible(roi, self._roi_angle_locked is None)
self._sync_angle_spinbox(roi)
@staticmethod
def _set_rotate_handle_visible(roi, visible: bool):
for handle in roi.handles:
if handle["type"] == "r":
handle["item"].setVisible(visible)
def _apply_locked_angle(self, roi):
"""Rotate `roi` about its centre to the locked angle (no-op if already there)."""
locked = self._roi_angle_locked
if locked is None:
return
if math.isclose(_normalize_angle(roi.angle()), _normalize_angle(locked), abs_tol=1e-6):
return
roi.setAngle(locked, center=(0.5, 0.5))
def _on_ellipse_changed(self, roi):
self._apply_locked_angle(roi)
self._sync_angle_spinbox(roi)
def _sync_angle_spinbox(self, roi=None):
"""Show the active ellipse's rotation in the spin box without re-triggering it."""
if roi is None:
roi = self.roi_manager.single_active_roi
if not isinstance(roi, EllipticalROI):
return
self.roi_angle_spinbox.blockSignals(True)
self.roi_angle_spinbox.setValue(ellipse_geometry(roi)["angle"])
self.roi_angle_spinbox.blockSignals(False)
@SafeSlot(bool)
@rpc_timeout(20)
def enable_ellipse_roi(self, enable: bool):
"""Show/hide the elliptical ROI tool and its angle controls.
Hidden by default; the LamNI client enables it for the rotation-centre
calibration modes after the FZP centre has been submitted. Disabling removes
an existing ellipse and releases the angle lock.
Args:
enable(bool): True to offer the ellipse tool.
"""
self._ellipse_enabled = enable
self.roi_manager.toolbar.components.get_action("roi_ellipse").action.setVisible(enable)
for widget in (self.roi_angle_lock_label, self.roi_angle_lock_toggle, self.roi_angle_spinbox):
widget.setVisible(enable)
if not enable:
self.unlock_roi_angle()
self.roi_manager.toolbar.components.get_action("roi_ellipse").action.setChecked(False)
active = self.roi_manager.single_active_roi
if isinstance(active, EllipticalROI):
self.roi_manager.controller.remove_roi(active)
self._update_roi_size_readout(None)
@SafeSlot()
@rpc_timeout(20)
def lock_roi_angle(self, angle: float | None = None):
"""Fix the rotation of elliptical ROIs, now and for ellipses drawn later.
Args:
angle(float | None): Rotation in degrees. If None, the active ellipse's
current rotation (or, without one, the angle in the spin box) is used.
"""
if angle is None:
active = self.roi_manager.single_active_roi
if isinstance(active, EllipticalROI):
angle = ellipse_geometry(active)["angle"]
else:
angle = self.roi_angle_spinbox.value()
self._roi_angle_locked = _normalize_angle(float(angle))
self.roi_angle_lock_toggle.blockSignals(True)
self.roi_angle_lock_toggle.checked = True
self.roi_angle_lock_toggle.blockSignals(False)
self.roi_angle_spinbox.blockSignals(True)
self.roi_angle_spinbox.setValue(self._roi_angle_locked)
self.roi_angle_spinbox.blockSignals(False)
for roi in self.roi_manager.controller.rois:
if isinstance(roi, EllipticalROI):
self._apply_locked_angle(roi)
self._set_rotate_handle_visible(roi, False)
self._update_roi_size_readout(roi)
@SafeSlot()
@rpc_timeout(20)
def unlock_roi_angle(self):
"""Let elliptical ROIs rotate freely again -- see ``lock_roi_angle()``."""
self._roi_angle_locked = None
self.roi_angle_lock_toggle.blockSignals(True)
self.roi_angle_lock_toggle.checked = False
self.roi_angle_lock_toggle.blockSignals(False)
for roi in self.roi_manager.controller.rois:
if isinstance(roi, EllipticalROI):
self._set_rotate_handle_visible(roi, True)
@SafeSlot(bool)
def _on_roi_angle_lock_toggle_changed(self, enabled: bool):
if enabled:
self.lock_roi_angle()
else:
self.unlock_roi_angle()
@SafeSlot(float)
def _on_roi_angle_spinbox_changed(self, angle: float):
"""Operator typed an angle: rotate the active ellipse (and the lock, if engaged)."""
if self._roi_angle_locked is not None:
self.lock_roi_angle(angle)
return
active = self.roi_manager.single_active_roi
if isinstance(active, EllipticalROI):
active.setAngle(angle, center=(0.5, 0.5))
@SafeSlot()
def roi_angle(self) -> float | None:
"""Rotation in degrees of the active elliptical ROI, or None if there is none."""
active = self.roi_manager.single_active_roi
if isinstance(active, EllipticalROI):
return ellipse_geometry(active)["angle"]
return None
@SafeProperty(bool)
def crosshair_visible(self) -> bool:
"""Whether the alignment target crosshair is currently shown."""
@@ -1390,11 +1602,20 @@ class OMNY_XRayEye(BECWidget, QWidget):
if self.roi_manager.single_active_roi is None:
logger.warning("No active ROI")
return
roi_coordinates = self.roi_manager.single_active_roi.get_coordinates()
active_roi = self.roi_manager.single_active_roi
roi_angle = None
if isinstance(active_roi, EllipticalROI):
roi_coordinates = ellipse_geometry(active_roi)
roi_angle = roi_coordinates["angle"]
else:
roi_coordinates = active_roi.get_coordinates()
roi_center_x = roi_coordinates["center_x"]
roi_center_y = roi_coordinates["center_y"]
# Case of rectangular ROI
if isinstance(self.roi_manager.single_active_roi, RectangularROI):
if isinstance(active_roi, EllipticalROI):
roi_width = roi_coordinates["width"]
roi_height = roi_coordinates["height"]
elif isinstance(self.roi_manager.single_active_roi, RectangularROI):
roi_width = roi_coordinates["width"]
roi_height = roi_coordinates["height"]
elif isinstance(self.roi_manager.single_active_roi, CircularROI):
@@ -1411,6 +1632,11 @@ class OMNY_XRayEye(BECWidget, QWidget):
getattr(self.dev.omny_xray_gui, f"yval_y_{step}").set(roi_center_y)
getattr(self.dev.omny_xray_gui, f"width_x_{step}").set(roi_width)
getattr(self.dev.omny_xray_gui, f"width_y_{step}").set(roi_height)
# Rotation only matters for the ellipse; reset it for every other ROI so a
# stale angle from an earlier submit is never read for this step.
angle_signal = getattr(self.dev.omny_xray_gui, f"roi_angle_{step}", None)
if angle_signal is not None:
angle_signal.set(roi_angle if roi_angle is not None else 0.0)
self.dev.omny_xray_gui.submit.set(1)
finally:
self.submit_button.blockSignals(False)
+6
View File
@@ -32,6 +32,12 @@ class OMNYXRayAlignGUI(Device):
for i in range(11):
locals()[f"width_x_{i}"] = Cpt(Signal, value=0)
# Generate roi_angle_0 to roi_angle_10 (rotation of an elliptical ROI in degrees,
# see OMNY_XRayEye; width_x/width_y are then the ellipse axes along/perpendicular
# to this rotation)
for i in range(11):
locals()[f"roi_angle_{i}"] = Cpt(Signal, value=0)
# Generate xval_x_0 to xval_x_10
for i in range(11):
locals()[f"xval_x_{i}"] = Cpt(Signal, value=0)
@@ -0,0 +1,165 @@
"""lamni.xrayeye_refit(): redo the X-ray eye sine fit without bad clicks."""
import h5py
import numpy as np
import pytest
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_alignment_mixin import (
XEYE_FIT_DATA_VAR,
fit_xray_eye_sine,
)
ANGLES = np.arange(8) * 45.0
TRUE_X = (12.0, 0.7, -3.0)
TRUE_Y = (5.0, -2.1, 8.0)
def _model(params, angles=ANGLES):
amp, shift, const = params
return amp * np.sin(np.radians(angles) + shift) + const
class _FakeClient:
def __init__(self):
self._vars = {}
def get_global_var(self, key):
return self._vars.get(key)
def set_global_var(self, key, value):
self._vars[key] = value
def delete_global_var(self, key):
self._vars.pop(key, None)
class _FakeTools:
def __init__(self, answer=True):
self.answer = answer
self.asked = 0
def yesno(self, *_args, **_kwargs):
self.asked += 1
return self.answer
class _FakeXeyeGui:
def __init__(self):
self.submitted = None
def submit_fit_array(self, data):
self.submitted = data
def make_lamni(data=None, answer=True) -> LamNI:
obj = object.__new__(LamNI)
obj.client = _FakeClient()
obj.OMNYTools = _FakeTools(answer)
obj.xeyegui = _FakeXeyeGui()
obj.lamnigui_show_xeyealign_fittab = lambda: None
if data is not None:
obj.client.set_global_var(XEYE_FIT_DATA_VAR, data)
return obj
def _data_with_outlier(angle_idx=3, dx=40.0, dy=-25.0):
x = _model(TRUE_X)
y = _model(TRUE_Y)
x[angle_idx] += dx
y[angle_idx] += dy
return [ANGLES.tolist(), x.tolist(), y.tolist()]
def test_fit_recovers_parameters():
assert np.allclose(fit_xray_eye_sine(ANGLES, _model(TRUE_X)), TRUE_X)
def test_fit_negative_amplitude_is_equivalent():
# A DAP fit may report -A with shift+pi; the function must be the same.
amp, shift, const = fit_xray_eye_sine(ANGLES, _model((-12.0, 0.7, -3.0)))
assert amp > 0
assert np.allclose(_model((amp, shift, const)), _model((-12.0, 0.7, -3.0)))
def test_exclude_outlier_recovers_true_fit():
lamni = make_lamni(_data_with_outlier())
lamni.xrayeye_refit([135])
fit = lamni.client.get_global_var("tomo_fit_xray_eye")
assert np.allclose(fit[0], TRUE_X)
assert np.allclose(fit[1], TRUE_Y)
# Only the kept points go to the GUI.
assert lamni.xeyegui.submitted.shape == (3, 7)
assert 135 not in lamni.xeyegui.submitted[0]
def test_scalar_and_wrapped_angle_accepted():
lamni = make_lamni(_data_with_outlier(angle_idx=0))
lamni.xrayeye_refit(360)
assert np.allclose(lamni.client.get_global_var("tomo_fit_xray_eye")[0], TRUE_X)
def test_no_exclusion_fits_all_points():
lamni = make_lamni(_data_with_outlier())
lamni.xrayeye_refit()
fit = lamni.client.get_global_var("tomo_fit_xray_eye")
assert not np.allclose(fit[0], TRUE_X)
assert lamni.xeyegui.submitted.shape == (3, 8)
def test_declined_leaves_stored_fit():
lamni = make_lamni(_data_with_outlier(), answer=False)
lamni.client.set_global_var("tomo_fit_xray_eye", [[1, 2, 3], [4, 5, 6]])
lamni.xrayeye_refit([135])
assert lamni.OMNYTools.asked == 1
assert lamni.client.get_global_var("tomo_fit_xray_eye") == [[1, 2, 3], [4, 5, 6]]
def test_confirm_false_does_not_ask():
lamni = make_lamni(_data_with_outlier())
lamni.xrayeye_refit([135], confirm=False)
assert lamni.OMNYTools.asked == 0
assert lamni.client.get_global_var("tomo_fit_xray_eye") is not None
def test_unknown_angle_rejected():
lamni = make_lamni(_data_with_outlier())
with pytest.raises(ValueError, match="No alignment point at 100"):
lamni.xrayeye_refit([100])
def test_too_few_points_rejected():
lamni = make_lamni(_data_with_outlier())
with pytest.raises(ValueError, match="at least 4"):
lamni.xrayeye_refit([0, 45, 90, 135, 180])
def test_no_data_gives_clear_error():
with pytest.raises(RuntimeError, match="xrayeye_alignment_start"):
make_lamni().xrayeye_refit()
def test_reset_clears_points():
lamni = make_lamni(_data_with_outlier())
lamni.reset_xray_eye_correction()
assert lamni.client.get_global_var(XEYE_FIT_DATA_VAR) is None
def test_refit_from_archive_file(tmp_path):
path = tmp_path / "xrayeye_alignmentvalues_20261013_120000.h5"
with h5py.File(path, "w") as f:
f.create_dataset("alignment_fit", data=np.array(_data_with_outlier()))
lamni = make_lamni()
lamni.xrayeye_refit([135], file=str(path))
assert np.allclose(lamni.client.get_global_var("tomo_fit_xray_eye")[1], TRUE_Y)
def test_gui_failure_does_not_block_refit():
lamni = make_lamni(_data_with_outlier())
def _boom():
raise RuntimeError("GUI closed")
lamni.lamnigui_show_xeyealign_fittab = _boom
lamni.xrayeye_refit([135])
assert np.allclose(lamni.client.get_global_var("tomo_fit_xray_eye")[0], TRUE_X)
@@ -0,0 +1,35 @@
"""The 'no web authentication' hint names the setup's own client object."""
import types
import pytest
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_webpage_generator import (
FlomniWebpageGenerator,
)
from csaxs_bec.bec_ipython_client.plugins.LamNI.LamNI_webpage_generator import (
LamniWebpageGenerator,
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base import (
WebpageGeneratorBase,
)
@pytest.mark.parametrize(
"cls, expected",
[
(LamniWebpageGenerator, 'lamni.set_web_password("your_password")'),
(FlomniWebpageGenerator, 'flomni.set_web_password("your_password")'),
(WebpageGeneratorBase, 'run: set_web_password("your_password")'),
],
)
def test_password_hint_uses_client_name(cls, expected, capsys):
gen = object.__new__(cls)
gen._session_query_url = "http://example.invalid/session.php"
gen._bec = types.SimpleNamespace(active_account="p12345")
gen._get_remote_session_user = lambda: None
gen._check_session_user()
out = capsys.readouterr().out
assert expected in out
if cls is not FlomniWebpageGenerator:
assert "flomni." not in out
@@ -0,0 +1,130 @@
"""Tests for OMNY_XRayEye's elliptical ROI (LamNI rotation-centre calibration):
rotation-aware geometry, angle lock, vertical snap, and the submit payload.
Like test_x_ray_eye_vertical_lock.py, the widget methods are borrowed onto a
minimal stub (the full widget needs a live BEC client context). The ROIs
themselves are pyqtgraph ellipses; ``EllipticalROI`` is only needed for the
``isinstance`` dispatch, so a bare subclass of ``pg.EllipseROI`` that is
registered as one is used instead of the BECConnector-based real class.
"""
import math
from unittest import mock
import pyqtgraph as pg
import pytest
from csaxs_bec.bec_widgets.widgets.xray_eye import x_ray_eye
from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import (
OMNY_XRayEye,
_normalize_angle,
ellipse_geometry,
)
@pytest.fixture(autouse=True, scope="module")
def _qapp():
return pg.mkQApp()
class _FakeEllipticalROI(pg.EllipseROI):
"""pg ellipse standing in for bec_widgets' EllipticalROI in isinstance checks."""
@pytest.fixture(autouse=True)
def _patch_roi_class():
with mock.patch.object(x_ray_eye, "EllipticalROI", _FakeEllipticalROI):
yield
def _roi(pos=(10.0, 20.0), size=(40.0, 20.0), angle=0.0):
roi = _FakeEllipticalROI(pos, size, angle=angle)
return roi
class _Stub:
_apply_locked_angle = OMNY_XRayEye._apply_locked_angle
_set_rotate_handle_visible = staticmethod(OMNY_XRayEye._set_rotate_handle_visible)
_snap_roi_vertical = OMNY_XRayEye._snap_roi_vertical
def _stub(locked=None, lock_vertical=False, fzp_y=100.0):
stub = _Stub()
stub._roi_angle_locked = locked
stub._vertical_lock_enabled = lock_vertical
stub.target_crosshair = mock.Mock()
stub.target_crosshair.is_visible.return_value = True
stub.crosshair_position = mock.Mock(return_value=(0.0, fzp_y))
return stub
@pytest.mark.parametrize("raw, expected", [(0, 0), (90, 90), (-90, 90), (100, -80), (270, 90), (45, 45)])
def test_normalize_angle(raw, expected):
assert _normalize_angle(raw) == pytest.approx(expected)
def test_geometry_unrotated():
geo = ellipse_geometry(_roi())
assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 30.0))
assert (geo["width"], geo["height"], geo["angle"]) == pytest.approx((40.0, 20.0, 0.0))
def test_geometry_centre_is_independent_of_rotation_about_centre():
roi = _roi()
roi.setAngle(30.0, center=(0.5, 0.5))
geo = ellipse_geometry(roi)
assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 30.0))
assert geo["angle"] == pytest.approx(30.0)
# axes are not sorted by size: width stays the local-x axis
assert (geo["width"], geo["height"]) == pytest.approx((40.0, 20.0))
def test_geometry_handles_negative_size_from_up_left_drag():
geo = ellipse_geometry(_roi(pos=(50.0, 50.0), size=(-40.0, -20.0)))
assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 40.0))
assert (geo["width"], geo["height"]) == pytest.approx((40.0, 20.0))
def test_apply_locked_angle_rotates_about_centre():
roi = _roi()
_stub(locked=40.0)._apply_locked_angle(roi)
geo = ellipse_geometry(roi)
assert geo["angle"] == pytest.approx(40.0)
assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 30.0))
def test_apply_locked_angle_noop_when_free_or_already_at_angle():
roi = _roi(angle=25.0)
_stub(locked=None)._apply_locked_angle(roi)
assert roi.angle() == pytest.approx(25.0)
with mock.patch.object(roi, "setAngle") as set_angle:
_stub(locked=25.0)._apply_locked_angle(roi)
# equivalent angle mod 180 deg is also "already there"
_stub(locked=-155.0)._apply_locked_angle(roi)
set_angle.assert_not_called()
def test_rotate_handle_visibility():
roi = _roi()
rotate = [h["item"] for h in roi.handles if h["type"] == "r"]
assert len(rotate) == 1
_Stub._set_rotate_handle_visible(roi, False)
assert rotate[0].isVisible() is False
_Stub._set_rotate_handle_visible(roi, True)
assert rotate[0].isVisible() is True
def test_snap_vertical_moves_ellipse_centre_to_crosshair_when_rotated():
roi = _roi()
roi.setAngle(30.0, center=(0.5, 0.5))
_stub(lock_vertical=True, fzp_y=100.0)._snap_roi_vertical(roi)
geo = ellipse_geometry(roi)
assert geo["center_y"] == pytest.approx(100.0)
assert geo["center_x"] == pytest.approx(30.0)
assert geo["angle"] == pytest.approx(30.0)
def test_snap_vertical_noop_when_lock_off():
roi = _roi()
_stub(lock_vertical=False)._snap_roi_vertical(roi)
assert ellipse_geometry(roi)["center_y"] == pytest.approx(30.0)