Merge branch 'cchip' into 'v3'
CChip scan workflow See merge request openflexure/openflexure-microscope-server!474
This commit is contained in:
commit
ca5c49289e
3 changed files with 144 additions and 123 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue