diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py index f64dc4df..5ad2ac99 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py @@ -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" diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py index a60d914f..f829f225 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py @@ -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_.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.""" diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py index 761a4ed2..daa62265 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py @@ -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"] diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py index 38028410..8b0dbf25 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py @@ -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") diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 2e9dae97..7538e26c 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -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. diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 9c5945e8..48d0b7d0 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -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": diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 92da4806..03b32abc 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -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) diff --git a/csaxs_bec/devices/omny/xray_epics_gui.py b/csaxs_bec/devices/omny/xray_epics_gui.py index 0f0760de..a18e4df6 100644 --- a/csaxs_bec/devices/omny/xray_epics_gui.py +++ b/csaxs_bec/devices/omny/xray_epics_gui.py @@ -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) diff --git a/tests/tests_bec_ipython_client/test_lamni_xrayeye_refit.py b/tests/tests_bec_ipython_client/test_lamni_xrayeye_refit.py new file mode 100644 index 00000000..8cd08eab --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_xrayeye_refit.py @@ -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) diff --git a/tests/tests_bec_ipython_client/test_webpage_password_hint.py b/tests/tests_bec_ipython_client/test_webpage_password_hint.py new file mode 100644 index 00000000..7d46da88 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_webpage_password_hint.py @@ -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 diff --git a/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py b/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py new file mode 100644 index 00000000..e07ece3b --- /dev/null +++ b/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py @@ -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)