Validate and test stack inputs

Update picamera hardware test
This commit is contained in:
jaknapper 2026-02-24 14:55:46 +00:00 committed by Julian Stirling
parent 78b407fb43
commit b211cfd4c5
3 changed files with 223 additions and 48 deletions

View file

@ -21,7 +21,7 @@ from pydantic import BaseModel, computed_field, field_validator, model_validator
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
from .camera import BaseCamera, CaptureParams from .camera import BaseCamera, CaptureParams, validate_capture_params
from .stage import BaseStage from .stage import BaseStage
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -756,13 +756,19 @@ class AutofocusThing(lt.Thing):
saving all images captured. No sharpness testing, restart saving all images captured. No sharpness testing, restart
logic, or autofocus is performed. logic, or autofocus is performed.
:param stack_parameters: SmartStackParams defining stack spacing, :param stack_parameters: StackParams defining stack spacing,
image count, directory, and save resolution. image count and backlash correction.
:param capture_parameters: CaptureParams defining save
resolution, images directory.
:returns: :returns:
- Final z position - Final z position
- List of z positions captured - List of z positions captured
""" """
# Validate parameters and raise Exception if unsuitable
validate_stack_params(stack_parameters=stack_parameters)
validate_capture_params(capture_parameters=capture_parameters)
captures: list[CaptureInfo] = [] captures: list[CaptureInfo] = []
z_positions: list[int] = [] z_positions: list[int] = []
@ -863,3 +869,24 @@ def _count_turning_points(sharpnesses: np.ndarray) -> int:
d_sharpnesses = d_sharpnesses[prominent] d_sharpnesses = d_sharpnesses[prominent]
# count the sign changes # count the sign changes
return int(np.sum(d_sharpnesses[1:] * d_sharpnesses[:-1] < 0)) return int(np.sum(d_sharpnesses[1:] * d_sharpnesses[:-1] < 0))
def validate_stack_params(stack_parameters: StackParams) -> None:
"""Validate stack parameters for a z-stack acquisition.
Ensures that the parameters allow a physical stack, without negative or zero
values where they would cause crashes.
:param stack_parameters: StackParams object containing stacking settings.
:raises ValueError: If any stack_parameters are found to be unusable.
"""
if stack_parameters.images_to_save <= 0:
raise ValueError(
f"Invalid number of images to save: {stack_parameters.images_to_save}. Must be > 0."
)
if stack_parameters.settling_time < 0:
raise ValueError(
f"Invalid settling time: {stack_parameters.settling_time}. Must be positive or 0."
)

View file

@ -55,6 +55,37 @@ class CaptureParams(BaseModel):
save_resolution: tuple[int, int] save_resolution: tuple[int, int]
def validate_capture_params(capture_parameters: CaptureParams) -> None:
"""Validate capture parameters for a capture.
Ensures that the save resolution is a tuple of two positive integers
and that the images directory is a non-empty string.
:param capture_parameters: CaptureParams object containing acquisition settings.
:raises ValueError: If `save_resolution` is not a tuple of two positive integers,
or if `images_dir` is not a non-empty string.
"""
if (
not isinstance(capture_parameters.save_resolution, tuple)
or len(capture_parameters.save_resolution) != 2
or not all(
isinstance(x, int) and x > 0 for x in capture_parameters.save_resolution
)
):
raise ValueError(
f"Invalid save_resolution: {capture_parameters.save_resolution}. Must be a tuple of two positive integers."
)
if (
not isinstance(capture_parameters.images_dir, str)
or not capture_parameters.images_dir
):
raise ValueError(
f"Invalid images_dir: {capture_parameters.images_dir}. Must be a non-empty string."
)
class NoImageInMemoryError(RuntimeError): class NoImageInMemoryError(RuntimeError):
"""An error called if no image is in memory when accessed.""" """An error called if no image is in memory when accessed."""

View file

