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:
Joe Knapper 2025-12-02 15:47:03 +00:00
commit 99afe89c10
10 changed files with 874 additions and 81 deletions

Binary file not shown.

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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]:

View file

@ -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
View 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

View file

@ -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

View file

@ -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

View file

@ -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>