Merge branch 'cchip' into 'v3'

CChip scan workflow

See merge request openflexure/openflexure-microscope-server!474
This commit is contained in:
Julian Stirling 2026-03-12 18:16:52 +00:00
commit ca5c49289e
3 changed files with 144 additions and 123 deletions

View file

@ -67,11 +67,6 @@ class StackParams(BaseModel):
settling_time: float = Field(default=0.3, ge=0)
"""Time (in seconds) between moving and capturing an image"""
backlash_correction: int = 250
"""
Distance (in steps) to overshoot a move and then undo, to account for backlash
"""
origin: StackOrigin = StackOrigin.START
"""Where the stack is positioned relative to the current z position."""
@ -760,7 +755,7 @@ class AutofocusThing(lt.Thing):
logic, or autofocus is performed.
:param stack_parameters: StackParams defining stack spacing,
image count and backlash correction.
and image count.
:param capture_parameters: CaptureParams defining save
resolution, images directory.
@ -780,15 +775,8 @@ class AutofocusThing(lt.Thing):
target_offset = -total_range
else:
target_offset = 0
# Apply backlash correction: overshoot and move back
overshoot = target_offset - stack_parameters.backlash_correction
if overshoot != 0:
self._stage.move_relative(z=overshoot)
# Move back to starting point if needed
if stack_parameters.backlash_correction != 0:
self._stage.move_relative(z=stack_parameters.backlash_correction)
if target_offset != 0:
self._stage.move_relative(z=target_offset)
# Capture images_to_save images
for move_count in range(stack_parameters.images_to_save):

View file

