Start adding background detect tests

This commit is contained in:
Julian Stirling 2025-07-25 11:25:54 +01:00
parent ab6ccd429a
commit d0b5db21ba
2 changed files with 131 additions and 18 deletions

View file

@ -14,6 +14,10 @@ from scipy.stats import norm
from labthings_fastapi.thing_description import type_to_dataschema
class MissingBackgroundData(RuntimeError):
"""An error raised if checking for sample without background data set."""
class BackgroundDetectorStatus(BaseModel):
"""The status information about a background detector instance needed for the GUI.
@ -39,9 +43,9 @@ class BackgroundDetectorStatus(BaseModel):
class BackgroundDetectAlgorithm:
"""The base class for defining background detect algorithms."""
background_data_model: BaseModel
background_data_model: BaseModel = BaseModel
"""The data model of the background data. This must be set by child classes"""
settings_data_model: BaseModel
settings_data_model: BaseModel = BaseModel
"""The data model of algorithm settings. This must be set by child classes"""
def __init__(self):
@ -76,6 +80,7 @@ class BackgroundDetectAlgorithm:
@background_data.setter
def background_data(self, value: Optional[BaseModel | dict]) -> None:
try:
if value is None:
self._background_data = None
elif isinstance(value, self.background_data_model):
@ -86,6 +91,10 @@ class BackgroundDetectAlgorithm:
raise TypeError(
f"Cannot set background_data with an object of type {type(value)}"
)
except PydanticUserError as e:
raise NotImplementedError(
"BackgroundDetectAlgorithms must set their own background data model."
) from e
@property
def settings(self) -> BaseModel:
@ -168,10 +177,9 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
"""
d = self.background_data
if not d:
raise RuntimeError(
raise MissingBackgroundData(
"Background is not set: you need to calibrate background detection."
)
print(d)
# Only use the U and V channels of as brightness (L) often changes as the
# height of the sample changes.
return np.all(
@ -192,9 +200,7 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
"""
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
mask = self.background_mask(image_luv)
print(np.count_nonzero(mask))
print(np.prod(mask.shape))
print((1 - np.count_nonzero(mask) / np.prod(mask.shape)) * 100)
return (1 - np.count_nonzero(mask) / np.prod(mask.shape)) * 100
def image_is_sample(self, image: np.ndarray) -> tuple[bool, str]:
@ -206,7 +212,8 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
"""
sample_coverage = self.get_sample_coverage(image)
is_sample = sample_coverage > self.settings.min_sample_coverage
# 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

View file

@ -0,0 +1,106 @@
"""Test the scan planning algorithms of the Microscope.
As well as low level function by function tests, this test suite also provides tests
that simulate scanning a sample, checking that the expected path is followed.
"""
import re
import pytest
from pydantic import BaseModel
import numpy as np
# Just for testing importing as
from openflexure_microscope_server.background_detect import (
MissingBackgroundData,
BackgroundDetectorStatus,
BackgroundDetectAlgorithm,
ChannelDistributions,
ColourChannelDetectSettings,
ColourChannelDetectLUV
)
RNG = np.random.default_rng()
IMG_SHAPE = (820, 616, 3)
BG_COLOR = [220, 215, 217]
PERC_REGEX = re.compile(r"(\d+\.+\d)+%")
@pytest.fixture
def background_image():
image = np.ones(IMG_SHAPE, dtype=np.int16)
image[:, :, 0] *= BG_COLOR[0]
image[:, :, 1] *= BG_COLOR[1]
image[:, :, 2] *= BG_COLOR[2]
image += RNG.normal(scale=3, size=IMG_SHAPE).astype("int16")
image[image < 0] = 0
image[image > 255] = 255
return image.astype("uint8")
@pytest.fixture
def sample_image(background_image):
image = background_image.copy()
x_cent = IMG_SHAPE[0]//2
image[:x_cent,:,0] -= 180
return image
def test_bg_detect_base_class():
"""Test the base class for background detect.
If initialised as is it should raise not implemented error.
"""
with pytest.raises(NotImplementedError):
BackgroundDetectAlgorithm()
def test_partial_base_class(background_image):
"""Create a partial class so to initialise the base class and test other methods.
This test is to check that if the necessary methods are not set, that an
appropriate error is raise.
"""
class BadAlgo1(BackgroundDetectAlgorithm):
"""Only has a settings model so it can initialise."""
settings_data_model: BaseModel = ColourChannelDetectSettings
bad_algo1 = BadAlgo1()
status = bad_algo1.status
assert not status.ready
assert isinstance(status.settings, ColourChannelDetectSettings)
with pytest.raises(NotImplementedError):
# Should error on any dictionary input. This simulates loading settings from
# disk
bad_algo1.background_data = {"key": 1}
with pytest.raises(NotImplementedError):
bad_algo1.set_background(background_image)
with pytest.raises(NotImplementedError):
bad_algo1.image_is_sample(background_image)
def test_colour_channel_luv(background_image, sample_image):
cc_luv = ColourChannelDetectLUV()
# No background data so it is not ready and will error if image_is_sample is called.
assert not cc_luv.status.ready
with pytest.raises(MissingBackgroundData):
cc_luv.image_is_sample(background_image)
# Set the background
cc_luv.set_background(background_image)
# Now it is ready
assert cc_luv.status.ready
sample, message = cc_luv.image_is_sample(background_image)
assert not sample
assert "0.0%" in message
sample, message = cc_luv.image_is_sample(sample_image)
assert sample
match = PERC_REGEX.search(message)
assert match is not None
# Should be 50% background. Allowing 49.8-50.2% due to noise.
assert 49.8 < float(match.group(1)) < 50.2
# TODO Save data and reload it.