Merge branch 'scanning-stability' into 'v3'
Improve the stability of scanning Closes #599 and #600 See merge request openflexure/openflexure-microscope-server!434
This commit is contained in:
commit
99afe89c10
10 changed files with 874 additions and 81 deletions
Binary file not shown.
|
|
@ -10,7 +10,6 @@ import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
from pydantic.errors import PydanticUserError
|
from pydantic.errors import PydanticUserError
|
||||||
from scipy.stats import norm
|
|
||||||
from labthings_fastapi.thing_description import type_to_dataschema
|
from labthings_fastapi.thing_description import type_to_dataschema
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,6 +17,14 @@ class MissingBackgroundDataError(RuntimeError):
|
||||||
"""An error raised if checking for sample without background data set."""
|
"""An error raised if checking for sample without background data set."""
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelBlankError(RuntimeError):
|
||||||
|
"""An error raised if a channel has no measured standard deviation.
|
||||||
|
|
||||||
|
This is not physical and usually means the camera has not yet booted or changed
|
||||||
|
mode fully.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BackgroundDetectorStatus(BaseModel):
|
class BackgroundDetectorStatus(BaseModel):
|
||||||
"""The status information about a background detector instance needed for the GUI.
|
"""The status information about a background detector instance needed for the GUI.
|
||||||
|
|
||||||
|
|
@ -136,7 +143,7 @@ class BackgroundDetectAlgorithm:
|
||||||
"Each background detect algorithm must implement an image_is_sample method."
|
"Each background detect algorithm must implement an image_is_sample method."
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_background(self, image: np.ndarray) -> BaseModel:
|
def set_background(self, image: np.ndarray) -> None:
|
||||||
"""Use the input image to update the background data.
|
"""Use the input image to update the background data.
|
||||||
|
|
||||||
Background data must be a Pydantic BaseModel.
|
Background data must be a Pydantic BaseModel.
|
||||||
|
|
@ -188,6 +195,10 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
|
||||||
background_data_model: BaseModel = ChannelDistributions
|
background_data_model: BaseModel = ChannelDistributions
|
||||||
settings_data_model: BaseModel = ColourChannelDetectSettings
|
settings_data_model: BaseModel = ColourChannelDetectSettings
|
||||||
|
|
||||||
|
# These are the same as those used for ChannelDeviationLUV. More detail is
|
||||||
|
# provided there.
|
||||||
|
min_stds = [0.5, 0.3, 0.5]
|
||||||
|
|
||||||
def background_mask(self, image: np.ndarray) -> np.ndarray:
|
def background_mask(self, image: np.ndarray) -> np.ndarray:
|
||||||
"""Calculate a binary image, showing whether each pixel is background.
|
"""Calculate a binary image, showing whether each pixel is background.
|
||||||
|
|
||||||
|
|
@ -196,11 +207,6 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
|
||||||
The image should be in LUV format, the output will be binary with the
|
The image should be in LUV format, the output will be binary with the
|
||||||
same shape in the first two dimensions.
|
same shape in the first two dimensions.
|
||||||
"""
|
"""
|
||||||
if not self.background_data:
|
|
||||||
raise MissingBackgroundDataError(
|
|
||||||
"Background is not set: you need to calibrate background detection."
|
|
||||||
)
|
|
||||||
|
|
||||||
# The ``[1:]`` selects only the U and V channels of the image.
|
# The ``[1:]`` selects only the U and V channels of the image.
|
||||||
# Only U and V are used as brightness (L) often changes as
|
# Only U and V are used as brightness (L) often changes as
|
||||||
# the height of the sample changes.
|
# the height of the sample changes.
|
||||||
|
|
@ -225,6 +231,10 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
|
||||||
:returns: A value (between 0 and 100) is the percentage of the image that is
|
:returns: A value (between 0 and 100) is the percentage of the image that is
|
||||||
sample.
|
sample.
|
||||||
"""
|
"""
|
||||||
|
if not self.background_data:
|
||||||
|
raise MissingBackgroundDataError(
|
||||||
|
"Background is not set: you need to calibrate background detection."
|
||||||
|
)
|
||||||
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
|
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
|
||||||
mask = self.background_mask(image_luv)
|
mask = self.background_mask(image_luv)
|
||||||
|
|
||||||
|
|
@ -250,15 +260,114 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
|
||||||
"""Use the input image to update the background distributions."""
|
"""Use the input image to update the background distributions."""
|
||||||
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
|
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
|
||||||
|
|
||||||
ch1 = (image_luv.T[0]).flatten()
|
mu = np.mean(image_luv, axis=(0, 1))
|
||||||
ch2 = (image_luv.T[1]).flatten()
|
std = np.std(image_luv, axis=(0, 1))
|
||||||
ch3 = (image_luv.T[2]).flatten()
|
|
||||||
|
|
||||||
points = np.array([np.asarray(ch1), np.asarray(ch2), np.asarray(ch3)]).T
|
if np.any(std == 0):
|
||||||
|
raise ChannelBlankError("Some LUV channels have no standard deviation.")
|
||||||
# Get the mean and standard deviation of values in each channel
|
std = np.maximum(std, self.min_stds)
|
||||||
mu, std = np.apply_along_axis(norm.fit, 0, points)
|
|
||||||
|
|
||||||
self.background_data = ChannelDistributions(
|
self.background_data = ChannelDistributions(
|
||||||
means=mu.tolist(), standard_deviations=std.tolist()
|
means=mu.tolist(), standard_deviations=std.tolist()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelDeviationLUV(BackgroundDetectAlgorithm):
|
||||||
|
"""Compare the standard deviations of the LUV channels in a grid to background data.
|
||||||
|
|
||||||
|
Using an LUV colour space, each image is divided into an 8x8 grid of images.
|
||||||
|
The standard deviation of each channel of each image is calculated and compared
|
||||||
|
to the median standard deviation for a grid of background images.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Note we don't use the means in this algorithm but we use the same channel
|
||||||
|
# distributions model
|
||||||
|
background_data_model: BaseModel = ChannelDistributions
|
||||||
|
settings_data_model: BaseModel = ColourChannelDetectSettings
|
||||||
|
|
||||||
|
# Empirically, 0.5 seems to be approximate the standard deviation for a good image
|
||||||
|
# in L and V. U appears to be about 60% of this value. U is about 65% of V when
|
||||||
|
# converting white-noise in RGB into LUV (note that we are using cv2's internal
|
||||||
|
# LUV colour space not converting to the CIELUV numbers)
|
||||||
|
min_stds = [0.5, 0.3, 0.5]
|
||||||
|
|
||||||
|
def get_sample_coverage(self, image: np.ndarray) -> float:
|
||||||
|
"""Return the percentage of the input image that is background.
|
||||||
|
|
||||||
|
Evaluate whether it is foreground or background by comparing the standard
|
||||||
|
deviations of an 8x8 grid of sub-images to the median standard deviation
|
||||||
|
from a background image.
|
||||||
|
|
||||||
|
:returns: A value (between 0 and 100) that is the percentage of the image that is
|
||||||
|
sample.
|
||||||
|
"""
|
||||||
|
if not self.background_data:
|
||||||
|
raise MissingBackgroundDataError(
|
||||||
|
"Background is not set: you need to calibrate background detection."
|
||||||
|
)
|
||||||
|
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
|
||||||
|
stds = _chunked_stds(image_luv, 8, 8)
|
||||||
|
|
||||||
|
bg_stds = self.background_data.standard_deviations
|
||||||
|
l_cut = bg_stds[0] * self.settings.channel_tolerance
|
||||||
|
u_cut = bg_stds[1] * self.settings.channel_tolerance
|
||||||
|
v_cut = bg_stds[2] * self.settings.channel_tolerance
|
||||||
|
|
||||||
|
populated_regions = (
|
||||||
|
(stds[:, :, 0] > l_cut) | (stds[:, :, 1] > u_cut) | (stds[:, :, 2] > v_cut)
|
||||||
|
)
|
||||||
|
|
||||||
|
return float(100 * np.sum(populated_regions) / 64)
|
||||||
|
|
||||||
|
def image_is_sample(self, image: np.ndarray) -> tuple[bool, str]:
|
||||||
|
"""Label the current image as either background or sample.
|
||||||
|
|
||||||
|
:returns: A tuple of the result (boolean), and explanation string. The
|
||||||
|
explanation string is formatted so it can be added into a sentence such as
|
||||||
|
``An action was taken because the image is {message}.``
|
||||||
|
"""
|
||||||
|
sample_coverage = self.get_sample_coverage(image)
|
||||||
|
|
||||||
|
# Use bool otherwise get numpy variants of True and False.
|
||||||
|
is_sample = bool(sample_coverage > self.settings.min_sample_coverage)
|
||||||
|
message = f"{sample_coverage:0.1f}% sample"
|
||||||
|
if not is_sample:
|
||||||
|
message = "only " + message
|
||||||
|
return is_sample, message
|
||||||
|
|
||||||
|
def set_background(self, image: np.ndarray) -> None:
|
||||||
|
"""Use the input image to update the background distributions."""
|
||||||
|
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
|
||||||
|
mu = np.zeros(3)
|
||||||
|
c_stds = _chunked_stds(image_luv, 8, 8)
|
||||||
|
channel_blank = np.all(c_stds == 0, axis=(0, 1))
|
||||||
|
if np.any(channel_blank):
|
||||||
|
raise ChannelBlankError("Some LUV channels have no standard devaition.")
|
||||||
|
|
||||||
|
std = np.median(c_stds, axis=(0, 1))
|
||||||
|
std = np.maximum(std, self.min_stds)
|
||||||
|
self.background_data = ChannelDistributions(
|
||||||
|
means=mu.tolist(), standard_deviations=std.tolist()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunked_stds(img: np.ndarray, n_rows: int = 8, n_cols: int = 8) -> np.ndarray:
|
||||||
|
"""Split image into a grid and calculate std of each channel in each chunk.
|
||||||
|
|
||||||
|
:param img: The image to analyse
|
||||||
|
:param n_rows: The number of rows in the grid
|
||||||
|
:param n_cols: The number of cols in the grid
|
||||||
|
:return: A numpy array of the grid of standard deviations.
|
||||||
|
"""
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
row_height = h // n_rows
|
||||||
|
col_width = w // n_cols
|
||||||
|
out = np.zeros((n_rows, n_cols, 3))
|
||||||
|
for i in range(n_rows):
|
||||||
|
for j in range(n_cols):
|
||||||
|
chunk = img[
|
||||||
|
i * row_height : (i + 1) * row_height,
|
||||||
|
j * col_width : (j + 1) * col_width,
|
||||||
|
]
|
||||||
|
out[i, j, :] = np.std(chunk, axis=(0, 1))
|
||||||
|
return out
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ from .stage import StageDependency as Stage
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
MIN_TEST_IMAGE_COUNT = 3
|
MIN_TEST_IMAGE_COUNT = 3
|
||||||
MAX_TEST_IMAGE_COUNT = 9
|
MAX_TEST_IMAGE_COUNT = 9
|
||||||
|
EXTRA_STACK_CAPTURES = 15
|
||||||
|
|
||||||
|
|
||||||
class NotStreamingError(RuntimeError):
|
class NotStreamingError(RuntimeError):
|
||||||
|
|
@ -133,7 +134,7 @@ class StackParams(BaseModel):
|
||||||
|
|
||||||
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 + 15
|
return self.min_images_to_test + EXTRA_STACK_CAPTURES
|
||||||
|
|
||||||
def slice_to_save(self, sharpest_index: int) -> slice:
|
def slice_to_save(self, sharpest_index: int) -> slice:
|
||||||
"""Return the slice of images to save given the index of the sharpest image."""
|
"""Return the slice of images to save given the index of the sharpest image."""
|
||||||
|
|
@ -421,38 +422,38 @@ class AutofocusThing(lt.Thing):
|
||||||
is close to focus, but not quite within ``dz/2``. It will attempt to autofocus
|
is close to focus, but not quite within ``dz/2``. It will attempt to autofocus
|
||||||
up to 10 times.
|
up to 10 times.
|
||||||
"""
|
"""
|
||||||
repeat = True
|
attempt = 0
|
||||||
attempts = 0
|
|
||||||
backlash = 200
|
backlash = 200
|
||||||
|
|
||||||
with sharpness_monitor.run():
|
with sharpness_monitor.run():
|
||||||
while repeat and attempts < 10:
|
while attempt < 10:
|
||||||
|
attempt += 1
|
||||||
if start == "centre":
|
if start == "centre":
|
||||||
stage.move_relative(x=0, y=0, z=-(backlash + dz / 2))
|
stage.move_relative(x=0, y=0, z=-(backlash + dz / 2))
|
||||||
stage.move_relative(x=0, y=0, z=backlash)
|
stage.move_relative(x=0, y=0, z=backlash)
|
||||||
|
|
||||||
|
# Always start centrally for future runs
|
||||||
|
start = "centre"
|
||||||
|
|
||||||
focus_data_index, _ = sharpness_monitor.focus_rel(
|
focus_data_index, _ = sharpness_monitor.focus_rel(
|
||||||
dz, block_cancellation=True
|
dz, block_cancellation=True
|
||||||
)
|
)
|
||||||
_times, heights, sizes = sharpness_monitor.move_data(focus_data_index)
|
_times, heights, sizes = sharpness_monitor.move_data(focus_data_index)
|
||||||
|
|
||||||
peak_height = heights[np.argmax(sizes)]
|
peak_height = heights[np.argmax(sizes)]
|
||||||
height_min = np.min(heights)
|
target_min = np.min(heights) + dz / 5
|
||||||
height_max = np.max(heights)
|
target_max = np.max(heights) - dz / 5
|
||||||
|
|
||||||
if (
|
# move to the peak
|
||||||
peak_height - height_min < dz / 5
|
|
||||||
or height_max - peak_height < dz / 5
|
|
||||||
):
|
|
||||||
attempts += 1
|
|
||||||
start = "centre"
|
|
||||||
stage.move_absolute(z=peak_height - backlash)
|
stage.move_absolute(z=peak_height - backlash)
|
||||||
stage.move_absolute(z=peak_height)
|
stage.move_absolute(z=peak_height)
|
||||||
else:
|
|
||||||
repeat = False
|
if target_min < peak_height < target_max:
|
||||||
stage.move_relative(x=0, y=0, z=-(dz + backlash))
|
# If it is within the target range then return
|
||||||
stage.move_absolute(z=peak_height)
|
|
||||||
return heights.tolist(), sizes.tolist()
|
return heights.tolist(), sizes.tolist()
|
||||||
|
raise NoFocusFoundError(
|
||||||
|
"Looping autofocus couldn't converge on a focus location."
|
||||||
|
)
|
||||||
|
|
||||||
stack_images_to_save = lt.ThingSetting(
|
stack_images_to_save = lt.ThingSetting(
|
||||||
initial_value=1,
|
initial_value=1,
|
||||||
|
|
@ -566,7 +567,9 @@ class AutofocusThing(lt.Thing):
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
sharpness_monitor: SharpnessMonitorDep,
|
sharpness_monitor: SharpnessMonitorDep,
|
||||||
stack_parameters: StackParams,
|
stack_parameters: StackParams,
|
||||||
) -> tuple[bool, int]:
|
save_on_failure: bool = False,
|
||||||
|
check_turning_points: bool = True,
|
||||||
|
) -> tuple[bool, Optional[int]]:
|
||||||
"""Run a smart stack.
|
"""Run a smart stack.
|
||||||
|
|
||||||
A smart stack captures images offset in z, testing whether the sharpest image
|
A smart stack captures images offset in z, testing whether the sharpest image
|
||||||
|
|
@ -582,17 +585,22 @@ class AutofocusThing(lt.Thing):
|
||||||
supplied by LabThings dependency injection
|
supplied by LabThings dependency injection
|
||||||
:param stack_parameters: A StackParams object containing the required
|
:param stack_parameters: A StackParams object containing the required
|
||||||
parameters to run a stack.
|
parameters to run a stack.
|
||||||
|
:param save_on_failure: Whether to save an image even if no focus was found.
|
||||||
|
:param check_turning_points: Whether to check the number of turning points in
|
||||||
|
the sharpnesses of the images in the stack is exactly 1. (May fail with
|
||||||
|
thick samples)
|
||||||
|
|
||||||
:returns: A tuple containing:
|
:returns: A tuple containing:
|
||||||
|
|
||||||
* A boolean, True if stack was successfully
|
* A boolean, True if stack was successfully
|
||||||
* The z position of the sharpest image
|
* The z position of the sharpest image
|
||||||
"""
|
"""
|
||||||
tries = 0
|
attempt = 0
|
||||||
# Loop until a stack is successful
|
while True:
|
||||||
while tries < stack_parameters.max_attempts:
|
attempt += 1
|
||||||
success, captures, sharpest_id = self.z_stack(
|
success, captures, sharpest_id = self.z_stack(
|
||||||
stack_parameters=stack_parameters,
|
stack_parameters=stack_parameters,
|
||||||
|
check_turning_points=check_turning_points,
|
||||||
cam=cam,
|
cam=cam,
|
||||||
stage=stage,
|
stage=stage,
|
||||||
)
|
)
|
||||||
|
|
@ -600,19 +608,26 @@ class AutofocusThing(lt.Thing):
|
||||||
if success:
|
if success:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if attempt >= stack_parameters.max_attempts:
|
||||||
|
break
|
||||||
|
|
||||||
# The z position of the first images in the previous attempt.
|
# The z position of the first images in the previous attempt.
|
||||||
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:
|
||||||
self.reset_stack(
|
self.reset_stack(
|
||||||
initial_z_pos,
|
initial_z_pos,
|
||||||
stack_parameters.autofocus_dz,
|
stack_parameters.autofocus_dz,
|
||||||
stage,
|
stage,
|
||||||
sharpness_monitor,
|
sharpness_monitor,
|
||||||
)
|
)
|
||||||
|
except NoFocusFoundError:
|
||||||
|
break
|
||||||
|
|
||||||
# Save stack_parameters.image_to_save images centred on the sharpest capture.
|
# Save stack_parameters.image_to_save images centred on the sharpest capture.
|
||||||
# If the smart_stack failed the exact number of images saved may not be
|
# If the smart_stack failed the exact number of images saved may not be
|
||||||
# stack_parameters.image_to_save
|
# stack_parameters.image_to_save
|
||||||
|
if success or save_on_failure:
|
||||||
self.save_stack(
|
self.save_stack(
|
||||||
sharpest_id=sharpest_id,
|
sharpest_id=sharpest_id,
|
||||||
captures=captures,
|
captures=captures,
|
||||||
|
|
@ -620,8 +635,6 @@ class AutofocusThing(lt.Thing):
|
||||||
cam=cam,
|
cam=cam,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return whether or not the smart stack was successful, and the z position of
|
|
||||||
# the sharpest image, for path planning and tracking
|
|
||||||
return success, _get_capture_by_id(captures, sharpest_id).position["z"]
|
return success, _get_capture_by_id(captures, sharpest_id).position["z"]
|
||||||
|
|
||||||
def reset_stack(
|
def reset_stack(
|
||||||
|
|
@ -681,9 +694,10 @@ class AutofocusThing(lt.Thing):
|
||||||
def z_stack(
|
def z_stack(
|
||||||
self,
|
self,
|
||||||
stack_parameters: StackParams,
|
stack_parameters: StackParams,
|
||||||
|
check_turning_points: bool,
|
||||||
cam: CameraClient,
|
cam: CameraClient,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
) -> tuple[bool, list[CaptureInfo], Optional[int]]:
|
) -> tuple[bool, list[CaptureInfo], int]:
|
||||||
"""Capture a series of images checking that sharpest image central.
|
"""Capture a series of images checking that sharpest image central.
|
||||||
|
|
||||||
The images are separated in z offset by stack_parameters.stack_dz, as they
|
The images are separated in z offset by stack_parameters.stack_dz, as they
|
||||||
|
|
@ -692,6 +706,9 @@ class AutofocusThing(lt.Thing):
|
||||||
completes.
|
completes.
|
||||||
|
|
||||||
:param stack_parameters: a StackParams object holding stack parameters
|
:param stack_parameters: a StackParams object holding stack parameters
|
||||||
|
:param check_turning_points: Whether to check the number of turning points in
|
||||||
|
the sharpnesses of the images in the stack is exactly 1. (May fail with
|
||||||
|
thick samples)
|
||||||
:param cam: Camera Dependency to be passed through from the calling action
|
:param cam: Camera Dependency to be passed through from the calling action
|
||||||
:param stage: Stage Dependency to be passed through from the calling action
|
:param stage: Stage Dependency to be passed through from the calling action
|
||||||
|
|
||||||
|
|
@ -699,7 +716,7 @@ class AutofocusThing(lt.Thing):
|
||||||
|
|
||||||
* the stack result (True for successful stack, False for failed stack),
|
* the stack result (True for successful stack, False for failed stack),
|
||||||
* a list of CaptureInfo objects,
|
* a list of CaptureInfo objects,
|
||||||
* the buffer_id of the sharpest image (or None if the stack failed).
|
* the buffer_id of the sharpest image
|
||||||
"""
|
"""
|
||||||
# Move down by the height of the z stack, plus an overshoot
|
# Move down by the height of the z stack, plus an overshoot
|
||||||
# Better to start too low and take too many images than too high and need to refocus
|
# Better to start too low and take too many images than too high and need to refocus
|
||||||
|
|
@ -718,7 +735,7 @@ class AutofocusThing(lt.Thing):
|
||||||
ims_to_check = slice(-stack_parameters.min_images_to_test, None)
|
ims_to_check = slice(-stack_parameters.min_images_to_test, None)
|
||||||
|
|
||||||
# If the sharpest image isn't found within the maximum number of images
|
# If the sharpest image isn't found within the maximum number of images
|
||||||
# end the loop and return "restart"
|
# end the loop and return False indicating the stack failed.
|
||||||
while len(captures) < stack_parameters.max_images_to_test:
|
while len(captures) < stack_parameters.max_images_to_test:
|
||||||
time.sleep(stack_parameters.settling_time)
|
time.sleep(stack_parameters.settling_time)
|
||||||
|
|
||||||
|
|
@ -733,16 +750,18 @@ class AutofocusThing(lt.Thing):
|
||||||
|
|
||||||
# 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:
|
||||||
result, capture_id = self.check_stack_result(captures[ims_to_check])
|
result, capture_id = self.check_stack_result(
|
||||||
|
captures[ims_to_check], check_turning_points=check_turning_points
|
||||||
|
)
|
||||||
|
|
||||||
if result == "success":
|
if result == "success":
|
||||||
return True, captures, capture_id
|
return True, captures, capture_id
|
||||||
|
|
||||||
if result == "restart":
|
if result == "restart":
|
||||||
return False, captures, None
|
return False, captures, capture_id
|
||||||
# If reached here the result was "continue"
|
# If reached here the result was "continue"
|
||||||
stage.move_relative(z=stack_parameters.stack_dz)
|
stage.move_relative(z=stack_parameters.stack_dz)
|
||||||
return False, captures, None
|
return False, captures, capture_id
|
||||||
|
|
||||||
def capture_stack_image(
|
def capture_stack_image(
|
||||||
self,
|
self,
|
||||||
|
|
@ -774,12 +793,15 @@ class AutofocusThing(lt.Thing):
|
||||||
# unlikely to improve readability. This function is basically a complex switch
|
# unlikely to improve readability. This function is basically a complex switch
|
||||||
# statement, having an explicit return after each option is clear.
|
# statement, having an explicit return after each option is clear.
|
||||||
def check_stack_result( # noqa: PLR0911
|
def check_stack_result( # noqa: PLR0911
|
||||||
self, captures: list[CaptureInfo]
|
self, captures: list[CaptureInfo], check_turning_points: bool
|
||||||
) -> tuple[Literal["success", "continue", "restart"], int]:
|
) -> tuple[Literal["success", "continue", "restart"], int]:
|
||||||
"""Check if the sharpest image in a list of captures is central enough.
|
"""Check if the sharpest image in a list of captures is central enough.
|
||||||
|
|
||||||
:param captures: a list of the capture objects to for testing if the
|
:param captures: a list of the capture objects to for testing if the
|
||||||
sharpness has converged in the centre
|
sharpness has converged in the centre
|
||||||
|
:param check_turning_points: Whether to check the number of turning points in
|
||||||
|
the sharpnesses of the images in the stack is exactly 1. (May fail with
|
||||||
|
thick samples)
|
||||||
|
|
||||||
:returns: A tuple with two values:
|
:returns: A tuple with two values:
|
||||||
|
|
||||||
|
|
@ -793,28 +815,91 @@ class AutofocusThing(lt.Thing):
|
||||||
|
|
||||||
* capture_id - the buffer id of the sharpest image
|
* capture_id - the buffer id of the sharpest image
|
||||||
"""
|
"""
|
||||||
sharpest_index = np.argmax([capture.sharpness for capture in captures])
|
sharpnesses = np.array([capture.sharpness for capture in captures])
|
||||||
|
sharpest_index = np.argmax(sharpnesses)
|
||||||
# The buffer id of the sharpest image
|
# The buffer id of the sharpest image
|
||||||
capture_id = captures[sharpest_index].buffer_id
|
capture_id = captures[sharpest_index].buffer_id
|
||||||
sharpness_length = len(captures)
|
n_imgs = 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 n_imgs == 1:
|
||||||
return "success", capture_id
|
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 n_imgs == 3:
|
||||||
if sharpest_index == 1:
|
if sharpest_index == 1:
|
||||||
return "success", capture_id
|
return "success", capture_id
|
||||||
if sharpest_index == 0:
|
if sharpest_index == 0:
|
||||||
return "restart", capture_id
|
return "restart", capture_id
|
||||||
return "continue", capture_id
|
return "continue", capture_id
|
||||||
|
|
||||||
# For larger stacks, test if the best image is not within two of the edge of the stack
|
try:
|
||||||
# ie for a stack of 7 images, best image must be between 3rd and and 5th
|
turning = _get_peak_turning_point(sharpnesses)
|
||||||
exclusion_range = 2
|
except NotAPeakError:
|
||||||
|
|
||||||
if sharpest_index < exclusion_range:
|
|
||||||
return "restart", capture_id
|
|
||||||
if sharpest_index >= sharpness_length - exclusion_range:
|
|
||||||
return "continue", capture_id
|
return "continue", capture_id
|
||||||
|
|
||||||
|
# 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 5th
|
||||||
|
edge_size = 2
|
||||||
|
|
||||||
|
# Check both the peak from fitting and the sharpest image are not at the
|
||||||
|
# edge of the stack.
|
||||||
|
if turning < edge_size - 0.5 or sharpest_index < edge_size:
|
||||||
|
return "restart", capture_id
|
||||||
|
if turning > n_imgs - edge_size - 0.5 or sharpest_index >= n_imgs - edge_size:
|
||||||
|
return "continue", capture_id
|
||||||
|
|
||||||
|
if check_turning_points:
|
||||||
|
turning_points = _count_turning_points(sharpnesses)
|
||||||
|
if turning_points > 1:
|
||||||
|
return "continue", capture_id
|
||||||
|
|
||||||
return "success", capture_id
|
return "success", capture_id
|
||||||
|
|
||||||
|
|
||||||
|
class NotAPeakError(RuntimeError):
|
||||||
|
"""The data to fit isn't a peak."""
|
||||||
|
|
||||||
|
|
||||||
|
class NoFocusFoundError(RuntimeError):
|
||||||
|
"""No focus found during looping Autofocus."""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_peak_turning_point(sharpnesses: np.ndarray) -> float:
|
||||||
|
"""Get the turning point for a sharpnesses in a z-stack.
|
||||||
|
|
||||||
|
:param sharpnesses: A numpy array of sharpnesses
|
||||||
|
:return: The x value of the turning point where x-axis is 0 to N-1 for the N
|
||||||
|
sharpness values
|
||||||
|
|
||||||
|
:raise NotAPeakError: If the fit doesn't have a maximum within 95% confidence.
|
||||||
|
"""
|
||||||
|
# Fit the peak
|
||||||
|
x = range(len(sharpnesses))
|
||||||
|
coeffs, cov = np.polyfit(x, sharpnesses, deg=2, cov=True)
|
||||||
|
# a in the equation a*x^2 + bx + c
|
||||||
|
a = float(coeffs[0])
|
||||||
|
fit_func = np.poly1d(coeffs)
|
||||||
|
|
||||||
|
# find the peaks's x-position
|
||||||
|
turning = float(fit_func.deriv().roots[0])
|
||||||
|
# sigma_a the standard error of a
|
||||||
|
sigma_a = float(np.sqrt(cov[0, 0]))
|
||||||
|
# Estimate the upper 95% confidence bound for the x^2 term
|
||||||
|
ci_high = a + 2 * sigma_a
|
||||||
|
# If the high 95% confindence interval of the peak is not negative then the
|
||||||
|
# fit isn't sure if this is a peak or a U shape, so we continue.
|
||||||
|
# -1e-9 is used to guard against weird effects for flat and straight data
|
||||||
|
if ci_high > -1e-9:
|
||||||
|
raise NotAPeakError("Not a peak to within 95% confidence.")
|
||||||
|
return turning
|
||||||
|
|
||||||
|
|
||||||
|
def _count_turning_points(sharpnesses: np.ndarray) -> int:
|
||||||
|
"""Count the number of turing points, after rejecting those from noise."""
|
||||||
|
# Also take the difference of neighbouring points to check for turning points
|
||||||
|
d_sharpnesses = sharpnesses[1:] - sharpnesses[:-1]
|
||||||
|
# Filter out any points that are not prominent
|
||||||
|
prominent = abs(d_sharpnesses) / np.mean(abs(d_sharpnesses)) > 0.5
|
||||||
|
d_sharpnesses = d_sharpnesses[prominent]
|
||||||
|
# count the sign changes
|
||||||
|
return int(np.sum(d_sharpnesses[1:] * d_sharpnesses[:-1] < 0))
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ from labthings_fastapi.types.numpy import NDArray
|
||||||
from openflexure_microscope_server.ui import ActionButton, PropertyControl
|
from openflexure_microscope_server.ui import ActionButton, PropertyControl
|
||||||
from openflexure_microscope_server.background_detect import (
|
from openflexure_microscope_server.background_detect import (
|
||||||
ColourChannelDetectLUV,
|
ColourChannelDetectLUV,
|
||||||
|
ChannelDeviationLUV,
|
||||||
BackgroundDetectAlgorithm,
|
BackgroundDetectAlgorithm,
|
||||||
BackgroundDetectorStatus,
|
BackgroundDetectorStatus,
|
||||||
)
|
)
|
||||||
|
|
@ -182,8 +183,11 @@ class BaseCamera(lt.Thing):
|
||||||
dictionary in this function. Configuration will be added at a later date.
|
dictionary in this function. Configuration will be added at a later date.
|
||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.background_detectors = {"Colour Channels (LUV)": ColourChannelDetectLUV()}
|
self.background_detectors = {
|
||||||
self._detector_name = "Colour Channels (LUV)"
|
"Colour Channels (LUV)": ColourChannelDetectLUV(),
|
||||||
|
"Channel Deviations (LUV)": ChannelDeviationLUV(),
|
||||||
|
}
|
||||||
|
self._detector_name = "Channel Deviations (LUV)"
|
||||||
|
|
||||||
def __enter__(self) -> None:
|
def __enter__(self) -> None:
|
||||||
"""Open hardware connection when the Thing context manager is opened."""
|
"""Open hardware connection when the Thing context manager is opened."""
|
||||||
|
|
@ -637,7 +641,7 @@ class BaseCamera(lt.Thing):
|
||||||
def detector_name(self, name: str) -> None:
|
def detector_name(self, name: str) -> None:
|
||||||
"""Validate and set detector_name."""
|
"""Validate and set detector_name."""
|
||||||
if name not in self.background_detectors:
|
if name not in self.background_detectors:
|
||||||
raise ValueError(f"{name} is not a valid background detector name")
|
LOGGER.warning(f"{name} is not a valid background detector name.")
|
||||||
self._detector_name = name
|
self._detector_name = name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ from openflexure_microscope_server.ui import (
|
||||||
action_button_for,
|
action_button_for,
|
||||||
property_control_for,
|
property_control_for,
|
||||||
)
|
)
|
||||||
|
from openflexure_microscope_server.background_detect import ChannelBlankError
|
||||||
from . import picamera_recalibrate_utils as recalibrate_utils
|
from . import picamera_recalibrate_utils as recalibrate_utils
|
||||||
from . import picamera_tuning_file_utils as tf_utils
|
from . import picamera_tuning_file_utils as tf_utils
|
||||||
|
|
||||||
|
|
@ -767,7 +768,6 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
* ``auto_expose_from_minimum``
|
* ``auto_expose_from_minimum``
|
||||||
* ``set_static_green_equalisation`` to set geq offset to max
|
* ``set_static_green_equalisation`` to set geq offset to max
|
||||||
* ``calibrate_lens_shading`` (also sets colour gains for white balance)
|
* ``calibrate_lens_shading`` (also sets colour gains for white balance)
|
||||||
|
|
||||||
* ``set_background``
|
* ``set_background``
|
||||||
"""
|
"""
|
||||||
self.flat_lens_shading()
|
self.flat_lens_shading()
|
||||||
|
|
@ -775,8 +775,16 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
self.set_static_green_equalisation()
|
self.set_static_green_equalisation()
|
||||||
self.set_ce_enable_to_off()
|
self.set_ce_enable_to_off()
|
||||||
self.calibrate_lens_shading()
|
self.calibrate_lens_shading()
|
||||||
time.sleep(0.5)
|
for _i in range(3):
|
||||||
|
try:
|
||||||
|
time.sleep(self._sensor_info.long_pause)
|
||||||
self.set_background(portal)
|
self.set_background(portal)
|
||||||
|
# Return if background is set
|
||||||
|
return
|
||||||
|
except ChannelBlankError:
|
||||||
|
# If channel is blank, sleep a second and try again.
|
||||||
|
pass
|
||||||
|
raise RuntimeError("Couldn't set background")
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def primary_calibration_actions(self) -> list[ActionButton]:
|
def primary_calibration_actions(self) -> list[ActionButton]:
|
||||||
|
|
|
||||||
|
|
@ -467,13 +467,17 @@ class SmartScanThing(lt.Thing):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
focused, focused_height = self._autofocus.run_smart_stack(
|
focused, focused_height = self._autofocus.run_smart_stack(
|
||||||
stack_parameters=self._stack_params
|
stack_parameters=self._stack_params,
|
||||||
|
save_on_failure=not self._scan_data.skip_background,
|
||||||
)
|
)
|
||||||
|
|
||||||
current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focused_height)
|
current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focused_height)
|
||||||
|
|
||||||
|
# An image was captured if we are focussed or we are not skipping background.
|
||||||
|
imaged = focused or not self._scan_data.skip_background
|
||||||
|
|
||||||
route_planner.mark_location_visited(
|
route_planner.mark_location_visited(
|
||||||
current_pos_xyz, imaged=True, focused=focused
|
current_pos_xyz, imaged=imaged, focused=focused
|
||||||
)
|
)
|
||||||
|
|
||||||
# increment capture counter as thread has completed
|
# increment capture counter as thread has completed
|
||||||
|
|
|
||||||
97
tests/test_autofocus.py
Normal file
97
tests/test_autofocus.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""Tests for the autofoucs logic.
|
||||||
|
|
||||||
|
This doesn't check the behaviour of the JPEG shaprness monitor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from openflexure_microscope_server.things.autofocus import (
|
||||||
|
AutofocusThing,
|
||||||
|
NoFocusFoundError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fake_sharpness_data(
|
||||||
|
dz: int, start_z: int, max_loc: int, length: int = 41
|
||||||
|
) -> tuple[list[float], np.ndarray, np.ndarray]:
|
||||||
|
"""Create some fake data for the shapeness.
|
||||||
|
|
||||||
|
The highest returned sharpness is closest to max_loc
|
||||||
|
"""
|
||||||
|
# Some fake timestamps
|
||||||
|
times = [i / 10 + 100000 for i in range(length)]
|
||||||
|
img_dz = dz / (length - 1)
|
||||||
|
heights = [round(start_z + i * img_dz) for i in range(length)]
|
||||||
|
# Sharpnesses fall off linearly in this model.
|
||||||
|
sharpnesses = [10 * dz - abs(max_loc - h) for h in heights]
|
||||||
|
return times, np.array(heights), np.array(sharpnesses)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("start_z", "max_loc", "centre", "attempts_expected", "passes"),
|
||||||
|
[
|
||||||
|
# To complete, the max must be in the central 1200, so -600 to 600 when looping
|
||||||
|
# from -1000 to 1000
|
||||||
|
(0, 550, True, 1, True), # Found in loop1 from -1000 to 1000
|
||||||
|
(0, 650, True, 2, True), # Just outside the limit in loop1
|
||||||
|
(0, 1300, True, 2, True), # Found in loop2 from 0 to 2000
|
||||||
|
(0, 1300, False, 1, True), # Found in loop1 from 0 to 2000 (as start="base")
|
||||||
|
(0, -1300, True, 2, True), # Found in loop2 from 0 to 2000
|
||||||
|
(0, 7300, True, 8, True), # Found in loop8 from 6000 to 8000
|
||||||
|
(0, 9300, True, 10, True), # Found in loop10 from 8000 to 10000
|
||||||
|
(0, 9900, True, 10, False), # Still not central in loop 10, doesn't pass
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes, mocker):
|
||||||
|
"""Test the high level looping autofocus algorithm."""
|
||||||
|
dz = 2000
|
||||||
|
# Make a mock stage where move_absolute abs and relative updates the position counter.
|
||||||
|
stage = mocker.Mock()
|
||||||
|
stage.position = {"x": 0, "y": 0, "z": start_z}
|
||||||
|
|
||||||
|
def set_pos(**kwargs: int) -> None:
|
||||||
|
"""Move absolute should update position. So make a side effect for the mock."""
|
||||||
|
for axis, value in kwargs.items():
|
||||||
|
stage.position[axis] = value
|
||||||
|
|
||||||
|
def adjust_pos(**kwargs: int) -> None:
|
||||||
|
"""Move relative should update position. So make a side effect for the mock."""
|
||||||
|
for axis, value in kwargs.items():
|
||||||
|
stage.position[axis] += value
|
||||||
|
|
||||||
|
stage.move_absolute.side_effect = set_pos
|
||||||
|
stage.move_relative.side_effect = adjust_pos
|
||||||
|
|
||||||
|
# Make a mock sharpness monitor that can generate sharpness data.
|
||||||
|
sharpness_monitor = mocker.MagicMock()
|
||||||
|
sharpness_monitor.focus_rel.return_value = (0, 0)
|
||||||
|
|
||||||
|
def return_sharpness(*_args) -> tuple[list[float], np.ndarray, np.ndarray]:
|
||||||
|
"""Generate sharpnesses based on parameterised input, and mock stage position."""
|
||||||
|
return fake_sharpness_data(
|
||||||
|
dz=dz,
|
||||||
|
start_z=stage.position["z"],
|
||||||
|
max_loc=max_loc,
|
||||||
|
)
|
||||||
|
|
||||||
|
sharpness_monitor.move_data.side_effect = return_sharpness
|
||||||
|
|
||||||
|
autofocus_thing = AutofocusThing()
|
||||||
|
if passes:
|
||||||
|
autofocus_thing.looping_autofocus(
|
||||||
|
stage=stage,
|
||||||
|
sharpness_monitor=sharpness_monitor,
|
||||||
|
dz=dz,
|
||||||
|
start="centre" if centre else "base",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with pytest.raises(NoFocusFoundError):
|
||||||
|
autofocus_thing.looping_autofocus(
|
||||||
|
stage=stage,
|
||||||
|
sharpness_monitor=sharpness_monitor,
|
||||||
|
dz=dz,
|
||||||
|
start="centre" if centre else "base",
|
||||||
|
)
|
||||||
|
assert sharpness_monitor.focus_rel.call_count == attempts_expected
|
||||||
|
assert abs(max_loc - stage.position["z"]) < dz / 40
|
||||||
|
|
@ -15,6 +15,9 @@ from openflexure_microscope_server.background_detect import (
|
||||||
ChannelDistributions,
|
ChannelDistributions,
|
||||||
ColourChannelDetectSettings,
|
ColourChannelDetectSettings,
|
||||||
ColourChannelDetectLUV,
|
ColourChannelDetectLUV,
|
||||||
|
_chunked_stds,
|
||||||
|
ChannelDeviationLUV,
|
||||||
|
ChannelBlankError,
|
||||||
)
|
)
|
||||||
|
|
||||||
RNG = np.random.default_rng()
|
RNG = np.random.default_rng()
|
||||||
|
|
@ -181,3 +184,140 @@ def test_colour_channel_luv_load_bad_data():
|
||||||
cc_luv.settings = WrongModel()
|
cc_luv.settings = WrongModel()
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
cc_luv.background_data = WrongModel()
|
cc_luv.background_data = WrongModel()
|
||||||
|
|
||||||
|
|
||||||
|
def create_patchwork_image(magnitude=3, blank_channels=None):
|
||||||
|
"""Create a patchwork image, with known stds per chunk.
|
||||||
|
|
||||||
|
:return: The image, and the (8,8,3) array of stds
|
||||||
|
"""
|
||||||
|
if blank_channels is None:
|
||||||
|
blank_channels = []
|
||||||
|
n_rows = 8
|
||||||
|
n_cols = 8
|
||||||
|
chunk_h = 10
|
||||||
|
chunk_w = 10
|
||||||
|
|
||||||
|
# Precompute chunk stds and construct the final image
|
||||||
|
expected_stds = np.zeros((n_rows, n_cols, 3), dtype=float)
|
||||||
|
img = np.zeros((n_rows * chunk_h, n_cols * chunk_w, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
for i in range(n_rows):
|
||||||
|
for j in range(n_cols):
|
||||||
|
for channel in range(3):
|
||||||
|
if channel in blank_channels:
|
||||||
|
chunk = np.zeros((chunk_h, chunk_w), dtype=np.uint8)
|
||||||
|
else:
|
||||||
|
chunk = 9.8 * np.ones((chunk_h, chunk_w))
|
||||||
|
chunk += RNG.normal(scale=magnitude, size=(chunk_h, chunk_w))
|
||||||
|
chunk = chunk.astype(np.uint8)
|
||||||
|
|
||||||
|
# Store its std
|
||||||
|
expected_stds[i, j, channel] = np.std(chunk)
|
||||||
|
|
||||||
|
# Insert chunk into the final large image
|
||||||
|
y0, y1 = i * chunk_h, (i + 1) * chunk_h
|
||||||
|
x0, x1 = j * chunk_w, (j + 1) * chunk_w
|
||||||
|
img[y0:y1, x0:x1, channel] = chunk
|
||||||
|
return img, expected_stds
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunked_stds_with_precomputed_chunk_stds():
|
||||||
|
"""Test _chunked_stds returns the answer calculated when making a patchwork image."""
|
||||||
|
img, expected_stds = create_patchwork_image()
|
||||||
|
|
||||||
|
# Run the function under test
|
||||||
|
result = _chunked_stds(img, n_rows=8, n_cols=8)
|
||||||
|
|
||||||
|
# Compare
|
||||||
|
np.testing.assert_allclose(result, expected_stds, rtol=1e-6, atol=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_deviation_luv_set_background(mocker):
|
||||||
|
"""Test set_background takes the median of each channel, and errors for blank channels."""
|
||||||
|
cd_luv = ChannelDeviationLUV()
|
||||||
|
|
||||||
|
# Patch RGB to LUV so or we don't know what the STDs should be
|
||||||
|
mocker.patch("cv2.cvtColor", side_effect=lambda img, _method: img)
|
||||||
|
|
||||||
|
for magnitude in [0.2, 1, 10]:
|
||||||
|
# Create an image and set it as background
|
||||||
|
img, expected_stds = create_patchwork_image(magnitude=magnitude)
|
||||||
|
cd_luv.set_background(img)
|
||||||
|
|
||||||
|
# Do a somewhat verbose checking for clarity
|
||||||
|
for channel in range(3):
|
||||||
|
# Saved std
|
||||||
|
channel_std = cd_luv.background_data.standard_deviations[channel]
|
||||||
|
# Expected median
|
||||||
|
channel_median = np.median(expected_stds[:, :, channel])
|
||||||
|
# If the median is above the minimum allowed then it should be returned
|
||||||
|
if channel_median > cd_luv.min_stds[channel]:
|
||||||
|
assert np.isclose(channel_std, channel_median, rtol=1e-6, atol=1e-12)
|
||||||
|
else:
|
||||||
|
# If not the minimum is returned.
|
||||||
|
assert channel_std == cd_luv.min_stds[channel]
|
||||||
|
|
||||||
|
# Also check that if channels are blank then an error is thrown
|
||||||
|
img, _expected_stds = create_patchwork_image(magnitude=1, blank_channels=[1, 2])
|
||||||
|
with pytest.raises(ChannelBlankError):
|
||||||
|
cd_luv.set_background(img)
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_deviation_luv_image_is_sample(background_image, mocker):
|
||||||
|
"""Check image_is_sample reports the result from get_sample_coverage."""
|
||||||
|
cd_luv = ChannelDeviationLUV()
|
||||||
|
|
||||||
|
# No background data so it is not ready and will error if image_is_sample is called.
|
||||||
|
assert not cd_luv.status.ready
|
||||||
|
with pytest.raises(MissingBackgroundDataError):
|
||||||
|
cd_luv.image_is_sample(background_image)
|
||||||
|
|
||||||
|
cd_luv.settings.min_sample_coverage = 20
|
||||||
|
cd_luv.get_sample_coverage = mocker.Mock(return_value=10)
|
||||||
|
is_sample, message = cd_luv.image_is_sample(background_image)
|
||||||
|
assert not is_sample
|
||||||
|
assert message == r"only 10.0% sample"
|
||||||
|
|
||||||
|
# Reduce the min coverage
|
||||||
|
cd_luv.settings.min_sample_coverage = 9
|
||||||
|
|
||||||
|
is_sample, message = cd_luv.image_is_sample(background_image)
|
||||||
|
assert is_sample
|
||||||
|
assert message == r"10.0% sample"
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_deviation_luv_get_sample_coverage(background_image, mocker):
|
||||||
|
"""Check _get_sample_coverage returns the values expected."""
|
||||||
|
cd_luv = ChannelDeviationLUV()
|
||||||
|
|
||||||
|
# Create fake chunked STD data where each channel is the numbers 0 -> 31.5 in 0.5
|
||||||
|
# steps
|
||||||
|
grid = np.arange(0, 32, 0.5).reshape(8, 8)
|
||||||
|
fake_stds = np.stack([grid, grid, grid], axis=-1)
|
||||||
|
mocker.patch(
|
||||||
|
"openflexure_microscope_server.background_detect._chunked_stds",
|
||||||
|
return_value=fake_stds,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create fake background
|
||||||
|
cd_luv.background_data = ChannelDistributions(
|
||||||
|
means=[0, 0, 0], standard_deviations=[1.1, 1.1, 1.1]
|
||||||
|
)
|
||||||
|
# Get sample coverage with channel tolerance of 7. Checking each channel for the
|
||||||
|
# numbers below 7.7. There are 16 out of 64. So 75% should be sample
|
||||||
|
cd_luv.settings.channel_tolerance = 7
|
||||||
|
assert cd_luv.get_sample_coverage(background_image) == 75
|
||||||
|
# This is unchanged if two channels have larger background values.
|
||||||
|
cd_luv.background_data = ChannelDistributions(
|
||||||
|
means=[0, 0, 0], standard_deviations=[1.6, 1.6, 1.1]
|
||||||
|
)
|
||||||
|
assert cd_luv.get_sample_coverage(background_image) == 75
|
||||||
|
# But coverage increases if any channels has a lower background value.
|
||||||
|
cd_luv.background_data = ChannelDistributions(
|
||||||
|
means=[0, 0, 0], standard_deviations=[1.6, 0.6, 1.1]
|
||||||
|
)
|
||||||
|
assert cd_luv.get_sample_coverage(background_image) == 85.9375
|
||||||
|
# Returns to 75% if that channel is empty
|
||||||
|
fake_stds[:, :, 1] = 0
|
||||||
|
assert cd_luv.get_sample_coverage(background_image) == 75
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
"""Tests for the smart/fast stacking.
|
"""Tests for the smart and fast stacking."""
|
||||||
|
|
||||||
Currently these tests don't test the Thing itself, just surrounding functionality
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
@ -23,6 +20,10 @@ from openflexure_microscope_server.things.autofocus import (
|
||||||
MAX_TEST_IMAGE_COUNT,
|
MAX_TEST_IMAGE_COUNT,
|
||||||
_get_capture_by_id,
|
_get_capture_by_id,
|
||||||
_get_capture_index_by_id,
|
_get_capture_index_by_id,
|
||||||
|
EXTRA_STACK_CAPTURES,
|
||||||
|
NotAPeakError,
|
||||||
|
_get_peak_turning_point,
|
||||||
|
_count_turning_points,
|
||||||
)
|
)
|
||||||
from openflexure_microscope_server.scan_directories import IMAGE_REGEX
|
from openflexure_microscope_server.scan_directories import IMAGE_REGEX
|
||||||
|
|
||||||
|
|
@ -352,3 +353,338 @@ def test_coercing_stack_save_ims(
|
||||||
assert stack_params.images_to_save == coerced_save_ims
|
assert stack_params.images_to_save == coerced_save_ims
|
||||||
# Check that the setting in the Thing was updated to the coerced value
|
# Check that the setting in the Thing was updated to the coerced value
|
||||||
assert stack_params.images_to_save == autofocus_thing.stack_images_to_save
|
assert stack_params.images_to_save == autofocus_thing.stack_images_to_save
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pass_on", [1, 2, 3, 4])
|
||||||
|
def test_run_smart_stack(pass_on, autofocus_thing, mocker):
|
||||||
|
"""Test Running smart stack with the stack passing on different attempts."""
|
||||||
|
cam = mocker.Mock()
|
||||||
|
stage = mocker.Mock()
|
||||||
|
sharpness_monitor = mocker.MagicMock()
|
||||||
|
stack_params = autofocus_thing.create_stack_params(
|
||||||
|
autofocus_dz=2000,
|
||||||
|
images_dir="/this/is/fake",
|
||||||
|
save_resolution=(1640, 1232),
|
||||||
|
logger=LOGGER,
|
||||||
|
)
|
||||||
|
assert stack_params.max_attempts == 3
|
||||||
|
|
||||||
|
# Set up returns from z-stack
|
||||||
|
fake_captures = [
|
||||||
|
CaptureInfo(
|
||||||
|
buffer_id="first", position={"x": 0, "y": 0, "z": -99}, sharpness=123
|
||||||
|
),
|
||||||
|
CaptureInfo(
|
||||||
|
buffer_id="pick_me", position={"x": 0, "y": 0, "z": 555}, sharpness=456
|
||||||
|
),
|
||||||
|
CaptureInfo(
|
||||||
|
buffer_id="last", position={"x": 0, "y": 0, "z": 999}, sharpness=123
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
successful_return = (True, fake_captures, "pick_me")
|
||||||
|
failed_return = (False, fake_captures, "pick_me")
|
||||||
|
return_list = [failed_return] * (pass_on - 1) + [successful_return]
|
||||||
|
|
||||||
|
# Mock z_stack and looping_autofocus
|
||||||
|
autofocus_thing.z_stack = mocker.Mock(side_effect=return_list)
|
||||||
|
autofocus_thing.looping_autofocus = mocker.Mock()
|
||||||
|
|
||||||
|
# Run it
|
||||||
|
success, final_z = autofocus_thing.run_smart_stack(
|
||||||
|
cam=cam,
|
||||||
|
stage=stage,
|
||||||
|
sharpness_monitor=sharpness_monitor,
|
||||||
|
stack_parameters=stack_params,
|
||||||
|
save_on_failure=False,
|
||||||
|
check_turning_points=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only passes if the attempt it passes on is less than max attempts
|
||||||
|
assert success == (pass_on <= stack_params.max_attempts)
|
||||||
|
# Final z is the one from the id returned by the stack "pick_me"
|
||||||
|
assert final_z == 555
|
||||||
|
|
||||||
|
# z_stack should run up until the time it passes. Running no more than max_attempts
|
||||||
|
n_stacks = min(pass_on, stack_params.max_attempts)
|
||||||
|
assert autofocus_thing.z_stack.call_count == n_stacks
|
||||||
|
# Move absolute should be 1 less time that the number of times z_stack_run
|
||||||
|
assert stage.move_absolute.call_count == n_stacks - 1
|
||||||
|
# As should looping autofocus
|
||||||
|
assert autofocus_thing.looping_autofocus.call_count == n_stacks - 1
|
||||||
|
|
||||||
|
# Check rest stack is moving to the first image in the stack.
|
||||||
|
if n_stacks > 1:
|
||||||
|
assert stage.move_absolute.call_args.kwargs["z"] == -99
|
||||||
|
|
||||||
|
# Mock called to save image
|
||||||
|
assert cam.save_from_memory.call_count == (1 if success else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing, mocker):
|
||||||
|
"""Set up a z_stack, run it, and return the result.
|
||||||
|
|
||||||
|
:param check_returns: The return values from check_stack_result. Note that if this
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
stack_params = autofocus_thing.create_stack_params(
|
||||||
|
autofocus_dz=2000,
|
||||||
|
images_dir="/this/is/fake",
|
||||||
|
save_resolution=(1640, 1232),
|
||||||
|
logger=LOGGER,
|
||||||
|
)
|
||||||
|
stack_params.settling_time = 0 # Don't settle or tests take forever.
|
||||||
|
|
||||||
|
stage = mocker.Mock()
|
||||||
|
cam = mocker.Mock()
|
||||||
|
autofocus_thing.capture_stack_image = mocker.Mock()
|
||||||
|
if isinstance(check_returns, list):
|
||||||
|
autofocus_thing.check_stack_result = mocker.Mock(side_effect=check_returns)
|
||||||
|
else:
|
||||||
|
autofocus_thing.check_stack_result = mocker.Mock(return_value=check_returns)
|
||||||
|
return autofocus_thing.z_stack(
|
||||||
|
stack_parameters=stack_params,
|
||||||
|
check_turning_points=check_turning_points,
|
||||||
|
cam=cam,
|
||||||
|
stage=stage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_z_stack_turning_toggle_passed(autofocus_thing, mocker):
|
||||||
|
"""Check that the toggling of turning points is passed to the check."""
|
||||||
|
check_returns = ("success", "mock_id")
|
||||||
|
for check_turning in [True, False]:
|
||||||
|
setup_and_run_z_stack(check_returns, check_turning, autofocus_thing, mocker)
|
||||||
|
check_kwargs = autofocus_thing.check_stack_result.call_args.kwargs
|
||||||
|
assert check_kwargs["check_turning_points"] == check_turning
|
||||||
|
|
||||||
|
|
||||||
|
def test_z_stack_returns_on_success_and_restart(autofocus_thing, mocker):
|
||||||
|
"""Check that if the check returns success or restart then the stack exits with correct return value."""
|
||||||
|
for result in ["success", "restart"]:
|
||||||
|
check_returns = (result, "mock_id")
|
||||||
|
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
|
||||||
|
assert autofocus_thing.check_stack_result.call_count == 1
|
||||||
|
# Check the number of images taken is exactly the call count.
|
||||||
|
ims_taken = autofocus_thing.capture_stack_image.call_count
|
||||||
|
assert ims_taken == autofocus_thing.stack_min_images_to_test
|
||||||
|
# And the result is as expected.
|
||||||
|
assert ret[0] == (result == "success")
|
||||||
|
|
||||||
|
|
||||||
|
def test_z_stack_exits_if_focus_never_found(autofocus_thing, mocker):
|
||||||
|
"""Check that if the check returns continue the stack exits eventually with a failure."""
|
||||||
|
check_returns = ("continue", "mock_id")
|
||||||
|
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
|
||||||
|
|
||||||
|
assert autofocus_thing.check_stack_result.call_count == EXTRA_STACK_CAPTURES + 1
|
||||||
|
# Check the number of images taken is the maximum possible, set by the min images to
|
||||||
|
# test and the number of extra images that can be taken
|
||||||
|
ims_taken = autofocus_thing.capture_stack_image.call_count
|
||||||
|
max_ims = autofocus_thing.stack_min_images_to_test + EXTRA_STACK_CAPTURES
|
||||||
|
assert ims_taken == max_ims
|
||||||
|
# And the result is as expected.
|
||||||
|
assert not ret[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_z_stack_return(autofocus_thing, mocker):
|
||||||
|
"""Check z-stack returns as expected for more complex cases the fixed results above."""
|
||||||
|
for i in range(2, EXTRA_STACK_CAPTURES):
|
||||||
|
check_returns = [
|
||||||
|
("restart" if j == i - 1 else "continue", f"id_{j}") for j in range(i)
|
||||||
|
]
|
||||||
|
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
|
||||||
|
# Calculate images taken
|
||||||
|
images_taken = autofocus_thing.stack_min_images_to_test + i - 1
|
||||||
|
assert autofocus_thing.capture_stack_image.call_count == images_taken
|
||||||
|
# Check it reports a failure
|
||||||
|
assert not ret[0]
|
||||||
|
|
||||||
|
# Repeat ending with a success rather than a failure
|
||||||
|
check_returns = [
|
||||||
|
("success" if j == i - 1 else "continue", f"id_{j}") for j in range(i)
|
||||||
|
]
|
||||||
|
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
|
||||||
|
# Calculate images taken
|
||||||
|
assert autofocus_thing.capture_stack_image.call_count == images_taken
|
||||||
|
# Check it reports a success
|
||||||
|
assert ret[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_stack_image(autofocus_thing, mocker):
|
||||||
|
"""Check that capture stack image calls the expected functions and returns the expected data."""
|
||||||
|
stage = mocker.Mock()
|
||||||
|
stage.position = {"x": 123, "y": 456, "z": 789}
|
||||||
|
cam = mocker.Mock()
|
||||||
|
cam.capture_to_memory.return_value = "fake_buffer_id"
|
||||||
|
cam.grab_jpeg_size.return_value = 54321
|
||||||
|
buffer_max = 11
|
||||||
|
|
||||||
|
info = autofocus_thing.capture_stack_image(
|
||||||
|
cam=cam, stage=stage, buffer_max=buffer_max
|
||||||
|
)
|
||||||
|
assert cam.capture_to_memory.call_count == 1
|
||||||
|
assert cam.grab_jpeg_size.call_count == 1
|
||||||
|
assert info.buffer_id == "fake_buffer_id"
|
||||||
|
assert info.position == {"x": 123, "y": 456, "z": 789}
|
||||||
|
assert info.sharpness == 54321
|
||||||
|
|
||||||
|
|
||||||
|
def mock_capture(buffer_id: int, sharpness: int) -> CaptureInfo:
|
||||||
|
"""Create a CaptureInfo instance with a dummy position."""
|
||||||
|
return CaptureInfo(
|
||||||
|
buffer_id=buffer_id,
|
||||||
|
position={"x": 0, "y": 0, "z": buffer_id},
|
||||||
|
sharpness=sharpness,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_stack_single_image_returns_success(autofocus_thing):
|
||||||
|
"""A single image is always successful."""
|
||||||
|
captures = [mock_capture("mock-id", 10)]
|
||||||
|
result, cap_id = autofocus_thing.check_stack_result(
|
||||||
|
captures, check_turning_points=False
|
||||||
|
)
|
||||||
|
assert result == "success"
|
||||||
|
assert cap_id == "mock-id"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("sharpnesses", "expected"),
|
||||||
|
[
|
||||||
|
([5, 10, 3], "success"),
|
||||||
|
([10, 4, 2], "restart"),
|
||||||
|
([1, 2, 10], "continue"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_check_stack_three_image_logic(sharpnesses, expected, autofocus_thing):
|
||||||
|
"""For 3 images, success is the highest one is central."""
|
||||||
|
captures = [mock_capture(i, s) for i, s in enumerate(sharpnesses)]
|
||||||
|
result, _ = autofocus_thing.check_stack_result(captures, check_turning_points=False)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def _run_check_stack_with_good_peak(autofocus_thing, count_turnings=False):
|
||||||
|
"""Run check stack on a good peak that should pass, and return the result.
|
||||||
|
|
||||||
|
This can be used to check how other mocked results of subfunctions affects the
|
||||||
|
result.
|
||||||
|
"""
|
||||||
|
# Create an obvious peak that would normally pass.
|
||||||
|
sharpnesses = [1, 2, 4, 7, 12, 7, 4, 2, 1]
|
||||||
|
captures = [mock_capture(i, s) for i, s in enumerate(sharpnesses)]
|
||||||
|
|
||||||
|
result, cap_id = autofocus_thing.check_stack_result(
|
||||||
|
captures, check_turning_points=count_turnings
|
||||||
|
)
|
||||||
|
# Nothing a mocked function does should change which is the sharpest image.
|
||||||
|
assert cap_id == 4
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_stack_continues_if_no_tuning_point(autofocus_thing, mocker):
|
||||||
|
"""Check that continue is returned if no turning point is found."""
|
||||||
|
# Mock to simulate not finding a peak
|
||||||
|
mocker.patch(
|
||||||
|
"openflexure_microscope_server.things.autofocus._get_peak_turning_point",
|
||||||
|
side_effect=NotAPeakError,
|
||||||
|
)
|
||||||
|
result = _run_check_stack_with_good_peak(autofocus_thing)
|
||||||
|
# Check that the NotAPeakError causes it to continue instead.
|
||||||
|
assert result == "continue"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("location", "expected"),
|
||||||
|
[
|
||||||
|
(-10, "restart"), # Restart if lower than 1.5 (halfway between im 2 and 3)
|
||||||
|
(-1, "restart"),
|
||||||
|
(0, "restart"),
|
||||||
|
(1, "restart"),
|
||||||
|
(1.49, "restart"),
|
||||||
|
(1.5, "success"), # Success up to 6.5 (as we have 9 images, final index is 8)
|
||||||
|
(2.5, "success"),
|
||||||
|
(4.5, "success"),
|
||||||
|
(6.5, "success"),
|
||||||
|
(6.51, "continue"), # Continue if thrung point is after 6.5
|
||||||
|
(7, "continue"),
|
||||||
|
(8.1, "continue"),
|
||||||
|
(123, "continue"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_check_stack_affected_by_turning_point_location(
|
||||||
|
location, expected, autofocus_thing, mocker
|
||||||
|
):
|
||||||
|
"""Check that the turning point location affects the return as expected."""
|
||||||
|
# Mock to give the turning point location specified
|
||||||
|
mocker.patch(
|
||||||
|
"openflexure_microscope_server.things.autofocus._get_peak_turning_point",
|
||||||
|
return_value=location,
|
||||||
|
)
|
||||||
|
result = _run_check_stack_with_good_peak(autofocus_thing)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_stack_affected_by_number_of_turning_points(autofocus_thing, mocker):
|
||||||
|
"""Check that the turning point location affects the return as expected."""
|
||||||
|
# Set the turning point to the centre
|
||||||
|
mocker.patch(
|
||||||
|
"openflexure_microscope_server.things.autofocus._get_peak_turning_point",
|
||||||
|
return_value=5,
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
"openflexure_microscope_server.things.autofocus._count_turning_points",
|
||||||
|
return_value=1,
|
||||||
|
)
|
||||||
|
result = _run_check_stack_with_good_peak(autofocus_thing, count_turnings=True)
|
||||||
|
# Successful with 1 peak
|
||||||
|
assert result == "success"
|
||||||
|
|
||||||
|
# Change return to be 2 peaks
|
||||||
|
mocker.patch(
|
||||||
|
"openflexure_microscope_server.things.autofocus._count_turning_points",
|
||||||
|
return_value=2,
|
||||||
|
)
|
||||||
|
result = _run_check_stack_with_good_peak(autofocus_thing, count_turnings=True)
|
||||||
|
# Continue with 2 peaks
|
||||||
|
assert result == "continue"
|
||||||
|
# Unless this check is turned off
|
||||||
|
result = _run_check_stack_with_good_peak(autofocus_thing, count_turnings=False)
|
||||||
|
assert result == "success"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_peak_turning_point():
|
||||||
|
"""Check that the peak fitting returns expected value (or error)."""
|
||||||
|
with pytest.raises(NotAPeakError):
|
||||||
|
_get_peak_turning_point(np.ones(9))
|
||||||
|
|
||||||
|
linear = np.arange(9)
|
||||||
|
u_shape = 2 * (linear - 4) ** 2 + 17
|
||||||
|
peak = -2 * (linear - 4) ** 2 + 55
|
||||||
|
|
||||||
|
with pytest.raises(NotAPeakError):
|
||||||
|
_get_peak_turning_point(linear)
|
||||||
|
|
||||||
|
with pytest.raises(NotAPeakError):
|
||||||
|
_get_peak_turning_point(u_shape)
|
||||||
|
|
||||||
|
# Should be 4 to within a fitting error
|
||||||
|
assert abs(_get_peak_turning_point(peak) - 4) < 1e-7
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_turning_points():
|
||||||
|
"""Check the turing point count works as expected."""
|
||||||
|
linear = np.arange(9)
|
||||||
|
u_shape = 2 * (linear - 4) ** 2 + 17
|
||||||
|
peak = -2 * (linear - 4) ** 2 + 55
|
||||||
|
assert _count_turning_points(np.ones(9)) == 0
|
||||||
|
assert _count_turning_points(linear) == 0
|
||||||
|
assert _count_turning_points(u_shape) == 1
|
||||||
|
assert _count_turning_points(peak) == 1
|
||||||
|
|
||||||
|
assert _count_turning_points(np.array([1, 2, 3, 4, 5, 4, 3, 2, 1])) == 1
|
||||||
|
# Double peak is 3 points
|
||||||
|
assert _count_turning_points(np.array([1, 2, 3, 4, 2, 4, 3, 2, 1])) == 3
|
||||||
|
# But only one if the dip isn't prominent
|
||||||
|
assert _count_turning_points(np.array([1, 2, 3, 4, 3.8, 4, 3, 2, 1])) == 1
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
<li>
|
<li>
|
||||||
<a class="uk-accordion-title" href="#">Configure</a>
|
<a class="uk-accordion-title" href="#">Configure</a>
|
||||||
<div class="uk-accordion-content">
|
<div class="uk-accordion-content">
|
||||||
|
<h4 v-if="backgroundDetectorName" class="detector-name">
|
||||||
|
{{ backgroundDetectorName }}
|
||||||
|
</h4>
|
||||||
<input-from-schema
|
<input-from-schema
|
||||||
v-if="backgroundDetectorStatus"
|
v-if="backgroundDetectorStatus"
|
||||||
v-model="backgroundDetectorStatus.settings"
|
v-model="backgroundDetectorStatus.settings"
|
||||||
|
|
@ -59,6 +62,7 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
backgroundDetectorStatus: undefined,
|
backgroundDetectorStatus: undefined,
|
||||||
|
backgroundDetectorName: undefined,
|
||||||
animate: false,
|
animate: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
@ -90,6 +94,7 @@ export default {
|
||||||
this.modalNotify(`Current image is ${label} (${r.output[1]})`);
|
this.modalNotify(`Current image is ${label} (${r.output[1]})`);
|
||||||
},
|
},
|
||||||
readSettings: async function () {
|
readSettings: async function () {
|
||||||
|
this.backgroundDetectorName = await this.readThingProperty("camera", "detector_name");
|
||||||
this.backgroundDetectorStatus = await this.readThingProperty(
|
this.backgroundDetectorStatus = await this.readThingProperty(
|
||||||
"camera",
|
"camera",
|
||||||
"background_detector_status",
|
"background_detector_status",
|
||||||
|
|
@ -106,3 +111,8 @@ export default {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<style scoped lang="less">
|
||||||
|
.detector-name {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue