Initial commit, splitting params into focus, capture, stack

This commit is contained in:
Joe Knapper 2026-02-18 17:55:23 +00:00 committed by Julian Stirling
parent de08bb1740
commit e750129de2
4 changed files with 150 additions and 76 deletions

View file

@ -33,15 +33,25 @@ class NotStreamingError(RuntimeError):
"""No images captured from stream. The camera is almost certainly not streaming.""" """No images captured from stream. The camera is almost certainly not streaming."""
class SmartStackParams(BaseModel): class AutofocusParams(BaseModel):
"""A class for holding for smart stack parameters, and returning computed ones.""" """A class for running autofocus routines."""
dz: int
sharpness_method: str = "jpeg"
class CaptureParams(BaseModel):
"""A class for capturing at least a single image."""
images_dir: str
save_resolution: tuple[int, int]
class StackParams(BaseModel):
"""A class for holding stack parameters, and returning computed ones."""
stack_dz: int stack_dz: int
images_to_save: int images_to_save: int
min_images_to_test: int
autofocus_dz: int
images_dir: str
save_resolution: tuple[int, int]
# Using docstrings under variables as this is how pdoc would expect # Using docstrings under variables as this is how pdoc would expect
# attributed to be documented # attributed to be documented
@ -49,6 +59,12 @@ class SmartStackParams(BaseModel):
settling_time: float = 0.3 settling_time: float = 0.3
"""Time (in seconds) between moving and capturing an image""" """Time (in seconds) between moving and capturing an image"""
class SmartStackParams(StackParams):
"""A class for holding smart stack parameters, and returning computed ones."""
min_images_to_test: int
backlash_correction: int = 250 backlash_correction: int = 250
""" """
Distance (in steps) to overshoot a move and then undo, to account for backlash Distance (in steps) to overshoot a move and then undo, to account for backlash
@ -457,6 +473,8 @@ class AutofocusThing(lt.Thing):
def run_smart_stack( def run_smart_stack(
self, self,
stack_parameters: SmartStackParams, stack_parameters: SmartStackParams,
capture_parameters: CaptureParams,
autofocus_parameters: AutofocusParams,
save_on_failure: bool = False, save_on_failure: bool = False,
check_turning_points: bool = True, check_turning_points: bool = True,
) -> tuple[bool, int]: ) -> tuple[bool, int]:
@ -498,7 +516,7 @@ class AutofocusThing(lt.Thing):
initial_z_pos = captures[0].position["z"] initial_z_pos = captures[0].position["z"]
# If a stack is not successful, move to the start and autofocus # If a stack is not successful, move to the start and autofocus
try: try:
self.reset_stack(initial_z_pos, stack_parameters.autofocus_dz) self.reset_stack(initial_z_pos, autofocus_parameters.dz)
except NoFocusFoundError: except NoFocusFoundError:
break break
@ -510,6 +528,7 @@ class AutofocusThing(lt.Thing):
sharpest_id=sharpest_id, sharpest_id=sharpest_id,
captures=captures, captures=captures,
stack_parameters=stack_parameters, stack_parameters=stack_parameters,
capture_parameters=capture_parameters,
) )
return success, _get_capture_by_id(captures, sharpest_id).position["z"] return success, _get_capture_by_id(captures, sharpest_id).position["z"]
@ -535,6 +554,7 @@ class AutofocusThing(lt.Thing):
sharpest_id: int, sharpest_id: int,
captures: list[CaptureInfo], captures: list[CaptureInfo],
stack_parameters: SmartStackParams, stack_parameters: SmartStackParams,
capture_parameters: CaptureParams,
) -> int: ) -> int:
"""Save the required captures to disk. """Save the required captures to disk.
@ -552,8 +572,8 @@ class AutofocusThing(lt.Thing):
# Loop through the range, saving each capture to disk # Loop through the range, saving each capture to disk
for capture in captures[slice_to_save]: for capture in captures[slice_to_save]:
self._cam.save_from_memory( self._cam.save_from_memory(
jpeg_path=os.path.join(stack_parameters.images_dir, capture.filename), jpeg_path=os.path.join(capture_parameters.images_dir, capture.filename),
save_resolution=stack_parameters.save_resolution, save_resolution=capture_parameters.save_resolution,
buffer_id=capture.buffer_id, buffer_id=capture.buffer_id,
) )
self._cam.clear_buffers() self._cam.clear_buffers()
@ -712,6 +732,56 @@ class AutofocusThing(lt.Thing):
return "success", capture_id return "success", capture_id
@lt.action
def run_basic_stack(
self,
stack_parameters: StackParams,
capture_parameters: CaptureParams,
) -> tuple[int, list[int]]:
"""Capture a simple z-stack with no focus testing or fitting.
This performs a fixed stack of images spaced by `stack_dz`,
saving all images captured. No sharpness testing, restart
logic, or autofocus is performed.
:param stack_parameters: SmartStackParams defining stack spacing,
image count, directory, and save resolution.
:returns:
- Final z position
- List of z positions captured
"""
captures: list[CaptureInfo] = []
z_positions: list[int] = []
# Capture images_to_save images
for _ in range(stack_parameters.images_to_save):
time.sleep(stack_parameters.settling_time)
capture = self.capture_stack_image(
buffer_max=stack_parameters.images_to_save
)
captures.append(capture)
z_positions.append(capture.position["z"])
self._stage.move_relative(z=stack_parameters.stack_dz)
# Save all captures
for capture in captures:
self._cam.save_from_memory(
jpeg_path=os.path.join(
capture_parameters.images_dir,
capture.filename,
),
save_resolution=capture_parameters.save_resolution,
buffer_id=capture.buffer_id,
)
self._cam.clear_buffers()
final_z = self._stage.position["z"]
return final_z, z_positions
class NotAPeakError(lt.exceptions.InvocationError): class NotAPeakError(lt.exceptions.InvocationError):
"""The data to fit isn't a peak.""" """The data to fit isn't a peak."""

