diff --git a/picamera_coverage.zip b/picamera_coverage.zip index 38b37143..6bc5e66f 100644 Binary files a/picamera_coverage.zip and b/picamera_coverage.zip differ diff --git a/src/openflexure_microscope_server/background_detect.py b/src/openflexure_microscope_server/background_detect.py index 6a1b417f..d9e39e15 100644 --- a/src/openflexure_microscope_server/background_detect.py +++ b/src/openflexure_microscope_server/background_detect.py @@ -8,7 +8,7 @@ current camera field of view contains sample. from typing import Optional, Any import cv2 import numpy as np -from pydantic import BaseModel +from pydantic import BaseModel, Field, ConfigDict from pydantic.errors import PydanticUserError from scipy.stats import norm from labthings_fastapi.thing_description import type_to_dataschema @@ -32,13 +32,19 @@ class BackgroundDetectorStatus(BaseModel): ``ready`` is used in case more complex methods are added in the future, which need different initialisation. """ - settings: BaseModel - """The settings for this this background detect Algorithm""" + settings: dict[str, Any] + """The settings for the current background detect Algorithm. These are a dictionary + dumped from the base model.""" # Setting schema is a dict until LabThings FastAPI issue #154 is fixed and # DataSchema can be used directly. For now `model_dump()` must be used to dump schema # to a dict. settings_schema: dict[str, Any] + """The schema for the settings for the current background detect Algorithm. + + This is reported so that the UI can dynamically create a UI for any background detector + algorithm. + """ class BackgroundDetectAlgorithm: @@ -63,7 +69,7 @@ class BackgroundDetectAlgorithm: """The status information needed for the GUI. Read only.""" return BackgroundDetectorStatus( ready=self.background_data is not None, - settings=self.settings, + settings=self.settings.model_dump(), # Dump model with `model_dump()` for reason explained when defining # BackgroundDetectorStatus settings_schema=type_to_dataschema(self.settings_data_model).model_dump(), @@ -152,13 +158,18 @@ class ChannelDistributions(BaseModel): class ColourChannelDetectSettings(BaseModel): """A BaseModel for storing the settings for colour channel detectors.""" + model_config = ConfigDict(extra="forbid") + channel_tolerance: float = 7.0 """Channel Tolerance The number of standard deviations a pixel value must be from the background mean to be considered sample. """ - min_sample_coverage: float = 25.0 + + # Use Field to set Title reported to UI. By default Pydantic will convert the name + # from snake_case to Title Case. + min_sample_coverage: float = Field(25, title="Sample Coverage Required (%)") """Sample Coverage Required (%) The minimum percentage of the image that needs to be identified as sample for the diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 4225b43d..656f0a0f 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -20,6 +20,7 @@ import piexif import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray +from openflexure_microscope_server.ui import ActionButton, PropertyControl from openflexure_microscope_server.background_detect import ( ColourChannelDetectLUV, BackgroundDetectAlgorithm, @@ -476,6 +477,21 @@ class BaseCamera(lt.Thing): time.sleep(self.settling_time) self.discard_frames() + @lt.thing_property + def primary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions for both calibration wizard and settings panel.""" + return [] + + @lt.thing_property + def secondary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions that appear only in settings panel.""" + return [] + + @lt.thing_property + def manual_camera_settings(self) -> list[PropertyControl]: + """The camera settings to expose as property controls in the settings panel.""" + return [] + # Note that the default detector name is set at init. This is over written if # setting is loaded from disk. @lt.thing_setting @@ -500,6 +516,22 @@ class BaseCamera(lt.Thing): """The status of the active detector for the UI.""" return self.active_detector.status + @lt.thing_action + def update_detector_settings(self, data) -> None: + """Update the settings of the current detector. + + This is an action not a setting/property as the data model depends on the + selected detector. As such, it cannot be specified with the necessary precision + to be included in a ThingDescription as a setting/property, while retaining + enough useful information to communicate to the UI how it is set and read. + + The information on how to read the settings is exposed in + ``background_detector_status``. + """ + self.active_detector.settings = data + # Manually save settings as the setter is not called. + self.save_settings() + @lt.thing_setting def background_detector_data(self) -> dict: """The data for each background detector, used to save to disk.""" diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 6ee9e200..1f67741f 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -35,6 +35,12 @@ from picamera2.outputs import Output import labthings_fastapi as lt from labthings_fastapi.exceptions import NotConnectedToServerError +from openflexure_microscope_server.ui import ( + ActionButton, + PropertyControl, + action_button_for, + property_control_for, +) from . import picamera_recalibrate_utils as recalibrate_utils from . import BaseCamera, JPEGBlob, ArrayModel @@ -777,6 +783,88 @@ class StreamingPiCamera2(BaseCamera): ) self._initialise_picamera() + @lt.thing_property + def primary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions for both calibration wizard and settings panel.""" + return [ + action_button_for( + self.full_auto_calibrate, + submit_label="Full Auto-Calibrate", + can_terminate=False, + requires_confirmation=True, + confirmation_message=( + "Start recalibration? This may take a while, and the microscope " + "will be locked during this time." + ), + notify_on_success=True, + success_message="Finished recalibration.", + ), + action_button_for( + self.auto_expose_from_minimum, + submit_label="Auto Gain & Shutter Speed", + can_terminate=False, + ), + action_button_for( + self.calibrate_white_balance, + submit_label="Auto White Balance", + can_terminate=False, + ), + action_button_for( + self.calibrate_lens_shading, + submit_label="Auto Flat Field Correction", + can_terminate=False, + requires_confirmation=True, + confirmation_message=( + "Is the microscope looking at an evenly illuminated, empty field " + "of view? If not, the current image will show through in any " + "images captured afterwards." + ), + ), + ] + + @lt.thing_property + def secondary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions that appear only in settings panel.""" + return [ + action_button_for( + self.flat_lens_shading, + submit_label="Disable Flat Field Correction", + can_terminate=False, + ), + action_button_for( + self.reset_lens_shading, + submit_label="Reset Flat Field Correction", + can_terminate=False, + ), + ] + + @lt.thing_property + def manual_camera_settings(self) -> list[PropertyControl]: + """The camera settings to expose as property controls in the settings panel.""" + return [ + property_control_for( + self, + "exposure_time", + label="Exposure Time (0-33251)", + read_back=True, + read_back_delay=1000, + ), + property_control_for( + self, + "analogue_gain", + label="Analogue Gain", + read_back=True, + read_back_delay=1000, + ), + property_control_for( + self, + "colour_gains", + label="Colour Gains", + read_back=True, + read_back_delay=1000, + ), + ] + @lt.thing_property def lens_shading_tables(self) -> Optional[LensShading]: """The current lens shading (i.e. flat-field correction). diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 76d11eb9..5903891b 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -21,6 +21,13 @@ from scipy.ndimage import gaussian_filter import labthings_fastapi as lt +from openflexure_microscope_server.ui import ( + ActionButton, + PropertyControl, + action_button_for, + property_control_for, +) + from . import BaseCamera, JPEGBlob, ArrayModel from ..stage import BaseStage @@ -40,6 +47,7 @@ class SimulatedCamera(BaseCamera): _stage: Optional[BaseStage] = None _server: Optional[lt.ThingServer] = None + _show_sample: bool = True def __init__( self, @@ -124,10 +132,11 @@ class SimulatedCamera(BaseCamera): Canvas is int16 so that random noise can be added to simulation image before changing to unit8 to stop wrapping. """ - self.canvas = np.ones(self.canvas_shape, dtype=np.int16) - self.canvas[:, :, 0] *= BG_COLOR[0] - self.canvas[:, :, 1] *= BG_COLOR[1] - self.canvas[:, :, 2] *= BG_COLOR[2] + self.blank_canvas = np.ones(self.canvas_shape, dtype=np.int16) + self.blank_canvas[:, :, 0] *= BG_COLOR[0] + self.blank_canvas[:, :, 1] *= BG_COLOR[1] + self.blank_canvas[:, :, 2] *= BG_COLOR[2] + self.canvas = self.blank_canvas.copy() w, h, _ = self.glyph_shape for x, y, sprite_size_index in self.blobs: self.canvas[ @@ -149,7 +158,8 @@ class SimulatedCamera(BaseCamera): x_slice = slice(top_left[0], top_left[0] + self.shape[0]) y_slice = slice(top_left[1], top_left[1] + self.shape[1]) z_slice = slice(None) - focused_image = self.canvas[(x_slice, y_slice, z_slice)] + canvas = self.canvas if self._show_sample else self.blank_canvas + focused_image = canvas[(x_slice, y_slice, z_slice)] image = gaussian_filter( focused_image, sigma=np.abs(pos[2]) / 5, @@ -161,7 +171,7 @@ class SimulatedCamera(BaseCamera): ) # Add noise and convert to uint8 - image += RNG.normal(scale=2, size=self.shape).astype("int16") + image += RNG.normal(scale=self.noise_level, size=self.shape).astype("int16") image[image < 0] = 0 image[image > 255] = 255 return image.astype("uint8") @@ -216,6 +226,8 @@ class SimulatedCamera(BaseCamera): return self._capture_thread.is_alive() return False + noise_level = lt.ThingProperty(float, 2.0) + def _capture_frames(self): portal = lt.get_blocking_portal(self) while self._capture_enabled: @@ -279,3 +291,30 @@ class SimulatedCamera(BaseCamera): output = io.BytesIO() piexif.insert(piexif.dump(exif_dict), jpeg, output) return JPEGBlob.from_bytes(output.getvalue()) + + @lt.thing_action + def remove_sample(self): + """Show the simulated background with no sample.""" + if not self._show_sample: + raise RuntimeError("Sample is already removed.") + self._show_sample = False + + @lt.thing_action + def load_sample(self): + """Show the simulated sample.""" + if self._show_sample: + raise RuntimeError("Sample is already in place.") + self._show_sample = True + + @lt.thing_property + def secondary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions that appear only in settings panel.""" + return [ + action_button_for(self.load_sample, submit_label="Load Sample"), + action_button_for(self.remove_sample, submit_label="Remove Sample"), + ] + + @lt.thing_property + def manual_camera_settings(self) -> list[PropertyControl]: + """The camera settings to expose as property controls in the settings panel.""" + return [property_control_for(self, "noise_level", label="Noise Level")] diff --git a/src/openflexure_microscope_server/ui.py b/src/openflexure_microscope_server/ui.py new file mode 100644 index 00000000..be2a5c12 --- /dev/null +++ b/src/openflexure_microscope_server/ui.py @@ -0,0 +1,94 @@ +"""Functionality for communicating the required user interface for a thing.""" + +from pydantic import BaseModel + + +class ActionButton(BaseModel): + """The data required for creating an actionButton in Vue. + + Currently this cannot be used to submitData, or to submitOnEvent. + """ + + thing: str + """The Thing "path" for the Thing instance.""" + + action: str + """The name of the action to be triggered.""" + + poll_interval: int = 1 + """The interval for polling in seconds.""" + + submit_label: str = "Submit" + """The label for the action button.""" + + can_terminate: bool = True + """Specify whether the action can be terminated.""" + + requires_confirmation: bool = False + """Specify whether a confirmation modal is needed.""" + + confirmation_message: str = "Start task?" + """The message for the confirmation modal.""" + + button_primary: bool = True + """Specify whether the button is styled as a primary button.""" + + modal_progress: bool = False + """Specify whether to show a progress modal.""" + + notify_on_success: bool = False + """Specify whether to a notification on successful completion.""" + + success_message: str = "Success!" + """The message to show on successful completion.""" + + +def action_button_for(action, **kwargs) -> ActionButton: + """Create a ActionButton data for the specified Thing Action. + + :param action: The thing action to create a button for. + :param kwargs: Any attribute of `ActionButton` except for ``thing`` or ``action``. + """ + thing = action.args[0] + thing_path = thing.path.strip("/") + action_name = action.func.__name__ + return ActionButton(thing=thing_path, action=action_name, **kwargs) + + +class PropertyControl(BaseModel): + """The data required for creating an actionButton in Vue. + + Currently this cannot be used to submitData, or to submitOnEvent. + """ + + thing: str + """The Thing "path" for the Thing instance.""" + + property_name: str + """The name of the property (or setting).""" + + label: str + """The label to show in the UI""" + + read_back: bool = False + """Whether or not to read back the property after setting. + + This is useful for hardware settings that may be coerced to the closest value. + """ + + read_back_delay: int = 1000 + """The delay in ms before reading back the property.""" + + +def property_control_for(thing, property_name, **kwargs) -> PropertyControl: + """Create an PropertyControl data for the specified Thing Property. + + :param thing: The instance of the thing that has the property to be controlled. + :param property_name: The name of the property to create a control for. + :param kwargs: Any attribute of `PropertyControl` except for ``thing`` or + ``property_name``. If label is not set here it will be the property name. + """ + thing_path = thing.path.strip("/") + if "label" not in kwargs: + kwargs["label"] = property_name + return PropertyControl(thing=thing_path, property_name=property_name, **kwargs) diff --git a/tests/mock_things/mock_camera.py b/tests/mock_things/mock_camera.py index 064812d1..81104dde 100644 --- a/tests/mock_things/mock_camera.py +++ b/tests/mock_things/mock_camera.py @@ -23,6 +23,6 @@ class MockCameraThing: background_detector_status = BackgroundDetectorStatus( ready=True, - settings=ColourChannelDetectSettings(), + settings=ColourChannelDetectSettings().model_dump(), settings_schema={"fake": "schema"}, ) diff --git a/tests/test_background_detectors.py b/tests/test_background_detectors.py index 4fc2f395..956a96e9 100644 --- a/tests/test_background_detectors.py +++ b/tests/test_background_detectors.py @@ -70,7 +70,8 @@ def test_partial_base_class(background_image): bad_algo1 = BadAlgo1() status = bad_algo1.status assert not status.ready - assert isinstance(status.settings, ColourChannelDetectSettings) + # Check the settings dictionary can be validated as ``ColourChannelDetectSettings`` + ColourChannelDetectSettings(**status.settings) with pytest.raises(NotImplementedError): # Should error on any dictionary input. This simulates loading settings from diff --git a/tests/test_cameras.py b/tests/test_cameras.py new file mode 100644 index 00000000..2960bc36 --- /dev/null +++ b/tests/test_cameras.py @@ -0,0 +1,133 @@ +"""Generic tests for cameras, specific camera hardware reqipres hardware specific tests.""" + +import pytest + +from openflexure_microscope_server.things.camera import BaseCamera +from openflexure_microscope_server.things.camera.simulation import SimulatedCamera +from openflexure_microscope_server.things.camera.opencv import OpenCVCamera + + +@pytest.fixture +def mock_picam_thing(mocker): + """Import PiCamera without hardware well enough to get a ThingDescription.""" + dummy_cam = mocker.Mock() + mock_picamera2 = mocker.MagicMock() + mock_picamera2.return_value.__enter__.return_value = dummy_cam + mock_picamera2.return_value.__exit__.return_value = None + + mocker.patch.dict( + "sys.modules", + { + "picamera2": mock_picamera2, + "picamera2.encoders": mocker.Mock(), + "picamera2.outputs": mocker.Mock(), + }, + ) + mocker.patch( + "openflexure_microscope_server.things.camera.picamera.recalibrate_utils.load_default_tuning", + return_value={"mock": "tuning"}, + ) + from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2 + + return StreamingPiCamera2() + + +def _get_clean_camera_description(camera_thing): + """Return actions and properties for a camera Thing, separating those exposed to the UI. + + Return cleaned sets of actions and properties for camera_thing, excluding + calibration actions exposed to the UI as primary or secondary calibration actions, + and excluding the properties exposed in manual camera settings. + + :returns: Dict with keys: + + * "actions" - Actions not explicitly exposed to the UI by this camera. + * "properties" - Properties not explicitly exposed to the UI as manual camera + settings. + * "calibration_actions" - Actions explicitly exposed to the UI by this camera. + * "manual_properties" - Properties explicitly exposed to the UI as manual + camera settings. + """ + camera_thing.path = "/mock/" + + td = camera_thing.thing_description() + actions = set(td.actions.keys()) + properties = set(td.properties.keys()) + + # Calibration actions are camera specific + primary = [btn.action for btn in camera_thing.primary_calibration_actions] + secondary = [btn.action for btn in camera_thing.secondary_calibration_actions] + calibration_actions = set(primary + secondary) + + actions -= calibration_actions + + # Manual properties are also camera specific + manual_props = {ctrl.property_name for ctrl in camera_thing.manual_camera_settings} + properties -= manual_props + + return { + "actions": actions, + "properties": properties, + "calibration_actions": calibration_actions, + "manual_properties": manual_props, + } + + +def test_thing_description_equivalence(mock_picam_thing): + """Ensure the built-in camera Thing subclasses retain consistent core functionality. + + This test verifies that extra actions are not unintentionally introduced to + camera child classes. Any addition of actions must be accompanied by an update + to this test, prompting discussion of whether the action belongs in the subclass + or the base camera class. + """ + base_td = BaseCamera().thing_description() + base_actions = set(base_td.actions.keys()) + base_props = set(base_td.properties.keys()) + + sim_description = _get_clean_camera_description(SimulatedCamera()) + opencv_description = _get_clean_camera_description(OpenCVCamera()) + picamera_description = _get_clean_camera_description(mock_picam_thing) + + # Note. These are the actions and properties that are not exposed to the UI via + # `primary_calibration_actions`, `secondary_calibration_actions`, or + # `manual_camera_settings`. + sim_actions = sim_description["actions"] + sim_props = sim_description["properties"] + + opencv_actions = opencv_description["actions"] + opencv_props = opencv_description["properties"] + + picamera_actions = picamera_description["actions"] + picamera_props = picamera_description["properties"] + + # Camera actions and properties should generally be equivalent except for exposed + # manual settings and calibration actions. + assert opencv_actions == sim_actions == base_actions + assert opencv_props == sim_props == base_props + + # For now PiCamera has a number of extra actions and properties. These should be + # reduced over time by creating a way to use the functionality in a way as clearly + # defined as PiCamera specific, or by replicating the functionality for other + # cameras. + picamera_extra_actions = { + "flat_lens_shading_chrominance", + "set_static_green_equalisation", + "stop_streaming", + "reset_ccm", + } + picamera_extra_props = { + "mjpeg_bitrate", + "colour_correction_matrix", + "lens_shading_tables", + "sensor_resolution", + "lens_shading_is_static", + "capture_metadata", + "camera_configuration", + "stream_resolution", + "tuning", + "sensor_modes", + "sensor_mode", + } + assert picamera_actions - base_actions == picamera_extra_actions + assert picamera_props - base_props == picamera_extra_props diff --git a/webapp/src/components/labThingsComponents/actionButton.vue b/webapp/src/components/labThingsComponents/actionButton.vue index 73ca9537..b33233a2 100644 --- a/webapp/src/components/labThingsComponents/actionButton.vue +++ b/webapp/src/components/labThingsComponents/actionButton.vue @@ -18,6 +18,7 @@