Feat/ellipse selector #328
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user