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.
This commit is contained in:
x12sa
2026-07-12 07:28:10 +02:00
committed by holler
co-authored by holler
parent 4181a4fcb2
commit 38431d9ebf
3 changed files with 102 additions and 9 deletions
@@ -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"):
@@ -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)
+2 -1
View File
@@ -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