@ -32,6 +32,7 @@ from openflexure_microscope_server.things.autofocus import (
AutofocusParams,
AutofocusThing,
SmartStackParams,
StackParams,
)
from openflexure_microscope_server.things.background_detect import (
ChannelDeviationLUV,
@ -43,6 +44,7 @@ from openflexure_microscope_server.ui import (
UI_ELEMENT_RESPONSE,
Accordion,
HeaderBlock,
PropertyControl,
TextBlock,
UIElementList,
action_button_for,
@ -666,7 +668,14 @@ class RegularGridSettingsModel(RectGridSettingsModel):
style: Literal["snake", "raster"]
class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
RegGridSettingModelType = TypeVar(
"RegGridSettingModelType", bound=RegularGridSettingsModel
)
class RegularGridWorkflow(
RectGridWorkflow[RegGridSettingModelType], Generic[RegGridSettingModelType]
):
"""A base workflow for any workflow that uses a regular rectangular grid."""
x_count: int = lt.setting(default=3, ge=1)
@ -674,20 +683,20 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
y_count: int = lt.setting(default=2, ge=1)
"""The number of rows in the scan."""
_settings_model = RegularGridSettingsModel
_settings_model: type[RegGridSettingModelType]
_planner_cls = RegularGridPlanner
_grid_style: Literal["snake", "raster"]
def _build_scan_settings(self, base_kwargs: dict) -> RegularGridSettingsModel:
def _build_scan_settings(self, base_kwargs: dict) -> RegGridSettingModelType:
"""Construct the SettingModel for all_settings."""
return RegularGridSettingsModel(
return self._settings_model(
**base_kwargs,
x_count=self.x_count,
y_count=self.y_count,
style=self._grid_style,
)
def pre_scan_routine(self, settings: RegularGridSettingsModel) -> None:
def pre_scan_routine(self, settings: RegGridSettingModelType) -> None:
"""Perform these steps before starting the scan.
In this case, only autofocus.
@ -699,11 +708,11 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
)
def new_scan_planner(
self, settings: RegularGridSettingsModel, position: Mapping[str, int]
self, settings: RegGridSettingModelType, position: Mapping[str, int]
) -> ScanPlanner:
"""Return a new scan planner object.
:param settings: The settings for this scan as a SnakeSettingsModel
:param settings: The settings for this scan as the relevant SettingsModel type.
:param position: The starting position as a mapping of axes names to int.
"""
planner_settings = {
@ -719,11 +728,11 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
)
def acquisition_routine(
self, settings: RegularGridSettingsModel, xyz_pos: tuple[int, int, int]
self, settings: RegGridSettingModelType, xyz_pos: tuple[int, int, int]
) -> tuple[bool, Optional[int]]:
"""Autofocus and capture.
:param settings: The settings for this scan as a RegularGridSettingsModel
:param settings: The settings for this scan as the relevant SettingsModel type.
:param xyz_pos: The current position as a tuple or 3 ints.
:return: A tuple of whether an image was taken, and the z-position for focus.
If failed to find focus, returns for the focus z-position.
@ -762,7 +771,7 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
)
class SnakeWorkflow(RegularGridWorkflow):
class SnakeWorkflow(RegularGridWorkflow[RegularGridSettingsModel]):
"""A workflow optimised for snaking around samples.
This workflow generates a list of coordinates in a rectangle, and snakes
@ -777,10 +786,11 @@ class SnakeWorkflow(RegularGridWorkflow):
),
readonly=True,
)
_settings_model = RegularGridSettingsModel
_grid_style = "snake"
class RasterWorkflow(RegularGridWorkflow):
class RasterWorkflow(RegularGridWorkflow[RegularGridSettingsModel]):
"""A workflow optimised for snaking around samples.
This workflow generates a list of coordinates in a rectangle, and always
@ -796,5 +806,124 @@ class RasterWorkflow(RegularGridWorkflow):
),
readonly=True,
)
_settings_model = RegularGridSettingsModel
_grid_style = "raster"
class CChipScanSettingsModel(RegularGridSettingsModel):
"""The settings for a scan with the CChipWorkflow.
This includes settings calculated when starting. This will be held by smart scan
during a scan and serialised to disk.
"""
stack_params: StackParams
class CChipWorkflow(RegularGridWorkflow[CChipScanSettingsModel]):
"""A workflow optimised for scanning the well of a CChip.
This workflow generates a list of coordinates in a rectangle, and snakes
around them from the top left (assuming positive dx and dy), stacking the
grid and above.
"""
display_name: str = lt.property(default="C-Chip Scan", readonly=True)
ui_blurb: str = lt.property(
default=(
"This scan workflow is optimised for scanning a C-Chip. It focuses on "
"a grid, then stacks images above the grid to complete a volumetric scan."
),
readonly=True,
)
_grid_style = "snake"
_settings_model = CChipScanSettingsModel
overlap: float = lt.setting(default=0.1, ge=0.1, le=0.7)
"""The fraction that adjacent images should overlap in x and y.
This must be between 0.1 and 0.7.
"""
x_count: int = lt.setting(default=5, readonly=False)
"""The number of columns in the scan."""
y_count: int = lt.setting(default=7, readonly=False)
"""The number of rows in the scan."""
stack_images_to_save: int = lt.setting(default=9, readonly=False)
"""The number of images to save in a stack.
Defaults to 1 unless you need to see either side of focus
"""
stack_dz: int = lt.setting(default=500, readonly=False)
"""Distance in steps between images in a z-stack."""
def create_stack_params(
self,
) -> StackParams:
"""Set up the parameters used for all stacks in a scan.
:returns: A StackSmartParams object with the required parameters.
"""
return StackParams(
stack_dz=self.stack_dz, images_to_save=self.stack_images_to_save
)
def _build_scan_settings(self, base_kwargs: dict) -> CChipScanSettingsModel:
"""Construct the SettingModel for all_settings."""
stack_params = self.create_stack_params()
return self._settings_model(
**base_kwargs,
x_count=self.x_count,
y_count=self.y_count,
style=self._grid_style,
stack_params=stack_params,
)
def pre_scan_routine(self, settings: CChipScanSettingsModel) -> None:
"""No autofocus, a looping autofocus on a CChip could corrupt the entire scan."""
pass
def acquisition_routine(
self,
settings: CChipScanSettingsModel,
xyz_pos: tuple[int, int, int], # noqa: ARG002
) -> tuple[bool, Optional[int]]:
"""Autofocus and capture a z-stack starting at the focused position.
The routine performs a fast autofocus using the provided ``dz``.
The focused z-height becomes the starting position for the stack
and is also the height of the first captured image.
A stack of ``self.stack_images_to_save`` images is then acquired.
Each subsequent image is captured after moving the stage upward
by ``self.stack_dz`` steps in z.
:param xyz_pos: The (x, y, z) position associated with this acquisition.
:param settings: The settings for this scan as a CChipSettingsModel
:return: (True, focus_height) where focus_height is the autofocus
z-position and the height of the first image in the stack.
"""
# Perform autofocus
self._autofocus.fast_autofocus(dz=settings.autofocus_params.dz)
focus_height = self._stage.get_xyz_position()[2]
self._autofocus.run_basic_stack(settings.stack_params, settings.capture_params)
return True, focus_height
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
return [
property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"),
property_control_for(self, "x_count", label="Number of columns"),
property_control_for(self, "y_count", label="Number of rows"),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),
property_control_for(
self, "stack_dz", label="Distance in z between images in stack (steps)"
),
property_control_for(
self, "stack_images_to_save", label="Images to save per xy site"
),
]

