diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 142c07e3..1279d662 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -694,10 +694,10 @@ class LamNI( raise exc def xrayeye_rotation_center_calibration_smear_experimental( - self, keep_shutter_open: bool = False, sweep_deg: float = 360.0 + self, keep_shutter_open: bool = False, sweep_deg: float = 360.0, projection: str = "min" ): """EXPERIMENTAL: extended-sample rotation-center calibration aided by - a continuous-rotation max-projection ("star-trail") composite image, + a continuous-rotation min- or max-projection ("star-trail") composite image, instead of a single instant of live rotation. Off-axis features smear into circular arcs while lsamrot rotates @@ -713,6 +713,9 @@ class LamNI( sweep_deg: angular range of the continuous smear sweep. Defaults to a full 360 deg (complete arcs), but a smaller range may already show enough curvature to locate the center. + projection: "min" (default) for absorption contrast, i.e. dark + features on a bright background; "max" for bright features + on a dark background. Returns: tuple: (new_lsamx_center, new_lsamy_center) in mm. @@ -720,7 +723,7 @@ class LamNI( aligner = XrayEyeAlignGUI(self.client, self) try: return aligner.find_rotation_center_smear_experimental( - keep_shutter_open=keep_shutter_open, sweep_deg=sweep_deg + keep_shutter_open=keep_shutter_open, sweep_deg=sweep_deg, projection=projection ) except KeyboardInterrupt as exc: print("Smear rotation-center calibration interrupted by user.") 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 8a124412..b3894777 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 @@ -757,7 +757,7 @@ class XrayEyeAlign: print("[rotation-center] live sweep done") # --- EXPERIMENTAL: rotation-center smear mode ------------------------- - # Max-projection "star-trail" composite aid for the extended-sample + # Min- or max-projection "star-trail" composite aid for the extended-sample # rotation-center click, built while lsamrot rotates continuously. # Operator-visual only -- no automatic circle fitting. See # find_rotation_center_smear_experimental() below for the entry point. @@ -785,9 +785,16 @@ class XrayEyeAlign: keep_shutter_open: bool = False, display_update_interval_s: float = 1.0, poll_interval_s: float = 0.15, + projection: str = "min", ) -> np.ndarray: """Rotate lsamrot continuously through sweep_deg while accumulating - a max-projection composite of camera frames grabbed along the way. + a min- or max-projection composite of camera frames grabbed along the way. + + projection="min" (default) keeps the darkest value per pixel and is + meant for absorption contrast (dark features on a bright background): + a max-projection would let the bright background that passes every + pixel at some point wash those features out. projection="max" keeps + the brightest value, for bright features on a dark background. Off-axis features smear into circular arcs as the sample rotates; the common center of curvature of those arcs is the rotation axis @@ -818,9 +825,12 @@ class XrayEyeAlign: The shutter is closed unless keep_shutter_open, so the operator isn't exposing the sample while deciding where to click. """ + if projection not in ("min", "max"): + raise ValueError(f"projection must be 'min' or 'max', got {projection!r}") + accumulate = np.minimum if projection == "min" else np.maximum print( - f"[rotation-center][smear] EXPERIMENTAL feature: max-projection composite aid, " - "operator-visual only -- no automatic circle fitting." + f"[rotation-center][smear] EXPERIMENTAL feature: {projection}-projection composite " + "aid, operator-visual only -- no automatic circle fitting." ) if not dev.cam_xeye.live_mode_enabled.get(): dev.cam_xeye.live_mode_enabled.put(True) @@ -862,7 +872,7 @@ class XrayEyeAlign: if last_frame is None or not np.array_equal(frame, last_frame): frames_seen += 1 last_distinct_time = time.time() - composite = np.maximum(composite, frame) + composite = accumulate(composite, frame) last_frame = frame if time.time() - last_push > display_update_interval_s: self._push_smear_composite(composite) @@ -898,10 +908,14 @@ class XrayEyeAlign: return composite def find_rotation_center_smear_experimental( - self, keep_shutter_open: bool = False, apply: bool = True, sweep_deg: float = 360.0 + self, + keep_shutter_open: bool = False, + apply: bool = True, + sweep_deg: float = 360.0, + projection: str = "min", ): """EXPERIMENTAL: extended-sample rotation-center calibration aided - by a continuous-rotation max-projection composite (see + by a continuous-rotation min- or max-projection composite (see _smear_sweep()), instead of a single instant of live rotation. Otherwise identical in structure to find_rotation_center(sample_type="extended"): @@ -917,10 +931,15 @@ class XrayEyeAlign: sweep_deg: angular range of the continuous smear sweep. Defaults to a full 360 deg (complete arcs), but a smaller range may already show enough curvature to locate the center. + projection: "min" (default) for absorption contrast, i.e. dark + features on a bright background; "max" for bright features + on a dark background. See _smear_sweep(). Returns: tuple: (new_lsamx_center, new_lsamy_center) in mm. """ + if projection not in ("min", "max"): + raise ValueError(f"projection must be 'min' or 'max', got {projection!r}") self._prompt_sample_name() self.lamni.lamnigui_show_xeyealign() self._sync_sample_name() @@ -937,7 +956,7 @@ class XrayEyeAlign: print( "[rotation-center][smear] starting find_rotation_center_smear_experimental" - f"(sweep_deg={sweep_deg!r})" + f"(sweep_deg={sweep_deg!r}, projection={projection!r})" ) self.send_message("Getting things ready. Please wait...") @@ -963,7 +982,9 @@ class XrayEyeAlign: 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) + composite = self._smear_sweep( + sweep_deg=sweep_deg, keep_shutter_open=keep_shutter_open, projection=projection + ) self.movement_buttons_enabled(True, True) center_x, center_y = self._collect_click( 1, @@ -1048,6 +1069,7 @@ class XrayEyeAlign: fzp_xy=(fzp_x, fzp_y), new_center=(new_lsamx, new_lsamy), sweep_deg=sweep_deg, + projection=projection, ) return (new_lsamx, new_lsamy) @@ -1058,6 +1080,7 @@ class XrayEyeAlign: fzp_xy: tuple[float, float], new_center: tuple[float, float], sweep_deg: float, + projection: str = "min", ): """Archive the raw record of a smear calibration run: one composite image and one clicked centre position per iteration, plus the FZP @@ -1076,6 +1099,7 @@ class XrayEyeAlign: ds.attrs["columns"] = ["center_x_mm", "center_y_mm"] f.create_dataset("new_center_mm", data=np.array(new_center)) f.attrs["sweep_deg"] = sweep_deg + f.attrs["projection"] = projection print(f"[rotation-center][smear] saved calibration record to {expanded}") # --- end EXPERIMENTAL: rotation-center smear mode --------------------- diff --git a/docs/developer/lamni_smear_architecture.md b/docs/developer/lamni_smear_architecture.md index 008b6788..5b12eafd 100644 --- a/docs/developer/lamni_smear_architecture.md +++ b/docs/developer/lamni_smear_architecture.md @@ -123,9 +123,19 @@ stepping the motor: finish. 2. A plain loop polls `report.status` (exactly what `ScanReport.wait()` does internally) and, while it isn't `"COMPLETED"`, grabs a frame via - `get_last_image()` and folds it into a running `np.maximum` composite — + `get_last_image()` and folds it into a running composite — all single-threaded, since the rotation is progressing concurrently on - the server regardless of what the client does between polls. + the server regardless of what the client does between polls. The + composite is `np.minimum` for `projection="min"` (default; absorption + contrast, dark features on a bright background) or `np.maximum` for + `projection="max"` (bright features on a dark background). A + max-projection is useless for absorption: the bright background passes + every pixel at some point during the rotation and washes the dark + features out. Taking the minimum is equivalent to inverting the frames, + taking the maximum and inverting back, without needing the camera's + full-scale value. The value is validated up front (`ValueError` before + any motion) and stored as the `projection` attribute in the calibration + HDF5. 3. Every `display_update_interval_s`, the current composite is pushed to `smear_preview` so the operator watches it grow; off-axis features trace arcs as the sample turns, and the common center of curvature of those diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index f3601be2..9a7c5795 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -33,7 +33,14 @@ At the end you're shown the computed `lsamx_center`/`lsamy_center` and asked to **Experimental: continuous-rotation "smear" aid** -`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0)` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` while accumulating a max-projection ("star-trail") composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. The composite builds up live on screen as the sweep progresses and stays frozen once the sweep ends, until you submit your click. `sweep_deg` can be reduced below 360 if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`. +`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0, projection="min")` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` while accumulating a "star-trail" composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. The composite builds up live on screen as the sweep progresses and stays frozen once the sweep ends, until you submit your click. `sweep_deg` can be reduced below 360 if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`. + +`projection` selects how the frames are combined: + +- `"min"` (default) keeps the darkest value seen at each pixel. Use it for absorption contrast, i.e. dark sample features on a bright background — the usual case on the X-ray eye. Small absorbing features leave dark arcs. A large sample fills in as a dark region covering everything it occupied during the rotation; its outline is symmetric about the projected rotation axis (an ellipse rather than a circle, because of the tilted axis), so click the centre of that outline. +- `"max"` keeps the brightest value at each pixel, for bright features on a dark background. Do not use it for absorption: every pixel sees the bright background at some point during the rotation, so the dark features are washed out. + +Each run is archived to `~/data/raw/logs/xrayeye_smear_calibration/` (composites, clicked centres, result, and the `sweep_deg`/`projection` used). **Manual fallback** diff --git a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py index d8024347..62d0acd2 100644 --- a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py +++ b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py @@ -415,7 +415,7 @@ def test_smear_sweep_max_projection(bec_client_mock): with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report): with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): - composite = align._smear_sweep() + composite = align._smear_sweep(projection="max") expected = np.maximum(np.maximum(frame_a, frame_b), frame_c) assert np.array_equal(composite, expected) @@ -432,6 +432,39 @@ def test_smear_sweep_max_projection(bec_client_mock): assert [c.args[0] for c in align.gui.set_smear_active.call_args_list] == [True, False] +def test_smear_sweep_defaults_to_min_projection(bec_client_mock): + # absorption contrast: dark features on a bright background must survive + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + frame_a = np.array([[1, 5], [3, 2]], dtype=np.uint8) + frame_b = np.array([[4, 2], [3, 6]], dtype=np.uint8) + frame_c = np.array([[0, 1], [9, 0]], dtype=np.uint8) + dev_mock.cam_xeye.get_last_image.side_effect = [frame_a, frame_b, frame_c] + report = _report_with_status_sequence("RUNNING", "RUNNING", "RUNNING", "COMPLETED") + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + composite = align._smear_sweep() + + expected = np.minimum(np.minimum(frame_a, frame_b), frame_c) + assert np.array_equal(composite, expected) + + +def test_smear_sweep_rejects_unknown_projection(bec_client_mock): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.mv") as mv_mock: + with pytest.raises(ValueError): + align._smear_sweep(projection="mean") + + # rejected before anything moved + mv_mock.assert_not_called() + + def test_smear_sweep_computes_capture_fps(bec_client_mock): client = bec_client_mock align, dev_mock = _make_calibration_align(client)