From 38431d9ebfa31eec409610aec15813dab092ecdb Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 8 Jul 2026 12:51:49 +0200 Subject: [PATCH] feat(flomni): live ROI size readout and camera-sourced pixel calibration X-Ray Eye alignment GUI + script now share a single, device-configured pixel calibration and the GUI shows live ROI dimensions. GUI (x_ray_eye.py): - Add read-only "ROI size" field below the message box, showing the active ROI's x/y extent, updated live via each ROI's sigRegionChanged. Rectangles report width/height; circular ROIs read raw state["size"] so an ellipse state would be reflected (currently aspect-locked upstream). - Add "Calibration" field showing microns/pixel, or "uncalibrated (pixels)" when the camera parameter is absent. - Resolve mm/pixel from dev.cam_xeye.user_parameter["pixel_calibration"]; fall back to raw pixels (scale 1.0, unit "px") if unavailable. - Enlarge the message field from 60 to 90 px height. Alignment script (x_ray_eye_align.py): - Replace the hard-coded PIXEL_CALIBRATION constant with a pixel_calibration property reading the same cam_xeye user parameter; fall back to PIXEL_CALIBRATION_DEFAULT if unavailable. - Fold the recurring factor of 2 into the calibration: xval_x_* come straight from raw ROI pixels on submit, so the true scale is 0.05/113 mm/pixel. Default is now 0.05/113 and the three /2 factors (FZP center x, height centering, FOV) are removed. Computed physical values are unchanged. Device config (cam_xeye_pixel_calibration.yaml): - Add pixel_calibration userParameter (0.05/113 mm per raw ROI pixel) to cam_xeye. Merge the userParameter block into the existing device entry. --- .../plugins/flomni/x_ray_eye_align.py | 33 ++++++-- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 75 ++++++++++++++++++- csaxs_bec/device_configs/ptycho_flomni.yaml | 3 +- 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index fe057f4..7116f58 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -28,9 +28,13 @@ if TYPE_CHECKING: class XrayEyeAlign: - # pixel calibration, multiply to get mm + # Pixel calibration in mm/pixel (multiply pixel values to get mm). + # The live value is read from the cam_xeye device's ``pixel_calibration`` + # user parameter (see the ``pixel_calibration`` property); this constant is + # only the fallback used when that device/parameter is unavailable. test_wo_movements = False - PIXEL_CALIBRATION = 0.1 / 113 # .2 with binning + PIXEL_CALIBRATION_DEFAULT = 0.05 / 113 # mm per raw ROI pixel (.1/113 with binning) + PIXEL_CALIBRATION_USER_PARAM = "pixel_calibration" # Sign for the automatic vertical-centering move in the height-centering # branch of _align_impl (search for `_height_centered`). @@ -62,6 +66,22 @@ class XrayEyeAlign: def gui(self): return self.flomni.xeyegui + @property + def pixel_calibration(self) -> float: + """Pixel calibration in mm/pixel. + + Reads the ``pixel_calibration`` user parameter from the cam_xeye device; + falls back to ``PIXEL_CALIBRATION_DEFAULT`` if the device or parameter is + unavailable. + """ + try: + mm_per_pixel = dev.cam_xeye.user_parameter.get(self.PIXEL_CALIBRATION_USER_PARAM) + except Exception: + mm_per_pixel = None + if mm_per_pixel is None: + return self.PIXEL_CALIBRATION_DEFAULT + return float(mm_per_pixel) + def _reset_init_values(self): self.shift_xy = [0, 0] self._xray_fov_xy = [0, 0] @@ -222,7 +242,7 @@ class XrayEyeAlign: if dev.omny_xray_gui.submit.get() == 1: self.alignment_values[k] = ( - getattr(dev.omny_xray_gui, f"xval_x_{k}").get() / 2 * self.PIXEL_CALIBRATION + getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration ) # in mm print(f"Clicked position {k}: x {self.alignment_values[k]}") rtx_position = dev.rtx.readback.get() / 1000 @@ -297,8 +317,7 @@ class XrayEyeAlign: delta_y_mm = ( self.HEIGHT_CENTERING_SIGN * (fzp_center_y - mark_y) - / 2 - * self.PIXEL_CALIBRATION + * self.pixel_calibration ) print( f"Height centering: fzp_center_y={fzp_center_y}, mark_y={mark_y}, " @@ -367,8 +386,8 @@ class XrayEyeAlign: time.sleep(0.1) self.write_output() - fovx = self._xray_fov_xy[0] * self.PIXEL_CALIBRATION * 1000 / 2 - fovy = self._xray_fov_xy[1] * self.PIXEL_CALIBRATION * 1000 / 2 + fovx = self._xray_fov_xy[0] * self.pixel_calibration * 1000 + fovy = self._xray_fov_xy[1] * self.pixel_calibration * 1000 if keep_shutter_open: if self.flomni.OMNYTools.yesno("Close the shutter now?", "y"): 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 3ad8b2a..3e86305 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 @@ -260,6 +260,13 @@ class XRayEye(BECWidget, QWidget): ROI_LINE_COLOR = "blue" ROI_LINE_WIDTH = 2 + # Image scale read from the camera's ``pixel_calibration`` user parameter + # (mm/pixel, matching XrayEyeAlign.PIXEL_CALIBRATION in x_ray_eye_align.py). + # If the camera device or the parameter is missing, fall back to 1 (i.e. + # sizes are reported in raw pixels). + PIXEL_CALIBRATION_USER_PARAM = "pixel_calibration" + PIXEL_CALIBRATION_DEFAULT = 1.0 + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self._connected_motor = None @@ -398,10 +405,21 @@ class XRayEye(BECWidget, QWidget): form.addWidget(QLabel("Sample", parent=self), 0, 0) form.addWidget(self.sample_name_line_edit, 0, 1) self.message_line_edit = QTextEdit(parent=self) - self.message_line_edit.setFixedHeight(60) + self.message_line_edit.setFixedHeight(90) self.message_line_edit.setReadOnly(True) form.addWidget(QLabel("Message", parent=self), 1, 0) form.addWidget(self.message_line_edit, 1, 1) + # Live ROI size readout (microns), updated as the active ROI is resized. + self.roi_size_line_edit = QLineEdit(parent=self) + self.roi_size_line_edit.setReadOnly(True) + form.addWidget(QLabel("ROI size", parent=self), 2, 0) + form.addWidget(self.roi_size_line_edit, 2, 1) + # Pixel calibration readout (microns/pixel), from the cam_xeye device. + self.calibration_line_edit = QLineEdit(parent=self) + self.calibration_line_edit.setReadOnly(True) + form.addWidget(QLabel("Calibration", parent=self), 3, 0) + form.addWidget(self.calibration_line_edit, 3, 1) + self._update_calibration_readout() self.control_panel_layout.addLayout(form) # Fix panel width and allow vertical expansion @@ -475,6 +493,61 @@ class 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 + # 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) + + def _microns_per_pixel(self): + """Resolve microns/pixel and unit label from the camera user parameter. + + Reads ``cam_xeye``'s ``pixel_calibration`` user parameter (mm/pixel) and + converts to microns. If the camera device or the parameter is not + available, falls back to ``PIXEL_CALIBRATION_DEFAULT`` and reports the + unit as raw pixels. + + Returns: + tuple[float, str]: (scale factor applied to pixel sizes, unit label). + """ + try: + cam = getattr(self.dev, CAMERA[0]) + mm_per_pixel = cam.user_parameter.get(self.PIXEL_CALIBRATION_USER_PARAM) + except Exception: + mm_per_pixel = None + if mm_per_pixel is None: + return self.PIXEL_CALIBRATION_DEFAULT, "px" + return float(mm_per_pixel) * 1000, "um" + + def _update_calibration_readout(self): + """Update the pixel-calibration readout from the camera user parameter.""" + scale, unit = self._microns_per_pixel() + if unit == "um": + self.calibration_line_edit.setText(f"{scale:.4f} um/pixel") + else: + self.calibration_line_edit.setText("uncalibrated (pixels)") + + def _update_roi_size_readout(self, roi=None): + """Update the ROI size readout (microns, or pixels if uncalibrated).""" + self._update_calibration_readout() + if roi is None: + roi = self.roi_manager.single_active_roi + if roi is None: + self.roi_size_line_edit.setText("") + return + if isinstance(roi, RectangularROI): + coords = roi.get_coordinates(typed=True) + size_x = coords["width"] + size_y = coords["height"] + elif isinstance(roi, CircularROI): + # 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"] + else: + self.roi_size_line_edit.setText("") + return + scale, unit = self._microns_per_pixel() + self.roi_size_line_edit.setText( + f"x = {abs(size_x) * scale:.1f} {unit}, y = {abs(size_y) * scale:.1f} {unit}" + ) def _create_separator(self): sep = QFrame(parent=self) diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index 43fcda9..cddb6d4 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -494,7 +494,8 @@ cam_xeye: onFailure: buffer readOnly: false readoutPriority: async - + userParameter: + pixel_calibration: 0.00044247787610619477 # mm/pixel (= 0.05 / 113) # cam_ids_rgb: # description: Camera flOMNI Xray eye ID203 # deviceClass: csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera