Update Thing Settings to use the new format provided by PR #110 of Labthings FastAPI

This commit is contained in:
Julian Stirling 2025-06-18 17:28:38 -04:00
parent fd4d51401f
commit 840ed7f20e
10 changed files with 169 additions and 203 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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