Compete first (untested) pass of trying to make fast stack compatible with new memory captures
This commit is contained in:
parent
fda0a6e1ab
commit
61dd5e4958
3 changed files with 258 additions and 141 deletions
|
|
@ -9,8 +9,9 @@ See repository root for licensing information.
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Annotated, Mapping, Optional, Sequence
|
from typing import Annotated, Mapping, Optional, Sequence, Literal
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -26,7 +27,8 @@ from labthings_fastapi.dependencies.invocation import InvocationLogger
|
||||||
from .camera import RawCameraDependency as Camera
|
from .camera import RawCameraDependency as Camera
|
||||||
from .camera import CameraDependency as WrappedCamera
|
from .camera import CameraDependency as WrappedCamera
|
||||||
from .stage import StageDependency as Stage
|
from .stage import StageDependency as Stage
|
||||||
from .capture import RawCaptureDependency as CaptureDep
|
|
||||||
|
# TODO Capture reolution should be save resolution for consistency
|
||||||
|
|
||||||
|
|
||||||
class StackParams(BaseModel):
|
class StackParams(BaseModel):
|
||||||
|
|
@ -39,7 +41,9 @@ class StackParams(BaseModel):
|
||||||
is always evaluated with the same number of images. i.e. if min_images_to_test=9,
|
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
|
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
|
captured and images 2 to 10 are evaluated for focus
|
||||||
:param autofocus_dz: The number of steps in a full autofocus (if required)
|
:param autofocus_dz: The number of steps in a full autofocus (when required)
|
||||||
|
:param images_dir: The directory to save images to disk
|
||||||
|
:param capture_resolution: The resolution to save the captures to disk with
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -48,13 +52,15 @@ class StackParams(BaseModel):
|
||||||
images_to_save: int
|
images_to_save: int
|
||||||
min_images_to_test: int
|
min_images_to_test: int
|
||||||
autofocus_dz: int
|
autofocus_dz: int
|
||||||
|
images_dir: str
|
||||||
|
capture_resolution: tuple[int, int]
|
||||||
|
|
||||||
# The following parameters have values that apply well to a standard scan
|
# The following parameters have values that apply well to a standard scan
|
||||||
# and are not altered by default when running a scan
|
# and are not altered by default when running a scan
|
||||||
|
|
||||||
# time (in seconds) between moving and capturing an image
|
# time (in seconds) between moving and capturing an image
|
||||||
settling_time: float = 0.3
|
settling_time: float = 0.3
|
||||||
|
|
||||||
# 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
|
||||||
backlash_correction: int = 250
|
backlash_correction: int = 250
|
||||||
|
|
||||||
|
|
@ -76,7 +82,7 @@ class StackParams(BaseModel):
|
||||||
@property
|
@property
|
||||||
def stack_z_range(self) -> int:
|
def stack_z_range(self) -> int:
|
||||||
"""The range of the z stack, in steps
|
"""The range of the z stack, in steps
|
||||||
|
|
||||||
Note that this is the range of the minimum number of image captured,
|
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
|
which is also the range of the images sored in memory that can be
|
||||||
saved."""
|
saved."""
|
||||||
|
|
@ -97,13 +103,58 @@ class StackParams(BaseModel):
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def max_images_to_test(self) -> int
|
def max_images_to_test(self) -> int:
|
||||||
"""The maximum number of images that will be captured and tested in a stack
|
"""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.
|
This is 15 images more then the minimum number that are captured.
|
||||||
"""
|
"""
|
||||||
return self.min_images_to_test
|
return self.min_images_to_test
|
||||||
|
|
||||||
|
def slice_to_save(self, sharpest_index):
|
||||||
|
"""Return the slice of images to save given the index of the sharpest image"""
|
||||||
|
images_each_side = (self.images_to_save - 1) // 2
|
||||||
|
return slice(
|
||||||
|
sharpest_index - images_each_side, sharpest_index + images_each_side + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CaptureInfo:
|
||||||
|
"""
|
||||||
|
The information from a capture in a z_stack
|
||||||
|
"""
|
||||||
|
|
||||||
|
buffer_id: int
|
||||||
|
position: tuple[int, int, int]
|
||||||
|
sharpness: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filename(self) -> str:
|
||||||
|
"""The filename for this image generated from the poistion"""
|
||||||
|
return f"{self.position[0]}_{self.position[1]}_{self.position[2]}.jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_capture_by_id(captures: list[CaptureInfo], buffer_id: int) -> CaptureInfo:
|
||||||
|
"""Return the capture from a list of CaptureInfo objects with the matching id.
|
||||||
|
|
||||||
|
:param captures: A list of capture objects
|
||||||
|
:param buffer_id: The buffer id of the image to return
|
||||||
|
"""
|
||||||
|
return captures[_get_capture_index_by_id(captures, buffer_id)]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_capture_index_by_id(captures: list[CaptureInfo], buffer_id: int) -> int:
|
||||||
|
"""Return the index of the capture with the matching id from a list of CaptureInfo
|
||||||
|
objects
|
||||||
|
|
||||||
|
:param captures: A list of capture objects
|
||||||
|
:param buffer_id: The buffer id of the image to return
|
||||||
|
"""
|
||||||
|
ids = [capture.buffer_id for capture in captures]
|
||||||
|
if buffer_id not in ids:
|
||||||
|
raise ValueError(f"No capture has a buffer id of {buffer_id}")
|
||||||
|
return ids.index(buffer_id)
|
||||||
|
|
||||||
|
|
||||||
class SharpnessDataArrays(BaseModel):
|
class SharpnessDataArrays(BaseModel):
|
||||||
jpeg_times: NDArray
|
jpeg_times: NDArray
|
||||||
|
|
@ -372,7 +423,11 @@ class AutofocusThing(Thing):
|
||||||
Arguments:
|
Arguments:
|
||||||
images_dir: the folder to save all images
|
images_dir: the folder to save all images
|
||||||
autofocus_dz: should the stack fail, the range to refocus over before retrying
|
autofocus_dz: should the stack fail, the range to refocus over before retrying
|
||||||
variables cam to sharpness_monitor are Thing dependencies injected automatically by LabThings FastAPI
|
variables cam to sharpness_monitor are Thing dependencies injected automatically by
|
||||||
|
LabThings FastAPI
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The z position of the sharpest image
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Set the variables to prevent changes from the GUI or other windows
|
# Set the variables to prevent changes from the GUI or other windows
|
||||||
|
|
@ -381,6 +436,8 @@ class AutofocusThing(Thing):
|
||||||
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,
|
autofocus_dz=autofocus_dz,
|
||||||
|
images_dir=images_dir,
|
||||||
|
capture_resolution=capture_resolution,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure the stack settings are appropriate
|
# Ensure the stack settings are appropriate
|
||||||
|
|
@ -388,22 +445,23 @@ class AutofocusThing(Thing):
|
||||||
|
|
||||||
success = False
|
success = False
|
||||||
# Loop until a stack is successful
|
# Loop until a stack is successful
|
||||||
while not success:
|
while not success: # TODO add a maximum number?
|
||||||
result, heights, captures, sharpest_index = self.z_stack(
|
success, captures, sharpest_id = self.z_stack(
|
||||||
images_dir,
|
stack_parameters=stack_parameters,
|
||||||
stack_parameters,
|
stage=stage,
|
||||||
stage,
|
cam=cam,
|
||||||
cam,
|
logger=logger,
|
||||||
metadata_getter,
|
metadata_getter=metadata_getter,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result == "success":
|
if success:
|
||||||
success = True
|
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# The z position of the first images in the previous attempt.
|
||||||
|
initial_z_pos = captures[0].position[2]
|
||||||
# If a stack is not successful, move to the start and autofocus
|
# If a stack is not successful, move to the start and autofocus
|
||||||
self.reset_stack(
|
self.reset_stack(
|
||||||
heights,
|
initial_z_pos,
|
||||||
stack_parameters.autofocus_dz,
|
stack_parameters.autofocus_dz,
|
||||||
stage,
|
stage,
|
||||||
sharpness_monitor,
|
sharpness_monitor,
|
||||||
|
|
@ -411,31 +469,33 @@ class AutofocusThing(Thing):
|
||||||
|
|
||||||
# Save the sharpest image, and images either side of focus
|
# Save the sharpest image, and images either side of focus
|
||||||
self.save_stack(
|
self.save_stack(
|
||||||
sharpest_index,
|
sharpest_id=sharpest_id,
|
||||||
captures,
|
captures=captures,
|
||||||
stack_parameters,
|
stack_parameters=stack_parameters,
|
||||||
logger,
|
cam=cam,
|
||||||
|
logger=logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return the z position of the sharpest image, for path planning and tracking
|
# Return the z position of the sharpest image, for path planning and tracking
|
||||||
return heights[-stack_parameters.min_images_to_test :][sharpest_index]
|
return _get_capture_by_id(captures, sharpest_id).position[2]
|
||||||
|
|
||||||
def reset_stack(
|
def reset_stack(
|
||||||
self,
|
self,
|
||||||
heights: list[int],
|
initial_z_pos: list[int],
|
||||||
autofocus_dz: int,
|
autofocus_dz: int,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
sharpness_monitor: SharpnessMonitorDep,
|
sharpness_monitor: SharpnessMonitorDep,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Return to the initial height of the current stack, and run
|
"""Return to the initial height of the current stack, and run
|
||||||
a looping autofocus. Clears all previous captures, heights and sharpnesses.
|
a looping autofocus.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
heights: a list of the z positions of previous captures
|
initial_z_pos: The initial z positions of previous captures
|
||||||
autofocus_dz: the range in steps to autofocus
|
autofocus_dz: the range in steps to autofocus
|
||||||
variables stage and sharpness_monitor are Thing dependencies passed through from the calling action
|
variables stage and sharpness_monitor are Thing dependencies passed through from
|
||||||
|
the calling action
|
||||||
"""
|
"""
|
||||||
stage.move_absolute(z=heights[0])
|
stage.move_absolute(z=initial_z_pos)
|
||||||
self.looping_autofocus(
|
self.looping_autofocus(
|
||||||
stage=stage,
|
stage=stage,
|
||||||
sharpness_monitor=sharpness_monitor,
|
sharpness_monitor=sharpness_monitor,
|
||||||
|
|
@ -444,48 +504,52 @@ class AutofocusThing(Thing):
|
||||||
|
|
||||||
def save_stack(
|
def save_stack(
|
||||||
self,
|
self,
|
||||||
sharpest_index: int,
|
sharpest_id: int,
|
||||||
captures: list[list],
|
captures: list[list],
|
||||||
stack_parameters: StackParams,
|
stack_parameters: StackParams,
|
||||||
|
cam: WrappedCamera,
|
||||||
logger: InvocationLogger,
|
logger: InvocationLogger,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Save the required captures to disk. Will save the sharpest image,
|
"""Save the required captures to disk. Will save the sharpest image,
|
||||||
and any images either side of focus.
|
and any images either side of focus.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
sharpest_index: the index of the sharpest image, within the "min_images_to_test" slice
|
sharpest_id: the buffer id index of the sharpest image
|
||||||
captures: a list of captures, including file name, image data and metadata
|
captures: a list of captures, including file name, image data and metadata
|
||||||
stack_parameters: a StackParams Pydantic model with stack settings
|
stack_parameters: a StackParams Pydantic model with stack settings
|
||||||
variables logger and capture are Thing dependencies passed through from the calling action
|
variables logger and capture are Thing dependencies passed through from the
|
||||||
|
calling action
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
sharpest_index = _get_capture_index_by_id(sharpest_id)
|
||||||
|
slice_to_save = stack_parameters.slice_to_save(sharpest_index)
|
||||||
|
|
||||||
# Loop through the range, saving each capture to disk
|
# Loop through the range, saving each capture to disk
|
||||||
for capture_index in stack_range:
|
for capture in captures[slice_to_save]:
|
||||||
capture._save_capture(
|
cam.save_from_memory(
|
||||||
jpeg_path=captures[-stack_parameters.min_images_to_test :][capture_index][
|
jpeg_path=os.path.join(stack_parameters.images_dir, capture.filename),
|
||||||
0
|
|
||||||
],
|
|
||||||
image=captures[-stack_parameters.min_images_to_test :][capture_index][1],
|
|
||||||
metadata=captures[-stack_parameters.min_images_to_test :][capture_index][2],
|
|
||||||
logger=logger,
|
logger=logger,
|
||||||
|
save_resolution=stack_parameters.capture_resolution,
|
||||||
|
buffer_id=capture.buffer_id,
|
||||||
)
|
)
|
||||||
|
cam.clear_buffers()
|
||||||
return sharpest_index
|
return sharpest_index
|
||||||
|
|
||||||
def z_stack(
|
def z_stack(
|
||||||
self,
|
self,
|
||||||
images_dir: str,
|
|
||||||
stack_parameters: StackParams,
|
stack_parameters: StackParams,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
cam: WrappedCamera,
|
cam: WrappedCamera,
|
||||||
capture: CaptureDep,
|
logger: InvocationLogger,
|
||||||
metadata_getter: GetThingStates,
|
metadata_getter: GetThingStates,
|
||||||
) -> list:
|
) -> tuple[bool, list[CaptureInfo], Optional[int]]:
|
||||||
"""Capture a series of images offset by stack_parameters.stack_dz, and test whether
|
"""Capture a series of images offset by stack_parameters.stack_dz, and test whether
|
||||||
the sharpest image is towards the centre of the stack.
|
the sharpest image is towards the centre of the stack.
|
||||||
|
|
||||||
Returns a test result string, a list of z positions, a list of captures and the
|
Returns:
|
||||||
index of the sharpest image in a successful stack.
|
- the stack result (True for successful stack, False for failed stack),
|
||||||
|
- a list of CaptureInfo objects,
|
||||||
|
- the buffer_id of the shapest image (or None if the stack failed)
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
images_dir: a string of the path to write all images
|
images_dir: a string of the path to write all images
|
||||||
|
|
@ -504,80 +568,60 @@ class AutofocusThing(Thing):
|
||||||
stage.move_relative(z=stack_parameters.backlash_correction)
|
stage.move_relative(z=stack_parameters.backlash_correction)
|
||||||
|
|
||||||
captures = []
|
captures = []
|
||||||
sharpnesses = []
|
# Always check for focus using the the last `min_images_to_test` in the
|
||||||
heights = []
|
# stack so check is fair
|
||||||
|
ims_to_check = slice(-stack_parameters.min_images_to_test, None)
|
||||||
|
|
||||||
# If the sharpest image isn't found within the 15 images above the estimated point, break
|
# If the sharpest image isn't found within the maximum number of images
|
||||||
# the loop and return "restart"
|
# end the loop and return "restart"
|
||||||
while len(captures) <= stack_parameters.min_images_to_test + 15:
|
while len(captures) <= stack_parameters.max_images_to_test:
|
||||||
time.sleep(stack_parameters.settling_time)
|
time.sleep(stack_parameters.settling_time)
|
||||||
|
|
||||||
# Append a new image to the stack
|
# Append a new image to the stack
|
||||||
captures, heights, sharpnesses = self.capture_stack_image(
|
captures.append(
|
||||||
captures,
|
self.capture_stack_image(
|
||||||
heights,
|
cam,
|
||||||
sharpnesses,
|
stage,
|
||||||
images_dir,
|
logger,
|
||||||
cam,
|
metadata_getter,
|
||||||
stage,
|
buffer_max=stack_parameters.min_images_to_test,
|
||||||
capture,
|
)
|
||||||
metadata_getter,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# If the number of images is enough to test, test them
|
# If the number of images is enough to test, test them
|
||||||
if len(captures) >= stack_parameters.min_images_to_test:
|
if len(captures) >= stack_parameters.min_images_to_test:
|
||||||
stack_result = self.check_stack_result(
|
result, capture_id = self.check_stack_result(captures[ims_to_check])
|
||||||
sharpnesses[-stack_parameters.min_images_to_test :]
|
|
||||||
)
|
|
||||||
|
|
||||||
if stack_result == "success":
|
if result == "success":
|
||||||
sharpest_index = np.argmax(
|
return True, captures, capture_id
|
||||||
sharpnesses[-stack_parameters.min_images_to_test :]
|
|
||||||
)
|
|
||||||
return "success", heights, captures, sharpest_index
|
|
||||||
|
|
||||||
if stack_result == "restart":
|
|
||||||
return "restart", heights, None, None
|
|
||||||
|
|
||||||
|
if result == "restart":
|
||||||
|
return False, captures, None
|
||||||
|
# If reached here the result was "continue"
|
||||||
stage.move_relative(z=stack_parameters.stack_dz)
|
stage.move_relative(z=stack_parameters.stack_dz)
|
||||||
return "restart", heights, None, None
|
return False, captures, None
|
||||||
|
|
||||||
def capture_stack_image(
|
def capture_stack_image(
|
||||||
self,
|
self,
|
||||||
captures: list[list],
|
|
||||||
heights: list[int],
|
|
||||||
sharpnesses: list[int],
|
|
||||||
images_dir: str,
|
|
||||||
cam: WrappedCamera,
|
cam: WrappedCamera,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
capture: CaptureDep,
|
logger: InvocationLogger,
|
||||||
metadata_getter: GetThingStates,
|
metadata_getter: GetThingStates,
|
||||||
|
buffer_max: int,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Append a new capture to the ongoing stack.
|
"""Capture another image and return the capture information.
|
||||||
Includes appending the height, image sharpness and image
|
|
||||||
data to the relevant lists.
|
|
||||||
|
|
||||||
arguments:
|
The capture is stored by the camera Thing, and can be saved by ID.
|
||||||
captures: a list of captures, including file name, image data and metadata
|
|
||||||
heights: a list of the z positions of previous captures
|
|
||||||
sharpnesses: a list of the sharpnesses of previous captures
|
|
||||||
images_dir: a path to the folder to save images
|
|
||||||
variables stage to metadata_getter are Thing dependencies passed through from the calling action
|
|
||||||
"""
|
"""
|
||||||
stage_location = stage.position
|
stage_location = stage.position
|
||||||
jpeg_path = os.path.join(
|
buffer_id = cam.capture_to_memory(
|
||||||
images_dir,
|
logger, metadata_getter, buffer_max=buffer_max
|
||||||
f"{stage_location['x']}_{stage_location['y']}_{stage_location['z']}.jpeg",
|
|
||||||
)
|
)
|
||||||
image, metadata = capture._capture_image(
|
return CaptureInfo(
|
||||||
cam=cam,
|
buffer_id=buffer_id,
|
||||||
metadata_getter=metadata_getter,
|
position=stage_location,
|
||||||
|
sharpness=cam.grab_jpeg_size(stream_name="lores"),
|
||||||
)
|
)
|
||||||
captures.append([jpeg_path, image, metadata])
|
|
||||||
sharpnesses.append(cam.grab_jpeg_size(stream_name="lores"))
|
|
||||||
heights.append(stage_location["z"])
|
|
||||||
|
|
||||||
return captures, heights, sharpnesses
|
|
||||||
|
|
||||||
def validate_stack_inputs(self, stack_parameters) -> None:
|
def validate_stack_inputs(self, stack_parameters) -> None:
|
||||||
"""Check the stack settings are appropriate, and raise an error if not
|
"""Check the stack settings are appropriate, and raise an error if not
|
||||||
|
|
@ -598,38 +642,44 @@ class AutofocusThing(Thing):
|
||||||
):
|
):
|
||||||
raise RuntimeError("Stack parameters need to be at least 1")
|
raise RuntimeError("Stack parameters need to be at least 1")
|
||||||
|
|
||||||
def check_stack_result(self, sharpnesses: list[int]) -> str:
|
def check_stack_result(
|
||||||
"""Test a list of sharpnesses, to decide whether the sharpest image from a stack is within them
|
self, captures: list[CaptureInfo]
|
||||||
|
) -> tuple[Literal["success", "continue", "restart"], int]:
|
||||||
|
"""Test a list of captures, to decide whether the sharpest image from a
|
||||||
|
stack is within them
|
||||||
|
|
||||||
Returns a string
|
Returns two values,
|
||||||
'success' if the sharpest image is towards the centre
|
result - which is one of three literal values:
|
||||||
'continue' if the sharpest image is in the final two images of the list
|
'success' if the sharpest image is towards the centre
|
||||||
'restart' if the sharpest image is in the first two images of the list
|
'continue' if the sharpest image is in the final two images of the list
|
||||||
|
'restart' if the sharpest image is in the first two images of the list
|
||||||
|
capture_id - the buffer id of the sharpest image
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
sharpnesses: a list of the sharpnesses to test for focus
|
sharpnesses: a list of the sharpnesses to test for focus
|
||||||
"""
|
"""
|
||||||
|
sharpest_index = np.argmax([capture.sharpness for capture in captures])
|
||||||
sharpest_index = np.argmax(sharpnesses)
|
# The buffer id of the sharpest image
|
||||||
sharpness_length = len(sharpnesses)
|
capture_id = captures[sharpest_index].buffer_id
|
||||||
|
sharpness_length = len(captures)
|
||||||
|
|
||||||
# If only testing one image, then by definition the sharpest is central
|
# If only testing one image, then by definition the sharpest is central
|
||||||
if sharpness_length == 1:
|
if sharpness_length == 1:
|
||||||
return "success"
|
return "success", capture_id
|
||||||
# If testing three images, test if the centre is the sharpest
|
# If testing three images, test if the centre is the sharpest
|
||||||
if sharpness_length == 3:
|
if sharpness_length == 3:
|
||||||
if sharpest_index == 1:
|
if sharpest_index == 1:
|
||||||
return "success"
|
return "success", capture_id
|
||||||
if sharpest_index == 0:
|
if sharpest_index == 0:
|
||||||
return "restart"
|
return "restart", capture_id
|
||||||
return "continue"
|
return "continue", capture_id
|
||||||
|
|
||||||
# For larger stacks, test if the best image is not within two of the edge of the stack
|
# For larger stacks, test if the best image is not within two of the edge of the stack
|
||||||
# ie for a stack of 7 images, best image must be between 3rd and and 5th
|
# ie for a stack of 7 images, best image must be between 3rd and and 5th
|
||||||
exclusion_range = 2
|
exclusion_range = 2
|
||||||
|
|
||||||
if sharpest_index < exclusion_range:
|
if sharpest_index < exclusion_range:
|
||||||
return "restart"
|
return "restart", capture_id
|
||||||
if sharpest_index >= sharpness_length - exclusion_range:
|
if sharpest_index >= sharpness_length - exclusion_range:
|
||||||
return "continue"
|
return "continue", capture_id
|
||||||
return "success"
|
return "success", capture_id
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ See repository root for licensing information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Literal, Optional, Tuple
|
from typing import Literal, Optional, Tuple, Any
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from pydantic import RootModel
|
from pydantic import RootModel
|
||||||
|
|
@ -50,13 +50,78 @@ class NoImageInMemoryError(RuntimeError):
|
||||||
"""An error called if no image in in memory when an method is called to use that image"""
|
"""An error called if no image in in memory when an method is called to use that image"""
|
||||||
|
|
||||||
|
|
||||||
|
class CameraMemoryBuffer:
|
||||||
|
"""
|
||||||
|
A class that holds images in memory. The images are by default PIL images.
|
||||||
|
|
||||||
|
However subclasses of BaseCamera can use this class to store other object types
|
||||||
|
"""
|
||||||
|
|
||||||
|
_storage: dict[int, tuple[Any, Optional[dict]]]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._storage = {}
|
||||||
|
self._latest_id: int = 0
|
||||||
|
|
||||||
|
def add_image(
|
||||||
|
self, image: Any, metadata: Optional[dict], buffer_max: int = 1
|
||||||
|
) -> None:
|
||||||
|
self._latest_id += 1
|
||||||
|
self._create_space(buffer_max)
|
||||||
|
self._storage[self._latest_id] = (image, metadata)
|
||||||
|
return self._latest_id
|
||||||
|
|
||||||
|
def get_image(
|
||||||
|
self, buffer_id: Optional[int] = None, remove: bool = True
|
||||||
|
) -> tuple[Any, Optional[dict]]:
|
||||||
|
"""
|
||||||
|
If the buffer ID is not set, always clear whole buffer
|
||||||
|
"""
|
||||||
|
|
||||||
|
# No id given
|
||||||
|
if buffer_id is None:
|
||||||
|
# Get the latest image and metadata tuple from storage
|
||||||
|
try:
|
||||||
|
image_tuple = list(self._storage.value())[-1]
|
||||||
|
except IndexError as e:
|
||||||
|
raise NoImageInMemoryError("No image in memory to save.") from e
|
||||||
|
# Clear the storage so images don't get retrieved out of order
|
||||||
|
self._storage.clear()
|
||||||
|
return image_tuple
|
||||||
|
|
||||||
|
if remove:
|
||||||
|
return self._storage.pop(buffer_id)
|
||||||
|
return self._storage[buffer_id]
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self._storage.clear()
|
||||||
|
|
||||||
|
def _create_space(self, buffer_max: int) -> None:
|
||||||
|
"""
|
||||||
|
Create space to add an image.
|
||||||
|
"""
|
||||||
|
# If only one image to be stored just clear the storage and return
|
||||||
|
if buffer_max <= 1:
|
||||||
|
self._storage.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Number to remove to get the storage down to 1 less than the buffer length
|
||||||
|
to_remove = len(self._storage) - (buffer_max - 1)
|
||||||
|
# If if there is space. Nothing to do, just return
|
||||||
|
if to_remove < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
keys_to_remove = list(self._storage.keys())[:to_remove]
|
||||||
|
for key in keys_to_remove:
|
||||||
|
del self._storage[key]
|
||||||
|
|
||||||
|
|
||||||
class BaseCamera(Thing):
|
class BaseCamera(Thing):
|
||||||
"""The base class for all cameras. All cameras must directly inherit from this class"""
|
"""The base class for all cameras. All cameras must directly inherit from this class"""
|
||||||
|
|
||||||
_memory_image: Optional[Image] = None
|
|
||||||
_memory_metadata: Optional[dict] = None
|
|
||||||
mjpeg_stream = MJPEGStreamDescriptor()
|
mjpeg_stream = MJPEGStreamDescriptor()
|
||||||
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
||||||
|
_memory_buffer = CameraMemoryBuffer()
|
||||||
|
|
||||||
def __enter__(self) -> None:
|
def __enter__(self) -> None:
|
||||||
raise NotImplementedError("CameraThings must define their own __enter__ method")
|
raise NotImplementedError("CameraThings must define their own __enter__ method")
|
||||||
|
|
@ -64,17 +129,6 @@ class BaseCamera(Thing):
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
|
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
|
||||||
raise NotImplementedError("CameraThings must define their own __exit__ method")
|
raise NotImplementedError("CameraThings must define their own __exit__ method")
|
||||||
|
|
||||||
@thing_property
|
|
||||||
def image_in_memory(self) -> bool:
|
|
||||||
"""True if an image is in memory ready to be saved"""
|
|
||||||
return self._memory_image is not None and self._memory_metadata is not None
|
|
||||||
|
|
||||||
@thing_action
|
|
||||||
def clear_image_memory(self) -> None:
|
|
||||||
"""Clear any image in memory"""
|
|
||||||
self._memory_image = None
|
|
||||||
self._memory_metadata = None
|
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def start_streaming(
|
def start_streaming(
|
||||||
self, main_resolution: tuple[int, int], buffer_count: int
|
self, main_resolution: tuple[int, int], buffer_count: int
|
||||||
|
|
@ -133,6 +187,18 @@ class BaseCamera(Thing):
|
||||||
frame = portal.call(stream.grab_frame)
|
frame = portal.call(stream.grab_frame)
|
||||||
return JPEGBlob.from_bytes(frame)
|
return JPEGBlob.from_bytes(frame)
|
||||||
|
|
||||||
|
@thing_action
|
||||||
|
def grab_jpeg_size(
|
||||||
|
self,
|
||||||
|
portal: BlockingPortal,
|
||||||
|
stream_name: Literal["main", "lores"] = "main",
|
||||||
|
) -> int:
|
||||||
|
"""Acquire one image from the preview stream and return its size"""
|
||||||
|
stream = (
|
||||||
|
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
||||||
|
)
|
||||||
|
return portal.call(stream.next_frame_size)
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def capture_image(
|
def capture_image(
|
||||||
self,
|
self,
|
||||||
|
|
@ -173,20 +239,18 @@ class BaseCamera(Thing):
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def capture_to_memory(
|
def capture_to_memory(
|
||||||
self,
|
self, logger: InvocationLogger, metadata_getter: GetThingStates, buffer_max
|
||||||
logger: InvocationLogger,
|
|
||||||
metadata_getter: GetThingStates,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Capture an image to memory. This can be saved later with `save_from_memory`
|
Capture an image to memory. This can be saved later with `save_from_memory`
|
||||||
|
|
||||||
Note that only one image is held in memory so this will overwrite any image
|
Note that only one image is held in memory so this will overwrite any image
|
||||||
in memory.
|
in memory.
|
||||||
|
|
||||||
|
Return the buffer id of the image captured
|
||||||
"""
|
"""
|
||||||
self._memory_image, self._memory_metadata = self._robust_image_capture(
|
image, metadata = self._robust_image_capture(metadata_getter, logger)
|
||||||
metadata_getter,
|
return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max)
|
||||||
logger=logger,
|
|
||||||
)
|
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def save_from_memory(
|
def save_from_memory(
|
||||||
|
|
@ -194,21 +258,21 @@ class BaseCamera(Thing):
|
||||||
jpeg_path: str,
|
jpeg_path: str,
|
||||||
logger: InvocationLogger,
|
logger: InvocationLogger,
|
||||||
save_resolution: Optional[Tuple[int, int]] = None,
|
save_resolution: Optional[Tuple[int, int]] = None,
|
||||||
|
buffer_id: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Save an image that has been captured to memory.
|
Save an image that has been captured to memory.
|
||||||
"""
|
"""
|
||||||
if not self.image_in_memory:
|
image, metadata = self._memory_buffer.get_image(buffer_id)
|
||||||
raise NoImageInMemoryError("No image in memory to save.")
|
|
||||||
|
|
||||||
self._save_capture(
|
self._save_capture(
|
||||||
jpeg_path=jpeg_path,
|
jpeg_path=jpeg_path,
|
||||||
image=self._memory_image,
|
image=image,
|
||||||
metadata=self._memory_metadata,
|
metadata=metadata,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
save_resolution=save_resolution,
|
save_resolution=save_resolution,
|
||||||
)
|
)
|
||||||
self.clear_image_memory()
|
self._memory_buffer.clear()
|
||||||
|
|
||||||
def _robust_image_capture(
|
def _robust_image_capture(
|
||||||
self,
|
self,
|
||||||
|
|
@ -248,10 +312,13 @@ class BaseCamera(Thing):
|
||||||
if save_resolution is not None and image.size != save_resolution:
|
if save_resolution is not None and image.size != save_resolution:
|
||||||
image = image.resize(save_resolution, Image.BOX)
|
image = image.resize(save_resolution, Image.BOX)
|
||||||
try:
|
try:
|
||||||
# Per PIL documentation, (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg)
|
# Per PIL documentation,
|
||||||
# there are two factors when saving a JPEG. subsampling affects the colour, quality the pixels
|
# (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg)
|
||||||
|
# there are two factors when saving a JPEG. Subsampling affects the colour,
|
||||||
|
# quality affects the pixels.
|
||||||
# subsampling = 0 disables subsampling of colour
|
# subsampling = 0 disables subsampling of colour
|
||||||
# quality = 95 is the maximum recommended - above this, JPEG compression is disabled, file size increases and quality is barely or not affected
|
# quality = 95 is the maximum recommended - above this, JPEG compression is
|
||||||
|
# disabled, file size increases and quality is barely or not affected
|
||||||
image.save(jpeg_path, quality=95, subsampling=0)
|
image.save(jpeg_path, quality=95, subsampling=0)
|
||||||
try:
|
try:
|
||||||
# Load EXIF metadata from image so it can be added to.
|
# Load EXIF metadata from image so it can be added to.
|
||||||
|
|
|
||||||
|
|
@ -665,7 +665,7 @@ class SmartScanThing(Thing):
|
||||||
)
|
)
|
||||||
|
|
||||||
route_planner.mark_location_visited(
|
route_planner.mark_location_visited(
|
||||||
current_pos_xyz, imaged=True, focused=focused
|
current_pos_xyz, imaged=True, focused=True
|
||||||
)
|
)
|
||||||
|
|
||||||
site_folder = os.path.join(
|
site_folder = os.path.join(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue