From 1b06729dba99b2dda72337c464761337a9a282a0 Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 12 Oct 2026 18:55:42 +0200 Subject: [PATCH] feat(lamni): skip opaque pixels in the smear min-projection With the min-projection, the opaque sample holder rotating through the X-ray eye view blackened every pixel it passed at any angle and shrank the usable field of view. SmearComposite now ignores, per frame, pixels darker than opaque_level (default 0.25) times the frame's 99th-percentile value and takes the minimum over the remaining frames; pixels opaque at every angle fall back to the plain minimum, and opaque_level=0 is the plain minimum. opaque_level is passed through from lamni.xrayeye_rotation_center_calibration_smear_experimental() and stored in the calibration HDF5. The smear capture-rate test now replaces the module's time instead of patching time.time globally, which EPICS CA threads left running by other tests also advanced. Docs: smear opaque_level, LamNI optics in/out incl. central stop, interferometer feedback commands, and moving to the scan centre including the X-ray eye fit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155LWmbktVgWdnRZqtveqQv --- .../bec_ipython_client/plugins/LamNI/lamni.py | 17 +++- .../plugins/LamNI/x_ray_eye_align.py | 99 ++++++++++++++++--- docs/developer/developer.md | 2 +- docs/developer/lamni_smear_architecture.md | 13 ++- docs/user/ptychography/lamni.md | 42 ++++++-- .../test_x_ray_eye_align.py | 62 +++++++++++- 6 files changed, 204 insertions(+), 31 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 1279d662..b7000253 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -694,7 +694,11 @@ class LamNI( raise exc def xrayeye_rotation_center_calibration_smear_experimental( - self, keep_shutter_open: bool = False, sweep_deg: float = 360.0, projection: str = "min" + self, + keep_shutter_open: bool = False, + sweep_deg: float = 360.0, + projection: str = "min", + opaque_level: float = 0.25, ): """EXPERIMENTAL: extended-sample rotation-center calibration aided by a continuous-rotation min- or max-projection ("star-trail") composite image, @@ -716,6 +720,12 @@ class LamNI( projection: "min" (default) for absorption contrast, i.e. dark features on a bright background; "max" for bright features on a dark background. + opaque_level: with projection="min", pixels darker than this + fraction of a frame's bright level are treated as opaque + (sample holder) and ignored, so the holder rotating through + the view does not black out the field of view. Raise it if + the holder still blocks too much, lower it if faint sample + features disappear; 0 = plain minimum. Returns: tuple: (new_lsamx_center, new_lsamy_center) in mm. @@ -723,7 +733,10 @@ 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, projection=projection + keep_shutter_open=keep_shutter_open, + sweep_deg=sweep_deg, + projection=projection, + opaque_level=opaque_level, ) 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 b3894777..fe7c9076 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 @@ -29,6 +29,62 @@ if TYPE_CHECKING: from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI +class SmearComposite: + """Running per-pixel projection of camera frames for the smear aid. + + projection="max" keeps the brightest value per pixel. projection="min" + keeps the darkest value per pixel, but skips pixels that are opaque in a + given frame: darker than ``opaque_level`` times that frame's bright + level (its 99th-percentile value). Without that, the sample holder, + which is opaque and rotates through the field of view, would blacken + every pixel it passes at any angle. A pixel that was opaque in every + frame falls back to its plain minimum. opaque_level=0 gives the plain + minimum everywhere. + """ + + def __init__(self, projection: str = "min", opaque_level: float = 0.25): + if projection not in ("min", "max"): + raise ValueError(f"projection must be 'min' or 'max', got {projection!r}") + if not 0 <= opaque_level < 1: + raise ValueError(f"opaque_level must be in [0, 1), got {opaque_level!r}") + self.projection = projection + self.opaque_level = opaque_level + self._plain = None # plain min or max over all frames + self._masked = None # min over non-opaque pixels only + self._seen = None # True where a pixel was non-opaque at least once + + @property + def shape(self): + return None if self._plain is None else self._plain.shape + + @property + def _plain_only(self) -> bool: + return self.projection == "max" or self.opaque_level == 0 + + def add(self, frame: np.ndarray) -> None: + if self._plain_only: + reduce = np.maximum if self.projection == "max" else np.minimum + self._plain = frame.copy() if self._plain is None else reduce(self._plain, frame) + return + open_px = frame > self.opaque_level * np.percentile(frame, 99) + if self._plain is None: + self._plain = frame.copy() + self._masked = frame.copy() + self._seen = open_px + return + self._plain = np.minimum(self._plain, frame) + # first non-opaque sighting of a pixel takes its value; after that, min + first = open_px & ~self._seen + self._masked = np.where(first, frame, self._masked) + self._masked = np.where(open_px, np.minimum(self._masked, frame), self._masked) + self._seen |= open_px + + def image(self) -> np.ndarray | None: + if self._plain_only or self._plain is None: + return self._plain + return np.where(self._seen, self._masked, self._plain) + + # Laminography alignment angles: full 360° in 45° steps LAMNI_ALIGNMENT_ANGLES = [k * 45 for k in range(8)] # [0, 45, 90, 135, 180, 225, 270, 315] @@ -786,6 +842,7 @@ class XrayEyeAlign: display_update_interval_s: float = 1.0, poll_interval_s: float = 0.15, projection: str = "min", + opaque_level: float = 0.25, ) -> np.ndarray: """Rotate lsamrot continuously through sweep_deg while accumulating a min- or max-projection composite of camera frames grabbed along the way. @@ -795,6 +852,10 @@ class XrayEyeAlign: 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. + With "min", pixels darker than opaque_level times a frame's bright + level are treated as opaque (sample holder) in that frame and + skipped, so the rotating holder does not black out the field of + view; see SmearComposite. opaque_level=0 gives the plain minimum. Off-axis features smear into circular arcs as the sample rotates; the common center of curvature of those arcs is the rotation axis @@ -825,9 +886,7 @@ 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 + accumulator = SmearComposite(projection=projection, opaque_level=opaque_level) print( f"[rotation-center][smear] EXPERIMENTAL feature: {projection}-projection composite " "aid, operator-visual only -- no automatic circle fitting." @@ -848,7 +907,6 @@ class XrayEyeAlign: ) report = mv(self.device_manager.devices.lsamrot, target) - composite = None last_frame = None frames_seen = 0 last_push = time.time() @@ -860,22 +918,22 @@ class XrayEyeAlign: try: while report.status != "COMPLETED": frame = dev.cam_xeye.get_last_image() - if composite is None: - composite = frame.copy() + if accumulator.shape is None: + accumulator.add(frame) first_frame_time = time.time() - elif frame.shape != composite.shape: + elif frame.shape != accumulator.shape: print( f"[rotation-center][smear] skipping frame with mismatched shape " - f"{frame.shape} != {composite.shape}" + f"{frame.shape} != {accumulator.shape}" ) else: if last_frame is None or not np.array_equal(frame, last_frame): frames_seen += 1 last_distinct_time = time.time() - composite = accumulate(composite, frame) + accumulator.add(frame) last_frame = frame if time.time() - last_push > display_update_interval_s: - self._push_smear_composite(composite) + self._push_smear_composite(accumulator.image()) last_push = time.time() time.sleep(poll_interval_s) except KeyboardInterrupt: @@ -885,6 +943,7 @@ class XrayEyeAlign: self.gui.set_live_view_signal("image") raise + composite = accumulator.image() self._push_smear_composite(composite) self.gui.set_smear_active(False) if not keep_shutter_open: @@ -913,6 +972,7 @@ class XrayEyeAlign: apply: bool = True, sweep_deg: float = 360.0, projection: str = "min", + opaque_level: float = 0.25, ): """EXPERIMENTAL: extended-sample rotation-center calibration aided by a continuous-rotation min- or max-projection composite (see @@ -934,12 +994,16 @@ class XrayEyeAlign: 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(). + opaque_level: with projection="min", pixels darker than this + fraction of a frame's bright level are treated as opaque + (sample holder) and skipped; 0 = plain minimum. See + SmearComposite. 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}") + # validate before anything moves + SmearComposite(projection=projection, opaque_level=opaque_level) self._prompt_sample_name() self.lamni.lamnigui_show_xeyealign() self._sync_sample_name() @@ -956,7 +1020,8 @@ class XrayEyeAlign: print( "[rotation-center][smear] starting find_rotation_center_smear_experimental" - f"(sweep_deg={sweep_deg!r}, projection={projection!r})" + f"(sweep_deg={sweep_deg!r}, projection={projection!r}, " + f"opaque_level={opaque_level!r})" ) self.send_message("Getting things ready. Please wait...") @@ -983,7 +1048,10 @@ class XrayEyeAlign: "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 + 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( @@ -1070,6 +1138,7 @@ class XrayEyeAlign: new_center=(new_lsamx, new_lsamy), sweep_deg=sweep_deg, projection=projection, + opaque_level=opaque_level, ) return (new_lsamx, new_lsamy) @@ -1081,6 +1150,7 @@ class XrayEyeAlign: new_center: tuple[float, float], sweep_deg: float, projection: str = "min", + opaque_level: float = 0.25, ): """Archive the raw record of a smear calibration run: one composite image and one clicked centre position per iteration, plus the FZP @@ -1100,6 +1170,7 @@ class XrayEyeAlign: f.create_dataset("new_center_mm", data=np.array(new_center)) f.attrs["sweep_deg"] = sweep_deg f.attrs["projection"] = projection + f.attrs["opaque_level"] = opaque_level print(f"[rotation-center][smear] saved calibration record to {expanded}") # --- end EXPERIMENTAL: rotation-center smear mode --------------------- diff --git a/docs/developer/developer.md b/docs/developer/developer.md index 664c3d38..9eb75c11 100644 --- a/docs/developer/developer.md +++ b/docs/developer/developer.md @@ -39,7 +39,7 @@ Conventions for writing these MyST/Sphinx docs and how changes go live on Read t ## LamNI smear aid architecture -Device/ipython-client/GUI widget interfaces for the smear rotation-center calibration aid, and the `bw-generate-cli` regeneration pitfall. +Device/ipython-client/GUI widget interfaces for the smear rotation-center calibration aid, the min/max composite with opaque-pixel masking, and the `bw-generate-cli` regeneration pitfall. ``` ```` diff --git a/docs/developer/lamni_smear_architecture.md b/docs/developer/lamni_smear_architecture.md index 5b12eafd..2a5b22dc 100644 --- a/docs/developer/lamni_smear_architecture.md +++ b/docs/developer/lamni_smear_architecture.md @@ -133,9 +133,16 @@ stepping the motor: 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. + full-scale value. The accumulation lives in `SmearComposite` + (`x_ray_eye_align.py`). For `"min"` it also skips opaque pixels: in each + frame, pixels darker than `opaque_level` (default 0.25) times the frame's + 99th-percentile value count as opaque and are left out, so the sample + holder rotating through the view does not black out every pixel it ever + covers. It keeps the plain minimum alongside the masked one and falls + back to it for pixels that were opaque in every frame; `opaque_level=0` + is the plain minimum. Both values are validated up front (`ValueError` + before any motion) and stored as `projection`/`opaque_level` attributes + 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 9a7c5795..276dc878 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -33,14 +33,16 @@ 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, 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()`. +`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0, projection="min", opaque_level=0.25)` 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. + + Opaque parts such as the sample holder frame would otherwise black out every pixel they pass at any angle and shrink the usable field of view. To avoid that, a pixel is ignored in any frame where it is darker than `opaque_level` (default `0.25`) times that frame's bright level (its 99th-percentile value), and the minimum is taken over the remaining frames only. A pixel that was opaque at every angle stays dark. If the holder still blacks out too much, raise `opaque_level` (e.g. `0.4`). If faint sample features disappear, lower it. `opaque_level=0` gives the plain minimum. - `"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). +Each run is archived to `~/data/raw/logs/xrayeye_smear_calibration/` (composites, clicked centres, result, and the `sweep_deg`/`projection`/`opaque_level` used). **Manual fallback** @@ -88,6 +90,22 @@ Example, checking the correction at 90°: 3. `lamni.xrayeye_update_frame(keep_shutter_open=True)` to look at the sample 4. `lamni.lamni_compute_additional_correction_xeye_mu(90)` prints the correction (x and y, in mm) that the scan will add on top of this position. The sample should appear displaced from the FZP centre by the size of this correction, and the correction compensates for that displacement. A clearly different displacement means the fit is off, e.g. a bad click, and the alignment should be repeated. +##### Moving to the scan centre including the X-ray eye fit + +`lamni_move_to_scan_center()` above places the sample at the base shift only. To go where a projection at the current angle would go, including the sine fit from the X-ray eye alignment (`tomo_fit_xray_eye`), rotate first and then run: + +```python +angle = dev.lsamrot.user_setpoint.get() +cx, cy = lamni.lamni_compute_additional_correction_xeye_mu(angle) # mm, from the X-ray eye fit +from csaxs_bec.scans.lamni_components import LamNIComponents +sx, sy = LamNIComponents.lamni_compute_scan_center(lamni.tomo_fovx_offset, lamni.tomo_fovy_offset, angle) +lamni.lamni_move_to_scan_center(sx + cx, sy + cy, 0).wait() +``` + +The base shift is rotated to the angle first, so the move itself is called with angle 0 to pass the target through unchanged. This matches a projection without stitching, manual shifts (`lamni.manual_shift_x/y`, add them) and the correction tables from fine alignment (`lamni.compute_additional_correction(angle)` and `..._2(angle)`, subtract them). The positions are relative to the `center` user parameters of `lsamx`/`lsamy`, which must be set. Check the result with `lamni.show_interferometer_positions()`. + +If the drift between coarse stages and interferometer found at the start of the move is larger than 150 µm, it is considered unreliable and ignored; the move then relies on the second, interferometer-based iteration. + * If slits were opened during alignment, close the slits: `slits` to around 0.3 * `lamni.leye_out()` remove the X-ray eye and move the flight tube in * *possibly check slit1, idgap* @@ -533,11 +551,23 @@ Example: `lamni.lfzp_info(6.2)` The laser feedback will be disabled and fine alignment lost if foptx/y are moved! -Following functions exist to move the optics in and out, the naming is self-explaining. +Following functions exist to move the optics in and out. The positions are the `in`/`out` user parameters of the devices; a move is skipped if the axes are already within 3 µm of the target. +* `lamni.loptics_in()` moves FZP, OSA and central stop in, in that order +* `lamni.loptics_out()` moves central stop, OSA and FZP out, in that order, with the interferometer feedback disabled, and re-enables the feedback with reset afterwards * `lamni.lfzp_in()` -* `lamni.loptics_in()` -* `lamni.loptics_out()` * `lamni.losa_in()` * `lamni.losa_out()` -* `lamni.lfzp_in()` +* `lamni.lcs_in()` moves `lcsx` and `lcsy` to their `in` positions +* `lamni.lcs_out()` moves `lcsy` to its `out` position; `lcsx` has no `out` position and stays where it is + +(user.ptychography.lamni.laser_feedback)= +#### Interferometer feedback commands + +* `lamni.feedback_enable_with_reset()` enables the closed-loop control and resets the interferometers +* `lamni.feedback_disable()` +* `lamni.feedback_status()` prints whether the loop is running +* `lamni.show_interferometer_positions()` prints the current interferometer x/y positions in µm and whether the loop is running, and returns them as a dict +* `lamni.show_signal_strength()` and `lamni.show_analog_signals()` print the interferometer signal strengths and the piezo/capacitive sensor readings + +With the feedback disabled, the interferometer follows the coarse stages: moving `lsamx` by +0.1 mm changes x by about +50 µm (`x = 0.5 · lsamx` in the LamNI geometry). This is a quick check that the interferometer is working before moving to a scan centre. 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 62d0acd2..5a60b552 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 @@ -5,7 +5,10 @@ import pytest from bec_lib.device import DeviceBase from csaxs_bec.bec_ipython_client.plugins.LamNI import LamNI -from csaxs_bec.bec_ipython_client.plugins.LamNI.x_ray_eye_align import XrayEyeAlign +from csaxs_bec.bec_ipython_client.plugins.LamNI.x_ray_eye_align import ( + SmearComposite, + XrayEyeAlign, +) # pylint: disable=unused-import @@ -446,7 +449,7 @@ def test_smear_sweep_defaults_to_min_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(opaque_level=0) expected = np.minimum(np.minimum(frame_a, frame_b), frame_c) assert np.array_equal(composite, expected) @@ -465,6 +468,50 @@ def test_smear_sweep_rejects_unknown_projection(bec_client_mock): mv_mock.assert_not_called() +def test_smear_composite_min_skips_opaque_holder(): + # bright background 200, absorbing particle 100, opaque holder 10 + frame_a = np.array([[200, 100], [200, 200]], dtype=np.uint8) # particle at (0, 1) + frame_b = np.array([[10, 200], [200, 200]], dtype=np.uint8) # holder at (0, 0) + frame_c = np.array([[200, 200], [10, 10]], dtype=np.uint8) # holder on bottom row + + comp = SmearComposite("min") + for frame in (frame_a, frame_b, frame_c): + comp.add(frame) + + # holder ignored where the pixel was open at another angle; particle kept + assert np.array_equal(comp.image(), np.array([[200, 100], [200, 200]], dtype=np.uint8)) + assert comp.image().dtype == np.uint8 + + +def test_smear_composite_min_always_opaque_pixel_stays_dark(): + frame_a = np.array([[10, 200], [200, 200]], dtype=np.uint8) + frame_b = np.array([[5, 200], [200, 200]], dtype=np.uint8) + + comp = SmearComposite("min") + comp.add(frame_a) + comp.add(frame_b) + + # never open -> plain minimum + assert comp.image()[0, 0] == 5 + + +def test_smear_composite_opaque_level_zero_is_plain_min(): + frames = [ + np.array([[200, 100], [200, 200]], dtype=np.uint8), + np.array([[10, 200], [200, 200]], dtype=np.uint8), + ] + comp = SmearComposite("min", opaque_level=0) + for frame in frames: + comp.add(frame) + + assert np.array_equal(comp.image(), np.minimum(*frames)) + + +def test_smear_composite_rejects_bad_opaque_level(): + with pytest.raises(ValueError): + SmearComposite("min", opaque_level=1.0) + + def test_smear_sweep_computes_capture_fps(bec_client_mock): client = bec_client_mock align, dev_mock = _make_calibration_align(client) @@ -486,11 +533,16 @@ def test_smear_sweep_computes_capture_fps(bec_client_mock): clock["t"] += 0.05 return clock["t"] + # Replace the module's `time` rather than patching time.time globally: + # background threads left running by other tests (e.g. EPICS CA + # callbacks) would otherwise also advance the fake clock. + fake_time_module = mock.MagicMock() + fake_time_module.time.side_effect = fake_time + 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"): - with mock.patch(f"{XRAY_EYE_ALIGN}.time.time", side_effect=fake_time): - align._smear_sweep() + with mock.patch(f"{XRAY_EYE_ALIGN}.time", fake_time_module): + align._smear_sweep(opaque_level=0) assert align.last_smear_fps == pytest.approx(10.0)