View file

@ -694,7 +694,6 @@ def test_run_basic_stack_simple(
stack_dz=10,
images_to_save=3,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.START,
)
@ -757,7 +756,6 @@ def test_run_basic_stack_center_origin(
stack_dz=10,
images_to_save=5,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.CENTER,
)
@ -804,97 +802,6 @@ def test_run_basic_stack_center_origin(
assert final_z == expected_final_z, "Final Z position is incorrect"
def test_run_basic_stack_backlash_applied(
autofocus_thing, mocker, fake_capture, fake_move_relative
):
"""Backlash correction should overshoot first, then move back before starting stack."""
stack_params = StackParams(
stack_dz=10,
images_to_save=3,
settling_time=0,
backlash_correction=50,
origin=StackOrigin.START,
)
capture_params = mocker.Mock()
capture_params.images_dir = "dummy"
capture_params.save_resolution = (100, 100)
start_z = 0
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture)
autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative)
autofocus_thing._cam.save_from_memory = mocker.Mock()
autofocus_thing._cam.clear_buffers = mocker.Mock()
autofocus_thing.run_basic_stack(stack_params, capture_params)
calls = autofocus_thing._stage.move_relative.call_args_list
# First move is overshoot for backlash
assert calls[0].kwargs["z"] == -stack_params.backlash_correction, (
"Backlash overshoot not applied correctly"
)
# Second move corrects back to starting point
assert calls[1].kwargs["z"] == stack_params.backlash_correction, (
"Backlash correction move not applied correctly"
)
def test_run_basic_stack_backlash_and_offset(
autofocus_thing, mocker, fake_capture, fake_move_relative
):
"""Backlash correction and stack offset both applied.
Stack should begin by moving down by half the height of the stack,
plus backlash correction, then move up by backlash correction, then run the basic stack.
"""
stack_params = StackParams(
stack_dz=100,
images_to_save=3,
settling_time=0,
backlash_correction=50,
origin=StackOrigin.CENTER,
)
capture_params = mocker.Mock()
capture_params.images_dir = "dummy"
capture_params.save_resolution = (100, 100)
start_z = 0
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture)
autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative)
autofocus_thing._cam.save_from_memory = mocker.Mock()
autofocus_thing._cam.clear_buffers = mocker.Mock()
autofocus_thing.run_basic_stack(stack_params, capture_params)
calls = autofocus_thing._stage.move_relative.call_args_list
# Combined initial offset + backlash overshoot
total_stack_range = stack_params.stack_dz * (stack_params.images_to_save - 1)
expected_first_move = -(total_stack_range // 2 + stack_params.backlash_correction)
assert calls[0].kwargs["z"] == expected_first_move, (
"Combined overshoot not applied correctly"
)
# Backlash correction back to base of stack
assert calls[1].kwargs["z"] == stack_params.backlash_correction, (
"Backlash correction move not applied correctly"
)
# Subsequent moves in stack
expected_stack_moves = [stack_params.stack_dz] * (stack_params.images_to_save - 1)
actual_stack_moves = [c.kwargs["z"] for c in calls[2:]]
assert actual_stack_moves == expected_stack_moves, (
"Stack moves after backlash/offset not correct"
)
def test_run_basic_stack_end_origin(
autofocus_thing, mocker, fake_capture, fake_move_relative
):
@ -903,7 +810,6 @@ def test_run_basic_stack_end_origin(
stack_dz=10,
images_to_save=4,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.END,
)
@ -948,7 +854,6 @@ def test_invalid_stack_images_raises():
stack_dz=10,
images_to_save=capture_count,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.START,
)
@ -960,7 +865,6 @@ def test_invalid_stack_settling_raises():
stack_dz=10,
images_to_save=1,
settling_time=-1,
backlash_correction=0,
origin=StackOrigin.START,
)