diff --git a/.gitignore b/.gitignore index c12227c5..19581d57 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,7 @@ var/ .installed.cfg *.egg pip-wheel-metadata/ - +/openflexure/ # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index bf507724..f098e3c9 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -19,7 +19,8 @@ from pydantic import BaseModel from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.blocking_portal import BlockingPortal -from labthings_fastapi.decorators import thing_action, thing_property +from labthings_fastapi.decorators import thing_action +from labthings_fastapi.descriptors import ThingSetting from labthings_fastapi.types.numpy import NDArray from .camera import RawCameraDependency as Camera @@ -396,37 +397,36 @@ class AutofocusThing(Thing): stage.move_absolute(z=peak_height) return heights.tolist(), sizes.tolist() - @thing_property - def stack_images_to_save(self) -> int: - """The number of images to capture and save in a stack - Defaults to 1 unless you need to see either side of focus""" - return self.thing_settings.get("stack_images_to_save", 1) + stack_images_to_save = ThingSetting( + initial_value=1, + model=int, + description="""The number of images to save in a stack. - @stack_images_to_save.setter - def stack_images_to_save(self, value: int) -> None: - self.thing_settings["stack_images_to_save"] = value + Defaults to 1 unless you need to see either side of focus""", + ) - @thing_property - def stack_min_images_to_test(self) -> int: - """The number of images to test for successful focusing in a stack - Defaults to 9, which balances reliability and speed""" - return self.thing_settings.get("stack_min_images_to_test", 9) + stack_min_images_to_test = ThingSetting( + initial_value=9, + model=int, + description="""The minimum number of images to capture in a stack. - @stack_min_images_to_test.setter - def stack_min_images_to_test(self, value: int) -> None: - self.thing_settings["stack_min_images_to_test"] = value + This many images are captures and tested for focus, if the focus + is not central enough more images may be captured. After new images + are captured the number sets the number of images used for checking + if focus is central. - @thing_property - def stack_dz(self) -> int: - """Space in steps between images in a z-stack - Suggested is 50 for 60-100x - 100 for 40x - 200 for 20x""" - return self.thing_settings.get("stack_dz", 50) + Defaults to 9 which balances reliability and speed + """, + ) - @stack_dz.setter - def stack_dz(self, value: int) -> None: - self.thing_settings["stack_dz"] = value + stack_dz = ThingSetting( + initial_value=50, + model=int, + description="""Space in steps between images in a z-stack + Suggested is 50 for 60-100x + 100 for 40x + 200 for 20x""", + ) @thing_action def run_smart_stack( diff --git a/src/openflexure_microscope_server/things/background_detect.py b/src/openflexure_microscope_server/things/background_detect.py index 897234c3..b6f4977f 100644 --- a/src/openflexure_microscope_server/things/background_detect.py +++ b/src/openflexure_microscope_server/things/background_detect.py @@ -6,7 +6,8 @@ from pydantic import BaseModel from scipy.stats import norm from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_property +from labthings_fastapi.decorators import thing_action, thing_setting +from labthings_fastapi.descriptors import ThingSetting from .camera import CameraDependency as CamDep @@ -17,38 +18,44 @@ class ChannelDistributions(BaseModel): class BackgroundDetectThing(Thing): - @thing_property + # Requires a getter and a setter to support being a BaseModel but being + # saved to file as a dict + _background_distributions: Optional[ChannelDistributions] = None + + @thing_setting def background_distributions(self) -> Optional[ChannelDistributions]: """The statistics of the background image""" - bd = self.thing_settings.get("background_distributions", None) - if bd: - return ChannelDistributions(**bd) - return None + bd = self._background_distributions + if bd is None: + return None + return ChannelDistributions(**bd) @background_distributions.setter - def background_distributions(self, value: Optional[ChannelDistributions]) -> None: - try: - self.thing_settings["background_distributions"] = value.model_dump() - except AttributeError: - self.thing_settings["background_distributions"] = None + def background_distributions( + self, value: Optional[ChannelDistributions | dict] + ) -> None: + if value is None: + self._background_distributions = None + elif isinstance(value, ChannelDistributions): + self._background_distributions = value.model_dump() + elif isinstance(value, dict): + self._background_distributions = value + else: + raise TypeError( + f"Cannot set background_distributions with an object of type {type(value)}" + ) - @thing_property - def tolerance(self) -> float: - """How many standard deviations to allow for the background""" - return self.thing_settings.get("tolerance", 7) + tolerance = ThingSetting( + initial_value=7.0, + model=float, + description="How many standard deviations to allow for the background", + ) - @tolerance.setter - def tolerance(self, value: float) -> None: - self.thing_settings["tolerance"] = value - - @thing_property - def fraction(self) -> float: - """How much of the image needs to be not background to label as sample""" - return self.thing_settings.get("fraction", 25) - - @fraction.setter - def fraction(self, value: float) -> None: - self.thing_settings["fraction"] = value + fraction = ThingSetting( + initial_value=25.0, + model=float, + description="How much of the image needs to be not background to label as sample", + ) def background_mask(self, image: np.ndarray) -> np.ndarray: """Calculate a binary image, showing whether each pixel is background diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 56f8ff84..3162e70f 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -26,7 +26,7 @@ from tempfile import TemporaryDirectory from pydantic import BaseModel, BeforeValidator -from labthings_fastapi.descriptors.property import PropertyDescriptor +from labthings_fastapi.descriptors.property import ThingProperty from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.outputs.mjpeg_stream import MJPEGStream from labthings_fastapi.utilities import get_blocking_portal @@ -46,14 +46,12 @@ from . import picamera_recalibrate_utils as recalibrate_utils from . import BaseCamera, JPEGBlob, ArrayModel -class PicameraControl(PropertyDescriptor): +class PicameraControl(ThingProperty): def __init__( self, control_name: str, model: type = float, description: Optional[str] = None ): """A property descriptor controlling a picamera control""" - PropertyDescriptor.__init__( - self, model, observable=False, description=description - ) + super().__init__(model, observable=False, description=description) self.control_name = control_name def _getter(self, obj: StreamingPiCamera2): @@ -213,19 +211,19 @@ class StreamingPiCamera2(BaseCamera): except KeyError: pass # If controls are missing, leave at default - stream_resolution = PropertyDescriptor( + stream_resolution = ThingProperty( tuple[int, int], initial_value=(820, 616), description="Resolution to use for the MJPEG stream", ) - mjpeg_bitrate = PropertyDescriptor( + mjpeg_bitrate = ThingProperty( Optional[int], initial_value=100000000, description="Bitrate for MJPEG stream (None for default)", ) - stream_active = PropertyDescriptor( + stream_active = ThingProperty( bool, initial_value=False, description="Whether the MJPEG stream is active", @@ -284,7 +282,7 @@ class StreamingPiCamera2(BaseCamera): with self.picamera() as cam: return cam.sensor_resolution - tuning = PropertyDescriptor(Optional[dict], None, readonly=True) + tuning = ThingProperty(Optional[dict], None, readonly=True) def settings_to_properties(self): """Set the values of properties based on the settings dict""" diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 5f6dfe49..28de3e5b 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -115,9 +115,11 @@ class SimulatedCamera(BaseCamera): ) return image - def attach_to_server(self, server: ThingServer, path: str): + def attach_to_server( + self, server: ThingServer, path: str, setting_storage_path: str + ): self._server = server - return super().attach_to_server(server, path) + return super().attach_to_server(server, path, setting_storage_path) def get_stage_position(self): if not self._stage and self._server: diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index 4e4f1d53..cfe178e1 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -37,8 +37,9 @@ from labthings_fastapi.dependencies.invocation import ( InvocationCancelledError, InvocationLogger, ) -from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict +from labthings_fastapi.types.numpy import NDArray, DenumpifyingDict from labthings_fastapi.decorators import thing_action, thing_property +from labthings_fastapi.descriptors import ThingSetting from labthings_fastapi.thing import Thing from camera_stage_mapping.camera_stage_tracker import Tracker from .camera import CameraDependency as Camera @@ -181,12 +182,6 @@ class CSMUncalibratedError(HTTPException): class CameraStageMapper(Thing): """A Thing to manage mapping between image and stage coordinates""" - def __enter__(self): - pass - - def __exit__(self, exc_type, exc_value, traceback): - self.thing_settings.write_to_file() - @thing_action def calibrate_1d( self, @@ -244,8 +239,6 @@ class CameraStageMapper(Thing): corrected_resolution = tuple( r * hw.grab_image_downsampling for r in cal_x["image_resolution"] ) - self.thing_settings.update(denumpify(cal_xy)) - self.thing_settings["image_resolution"] = corrected_resolution csm_matrix = cal_xy["image_to_stage_displacement"] csm_as_string = f"[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]" @@ -260,7 +253,7 @@ class CameraStageMapper(Thing): "downsampling": hw.grab_image_downsampling, } - self.thing_settings["last_calibration"] = DenumpifyingDict(data).model_dump() + self.last_calibration = DenumpifyingDict(data).model_dump() return data @@ -285,15 +278,28 @@ class CameraStageMapper(Thing): ) ``` """ - displacement_matrix = self.thing_settings.get("image_to_stage_displacement") - if not displacement_matrix: + if self.last_calibration is None: return None + displacement_matrix = self.last_calibration["camera_stage_mapping_calibration"][ + "image_to_stage_displacement" + ] return np.array(displacement_matrix).tolist() + last_calibration = ThingSetting( + initial_value=None, + model=Optional[dict], + readonly=True, + description="The most recent CSM calibration", + ) + @thing_property def image_resolution(self) -> Optional[Tuple[float, float]]: """The image size used to calibrate the image_to_stage_displacement_matrix""" - return self.thing_settings.get("image_resolution", None) + if self.last_calibration is None: + return None + return self.last_calibration["camera_stage_mapping_calibration"][ + "image_resolution" + ] def assert_calibrated(self): """Raise an exception if the image_to_stage_displacement matrix is not set""" @@ -302,11 +308,6 @@ class CameraStageMapper(Thing): # added by CSMUncalibratedError raise CSMUncalibratedError() # noqa: RSE102 - @thing_property - def last_calibration(self) -> Optional[Dict]: - """The results of the last calibration that was run""" - return self.thing_settings.get("last_calibration", None) - @thing_action def move_in_image_coordinates( self, diff --git a/src/openflexure_microscope_server/things/settings_manager.py b/src/openflexure_microscope_server/things/settings_manager.py index 38247b01..bd5535fd 100644 --- a/src/openflexure_microscope_server/things/settings_manager.py +++ b/src/openflexure_microscope_server/things/settings_manager.py @@ -13,10 +13,10 @@ from uuid import UUID, uuid4 from fastapi import Depends, HTTPException, Request from labthings_fastapi.dependencies.metadata import GetThingStates from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_property +from labthings_fastapi.decorators import thing_action, thing_property, thing_setting +from labthings_fastapi.descriptors import ThingSetting from labthings_fastapi.dependencies.thing_server import find_thing_server from labthings_fastapi.server import ThingServer -from labthings_fastapi.dependencies.invocation import InvocationLogger def thing_server_from_request(request: Request) -> ThingServer: @@ -49,10 +49,17 @@ def nested_dict_get(data: dict, key: Sequence[str], create=False) -> Any: class SettingsManager(Thing): - @thing_property - def external_metadata(self) -> Mapping: - """External metadata stored in the server's settings""" - return self.thing_settings.get("external_metadata", {}) + external_metadata = ThingSetting( + initial_value={}, + model=Mapping, + description="External metadata stored in the server's settings", + ) + + external_metadata_in_state = ThingSetting( + initial_value=[], + model=Sequence[str], + description='A list of strings that are included in the "state" metadata', + ) @thing_action def update_external_metadata( @@ -75,7 +82,7 @@ class SettingsManager(Thing): if key: subdict = nested_dict_get(subdict, key.split("/"), create=True) recursive_update(subdict, data) - self.thing_settings["external_metadata"] = metadata + self.external_metadata = metadata @thing_action def delete_external_metadata(self, key: str) -> None: @@ -93,14 +100,21 @@ class SettingsManager(Thing): raise HTTPException( status_code=404, detail="The specified key '{key}' was not found" ) - self.thing_settings["external_metadata"] = metadata + self.external_metadata = metadata - @thing_property + _microscope_id: Optional[str] = None + + @thing_setting def microscope_id(self) -> UUID: """A unique identifier for this microscope""" - if "microscope_id" not in self.thing_settings: - self.thing_settings["microscope_id"] = str(uuid4()) - return UUID(self.thing_settings["microscope_id"]) + if self._microscope_id is None: + self._microscope_id = str(uuid4()) + return UUID(self._microscope_id) + + @microscope_id.setter + def microscope_id(self, uuid: UUID): + # TODO make read only but still settable from disk + self._microscope_id = uuid @thing_property def hostname(self) -> str: @@ -112,16 +126,6 @@ class SettingsManager(Thing): """Metadata summarising the current state of all Things in the server""" return metadata_getter() - @thing_property - def external_metadata_in_state(self) -> Sequence[str]: - """A list of strings that are included in the "state" metadata""" - return self.thing_settings.get("external_metadata_in_state", []) - - @external_metadata_in_state.setter - def external_metadata_in_state(self, keys: Sequence[str]): - """Set the keys from external metadata that are returned in state""" - self.thing_settings["external_metadata_in_state"] = keys - @property def thing_state(self) -> Mapping: state = { @@ -138,29 +142,3 @@ class SettingsManager(Thing): except KeyError: continue return state - - @thing_action - def save_all_thing_settings( - self, thing_server: ThingServerDep, logger: InvocationLogger - ) -> None: - """Ensure all the Things sync their settings to disk. - - Normally, each Thing has a `thing_settings` attribute that can be - used to save settings. That dictionary is saved to a JSON file - when the server shuts down cleanly. - - This action causes all the `thing_settings` dictionaries to be - saved to files immediately, meaning that settings will not be - lost in the event of a server crash, power outage, etc. - """ - for name, thing in thing_server.things.items(): - try: - if thing._labthings_thing_settings: - thing._labthings_thing_settings.write_to_file() - logger.info(f"Wrote {name} settings to disk.") - except PermissionError: - logger.warning( - f"Could not write {name} settings to disk: permission error." - ) - except FileNotFoundError: - logger.warning(f"Could not write {name} settings, folder not found") diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index dafc9071..812c2c32 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -14,12 +14,17 @@ from PIL import Image from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.metadata import GetThingStates from labthings_fastapi.dependencies.thing import direct_thing_client_dependency +from labthings_fastapi.descriptors import ThingSetting from labthings_fastapi.dependencies.invocation import ( CancelHook, InvocationLogger, InvocationCancelledError, ) -from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint +from labthings_fastapi.decorators import ( + thing_action, + thing_property, + fastapi_endpoint, +) from labthings_fastapi.outputs.blob import blob_type from openflexure_microscope_server.utilities import ErrorCapturingThread @@ -590,72 +595,54 @@ class SmartScanThing(Thing): raise HTTPException(404, "File not found") return FileResponse(preview_path) - @thing_property - def save_resolution(self) -> tuple[int, int]: - """A tuple of the image resolution to capture. Should be in a - 4:3 aspect ratio""" - return self.thing_settings.get("save_resolution", ((1640, 1232))) + save_resolution = ThingSetting( + initial_value=(1640, 1232), + model=tuple[int, int], + description=("A tuple of the image resolution to capture."), + ) - @save_resolution.setter - def save_resolution(self, value: tuple[int, int]) -> None: - self.thing_settings["save_resolution"] = value + max_range = ThingSetting( + initial_value=45000, + model=int, + description=( + "The maximum distance from the centre of the scan before we break in steps" + ), + ) - @thing_property - def max_range(self) -> int: - """The maximum distance from the centre of the scan before we break in steps""" - return self.thing_settings.get("max_range", 45000) + stitch_tiff = ThingSetting( + initial_value=False, + model=bool, + description="Whether or not to also produce a pyramidal tiff", + ) - @max_range.setter - def max_range(self, value: int) -> None: - self.thing_settings["max_range"] = value + skip_background = ThingSetting( + initial_value=True, + model=bool, + description="""Whether to detect and skip empty fields of view - @thing_property - def stitch_tiff(self) -> bool: - """Whether or not to also produce a pyramidal tiff""" - return self.thing_settings.get("stitch_tiff", False) + This uses the settings from the `background_detect` Thing.""", + ) - @stitch_tiff.setter - def stitch_tiff(self, value: bool) -> None: - self.thing_settings["stitch_tiff"] = value + autofocus_dz = ThingSetting( + initial_value=1000, + model=int, + description="The z distance to perform an autofocus in steps", + ) - @thing_property - def skip_background(self) -> bool: - """Whether to detect and skip empty fields of view + overlap = ThingSetting( + initial_value=0.45, + model=float, + description="The fraction (0-1) that adjacent images should overlap in x or y", + ) - This uses the settings from the `background_detect` Thing. - """ - return self.thing_settings.get("skip_background", True) - - @skip_background.setter - def skip_background(self, value: bool) -> None: - self.thing_settings["skip_background"] = value - - @thing_property - def autofocus_dz(self) -> int: - """The z distance to perform an autofocus in steps""" - return self.thing_settings.get("autofocus_dz", 1000) - - @autofocus_dz.setter - def autofocus_dz(self, value: int) -> None: - self.thing_settings["autofocus_dz"] = value - - @thing_property - def overlap(self) -> float: - """The fraction (0-1) that adjacent images should overlap in x or y""" - return self.thing_settings.get("overlap", 0.45) - - @overlap.setter - def overlap(self, value: float) -> None: - self.thing_settings["overlap"] = value - - @thing_property - def stitch_automatically(self) -> bool: - """Whether to run a final stitch at the end of the scan (assuming scan success)""" - return self.thing_settings.get("stitch_automatically", True) - - @stitch_automatically.setter - def stitch_automatically(self, value: bool) -> None: - self.thing_settings["stitch_automatically"] = value + stitch_automatically = ThingSetting( + initial_value=True, + model=bool, + description=( + "Whether to run a final stitch at the end of the scan (assuming scan " + "success)" + ), + ) @thing_property def scans(self) -> list[scan_directories.ScanInfo]: diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 9ae9423a..f278ef1e 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TypeAlias from collections.abc import Sequence, Mapping -from labthings_fastapi.descriptors.property import PropertyDescriptor +from labthings_fastapi.descriptors.property import ThingProperty from labthings_fastapi.thing import Thing from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.dependencies.invocation import CancelHook @@ -25,7 +25,7 @@ class BaseStage(Thing): """The names of the stage's axes, in order.""" return self._axis_names - position = PropertyDescriptor( + position = ThingProperty( Mapping[str, int], dict.fromkeys(_axis_names, 0), description="Current position of the stage", @@ -33,7 +33,7 @@ class BaseStage(Thing): observable=True, ) - moving = PropertyDescriptor( + moving = ThingProperty( bool, False, description="Whether the stage is in motion", diff --git a/webapp/src/components/tabContentComponents/settingsContent.vue b/webapp/src/components/tabContentComponents/settingsContent.vue index 8ccc315d..b2ba0341 100644 --- a/webapp/src/components/tabContentComponents/settingsContent.vue +++ b/webapp/src/components/tabContentComponents/settingsContent.vue @@ -58,11 +58,6 @@ -