Merge branch 'v3' into fast-stack, with conflict resolution but not fully

working yet
This commit is contained in:
Julian Stirling 2025-06-26 11:50:24 +01:00
commit fda0a6e1ab
20 changed files with 2167 additions and 366 deletions

View file

@ -6,7 +6,6 @@ camera together to perform an autofocus routine.
See repository root for licensing information.
"""
from __future__ import annotations
from contextlib import contextmanager
import logging
import time
@ -31,45 +30,64 @@ from .capture import RawCaptureDependency as CaptureDep
class StackParams(BaseModel):
"""Pydantic model for scan parameters"""
"""Pydantic model for scan parameters
# These are generic settings that seem to apply well to a standard scan
:param stack_dz: The number of motor steps between images
:param images_to_save: The number of images to save to disk
:param min_images_to_test: The minimum number of images in the stack before, the
stack is evaluated for focus. As more images are captured evaluation of the focus
is always evaluated with the same number of images. i.e. if min_images_to_test=9,
then 9 images are captured, if the stack is not well focussed, a 10th image is
captured and images 2 to 10 are evaluated for focus
:param autofocus_dz: The number of steps in a full autofocus (if required)
"""
# These parameters must be set on initialisation
stack_dz: int
images_to_save: int
min_images_to_test: int
autofocus_dz: int
# The following parameters have values that apply well to a standard scan
# and are not altered by default when running a scan
# time (in seconds) between moving and capturing an image
settling_time: float = 0.3
# distance (in steps) to overshoot a move and then undo, to account for backlash
backlash_correction: int = 250
# how many images can be appended to the stack after the predicted peak to test for focus
# before assuming the focus was passed, and restarting the stack
# how many images can be appended to the stack after the predicted peak to test
# for focus before assuming the focus was passed, and restarting the stack
stack_height_limit: int = 15
# how far below (in factors of stack_dz) the estimated optimal starting position to
# begin the stack. Better to start slightly too low and require many images, rather than
# too high and needing to autofocus and restart the stack
# begin the stack. Better to start slightly too low and require many images, rather
# than too high and needing to autofocus and restart the stack
img_undershoot: int = 5
# These we expect to be overwritten by AutofocusThing properties
stack_dz: int = 50
images_to_capture: int = 1
images_to_test: int = 5
autofocus_dz: int = 2000
# Per pydantic docs, even with the @property applied before @computed_field,
# mypy may throw a Decorated property not supported error (mypy issue #1362)
# To avoid this error message, add # type: ignore[prop-decorator] to the @computed_field line.
# To avoid this error message, add # type: ignore[prop-decorator] to the
# @computed_field line.
@computed_field
@property
def stack_z_range(self) -> int:
"""The range of the entire z stack, in steps"""
return self.stack_dz * (self.images_to_test - 1)
"""The range of the z stack, in steps
Note that this is the range of the minimum number of image captured,
which is also the range of the images sored in memory that can be
saved."""
return self.stack_dz * (self.min_images_to_test - 1)
@computed_field
@property
def steps_undershoot(self) -> int:
"""The distance to deliberately undershoot the estimated optimal starting point"""
"""
The distance to deliberately undershoot the estimated optimal starting point
"""
# Starting too low by "steps_undershoot" makes smart stacking faster.
# Starting a stack too high requires it to move to the start,
@ -77,10 +95,24 @@ class StackParams(BaseModel):
# requires extra +z movements and captures.
return self.stack_dz * self.img_undershoot
@computed_field
@property
def max_images_to_test(self) -> int
"""The maximum number of images that will be captured and tested in a stack
This is 15 images more then the minimum number that are captured.
"""
return self.min_images_to_test
class SharpnessDataArrays(BaseModel):
jpeg_times: NDArray
jpeg_sizes: NDArray
stage_times: NDArray
stage_positions: list[dict[str, int]]
class JPEGSharpnessMonitor:
__globals__ = globals() # Required for FastAPI dependency
def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal):
self.camera = camera
self.stage = stage
@ -156,9 +188,7 @@ class JPEGSharpnessMonitor:
stop = len(jpeg_times)
logging.debug("changing stop to %s", (stop))
jpeg_times = jpeg_times[start:stop]
jpeg_zs: np.ndarray = np.interp(
jpeg_times, stage_times, stage_zs
) # np.ndarray[float]
jpeg_zs: np.ndarray = np.interp(jpeg_times, stage_times, stage_zs)
return jpeg_times, jpeg_zs, jpeg_sizes[start:stop]
def sharpest_z_on_move(self, index: int) -> int:
@ -181,13 +211,6 @@ class JPEGSharpnessMonitor:
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
class SharpnessDataArrays(BaseModel):
jpeg_times: NDArray
jpeg_sizes: NDArray
stage_times: NDArray
stage_positions: list[dict[str, int]]
class AutofocusThing(Thing):
"""The Thing concerned with combinations of z axis movements and the camera.
@ -298,24 +321,24 @@ class AutofocusThing(Thing):
return heights.tolist(), sizes.tolist()
@thing_property
def stack_images_to_capture(self) -> int:
def stack_images_to_save(self) -> int:
"""The number of images to capture and save in a stack
Defaults to 1 unless you need to see either side of focus"""
return self.thing_settings.get("stack_images_to_capture", 1)
return self.thing_settings.get("stack_images_to_save", 1)
@stack_images_to_capture.setter
def stack_images_to_capture(self, value: int) -> None:
self.thing_settings["stack_images_to_capture"] = value
@stack_images_to_save.setter
def stack_images_to_save(self, value: int) -> None:
self.thing_settings["stack_images_to_save"] = value
@thing_property
def stack_images_to_test(self) -> int:
def stack_min_images_to_test(self) -> int:
"""The number of images to test for successful focusing in a stack
Defaults to 9, which balances reliability and speed"""
return self.thing_settings.get("stack_images_to_test", 9)
return self.thing_settings.get("stack_min_images_to_test", 9)
@stack_images_to_test.setter
def stack_images_to_test(self, value: int) -> None:
self.thing_settings["stack_images_to_test"] = value
@stack_min_images_to_test.setter
def stack_min_images_to_test(self, value: int) -> None:
self.thing_settings["stack_min_images_to_test"] = value
@thing_property
def stack_dz(self) -> int:
@ -336,10 +359,10 @@ class AutofocusThing(Thing):
stage: Stage,
logger: InvocationLogger,
metadata_getter: GetThingStates,
capture: CaptureDep,
sharpness_monitor: SharpnessMonitorDep,
images_dir: str,
autofocus_dz: int,
capture_resolution: tuple[int, int],
) -> None:
"""Run a smart stack, which captures images offset in z, testing
whether the sharpest image is towards the centre of the stack.
@ -355,8 +378,8 @@ class AutofocusThing(Thing):
# Set the variables to prevent changes from the GUI or other windows
stack_parameters = StackParams(
stack_dz=self.stack_dz,
images_to_capture=self.stack_images_to_capture,
images_to_test=self.stack_images_to_test,
images_to_save=self.stack_images_to_save,
min_images_to_test=self.stack_min_images_to_test,
autofocus_dz=autofocus_dz,
)
@ -371,7 +394,6 @@ class AutofocusThing(Thing):
stack_parameters,
stage,
cam,
capture,
metadata_getter,
)
@ -393,11 +415,10 @@ class AutofocusThing(Thing):
captures,
stack_parameters,
logger,
capture,
)
# Return the z position of the sharpest image, for path planning and tracking
return heights[-stack_parameters.images_to_test :][sharpest_index]
return heights[-stack_parameters.min_images_to_test :][sharpest_index]
def reset_stack(
self,
@ -427,31 +448,26 @@ class AutofocusThing(Thing):
captures: list[list],
stack_parameters: StackParams,
logger: InvocationLogger,
capture: CaptureDep,
) -> int:
"""Save the required captures to disk. Will save the sharpest image,
and any images either side of focus.
Arguments:
sharpest_index: the index of the sharpest image, within the "images_to_test" slice
sharpest_index: the index of the sharpest image, within the "min_images_to_test" slice
captures: a list of captures, including file name, image data and metadata
stack_parameters: a StackParams Pydantic model with stack settings
variables logger and capture are Thing dependencies passed through from the calling action
"""
# Find the range of images from the stack to capture
stack_extent = int((stack_parameters.images_to_capture - 1) / 2)
stack_range = range(
sharpest_index - stack_extent, sharpest_index + stack_extent + 1
)
# Loop through the range, saving each capture to disk
for capture_index in stack_range:
capture._save_capture(
jpeg_path=captures[-stack_parameters.images_to_test :][capture_index][
jpeg_path=captures[-stack_parameters.min_images_to_test :][capture_index][
0
],
image=captures[-stack_parameters.images_to_test :][capture_index][1],
metadata=captures[-stack_parameters.images_to_test :][capture_index][2],
image=captures[-stack_parameters.min_images_to_test :][capture_index][1],
metadata=captures[-stack_parameters.min_images_to_test :][capture_index][2],
logger=logger,
)
return sharpest_index
@ -493,7 +509,7 @@ class AutofocusThing(Thing):
# If the sharpest image isn't found within the 15 images above the estimated point, break
# the loop and return "restart"
while len(captures) <= stack_parameters.images_to_test + 15:
while len(captures) <= stack_parameters.min_images_to_test + 15:
time.sleep(stack_parameters.settling_time)
# Append a new image to the stack
@ -509,14 +525,14 @@ class AutofocusThing(Thing):
)
# If the number of images is enough to test, test them
if len(captures) >= stack_parameters.images_to_test:
if len(captures) >= stack_parameters.min_images_to_test:
stack_result = self.check_stack_result(
sharpnesses[-stack_parameters.images_to_test :]
sharpnesses[-stack_parameters.min_images_to_test :]
)
if stack_result == "success":
sharpest_index = np.argmax(
sharpnesses[-stack_parameters.images_to_test :]
sharpnesses[-stack_parameters.min_images_to_test :]
)
return "success", heights, captures, sharpest_index
@ -567,18 +583,18 @@ class AutofocusThing(Thing):
"""Check the stack settings are appropriate, and raise an error if not
Arguments:
images_to_test: number of images in the stack to test for focus
images_to_capture: number of images to be captured around the focused image
min_images_to_test: number of images in the stack to test for focus
images_to_save: number of images to be captured around the focused image
"""
if stack_parameters.images_to_test < stack_parameters.images_to_capture:
if stack_parameters.min_images_to_test < stack_parameters.images_to_save:
raise RuntimeError(
"Can't capture more images than are tested. Please increase number to test, or decrease number to capture"
)
if stack_parameters.images_to_test % 2 == 0:
if stack_parameters.min_images_to_test % 2 == 0:
raise RuntimeError("Images to test should be odd")
if (
stack_parameters.images_to_test <= 0
or stack_parameters.images_to_capture <= 0
stack_parameters.min_images_to_test <= 0
or stack_parameters.images_to_save <= 0
):
raise RuntimeError("Stack parameters need to be at least 1")