View file

@ -31,7 +31,9 @@ from openflexure_microscope_server.stitching import (
from openflexure_microscope_server.things.autofocus import ( from openflexure_microscope_server.things.autofocus import (
MAX_TEST_IMAGE_COUNT, MAX_TEST_IMAGE_COUNT,
MIN_TEST_IMAGE_COUNT, MIN_TEST_IMAGE_COUNT,
AutofocusParams,
AutofocusThing, AutofocusThing,
CaptureParams,
SmartStackParams, SmartStackParams,
) )
from openflexure_microscope_server.things.background_detect import ( from openflexure_microscope_server.things.background_detect import (
@ -101,7 +103,7 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
is returned if it is not possible to stitch the scan. is returned if it is not possible to stitch the scan.
""" """
raise NotImplementedError( raise NotImplementedError(
"Each specific ScanWorkflow must implement a `all_settings`. method." "Each specific ScanWorkflow must implement a `all_settings` method."
) )
def pre_scan_routine(self, settings: SettingModelType) -> None: def pre_scan_routine(self, settings: SettingModelType) -> None:
@ -239,6 +241,36 @@ class RectGridWorkflow(ScanWorkflow[SettingModelType], Generic[SettingModelType]
correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0], correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0],
) )
def all_settings(
self, images_dir: str
) -> tuple[SettingModelType, Optional[StitchingSettings]]:
"""Return the scan settings and the stitching settings.
- `images_dir` is used to create the CaptureParams stored in the settings model.
- The returned SettingModelType now contains param objects:
* capture_params: CaptureParams
* autofocus_params: AutofocusParams
* stack_params: StackParams (if relevant)
"""
stitching_settings = self._get_stitching_settings_model()
dx, dy = self._calc_displacement_from_overlap(self.overlap)
capture_params = CaptureParams(
images_dir=images_dir, save_resolution=self.save_resolution
)
autofocus_params = AutofocusParams(dz=self.autofocus_dz)
scan_settings = self._settings_model(
overlap=self.overlap,
dx=dx,
dy=dy,
capture_params=capture_params,
autofocus_params=autofocus_params,
)
return scan_settings, stitching_settings
@lt.property @lt.property
def ready(self) -> bool: def ready(self) -> bool:
"""Whether this scanworkflow is ready to start.""" """Whether this scanworkflow is ready to start."""
@ -257,6 +289,8 @@ class HistoScanSettingsModel(BaseModel):
dy: int dy: int
max_dist: int max_dist: int
skip_background: bool skip_background: bool
capture_params: CaptureParams
autofocus_params: AutofocusParams
smart_stack_params: SmartStackParams smart_stack_params: SmartStackParams
@ -361,7 +395,7 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
def all_settings( def all_settings(
self, images_dir: str self, images_dir: str
) -> tuple[HistoScanSettingsModel, StitchingSettings]: ) -> tuple[HistoScanSettingsModel, StitchingSettings]:
"""Return the workflow and stitching settings. """Return the workflow settings and stitching settings.
:param images_dir: The directory that images are to be written to. :param images_dir: The directory that images are to be written to.
:return: A tuple containing the settings model for this workflow and the :return: A tuple containing the settings model for this workflow and the
@ -370,11 +404,12 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
stitching_settings = self._get_stitching_settings_model() stitching_settings = self._get_stitching_settings_model()
dx, dy = self._calc_displacement_from_overlap(self.overlap) dx, dy = self._calc_displacement_from_overlap(self.overlap)
smart_stack_params = self.create_smart_stack_params( capture_params = CaptureParams(
images_dir=images_dir, images_dir=images_dir,
autofocus_dz=self.autofocus_dz,
save_resolution=self.save_resolution, save_resolution=self.save_resolution,
) )
autofocus_params = AutofocusParams(dz=self.autofocus_dz)
smart_stack_params = self.create_smart_stack_params()
scan_settings = HistoScanSettingsModel( scan_settings = HistoScanSettingsModel(
overlap=self.overlap, overlap=self.overlap,
@ -382,6 +417,8 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
dx=dx, dx=dx,
dy=dy, dy=dy,
skip_background=self.skip_background, skip_background=self.skip_background,
capture_params=capture_params,
autofocus_params=autofocus_params,
smart_stack_params=smart_stack_params, smart_stack_params=smart_stack_params,
) )
@ -389,15 +426,8 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
def create_smart_stack_params( def create_smart_stack_params(
self, self,
images_dir: str,
autofocus_dz: int,
save_resolution: tuple[int, int],
) -> SmartStackParams: ) -> SmartStackParams:
"""Set up the parameters used for all stacks in a scan. """Set up the parameters used for all smart stacks in a scan.
:param images_dir: the folder to save all images
:param autofocus_dz: the range to autofocus over if a stack fails
:param save_resolution: The resolution to save the captures to disk with
:returns: A StackSmartParams object with the required parameters. :returns: A StackSmartParams object with the required parameters.
""" """
@ -452,9 +482,6 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
stack_dz=self.stack_dz, stack_dz=self.stack_dz,
images_to_save=self.stack_images_to_save, images_to_save=self.stack_images_to_save,
min_images_to_test=self.stack_min_images_to_test, min_images_to_test=self.stack_min_images_to_test,
autofocus_dz=autofocus_dz,
images_dir=images_dir,
save_resolution=save_resolution,
) )
def pre_scan_routine(self, settings: HistoScanSettingsModel) -> None: def pre_scan_routine(self, settings: HistoScanSettingsModel) -> None:
@ -463,7 +490,7 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
:param settings: The settings for this scan as a HistoScanSettingsModel :param settings: The settings for this scan as a HistoScanSettingsModel
""" """
self._autofocus.looping_autofocus( self._autofocus.looping_autofocus(
dz=settings.smart_stack_params.autofocus_dz, start="centre" dz=settings.autofocus_params.dz, start="centre"
) )
def new_scan_planner( def new_scan_planner(
@ -518,6 +545,8 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
focus_height: Optional[int] focus_height: Optional[int]
focused, focus_height = self._autofocus.run_smart_stack( focused, focus_height = self._autofocus.run_smart_stack(
stack_parameters=settings.smart_stack_params, stack_parameters=settings.smart_stack_params,
capture_parameters=settings.capture_params,
autofocus_parameters=settings.autofocus_params,
save_on_failure=save_on_failure, save_on_failure=save_on_failure,
) )
# An image was captured if we are focussed or we are not skipping background. # An image was captured if we are focussed or we are not skipping background.
@ -575,9 +604,8 @@ class RegularGridSettingsModel(BaseModel):
x_count: int x_count: int
y_count: int y_count: int
style: Literal["snake", "raster"] style: Literal["snake", "raster"]
images_dir: str capture_params: CaptureParams
autofocus_dz: int autofocus_params: AutofocusParams
save_resolution: tuple[int, int]
class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]): class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
@ -604,6 +632,13 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
stitching_settings = self._get_stitching_settings_model() stitching_settings = self._get_stitching_settings_model()
dx, dy = self._calc_displacement_from_overlap(self.overlap) dx, dy = self._calc_displacement_from_overlap(self.overlap)
capture_params = CaptureParams(
images_dir=images_dir,
save_resolution=self.save_resolution,
)
autofocus_params = AutofocusParams(dz=self.autofocus_dz)
scan_settings = self._settings_model( scan_settings = self._settings_model(
overlap=self.overlap, overlap=self.overlap,
dx=dx, dx=dx,
@ -611,9 +646,8 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
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,
images_dir=images_dir, capture_params=capture_params,
autofocus_dz=self.autofocus_dz, autofocus_params=autofocus_params,
save_resolution=self.save_resolution,
) )
return scan_settings, stitching_settings return scan_settings, stitching_settings
@ -625,7 +659,9 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
:param settings: The settings for this scan as as the relevant SettingsModel type. :param settings: The settings for this scan as as the relevant SettingsModel type.
""" """
self._autofocus.looping_autofocus(dz=settings.autofocus_dz, start="centre") self._autofocus.looping_autofocus(
dz=settings.autofocus_params.dz, start="centre"
)
def new_scan_planner( def new_scan_planner(
self, settings: RegularGridSettingsModel, position: Mapping[str, int] self, settings: RegularGridSettingsModel, position: Mapping[str, int]
@ -659,9 +695,9 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
""" """
return self._autofocus_and_capture( return self._autofocus_and_capture(
xyz_pos=xyz_pos, xyz_pos=xyz_pos,
dz=settings.autofocus_dz, dz=settings.autofocus_params.dz,
images_dir=settings.images_dir, images_dir=settings.capture_params.images_dir,
save_resolution=settings.save_resolution, save_resolution=settings.capture_params.save_resolution,
) )
@lt.property @lt.property

View file

@ -130,7 +130,7 @@ def test_histo_workflow_settings_generation(histo_workflow, mocker):
assert workflow_settings.dx == 123 assert workflow_settings.dx == 123
assert workflow_settings.dy == 456 assert workflow_settings.dy == 456
# And that the input image dir is passed to stack the stack parameter for saving # And that the input image dir is passed to stack the stack parameter for saving
assert workflow_settings.smart_stack_params.images_dir == "/this/img_dir" assert workflow_settings.capture_params.images_dir == "/this/img_dir"
# A CSM that is "normal" changing from camera maxtrix coordinates (y,x) to normal # A CSM that is "normal" changing from camera maxtrix coordinates (y,x) to normal
@ -196,7 +196,7 @@ def test_histo_pre_scan_routine(histo_workflow, mocker):
# Rather than create a whole Setting class, just create a mock with the value # Rather than create a whole Setting class, just create a mock with the value
# we need set # we need set
mock_settings = mocker.Mock() mock_settings = mocker.Mock()
mock_settings.smart_stack_params.autofocus_dz = 1234 mock_settings.autofocus_params.dz = 1234
# Run the function # Run the function
histo_workflow.pre_scan_routine(mock_settings) histo_workflow.pre_scan_routine(mock_settings)
# Check the autofocus was run using the mocked slot. # Check the autofocus was run using the mocked slot.

View file

@ -66,12 +66,7 @@ def test_stack_params_validation(save_ims, extra_ims):
# to do automatically in hypothesis. This clamps the number between 3 and 9. # to do automatically in hypothesis. This clamps the number between 3 and 9.
min_images_to_test = max(min(save_ims + extra_ims, 9), 3) min_images_to_test = max(min(save_ims + extra_ims, 9), 3)
SmartStackParams( SmartStackParams(
stack_dz=50, stack_dz=50, images_to_save=save_ims, min_images_to_test=min_images_to_test
images_to_save=save_ims,
min_images_to_test=min_images_to_test,
autofocus_dz=2000,
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
) )
@ -96,9 +91,6 @@ def test_stack_params_not_enough_test_images(save_ims, extra_ims):
stack_dz=50, stack_dz=50,
images_to_save=save_ims, images_to_save=save_ims,
min_images_to_test=save_ims + extra_ims, min_images_to_test=save_ims + extra_ims,
autofocus_dz=2000,
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
) )
@ -121,9 +113,6 @@ def test_stack_params_negative_images_to_save(save_ims, extra_ims):
stack_dz=50, stack_dz=50,
images_to_save=save_ims, images_to_save=save_ims,
min_images_to_test=save_ims + extra_ims, min_images_to_test=save_ims + extra_ims,
autofocus_dz=2000,
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
) )
@ -147,9 +136,6 @@ def test_even_min_images_to_test(save_ims, extra_ims):
stack_dz=50, stack_dz=50,
images_to_save=save_ims, images_to_save=save_ims,
min_images_to_test=save_ims + extra_ims, min_images_to_test=save_ims + extra_ims,
autofocus_dz=2000,
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
) )
@ -171,9 +157,6 @@ def test_even_images_to_save(save_ims, extra_ims):
stack_dz=50, stack_dz=50,
images_to_save=save_ims, images_to_save=save_ims,
min_images_to_test=save_ims + extra_ims, min_images_to_test=save_ims + extra_ims,
autofocus_dz=2000,
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
) )
@ -183,12 +166,7 @@ def test_computed_stack_params():
Not using hypothesis or we will just copy in the same formulas. Not using hypothesis or we will just copy in the same formulas.
""" """
stack_parameters = SmartStackParams( stack_parameters = SmartStackParams(
stack_dz=50, stack_dz=50, images_to_save=5, min_images_to_test=9
images_to_save=5,
min_images_to_test=9,
autofocus_dz=2000,
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
) )
assert stack_parameters.stack_z_range == 8 * 50 assert stack_parameters.stack_z_range == 8 * 50
@ -279,9 +257,7 @@ def test_create_stack(histo_scan_workflow, caplog):
initial_min_images_to_test = histo_scan_workflow.stack_min_images_to_test initial_min_images_to_test = histo_scan_workflow.stack_min_images_to_test
initial_images_to_save = histo_scan_workflow.stack_images_to_save initial_images_to_save = histo_scan_workflow.stack_images_to_save
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
stack_params = histo_scan_workflow.create_smart_stack_params( stack_params = histo_scan_workflow.create_smart_stack_params()
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
)
assert len(caplog.records) == 0 assert len(caplog.records) == 0
assert histo_scan_workflow.stack_min_images_to_test == initial_min_images_to_test assert histo_scan_workflow.stack_min_images_to_test == initial_min_images_to_test
@ -308,9 +284,7 @@ def test_coercing_stack_test_ims(
histo_scan_workflow.stack_min_images_to_test = initial_test_ims histo_scan_workflow.stack_min_images_to_test = initial_test_ims
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
stack_params = histo_scan_workflow.create_smart_stack_params( stack_params = histo_scan_workflow.create_smart_stack_params()
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
)
assert len(caplog.records) == 1 assert len(caplog.records) == 1
assert str(caplog.records[0].msg).startswith(expected_log_start) assert str(caplog.records[0].msg).startswith(expected_log_start)
@ -338,9 +312,7 @@ def test_coercing_stack_save_ims(
histo_scan_workflow.stack_images_to_save = initial_save_ims histo_scan_workflow.stack_images_to_save = initial_save_ims
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
stack_params = histo_scan_workflow.create_smart_stack_params( stack_params = histo_scan_workflow.create_smart_stack_params()
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
)
assert len(caplog.records) == 1 assert len(caplog.records) == 1
assert str(caplog.records[0].msg).startswith(expected_log_start) assert str(caplog.records[0].msg).startswith(expected_log_start)
@ -353,9 +325,7 @@ def test_coercing_stack_save_ims(
@pytest.mark.parametrize("pass_on", [1, 2, 3, 4]) @pytest.mark.parametrize("pass_on", [1, 2, 3, 4])
def test_run_smart_stack(pass_on, histo_scan_workflow, autofocus_thing, mocker): def test_run_smart_stack(pass_on, histo_scan_workflow, autofocus_thing, mocker):
"""Test Running smart stack with the stack passing on different attempts.""" """Test Running smart stack with the stack passing on different attempts."""
stack_params = histo_scan_workflow.create_smart_stack_params( stack_params = histo_scan_workflow.create_smart_stack_params()
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
)
assert stack_params.max_attempts == 3 assert stack_params.max_attempts == 3
# Set up returns from z-stack # Set up returns from z-stack
@ -417,9 +387,7 @@ def setup_and_run_smart_z_stack(
is a list, it will be set as a side effect (and should be a list of tuples of is a list, it will be set as a side effect (and should be a list of tuples of
results). If it a tuple (or anything else), it is set as a return value. results). If it a tuple (or anything else), it is set as a return value.
""" """
stack_params = histo_scan_workflow.create_smart_stack_params( stack_params = histo_scan_workflow.create_smart_stack_params()
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
)
stack_params.settling_time = 0 # Don't settle or tests take forever. stack_params.settling_time = 0 # Don't settle or tests take forever.
autofocus_thing.capture_stack_image = mocker.Mock() autofocus_thing.capture_stack_image = mocker.Mock()