From d0b5db21bae385d596d0f384912f7cdc7ef2b4c7 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 25 Jul 2025 11:25:54 +0100 Subject: [PATCH] Start adding background detect tests --- .../background_detect.py | 43 ++++--- tests/test_background_detectors.py | 106 ++++++++++++++++++ 2 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 tests/test_background_detectors.py diff --git a/src/openflexure_microscope_server/background_detect.py b/src/openflexure_microscope_server/background_detect.py index eb1297a8..283151fd 100644 --- a/src/openflexure_microscope_server/background_detect.py +++ b/src/openflexure_microscope_server/background_detect.py @@ -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,16 +80,21 @@ class BackgroundDetectAlgorithm: @background_data.setter def background_data(self, value: Optional[BaseModel | dict]) -> None: - if value is None: - self._background_data = None - elif isinstance(value, self.background_data_model): - self._background_data = value - elif isinstance(value, dict): - self._background_data = self.background_data_model(**value) - else: - raise TypeError( - f"Cannot set background_data with an object of type {type(value)}" - ) + try: + if value is None: + self._background_data = None + elif isinstance(value, self.background_data_model): + self._background_data = value + elif isinstance(value, dict): + self._background_data = self.background_data_model(**value) + else: + 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 diff --git a/tests/test_background_detectors.py b/tests/test_background_detectors.py new file mode 100644 index 00000000..cd3d272f --- /dev/null +++ b/tests/test_background_detectors.py @@ -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. \ No newline at end of file