@ -706,7 +706,8 @@ def test_run_basic_stack_simple(
capture_params.save_resolution = (100, 100) capture_params.save_resolution = (100, 100)
# Reset stage position # Reset stage position
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": 0} start_z = 0
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
# Patch capture and stage movement # Patch capture and stage movement
autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture) autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture)
@ -719,23 +720,28 @@ def test_run_basic_stack_simple(
capture_parameters=capture_params, capture_parameters=capture_params,
) )
# Expect 3 captures from the stack at Z = 0, 10, 20 # Expected Z positions for the stack
assert z_positions == [0, 10, 20], ( expected_z_positions = [
start_z + i * stack_params.stack_dz for i in range(stack_params.images_to_save)
]
assert z_positions == expected_z_positions, (
"Z positions captured do not match expected values" "Z positions captured do not match expected values"
) )
assert final_z == 20, "Final Z position is incorrect"
# Check that capture_stack_image was called exactly 3 times # Final Z should be the last captured Z
assert autofocus_thing.capture_stack_image.call_count == 3, ( expected_final_z = expected_z_positions[-1]
"Incorrect number of captures" assert final_z == expected_final_z, "Final Z position is incorrect"
)
# Check that stage moved correctly (should match z_positions relative increments) # Check that capture_stack_image was called exactly images_to_save times
assert (
autofocus_thing.capture_stack_image.call_count == stack_params.images_to_save
), "Incorrect number of captures"
# Check that stage moved correctly (should match relative increments)
moves = [ moves = [
call.kwargs["z"] for call in autofocus_thing._stage.move_relative.call_args_list call.kwargs["z"] for call in autofocus_thing._stage.move_relative.call_args_list
] ]
# Two moves in the stack, no other movement as stack began from the starting position expected_moves = [stack_params.stack_dz] * (stack_params.images_to_save - 1)
expected_moves = [stack_params.stack_dz, stack_params.stack_dz]
assert moves == expected_moves, ( assert moves == expected_moves, (
"Stage move_relative calls do not match expected increments" "Stage move_relative calls do not match expected increments"
) )
@ -761,12 +767,12 @@ def test_run_basic_stack_center_origin(
capture_params.images_dir = "dummy" capture_params.images_dir = "dummy"
capture_params.save_resolution = (100, 100) capture_params.save_resolution = (100, 100)
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": 0} start_z = 0
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
# Patch capture and stage movement # Patch capture and stage movement
autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture) 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._stage.move_relative = mocker.Mock(side_effect=fake_move_relative)
autofocus_thing._cam.save_from_memory = mocker.Mock() autofocus_thing._cam.save_from_memory = mocker.Mock()
autofocus_thing._cam.clear_buffers = mocker.Mock() autofocus_thing._cam.clear_buffers = mocker.Mock()
@ -776,7 +782,7 @@ def test_run_basic_stack_center_origin(
capture_parameters=capture_params, capture_parameters=capture_params,
) )
# Calculate expected starting offset - moving down by half the stack height # Calculate expected starting offset - half of stack range
total_range = stack_params.stack_dz * (stack_params.images_to_save - 1) total_range = stack_params.stack_dz * (stack_params.images_to_save - 1)
expected_offset = -total_range // 2 expected_offset = -total_range // 2
@ -785,14 +791,24 @@ def test_run_basic_stack_center_origin(
assert first_call.kwargs["z"] == expected_offset, ( assert first_call.kwargs["z"] == expected_offset, (
"Center origin offset not applied correctly" "Center origin offset not applied correctly"
) )
# Check 5 images captured, at expected heights
assert z_positions == [-20, -10, 0, 10, 20], ( # Expected Z positions after CENTER offset
expected_z_positions = [
expected_offset + i * stack_params.stack_dz
for i in range(stack_params.images_to_save)
]
assert z_positions == expected_z_positions, (
"Z positions captured do not match expected values" "Z positions captured do not match expected values"
) )
assert final_z == 20, "Final Z position is incorrect"
# Final Z should be last captured Z
expected_final_z = expected_z_positions[-1]
assert final_z == expected_final_z, "Final Z position is incorrect"
def test_run_basic_stack_backlash_applied(autofocus_thing, mocker, fake_capture): 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.""" """Backlash correction should overshoot first, then move back before starting stack."""
stack_params = StackParams( stack_params = StackParams(
stack_dz=10, stack_dz=10,
@ -801,14 +817,16 @@ def test_run_basic_stack_backlash_applied(autofocus_thing, mocker, fake_capture)
backlash_correction=50, backlash_correction=50,
origin=StackOrigin.START, origin=StackOrigin.START,
) )
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": 0}
capture_params = mocker.Mock() capture_params = mocker.Mock()
capture_params.images_dir = "dummy" capture_params.images_dir = "dummy"
capture_params.save_resolution = (100, 100) 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.capture_stack_image = mocker.Mock(side_effect=fake_capture)
autofocus_thing._stage.move_relative = mocker.Mock() autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative)
autofocus_thing._cam.save_from_memory = mocker.Mock() autofocus_thing._cam.save_from_memory = mocker.Mock()
autofocus_thing._cam.clear_buffers = mocker.Mock() autofocus_thing._cam.clear_buffers = mocker.Mock()
@ -817,13 +835,19 @@ def test_run_basic_stack_backlash_applied(autofocus_thing, mocker, fake_capture)
calls = autofocus_thing._stage.move_relative.call_args_list calls = autofocus_thing._stage.move_relative.call_args_list
# First move is overshoot for backlash # First move is overshoot for backlash
assert calls[0].kwargs["z"] == -50, "Backlash overshoot not applied correctly" assert calls[0].kwargs["z"] == -stack_params.backlash_correction, (
"Backlash overshoot not applied correctly"
)
# Second move corrects back to start # Second move corrects back to starting point
assert calls[1].kwargs["z"] == 50, "Backlash correction move not applied correctly" 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): def test_run_basic_stack_backlash_and_offset(
autofocus_thing, mocker, fake_capture, fake_move_relative
):
"""Backlash correction and stack offset both applied. """Backlash correction and stack offset both applied.
Stack should begin by moving down by half the height of the stack, Stack should begin by moving down by half the height of the stack,
@ -836,33 +860,46 @@ def test_run_basic_stack_backlash_and_offset(autofocus_thing, mocker, fake_captu
backlash_correction=50, backlash_correction=50,
origin=StackOrigin.CENTER, origin=StackOrigin.CENTER,
) )
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": 0}
capture_params = mocker.Mock() capture_params = mocker.Mock()
capture_params.images_dir = "dummy" capture_params.images_dir = "dummy"
capture_params.save_resolution = (100, 100) 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.capture_stack_image = mocker.Mock(side_effect=fake_capture)
autofocus_thing._stage.move_relative = mocker.Mock() autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative)
autofocus_thing._cam.save_from_memory = mocker.Mock() autofocus_thing._cam.save_from_memory = mocker.Mock()
autofocus_thing._cam.clear_buffers = mocker.Mock() autofocus_thing._cam.clear_buffers = mocker.Mock()
# Run stack
autofocus_thing.run_basic_stack(stack_params, capture_params) autofocus_thing.run_basic_stack(stack_params, capture_params)
calls = autofocus_thing._stage.move_relative.call_args_list calls = autofocus_thing._stage.move_relative.call_args_list
# First move is overshoot for backlash and stack offset # Combined initial offset + backlash overshoot
assert calls[0].kwargs["z"] == -150, "Combined overshoot not applied correctly" 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"
)
# Second move corrects back to base of stack # Backlash correction back to base of stack
assert calls[1].kwargs["z"] == 50, "Backlash correction move not applied correctly" assert calls[1].kwargs["z"] == stack_params.backlash_correction, (
"Backlash correction move not applied correctly"
)
# Third move is the first move in the stack # Subsequent moves in stack
assert calls[2].kwargs["z"] == 100, "Backlash correction move not applied correctly" 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): def test_run_basic_stack_end_origin(
autofocus_thing, mocker, fake_capture, fake_move_relative
):
"""END origin should shift stack down by full stack height before starting.""" """END origin should shift stack down by full stack height before starting."""
stack_params = StackParams( stack_params = StackParams(
stack_dz=10, stack_dz=10,
@ -871,34 +908,114 @@ def test_run_basic_stack_end_origin(autofocus_thing, mocker, fake_capture):
backlash_correction=0, backlash_correction=0,
origin=StackOrigin.END, origin=StackOrigin.END,
) )
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": 0}
capture_params = mocker.Mock() capture_params = mocker.Mock()
capture_params.images_dir = "dummy" capture_params.images_dir = "dummy"
capture_params.save_resolution = (100, 100) 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.capture_stack_image = mocker.Mock(side_effect=fake_capture)
autofocus_thing._stage.move_relative = mocker.Mock() autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative)
autofocus_thing._cam.save_from_memory = mocker.Mock() autofocus_thing._cam.save_from_memory = mocker.Mock()
autofocus_thing._cam.clear_buffers = mocker.Mock() autofocus_thing._cam.clear_buffers = mocker.Mock()
autofocus_thing.run_basic_stack(stack_params, capture_params) autofocus_thing.run_basic_stack(stack_params, capture_params)
# Calculate the stack offset based on StackOrigin.END - moving down by the full stack height # Calculate the stack offset based on StackOrigin.END
total_range = stack_params.stack_dz * (stack_params.images_to_save - 1) total_range = stack_params.stack_dz * (stack_params.images_to_save - 1)
expected_first_move = -total_range
# First move should shift fully down
first_call = autofocus_thing._stage.move_relative.call_args_list[0] first_call = autofocus_thing._stage.move_relative.call_args_list[0]
assert first_call.kwargs["z"] == -total_range, ( assert first_call.kwargs["z"] == expected_first_move, (
"End origin offset not applied correctly" "End origin offset not applied correctly"
) )
# Check number of captures # Check number of captures
assert autofocus_thing.capture_stack_image.call_count == 4, ( assert (
"Incorrect number of captures for END origin" autofocus_thing.capture_stack_image.call_count == stack_params.images_to_save
) ), "Incorrect number of captures for END origin"
# Check final Z is equal to starting Z, as StackOrigin.END should finish at the starting height # Check final Z is equal to starting Z
final_z = autofocus_thing._stage.position["z"] final_z = autofocus_thing._stage.position["z"]
expected_final_z = 0 expected_final_z = start_z
assert final_z == expected_final_z, "Final Z position for END origin incorrect" assert final_z == expected_final_z, "Final Z position for END origin incorrect"
def test_invalid_stack_images_raises(autofocus_thing, mocker):
"""Test basic stack raises expected error for negative or zero image count."""
for capture_count in [-3, 0]:
stack_params = StackParams(
stack_dz=10,
images_to_save=capture_count,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.START,
)
capture_params = mocker.Mock()
with pytest.raises(ValueError, match="Invalid number of images to save"):
autofocus_thing.run_basic_stack(stack_params, capture_params)
def test_invalid_stack_settling_raises(autofocus_thing, mocker):
"""Test basic stack raises expected error for negative settling time."""
stack_params = StackParams(
stack_dz=10,
images_to_save=1,
settling_time=-10,
backlash_correction=0,
origin=StackOrigin.START,
)
capture_params = mocker.Mock()
with pytest.raises(ValueError, match="Invalid settling time"):
autofocus_thing.run_basic_stack(stack_params, capture_params)
def test_invalid_capture_dir_raises(autofocus_thing, mocker):
"""Test basic stack raises expected error for bad image dir paths."""
for bad_path in ["", None, 67, ["path"]]:
stack_params = StackParams(
stack_dz=10,
images_to_save=1,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.START,
)
capture_params = mocker.Mock()
capture_params.images_dir = bad_path
capture_params.save_resolution = (100, 100)
mocker.patch.object(autofocus_thing._cam, "save_from_memory")
mocker.patch.object(autofocus_thing._cam, "clear_buffers")
with pytest.raises(ValueError, match="Invalid images_dir"):
autofocus_thing.run_basic_stack(stack_params, capture_params)
def test_invalid_capture_res_raises(autofocus_thing, mocker):
"""Test basic stack raises expected error for invalid save resolutions."""
for bad_res in [
"",
None,
67,
["path"],
(-100, 50),
(20, 0),
(20, 20, 20),
[30, 30],
]:
stack_params = StackParams(
stack_dz=10,
images_to_save=1,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.START,
)
capture_params = mocker.Mock()
capture_params.images_dir = "dummy"
capture_params.save_resolution = bad_res
autofocus_thing.capture_stack_image = mocker.Mock()
with pytest.raises(ValueError, match="Invalid save_resolution:"):
autofocus_thing.run_basic_stack(stack_params, capture_params)