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)
|
settling_time: float = Field(default=0.3, ge=0)
|
||||||
"""Time (in seconds) between moving and capturing an image"""
|
"""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
|
origin: StackOrigin = StackOrigin.START
|
||||||
"""Where the stack is positioned relative to the current z position."""
|
"""Where the stack is positioned relative to the current z position."""
|
||||||
|
|
||||||
|
|
@ -760,7 +755,7 @@ class AutofocusThing(lt.Thing):
|
||||||
logic, or autofocus is performed.
|
logic, or autofocus is performed.
|
||||||
|
|
||||||
:param stack_parameters: StackParams defining stack spacing,
|
:param stack_parameters: StackParams defining stack spacing,
|
||||||
image count and backlash correction.
|
and image count.
|
||||||
:param capture_parameters: CaptureParams defining save
|
:param capture_parameters: CaptureParams defining save
|
||||||
resolution, images directory.
|
resolution, images directory.
|
||||||
|
|
||||||
|
|
@ -780,15 +775,8 @@ class AutofocusThing(lt.Thing):
|
||||||
target_offset = -total_range
|
target_offset = -total_range
|
||||||
else:
|
else:
|
||||||
target_offset = 0
|
target_offset = 0
|
||||||
|
if target_offset != 0:
|
||||||
# Apply backlash correction: overshoot and move back
|
self._stage.move_relative(z=target_offset)
|
||||||
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)
|
|
||||||
|
|
||||||
# Capture images_to_save images
|
# Capture images_to_save images
|
||||||
for move_count in range(stack_parameters.images_to_save):
|
for move_count in range(stack_parameters.images_to_save):
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from openflexure_microscope_server.things.autofocus import (
|
||||||
AutofocusParams,
|
AutofocusParams,
|
||||||
AutofocusThing,
|
AutofocusThing,
|
||||||
SmartStackParams,
|
SmartStackParams,
|
||||||
|
StackParams,
|
||||||
)
|
)
|
||||||
from openflexure_microscope_server.things.background_detect import (
|
from openflexure_microscope_server.things.background_detect import (
|
||||||
ChannelDeviationLUV,
|
ChannelDeviationLUV,
|
||||||
|
|
@ -43,6 +44,7 @@ from openflexure_microscope_server.ui import (
|
||||||
UI_ELEMENT_RESPONSE,
|
UI_ELEMENT_RESPONSE,
|
||||||
Accordion,
|
Accordion,
|
||||||
HeaderBlock,
|
HeaderBlock,
|
||||||
|
PropertyControl,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
UIElementList,
|
UIElementList,
|
||||||
action_button_for,
|
action_button_for,
|
||||||
|
|
@ -666,7 +668,14 @@ class RegularGridSettingsModel(RectGridSettingsModel):
|
||||||
style: Literal["snake", "raster"]
|
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."""
|
"""A base workflow for any workflow that uses a regular rectangular grid."""
|
||||||
|
|
||||||
x_count: int = lt.setting(default=3, ge=1)
|
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)
|
y_count: int = lt.setting(default=2, ge=1)
|
||||||
"""The number of rows in the scan."""
|
"""The number of rows in the scan."""
|
||||||
|
|
||||||
_settings_model = RegularGridSettingsModel
|
_settings_model: type[RegGridSettingModelType]
|
||||||
_planner_cls = RegularGridPlanner
|
_planner_cls = RegularGridPlanner
|
||||||
_grid_style: Literal["snake", "raster"]
|
_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."""
|
"""Construct the SettingModel for all_settings."""
|
||||||
return RegularGridSettingsModel(
|
return self._settings_model(
|
||||||
**base_kwargs,
|
**base_kwargs,
|
||||||
x_count=self.x_count,
|
x_count=self.x_count,
|
||||||
y_count=self.y_count,
|
y_count=self.y_count,
|
||||||
style=self._grid_style,
|
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.
|
"""Perform these steps before starting the scan.
|
||||||
|
|
||||||
In this case, only autofocus.
|
In this case, only autofocus.
|
||||||
|
|
@ -699,11 +708,11 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
|
||||||
)
|
)
|
||||||
|
|
||||||
def new_scan_planner(
|
def new_scan_planner(
|
||||||
self, settings: RegularGridSettingsModel, position: Mapping[str, int]
|
self, settings: RegGridSettingModelType, position: Mapping[str, int]
|
||||||
) -> ScanPlanner:
|
) -> ScanPlanner:
|
||||||
"""Return a new scan planner object.
|
"""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.
|
:param position: The starting position as a mapping of axes names to int.
|
||||||
"""
|
"""
|
||||||
planner_settings = {
|
planner_settings = {
|
||||||
|
|
@ -719,11 +728,11 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
|
||||||
)
|
)
|
||||||
|
|
||||||
def acquisition_routine(
|
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]]:
|
) -> tuple[bool, Optional[int]]:
|
||||||
"""Autofocus and capture.
|
"""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.
|
: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.
|
: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.
|
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.
|
"""A workflow optimised for snaking around samples.
|
||||||
|
|
||||||
This workflow generates a list of coordinates in a rectangle, and snakes
|
This workflow generates a list of coordinates in a rectangle, and snakes
|
||||||
|
|
@ -777,10 +786,11 @@ class SnakeWorkflow(RegularGridWorkflow):
|
||||||
),
|
),
|
||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
_settings_model = RegularGridSettingsModel
|
||||||
_grid_style = "snake"
|
_grid_style = "snake"
|
||||||
|
|
||||||
|
|
||||||
class RasterWorkflow(RegularGridWorkflow):
|
class RasterWorkflow(RegularGridWorkflow[RegularGridSettingsModel]):
|
||||||
"""A workflow optimised for snaking around samples.
|
"""A workflow optimised for snaking around samples.
|
||||||
|
|
||||||
This workflow generates a list of coordinates in a rectangle, and always
|
This workflow generates a list of coordinates in a rectangle, and always
|
||||||
|
|
@ -796,5 +806,124 @@ class RasterWorkflow(RegularGridWorkflow):
|
||||||
),
|
),
|
||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
_settings_model = RegularGridSettingsModel
|
||||||
_grid_style = "raster"
|
_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,
|
stack_dz=10,
|
||||||
images_to_save=3,
|
images_to_save=3,
|
||||||
settling_time=0,
|
settling_time=0,
|
||||||
backlash_correction=0,
|
|
||||||
origin=StackOrigin.START,
|
origin=StackOrigin.START,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -757,7 +756,6 @@ def test_run_basic_stack_center_origin(
|
||||||
stack_dz=10,
|
stack_dz=10,
|
||||||
images_to_save=5,
|
images_to_save=5,
|
||||||
settling_time=0,
|
settling_time=0,
|
||||||
backlash_correction=0,
|
|
||||||
origin=StackOrigin.CENTER,
|
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"
|
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(
|
def test_run_basic_stack_end_origin(
|
||||||
autofocus_thing, mocker, fake_capture, fake_move_relative
|
autofocus_thing, mocker, fake_capture, fake_move_relative
|
||||||
):
|
):
|
||||||
|
|
@ -903,7 +810,6 @@ def test_run_basic_stack_end_origin(
|
||||||
stack_dz=10,
|
stack_dz=10,
|
||||||
images_to_save=4,
|
images_to_save=4,
|
||||||
settling_time=0,
|
settling_time=0,
|
||||||
backlash_correction=0,
|
|
||||||
origin=StackOrigin.END,
|
origin=StackOrigin.END,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -948,7 +854,6 @@ def test_invalid_stack_images_raises():
|
||||||
stack_dz=10,
|
stack_dz=10,
|
||||||
images_to_save=capture_count,
|
images_to_save=capture_count,
|
||||||
settling_time=0,
|
settling_time=0,
|
||||||
backlash_correction=0,
|
|
||||||
origin=StackOrigin.START,
|
origin=StackOrigin.START,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -960,7 +865,6 @@ def test_invalid_stack_settling_raises():
|
||||||
stack_dz=10,
|
stack_dz=10,
|
||||||
images_to_save=1,
|
images_to_save=1,
|
||||||
settling_time=-1,
|
settling_time=-1,
|
||||||
backlash_correction=0,
|
|
||||||
origin=StackOrigin.START,
|
origin=StackOrigin.START,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue