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 @@
-