diff --git a/src/openflexure_microscope_server/things/background_detect.py b/src/openflexure_microscope_server/things/background_detect.py index ad113db4..3627ca18 100644 --- a/src/openflexure_microscope_server/things/background_detect.py +++ b/src/openflexure_microscope_server/things/background_detect.py @@ -36,6 +36,15 @@ class BackgroundDetectAlgorithm(lt.Thing): display_name: str = lt.property(default="Base Detector", readonly=True) + def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None: + """Initialise and create the lock.""" + if self.display_name == "Base Detector": + raise NotImplementedError( + "Do not try to use the BackgroungDetectAlgorithm directly. " + " Use a subclass" + ) + super().__init__(thing_server_interface) + @lt.property def ready(self) -> bool: """Whether the background detector is ready.""" diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index b0886614..c7fd1fa5 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -38,7 +38,7 @@ import labthings_fastapi as lt from labthings_fastapi.exceptions import ServerNotRunningError from labthings_fastapi.types.numpy import NDArray -from openflexure_microscope_server.background_detect import ChannelBlankError +from openflexure_microscope_server.things.background_detect import ChannelBlankError from openflexure_microscope_server.ui import ( ActionButton, PropertyControl, diff --git a/tests/hardware_specific_tests/picamera2/test_calibration.py b/tests/hardware_specific_tests/picamera2/test_calibration.py index 768461f9..d9181d90 100644 --- a/tests/hardware_specific_tests/picamera2/test_calibration.py +++ b/tests/hardware_specific_tests/picamera2/test_calibration.py @@ -19,7 +19,7 @@ def test_calibration(picamera_test_env): # Tuning should start the same as the server is loading with no settings. assert picamera_thing.default_tuning == picamera_thing.tuning # The background detector isn't ready as there is no background image. - assert not picamera_thing.background_detector_status.ready + assert not picamera_thing.active_detector.ready # Run full auto calibrate picamera_client.full_auto_calibrate() @@ -31,7 +31,7 @@ def test_calibration(picamera_test_env): # The default should be unchanged assert picamera_thing.default_tuning == original_default - assert picamera_thing.background_detector_status.ready + assert picamera_thing.active_detector.ready def test_tuning_is_persistent(): diff --git a/tests/unit_tests/test_background_detectors.py b/tests/unit_tests/test_background_detectors.py index 1484bfdc..299fa535 100644 --- a/tests/unit_tests/test_background_detectors.py +++ b/tests/unit_tests/test_background_detectors.py @@ -7,15 +7,15 @@ import re import numpy as np import pytest -from pydantic import BaseModel -from openflexure_microscope_server.background_detect import ( +import labthings_fastapi as lt +from labthings_fastapi.testing import create_thing_without_server + +from openflexure_microscope_server.things.background_detect import ( BackgroundDetectAlgorithm, ChannelBlankError, ChannelDeviationLUV, - ChannelDistributions, ColourChannelDetectLUV, - ColourChannelDetectSettings, MissingBackgroundDataError, _chunked_stds, ) @@ -55,56 +55,45 @@ def test_bg_detect_base_class(): If initialised as is it should raise not implemented error. """ with pytest.raises(NotImplementedError): - BackgroundDetectAlgorithm() + create_thing_without_server(BackgroundDetectAlgorithm) def test_partial_base_classes(): - """Create a partial classes and check they raise the correct errors.""" + """Create a partial class and check it raises the correct errors.""" - class BadAlgo1(BackgroundDetectAlgorithm): - """Only has a settings model so it cannot initialise.""" + class BadAlgo(BackgroundDetectAlgorithm): + """Can initialise. Other properties and methods error.""" - settings_data_model = ColourChannelDetectSettings + display_name: str = lt.property(default="Bad Algorithm", readonly=True) + + bad_algo = create_thing_without_server(BadAlgo) with pytest.raises(NotImplementedError): - BadAlgo1() - - class BadAlgo2(BackgroundDetectAlgorithm): - """Only has a background model so it cannot initialise.""" - - background_data_model = ChannelDistributions + bad_algo.ready with pytest.raises(NotImplementedError): - BadAlgo2() - - class BadAlgo3(BackgroundDetectAlgorithm): - """Has both models, intalises by cannot run set_background or image_is_sample.""" - - settings_data_model = ColourChannelDetectSettings - background_data_model = ChannelDistributions - - bad_algo3 = BadAlgo3() + bad_algo.settings_ui with pytest.raises(NotImplementedError): - bad_algo3.set_background(background_image) + bad_algo.set_background(background_image) with pytest.raises(NotImplementedError): - bad_algo3.image_is_sample(background_image) + bad_algo.image_is_sample(background_image) def test_colour_channel_luv(background_image, sample_image): """Test measuring if a sample is background.""" - cc_luv = ColourChannelDetectLUV() + cc_luv = create_thing_without_server(ColourChannelDetectLUV) # No background data so it is not ready and will error if image_is_sample is called. - assert not cc_luv.status.ready + assert not cc_luv.ready with pytest.raises(MissingBackgroundDataError): 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 + assert cc_luv.ready sample, message = cc_luv.image_is_sample(background_image) assert not sample assert "0.0%" in message @@ -116,7 +105,7 @@ def test_colour_channel_luv(background_image, sample_image): assert 49.8 < float(match.group(1)) < 50.2 # Require 75% coverage - cc_luv.settings = ColourChannelDetectSettings(min_sample_coverage=75.0) + cc_luv.min_sample_coverage = 75.0 sample, message = cc_luv.image_is_sample(sample_image) # No longer detected as a sample. @@ -127,69 +116,6 @@ def test_colour_channel_luv(background_image, sample_image): assert 49.8 < float(match.group(1)) < 50.2 -def test_colour_channel_luv_save_load(background_image, sample_image): - """Get settings and data as dicts, and creating new instance using these dicts. - - This is how the camera will load/save settings from/to disk. - """ - cc_luv = ColourChannelDetectLUV() - cc_luv.set_background(background_image) - - # Check types for background data - assert cc_luv.background_data_model is ChannelDistributions - assert isinstance(cc_luv.background_data, cc_luv.background_data_model) - # Change Settings - cc_luv.settings = ColourChannelDetectSettings(min_sample_coverage=10.0) - - setting_dict = cc_luv.settings.model_dump() - data_dict = cc_luv.background_data.model_dump() - - # Remove the old detector so we don't accidentally use it! - del cc_luv - - # Create a new instance - cc_luv2 = ColourChannelDetectLUV() - assert not cc_luv2.status.ready - # Load settings and channels from dictionary as the camera will do on init. - cc_luv2.settings = setting_dict - cc_luv2.background_data = data_dict - - # Now should be ready to use - assert cc_luv2.status.ready - assert cc_luv2.settings.min_sample_coverage == 10.0 - sample, _ = cc_luv2.image_is_sample(sample_image) - assert sample - - # Remove the 2nd detector so we don't accidentally use it! - del cc_luv2 - - # One final test that None can be set explicitly to background data as this will - # happen if loading with background detect not saved. - cc_luv3 = ColourChannelDetectLUV() - assert not cc_luv3.status.ready - # Load settings and channels from dictionary as the camera will do on init. - cc_luv3.settings = setting_dict - cc_luv3.background_data = None - # Still not ready - assert not cc_luv3.status.ready - - -def test_colour_channel_luv_load_bad_data(): - """Check a type error is thrown on bad data input.""" - - class WrongModel(BaseModel): - """Using a different BaseModel as this is most likely to cause confusion.""" - - prop1: int = 8 - prop2: str = "foo" - - cc_luv = ColourChannelDetectLUV() - with pytest.raises(TypeError): - cc_luv.settings = WrongModel() - with pytest.raises(TypeError): - cc_luv.background_data = WrongModel() - - def create_patchwork_image(magnitude=3, blank_channels=None): """Create a patchwork image, with known stds per chunk. @@ -239,7 +165,7 @@ def test_chunked_stds_with_precomputed_chunk_stds(): 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() + cd_luv = create_thing_without_server(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) @@ -252,7 +178,7 @@ def test_channel_deviation_luv_set_background(mocker): # Do a somewhat verbose checking for clarity for channel in range(3): # Saved std - channel_std = cd_luv.background_data.standard_deviations[channel] + channel_std = cd_luv.background_stds[channel] # Expected median channel_median = np.median(expected_stds[:, :, channel]) # If the median is above the minimum allowed then it should be returned @@ -270,21 +196,21 @@ def test_channel_deviation_luv_set_background(mocker): 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() + cd_luv = create_thing_without_server(ChannelDeviationLUV) # No background data so it is not ready and will error if image_is_sample is called. - assert not cd_luv.status.ready + assert not cd_luv.ready with pytest.raises(MissingBackgroundDataError): cd_luv.image_is_sample(background_image) - cd_luv.settings.min_sample_coverage = 20 + cd_luv.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 + cd_luv.min_sample_coverage = 9 is_sample, message = cd_luv.image_is_sample(background_image) assert is_sample @@ -293,34 +219,29 @@ def test_channel_deviation_luv_image_is_sample(background_image, mocker): def test_channel_deviation_luv_get_sample_coverage(background_image, mocker): """Check _get_sample_coverage returns the values expected.""" - cd_luv = ChannelDeviationLUV() + cd_luv = create_thing_without_server(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", + "openflexure_microscope_server.things.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] - ) + cd_luv.background_stds = [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 + cd_luv.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] - ) + cd_luv.background_stds = [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] - ) + cd_luv.background_stds = [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 diff --git a/tests/unit_tests/test_simulated_camera.py b/tests/unit_tests/test_simulated_camera.py index b9243f4a..db3d9d48 100644 --- a/tests/unit_tests/test_simulated_camera.py +++ b/tests/unit_tests/test_simulated_camera.py @@ -10,6 +10,7 @@ from hypothesis import strategies as st import labthings_fastapi as lt +from openflexure_microscope_server.things.background_detect import ChannelDeviationLUV from openflexure_microscope_server.things.camera import simulation from openflexure_microscope_server.things.camera.simulation import SimulatedCamera from openflexure_microscope_server.things.stage.dummy import DummyStage @@ -20,7 +21,11 @@ from ..shared_utils.lt_test_utils import LabThingsTestEnv @pytest.fixture def test_env() -> LabThingsTestEnv: """Yield a test environment with the Simulated Camera and Dummy Stage.""" - thing_conf = {"camera": SimulatedCamera, "stage": DummyStage} + thing_conf = { + "camera": SimulatedCamera, + "stage": DummyStage, + "bg_channel_deviations_luv": ChannelDeviationLUV, + } with LabThingsTestEnv(things=thing_conf) as env: yield env @@ -181,4 +186,4 @@ def test_simulation_cam_calibration(camera): assert camera.calibration_required camera.full_auto_calibrate() assert not camera.calibration_required - assert camera.background_detector_status.ready + assert camera.active_detector.ready