From 840ed7f20ec98026e0ee6b15df7934beb43900fb Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 18 Jun 2025 17:28:38 -0400 Subject: [PATCH 1/6] Update Thing Settings to use the new format provided by PR #110 of Labthings FastAPI --- .gitignore | 2 +- .../things/autofocus.py | 54 ++++----- .../things/background_detect.py | 61 +++++----- .../things/camera/picamera.py | 16 ++- .../things/camera/simulation.py | 6 +- .../things/camera_stage_mapping.py | 37 +++--- .../things/settings_manager.py | 74 +++++------- .../things/smart_scan.py | 107 ++++++++---------- .../things/stage/__init__.py | 6 +- .../tabContentComponents/settingsContent.vue | 9 +- 10 files changed, 169 insertions(+), 203 deletions(-) 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 @@ -
Date: Sat, 28 Jun 2025 17:57:31 +0100 Subject: [PATCH 2/6] And adjust for new as lt import --- .../server/__init__.py | 14 ++- .../server/legacy_api.py | 4 +- .../things/auto_recentre_stage.py | 15 +-- .../things/autofocus.py | 23 ++-- .../things/background_detect.py | 18 ++- .../things/camera/__init__.py | 70 +++++------- .../things/camera/opencv.py | 14 +-- .../things/camera/picamera.py | 86 +++++++------- .../things/camera/simulation.py | 19 ++-- .../things/camera_stage_mapping.py | 36 +++--- .../things/settings_manager.py | 31 +++-- .../things/smart_scan.py | 106 ++++++++---------- .../things/stage/__init__.py | 28 +++-- .../things/stage/dummy.py | 20 ++-- .../things/stage/sangaboard.py | 20 ++-- .../things/system_control.py | 11 +- tests/test_dummy_server.py | 18 +-- 17 files changed, 240 insertions(+), 293 deletions(-) diff --git a/src/openflexure_microscope_server/server/__init__.py b/src/openflexure_microscope_server/server/__init__.py index 601c83ba..485ea0c7 100644 --- a/src/openflexure_microscope_server/server/__init__.py +++ b/src/openflexure_microscope_server/server/__init__.py @@ -3,14 +3,16 @@ from __future__ import annotations from typing import Optional from copy import copy -from labthings_fastapi.server import cli, ThingServer +import labthings_fastapi as lt import uvicorn from .serve_static_files import add_static_files from .legacy_api import add_v2_endpoints from ..logging import configure_logging, retrieve_log, retrieve_log_from_file -def customise_server(server: ThingServer, log_folder: str, scans_folder: Optional[str]): +def customise_server( + server: lt.ThingServer, log_folder: str, scans_folder: Optional[str] +): """Customise the server with additional endpoints, etc.""" configure_logging(log_folder) add_v2_endpoints(server) @@ -39,7 +41,7 @@ def _get_scans_dir(config: dict) -> Optional[str]: def serve_from_cli(argv: Optional[list[str]] = None): """Start the server from the command line""" - args = cli.parse_args(argv) + args = lt.cli.parse_args(argv) log_config = copy(uvicorn.config.LOGGING_CONFIG) log_config["loggers"]["uvicorn"]["propagate"] = True @@ -50,10 +52,10 @@ def serve_from_cli(argv: Optional[list[str]] = None): config = None server = None try: - config = cli.config_from_args(args) + config = lt.cli.config_from_args(args) log_folder = config.get("log_folder", "./openflexure/logs") scans_folder = _get_scans_dir(config) - server = cli.server_from_config(config) + server = lt.cli.server_from_config(config) customise_server(server, log_folder, scans_folder) uvicorn.run( server.app, @@ -68,7 +70,7 @@ def serve_from_cli(argv: Optional[list[str]] = None): print(f"Error: {e}") fallback_server = "labthings_fastapi.server.fallback:app" print(f"Starting fallback server {fallback_server}.") - app = cli.object_reference_to_object(fallback_server) + app = lt.cli.object_reference_to_object(fallback_server) app.labthings_config = config app.labthings_server = server app.labthings_error = e diff --git a/src/openflexure_microscope_server/server/legacy_api.py b/src/openflexure_microscope_server/server/legacy_api.py index ee2ebe06..5fd9a3e9 100644 --- a/src/openflexure_microscope_server/server/legacy_api.py +++ b/src/openflexure_microscope_server/server/legacy_api.py @@ -1,9 +1,9 @@ -from . import ThingServer +import labthings_fastapi as lt from fastapi import Response from socket import gethostname -def add_v2_endpoints(thing_server: ThingServer): +def add_v2_endpoints(thing_server: lt.ThingServer): app = thing_server.app # TODO: update openflexure connect to make this unnecessary!! diff --git a/src/openflexure_microscope_server/things/auto_recentre_stage.py b/src/openflexure_microscope_server/things/auto_recentre_stage.py index bbdd5635..28ca6936 100644 --- a/src/openflexure_microscope_server/things/auto_recentre_stage.py +++ b/src/openflexure_microscope_server/things/auto_recentre_stage.py @@ -1,19 +1,20 @@ import numpy as np import logging -from labthings_fastapi.thing import Thing -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency -from labthings_fastapi.decorators import thing_action +import labthings_fastapi as lt + from .stage import StageDependency as StageDep from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper -CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") -AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") +CSMDep = lt.deps.direct_thing_client_dependency( + CameraStageMapper, "/camera_stage_mapping/" +) +AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") -class RecentringThing(Thing): - @thing_action +class RecentringThing(lt.Thing): + @lt.thing_action def recentre( self, autofocus: AutofocusDep, diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index f098e3c9..23766e72 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -17,10 +17,7 @@ from fastapi import Depends import numpy as np 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 -from labthings_fastapi.descriptors import ThingSetting +import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray from .camera import RawCameraDependency as Camera @@ -190,7 +187,7 @@ class SharpnessDataArrays(BaseModel): class JPEGSharpnessMonitor: - def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal): + def __init__(self, stage: Stage, camera: Camera, portal: lt.deps.BlockingPortal): self.camera = camera self.stage = stage self.portal = portal @@ -288,14 +285,14 @@ class JPEGSharpnessMonitor: SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()] -class AutofocusThing(Thing): +class AutofocusThing(lt.Thing): """The Thing concerned with combinations of z axis movements and the camera. Actions here involve moving a stage in z, and using the camera to either capture images (generally, z-stacking) and measuring the sharpness of the field of view to assess focus (autofocus and testing the success of a z-stack)""" - @thing_action + @lt.thing_action def fast_autofocus( self, sharpness_monitor: SharpnessMonitorDep, @@ -325,7 +322,7 @@ class AutofocusThing(Thing): # Return all focus data return sharpness_monitor.data_dict() - @thing_action + @lt.thing_action def z_move_and_measure_sharpness( self, sharpness_monitor: SharpnessMonitorDep, @@ -351,7 +348,7 @@ class AutofocusThing(Thing): sharpness_monitor.focus_rel(current_dz) return sharpness_monitor.data_dict() - @thing_action + @lt.thing_action def looping_autofocus( self, stage: Stage, @@ -397,7 +394,7 @@ class AutofocusThing(Thing): stage.move_absolute(z=peak_height) return heights.tolist(), sizes.tolist() - stack_images_to_save = ThingSetting( + stack_images_to_save = lt.ThingSetting( initial_value=1, model=int, description="""The number of images to save in a stack. @@ -405,7 +402,7 @@ class AutofocusThing(Thing): Defaults to 1 unless you need to see either side of focus""", ) - stack_min_images_to_test = ThingSetting( + stack_min_images_to_test = lt.ThingSetting( initial_value=9, model=int, description="""The minimum number of images to capture in a stack. @@ -419,7 +416,7 @@ class AutofocusThing(Thing): """, ) - stack_dz = ThingSetting( + stack_dz = lt.ThingSetting( initial_value=50, model=int, description="""Space in steps between images in a z-stack @@ -428,7 +425,7 @@ class AutofocusThing(Thing): 200 for 20x""", ) - @thing_action + @lt.thing_action def run_smart_stack( self, cam: WrappedCamera, diff --git a/src/openflexure_microscope_server/things/background_detect.py b/src/openflexure_microscope_server/things/background_detect.py index b6f4977f..f27b9096 100644 --- a/src/openflexure_microscope_server/things/background_detect.py +++ b/src/openflexure_microscope_server/things/background_detect.py @@ -5,9 +5,7 @@ from PIL import Image from pydantic import BaseModel from scipy.stats import norm -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_setting -from labthings_fastapi.descriptors import ThingSetting +import labthings_fastapi as lt from .camera import CameraDependency as CamDep @@ -17,12 +15,12 @@ class ChannelDistributions(BaseModel): colorspace: str = "LUV" -class BackgroundDetectThing(Thing): +class BackgroundDetectThing(lt.Thing): # 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 + @lt.thing_setting def background_distributions(self) -> Optional[ChannelDistributions]: """The statistics of the background image""" bd = self._background_distributions @@ -45,13 +43,13 @@ class BackgroundDetectThing(Thing): f"Cannot set background_distributions with an object of type {type(value)}" ) - tolerance = ThingSetting( + tolerance = lt.ThingSetting( initial_value=7.0, model=float, description="How many standard deviations to allow for the background", ) - fraction = ThingSetting( + fraction = lt.ThingSetting( initial_value=25.0, model=float, description="How much of the image needs to be not background to label as sample", @@ -78,7 +76,7 @@ class BackgroundDetectThing(Thing): axis=2, ) - @thing_action + @lt.thing_action def background_fraction(self, cam: CamDep) -> float: """Determine what fraction of the current image is background @@ -96,7 +94,7 @@ class BackgroundDetectThing(Thing): mask = self.background_mask(current_image_luv) return np.count_nonzero(mask) / np.prod(mask.shape) * 100 - @thing_action + @lt.thing_action def image_is_sample(self, cam: CamDep) -> bool: """Label the current image as either background or sample""" b_fraction = self.background_fraction(cam) @@ -104,7 +102,7 @@ class BackgroundDetectThing(Thing): return (100 - b_fraction) > fraction_threshold - @thing_action + @lt.thing_action def set_background(self, cam: CamDep): """Grab an image, and use its statistics to set the background diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 87f6a53a..74531053 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -1,6 +1,6 @@ """OpenFlexure Microscope Camera -This module defines the interface for cameras. Any compatible Thing +This module defines the interface for cameras. Any compatible lt.Thing should enabe the server to work. See repository root for licensing information. @@ -14,23 +14,15 @@ from pydantic import RootModel from PIL import Image import piexif -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_property -from labthings_fastapi.dependencies.metadata import GetThingStates -from labthings_fastapi.dependencies.blocking_portal import BlockingPortal -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency -from labthings_fastapi.dependencies.raw_thing import raw_thing_dependency -from labthings_fastapi.dependencies.invocation import InvocationLogger -from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor -from labthings_fastapi.outputs.blob import Blob +import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray -class JPEGBlob(Blob): +class JPEGBlob(lt.blob.Blob): media_type: str = "image/jpeg" -class PNGBlob(Blob): +class PNGBlob(lt.blob.Blob): """A class representing a PNG image as a LabThings FastAPI Blob""" media_type: str = "image/png" @@ -154,11 +146,11 @@ class CameraMemoryBuffer: del self._storage[key] -class BaseCamera(Thing): +class BaseCamera(lt.Thing): """The base class for all cameras. All cameras must directly inherit from this class""" - mjpeg_stream = MJPEGStreamDescriptor() - lores_mjpeg_stream = MJPEGStreamDescriptor() + mjpeg_stream = lt.outputs.MJPEGStreamDescriptor() + lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor() _memory_buffer = CameraMemoryBuffer() def __enter__(self) -> None: @@ -167,7 +159,7 @@ class BaseCamera(Thing): def __exit__(self, _exc_type, _exc_value, _traceback) -> None: raise NotImplementedError("CameraThings must define their own __exit__ method") - @thing_action + @lt.thing_action def start_streaming( self, main_resolution: tuple[int, int], buffer_count: int ) -> None: @@ -177,14 +169,14 @@ class BaseCamera(Thing): "CameraThings must define their own start_streaming method" ) - @thing_property + @lt.thing_property def stream_active(self) -> bool: "Whether the MJPEG stream is active" raise NotImplementedError( "CameraThings must define their own stream_active method" ) - @thing_action + @lt.thing_action def capture_array( self, stream_name: Literal["main", "lores", "raw", "full"] = "main", @@ -194,10 +186,10 @@ class BaseCamera(Thing): "CameraThings must define their own capture_array method" ) - @thing_action + @lt.thing_action def capture_jpeg( self, - metadata_getter: GetThingStates, + metadata_getter: lt.deps.GetThingStates, resolution: Literal["lores", "main", "full"] = "main", wait: Optional[float] = 5, ) -> JPEGBlob: @@ -206,10 +198,10 @@ class BaseCamera(Thing): "CameraThings must define their own capture_jpeg method" ) - @thing_action + @lt.thing_action def grab_jpeg( self, - portal: BlockingPortal, + portal: lt.deps.BlockingPortal, stream_name: Literal["main", "lores"] = "main", ) -> JPEGBlob: """Acquire one image from the preview stream and return as an array @@ -225,10 +217,10 @@ class BaseCamera(Thing): frame = portal.call(stream.grab_frame) return JPEGBlob.from_bytes(frame) - @thing_action + @lt.thing_action def grab_jpeg_size( self, - portal: BlockingPortal, + portal: lt.deps.BlockingPortal, stream_name: Literal["main", "lores"] = "main", ) -> int: """Acquire one image from the preview stream and return its size""" @@ -237,7 +229,7 @@ class BaseCamera(Thing): ) return portal.call(stream.next_frame_size) - @thing_action + @lt.thing_action def capture_image( self, stream_name: Literal["main", "lores", "raw"], @@ -248,12 +240,12 @@ class BaseCamera(Thing): "CameraThings must define their own capture_image method" ) - @thing_action + @lt.thing_action def capture_and_save( self, jpeg_path: str, - logger: InvocationLogger, - metadata_getter: GetThingStates, + logger: lt.deps.InvocationLogger, + metadata_getter: lt.deps.GetThingStates, save_resolution: Optional[Tuple[int, int]] = None, ) -> None: """Capture an image and save it to disk @@ -279,11 +271,11 @@ class BaseCamera(Thing): save_resolution, ) - @thing_action + @lt.thing_action def capture_to_memory( self, - logger: InvocationLogger, - metadata_getter: GetThingStates, + logger: lt.deps.InvocationLogger, + metadata_getter: lt.deps.GetThingStates, buffer_max: int = 1, ) -> None: """ @@ -304,11 +296,11 @@ class BaseCamera(Thing): image, metadata = self._robust_image_capture(metadata_getter, logger) return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max) - @thing_action + @lt.thing_action def save_from_memory( self, jpeg_path: str, - logger: InvocationLogger, + logger: lt.deps.InvocationLogger, save_resolution: Optional[Tuple[int, int]] = None, buffer_id: Optional[int] = None, ) -> None: @@ -333,15 +325,15 @@ class BaseCamera(Thing): save_resolution=save_resolution, ) - @thing_action + @lt.thing_action def clear_buffers(self) -> None: """Clear all images in memory""" self._memory_buffer.clear() def _robust_image_capture( self, - metadata_getter: GetThingStates, - logger: InvocationLogger, + metadata_getter: lt.deps.GetThingStates, + logger: lt.deps.InvocationLogger, ) -> Image: """Capture an image in memory and return it with metadata CaptureError raised if the capture fails for any reason @@ -366,7 +358,7 @@ class BaseCamera(Thing): jpeg_path: str, image: Image, metadata: dict, - logger: InvocationLogger, + logger: lt.deps.InvocationLogger, save_resolution: Optional[Tuple[int, int]] = None, ) -> None: """Saving the captured image and metadata to disk @@ -399,5 +391,5 @@ class BaseCamera(Thing): raise IOError(f"An error occurred while saving {jpeg_path}") from e -CameraDependency = direct_thing_client_dependency(BaseCamera, "/camera/") -RawCameraDependency = raw_thing_dependency(BaseCamera) +CameraDependency = lt.deps.direct_thing_client_dependency(BaseCamera, "/camera/") +RawCameraDependency = lt.deps.raw_thing_dependency(BaseCamera) diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index f793f209..66e0b050 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -16,9 +16,7 @@ from threading import Thread import cv2 import piexif -from labthings_fastapi.utilities import get_blocking_portal -from labthings_fastapi.decorators import thing_action, thing_property -from labthings_fastapi.dependencies.metadata import GetThingStates +import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray from . import BaseCamera, JPEGBlob @@ -45,7 +43,7 @@ class OpenCVCamera(BaseCamera): self._capture_thread.join() self.cap.release() - @thing_property + @lt.thing_property def stream_active(self) -> bool: "Whether the MJPEG stream is active" if self._capture_enabled and self._capture_thread: @@ -53,7 +51,7 @@ class OpenCVCamera(BaseCamera): return False def _capture_frames(self): - portal = get_blocking_portal(self) + portal = lt.get_blocking_portal(self) while self._capture_enabled: ret, frame = self.cap.read() if not ret: @@ -68,7 +66,7 @@ class OpenCVCamera(BaseCamera): ].tobytes() self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) - @thing_action + @lt.thing_action def capture_array( self, resolution: Literal["main", "full"] = "full", @@ -87,10 +85,10 @@ class OpenCVCamera(BaseCamera): ) return frame - @thing_action + @lt.thing_action def capture_jpeg( self, - metadata_getter: GetThingStates, + metadata_getter: lt.deps.GetThingStates, resolution: Literal["main", "full"] = "main", ) -> JPEGBlob: """Acquire one image from the camera and return as a JPEG blob diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 3162e70f..28c5ffc0 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -16,37 +16,31 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf """ from __future__ import annotations +from typing import Annotated, Any, Iterator, Literal, Mapping, Optional from datetime import datetime import json import logging import os import tempfile import time -from tempfile import TemporaryDirectory +from contextlib import contextmanager +from threading import RLock from pydantic import BaseModel, BeforeValidator - -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 -from labthings_fastapi.dependencies.metadata import GetThingStates -from labthings_fastapi.dependencies.blocking_portal import BlockingPortal -from typing import Annotated, Any, Iterator, Literal, Mapping, Optional -from contextlib import contextmanager import piexif -from threading import RLock import picamera2 +import numpy as np from picamera2 import Picamera2 from picamera2.encoders import MJPEGEncoder from picamera2.outputs import Output -import numpy as np -from . import picamera_recalibrate_utils as recalibrate_utils +import labthings_fastapi as lt + +from . import picamera_recalibrate_utils as recalibrate_utils from . import BaseCamera, JPEGBlob, ArrayModel -class PicameraControl(ThingProperty): +class PicameraControl(lt.ThingProperty): def __init__( self, control_name: str, model: type = float, description: Optional[str] = None ): @@ -66,7 +60,7 @@ class PicameraControl(ThingProperty): class PicameraStreamOutput(Output): """An Output class that sends frames to a stream""" - def __init__(self, stream: MJPEGStream, portal: BlockingPortal): + def __init__(self, stream: lt.outputs.MJPEGStream, portal: lt.deps.BlockingPortal): """Create an output that puts frames in an MJPEGStream We need to pass the stream object, and also the blocking portal, because @@ -211,19 +205,19 @@ class StreamingPiCamera2(BaseCamera): except KeyError: pass # If controls are missing, leave at default - stream_resolution = ThingProperty( + stream_resolution = lt.ThingProperty( tuple[int, int], initial_value=(820, 616), description="Resolution to use for the MJPEG stream", ) - mjpeg_bitrate = ThingProperty( + mjpeg_bitrate = lt.ThingProperty( Optional[int], initial_value=100000000, description="Bitrate for MJPEG stream (None for default)", ) - stream_active = ThingProperty( + stream_active = lt.ThingProperty( bool, initial_value=False, description="Whether the MJPEG stream is active", @@ -255,7 +249,7 @@ class StreamingPiCamera2(BaseCamera): _sensor_modes = None - @thing_property + @lt.thing_property def sensor_modes(self) -> list[SensorMode]: """All the available modes the current sensor supports""" if not self._sensor_modes: @@ -263,7 +257,7 @@ class StreamingPiCamera2(BaseCamera): self._sensor_modes = cam.sensor_modes return self._sensor_modes - @thing_property + @lt.thing_property def sensor_mode(self) -> Optional[SensorModeSelector]: """The intended sensor mode of the camera""" return self.thing_settings["sensor_mode"] @@ -276,13 +270,13 @@ class StreamingPiCamera2(BaseCamera): with self.picamera(pause_stream=True): self.thing_settings["sensor_mode"] = new_mode - @thing_property + @lt.thing_property def sensor_resolution(self) -> tuple[int, int]: """The native resolution of the camera's sensor""" with self.picamera() as cam: return cam.sensor_resolution - tuning = ThingProperty(Optional[dict], None, readonly=True) + tuning = lt.ThingProperty(Optional[dict], None, readonly=True) def settings_to_properties(self): """Set the values of properties based on the settings dict""" @@ -400,7 +394,7 @@ class StreamingPiCamera2(BaseCamera): cam.close() del self._picamera - @thing_action + @lt.thing_action def start_streaming( self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 6 ) -> None: @@ -451,7 +445,7 @@ class StreamingPiCamera2(BaseCamera): MJPEGEncoder(self.mjpeg_bitrate), PicameraStreamOutput( self.mjpeg_stream, - get_blocking_portal(self), + lt.get_blocking_portal(self), ), name=stream_name, ) @@ -459,7 +453,7 @@ class StreamingPiCamera2(BaseCamera): MJPEGEncoder(100000000), PicameraStreamOutput( self.lores_mjpeg_stream, - get_blocking_portal(self), + lt.get_blocking_portal(self), ), name="lores", ) @@ -472,7 +466,7 @@ class StreamingPiCamera2(BaseCamera): "Started MJPEG stream at %s on port %s", self.stream_resolution, 1 ) - @thing_action + @lt.thing_action def stop_streaming(self, stop_web_stream: bool = True) -> None: """ Stop the MJPEG stream @@ -493,7 +487,7 @@ class StreamingPiCamera2(BaseCamera): # Adding a sleep to prevent camera getting confused by rapid commands time.sleep(0.2) - @thing_action + @lt.thing_action def capture_image( self, stream_name: Literal["main", "lores", "raw"] = "main", @@ -511,7 +505,7 @@ class StreamingPiCamera2(BaseCamera): with self.picamera() as cam: return cam.capture_image(stream_name, wait=wait) - @thing_action + @lt.thing_action def capture_array( self, stream_name: Literal["main", "lores", "raw", "full"] = "main", @@ -538,7 +532,7 @@ class StreamingPiCamera2(BaseCamera): with self.picamera() as cam: return cam.capture_array(stream_name, wait=wait) - @thing_property + @lt.thing_property def camera_configuration(self) -> Mapping: """The "configuration" dictionary of the picamera2 object @@ -553,10 +547,10 @@ class StreamingPiCamera2(BaseCamera): with self.picamera() as cam: return cam.camera_configuration() - @thing_action + @lt.thing_action def capture_jpeg( self, - metadata_getter: GetThingStates, + metadata_getter: lt.deps.GetThingStates, resolution: Literal["lores", "main", "full"] = "main", wait: Optional[float] = 0.9, ) -> JPEGBlob: @@ -580,7 +574,7 @@ class StreamingPiCamera2(BaseCamera): bypass this, you must use a raw capture. """ fname = datetime.now().strftime("%Y-%m-%d-%H%M%S.jpeg") - folder = TemporaryDirectory() + folder = tempfile.TemporaryDirectory() path = os.path.join(folder.name, fname) config = self.camera_configuration # Low-res and main streams are running already - so we don't need @@ -609,13 +603,13 @@ class StreamingPiCamera2(BaseCamera): piexif.insert(piexif.dump(exif_dict), path) return JPEGBlob.from_temporary_directory(folder, fname) - @thing_property + @lt.thing_property def capture_metadata(self) -> dict: """Return the metadata from the camera""" with self.picamera() as cam: return cam.capture_metadata() - @thing_action + @lt.thing_action def auto_expose_from_minimum( self, target_white_level: int = 700, @@ -641,7 +635,7 @@ class StreamingPiCamera2(BaseCamera): ) self.update_persistent_controls() - @thing_action + @lt.thing_action def calibrate_white_balance( self, method: Literal["percentile", "centre"] = "centre", @@ -676,7 +670,7 @@ class StreamingPiCamera2(BaseCamera): ) self.update_persistent_controls() - @thing_action + @lt.thing_action def calibrate_lens_shading(self) -> None: """Take an image and use it for flat-field correction. @@ -694,7 +688,7 @@ class StreamingPiCamera2(BaseCamera): recalibrate_utils.set_static_lst(self.tuning, L, Cr, Cb) self.initialise_picamera() - @thing_property + @lt.thing_property def colour_correction_matrix( self, ) -> tuple[float, float, float, float, float, float, float, float, float]: @@ -709,7 +703,7 @@ class StreamingPiCamera2(BaseCamera): self.thing_settings["colour_correction_matrix"] = value self.calibrate_colour_correction(value) - @thing_action + @lt.thing_action def reset_ccm(self): """ Overwrite the colour correction matrix in camera tuning with default values. @@ -731,7 +725,7 @@ class StreamingPiCamera2(BaseCamera): ] self.colour_correction_matrix = col_corr_matrix - @thing_action + @lt.thing_action def calibrate_colour_correction( self, col_corr_matrix: tuple[ @@ -750,7 +744,7 @@ class StreamingPiCamera2(BaseCamera): recalibrate_utils.set_static_ccm(self.tuning, col_corr_matrix) self.initialise_picamera() - @thing_action + @lt.thing_action def set_static_green_equalisation(self, offset: int = 65535) -> None: """Set the green equalisation to a static value. @@ -765,7 +759,7 @@ class StreamingPiCamera2(BaseCamera): recalibrate_utils.set_static_geq(self.tuning, offset) self.initialise_picamera() - @thing_action + @lt.thing_action def full_auto_calibrate(self) -> None: """Perform a full auto-calibration @@ -783,7 +777,7 @@ class StreamingPiCamera2(BaseCamera): self.calibrate_lens_shading() self.calibrate_white_balance() - @thing_action + @lt.thing_action def flat_lens_shading(self) -> None: """Disable flat-field correction @@ -802,7 +796,7 @@ class StreamingPiCamera2(BaseCamera): ) self.initialise_picamera() - @thing_property + @lt.thing_property def lens_shading_tables(self) -> Optional[LensShading]: """The current lens shading (i.e. flat-field correction) @@ -879,7 +873,7 @@ class StreamingPiCamera2(BaseCamera): float(gain_b / np.max(lst.Cb)), ) - @thing_action + @lt.thing_action def flat_lens_shading_chrominance(self) -> None: """Disable flat-field correction @@ -894,7 +888,7 @@ class StreamingPiCamera2(BaseCamera): recalibrate_utils.set_static_lst(self.tuning, luminance, flat, flat) self.initialise_picamera() - @thing_action + @lt.thing_action def reset_lens_shading(self) -> None: """Revert to default lens shading settings @@ -905,7 +899,7 @@ class StreamingPiCamera2(BaseCamera): recalibrate_utils.copy_alsc_section(self.default_tuning, self.tuning) self.initialise_picamera() - @thing_property + @lt.thing_property def lens_shading_is_static(self) -> bool: """Whether the lens shading is static diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 28de3e5b..c39e912a 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -19,10 +19,7 @@ import numpy as np import piexif from scipy.ndimage import gaussian_filter -from labthings_fastapi.utilities import get_blocking_portal -from labthings_fastapi.decorators import thing_action, thing_property -from labthings_fastapi.dependencies.metadata import GetThingStates -from labthings_fastapi.server import ThingServer +import labthings_fastapi as lt from . import BaseCamera, JPEGBlob, ArrayModel from ..stage import BaseStage @@ -36,7 +33,7 @@ class SimulatedCamera(BaseCamera): """A Thing representing an OpenCV camera""" _stage: Optional[BaseStage] = None - _server: Optional[ThingServer] = None + _server: Optional[lt.ThingServer] = None def __init__( self, @@ -116,7 +113,7 @@ class SimulatedCamera(BaseCamera): return image def attach_to_server( - self, server: ThingServer, path: str, setting_storage_path: str + self, server: lt.ThingServer, path: str, setting_storage_path: str ): self._server = server return super().attach_to_server(server, path, setting_storage_path) @@ -146,7 +143,7 @@ class SimulatedCamera(BaseCamera): self._capture_enabled = False self._capture_thread.join() - @thing_property + @lt.thing_property def stream_active(self) -> bool: "Whether the MJPEG stream is active" if self._capture_enabled and self._capture_thread: @@ -154,7 +151,7 @@ class SimulatedCamera(BaseCamera): return False def _capture_frames(self): - portal = get_blocking_portal(self) + portal = lt.get_blocking_portal(self) while self._capture_enabled: time.sleep(self.frame_interval) try: @@ -168,7 +165,7 @@ class SimulatedCamera(BaseCamera): except Exception as e: logging.error(f"Failed to capture frame: {e}, retrying...") - @thing_action + @lt.thing_action def capture_array( self, resolution: Literal["main", "full"] = "full", @@ -182,10 +179,10 @@ class SimulatedCamera(BaseCamera): logging.warning(f"Simulation camera doen't respect {resolution} setting") return self.generate_frame() - @thing_action + @lt.thing_action def capture_jpeg( self, - metadata_getter: GetThingStates, + metadata_getter: lt.deps.GetThingStates, resolution: Literal["main", "full"] = "main", ) -> JPEGBlob: """Acquire one image from the camera and return as a JPEG blob diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index cfe178e1..ae969b6c 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -33,14 +33,10 @@ from camera_stage_mapping.camera_stage_calibration_1d import ( image_to_stage_displacement_from_1d, ) from camera_stage_mapping.exceptions import MappingError -from labthings_fastapi.dependencies.invocation import ( - InvocationCancelledError, - InvocationLogger, -) + +import labthings_fastapi as lt 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 from .stage import StageDependency as Stage @@ -179,15 +175,15 @@ class CSMUncalibratedError(HTTPException): ) -class CameraStageMapper(Thing): +class CameraStageMapper(lt.Thing): """A Thing to manage mapping between image and stage coordinates""" - @thing_action + @lt.thing_action def calibrate_1d( self, hw: HardwareInterfaceDep, stage: Stage, - logger: InvocationLogger, + logger: lt.deps.InvocationLogger, direction: Tuple[float, float, float], ) -> DenumpifyingDict: """Move a microscope's stage in 1D, and figure out the relationship with the camera""" @@ -202,7 +198,7 @@ class CameraStageMapper(Thing): result: dict = calibrate_backlash_1d( tracker, move, direction_array, logger=logger ) - except InvocationCancelledError as e: + except lt.exceptions.InvocationCancelledError as e: logger.info("User cancelled the camera stage mapping calibration") logger.info("Returning to starting position") stage.move_absolute(**starting_position, block_cancellation=True) @@ -215,9 +211,9 @@ class CameraStageMapper(Thing): result["image_resolution"] = hw.grab_image().shape[:2] return result - @thing_action + @lt.thing_action def calibrate_xy( - self, hw: HardwareInterfaceDep, stage: Stage, logger: InvocationLogger + self, hw: HardwareInterfaceDep, stage: Stage, logger: lt.deps.InvocationLogger ) -> DenumpifyingDict: """Move the microscope's stage in X and Y, to calibrate its relationship to the camera @@ -257,7 +253,7 @@ class CameraStageMapper(Thing): return data - @thing_property + @lt.thing_property def image_to_stage_displacement_matrix( self, ) -> Optional[List[List[float]]]: # 2x2 integer array @@ -285,21 +281,19 @@ class CameraStageMapper(Thing): ] return np.array(displacement_matrix).tolist() - last_calibration = ThingSetting( + last_calibration = lt.ThingSetting( initial_value=None, model=Optional[dict], readonly=True, description="The most recent CSM calibration", ) - @thing_property + @lt.thing_property def image_resolution(self) -> Optional[Tuple[float, float]]: """The image size used to calibrate the image_to_stage_displacement_matrix""" if self.last_calibration is None: return None - return self.last_calibration["camera_stage_mapping_calibration"][ - "image_resolution" - ] + return self.last_calibration["image_resolution"] def assert_calibrated(self): """Raise an exception if the image_to_stage_displacement matrix is not set""" @@ -308,7 +302,7 @@ class CameraStageMapper(Thing): # added by CSMUncalibratedError raise CSMUncalibratedError() # noqa: RSE102 - @thing_action + @lt.thing_action def move_in_image_coordinates( self, stage: Stage, @@ -332,7 +326,7 @@ class CameraStageMapper(Thing): ) stage.move_relative(x=relative_move[0], y=relative_move[1]) - @thing_property + @lt.thing_property def thing_state(self) -> dict[str, Any]: """Summary metadata describing the current state of the Thing""" return { diff --git a/src/openflexure_microscope_server/things/settings_manager.py b/src/openflexure_microscope_server/things/settings_manager.py index bd5535fd..6dacd7dd 100644 --- a/src/openflexure_microscope_server/things/settings_manager.py +++ b/src/openflexure_microscope_server/things/settings_manager.py @@ -11,19 +11,14 @@ from socket import gethostname from typing import Annotated, Any, MutableMapping, Optional, Sequence 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, thing_setting -from labthings_fastapi.descriptors import ThingSetting -from labthings_fastapi.dependencies.thing_server import find_thing_server -from labthings_fastapi.server import ThingServer +import labthings_fastapi as lt -def thing_server_from_request(request: Request) -> ThingServer: - return find_thing_server(request.app) +def thing_server_from_request(request: Request) -> lt.ThingServer: + return lt.find_thing_server(request.app) -ThingServerDep = Annotated[ThingServer, Depends(thing_server_from_request)] +ThingServerDep = Annotated[lt.ThingServer, Depends(thing_server_from_request)] def recursive_update(old_dict: MutableMapping, update: Mapping): @@ -48,20 +43,20 @@ def nested_dict_get(data: dict, key: Sequence[str], create=False) -> Any: return subdict -class SettingsManager(Thing): - external_metadata = ThingSetting( +class SettingsManager(lt.Thing): + external_metadata = lt.ThingSetting( initial_value={}, model=Mapping, description="External metadata stored in the server's settings", ) - external_metadata_in_state = ThingSetting( + external_metadata_in_state = lt.ThingSetting( initial_value=[], model=Sequence[str], description='A list of strings that are included in the "state" metadata', ) - @thing_action + @lt.thing_action def update_external_metadata( self, data: Mapping, key: Optional[str] = None ) -> None: @@ -84,7 +79,7 @@ class SettingsManager(Thing): recursive_update(subdict, data) self.external_metadata = metadata - @thing_action + @lt.thing_action def delete_external_metadata(self, key: str) -> None: """Delete a key from the stored metadata. @@ -104,7 +99,7 @@ class SettingsManager(Thing): _microscope_id: Optional[str] = None - @thing_setting + @lt.thing_setting def microscope_id(self) -> UUID: """A unique identifier for this microscope""" if self._microscope_id is None: @@ -116,13 +111,13 @@ class SettingsManager(Thing): # TODO make read only but still settable from disk self._microscope_id = uuid - @thing_property + @lt.thing_property def hostname(self) -> str: """The hostname of the microscope, as reported by its operating system.""" return gethostname() - @thing_action - def get_things_state(self, metadata_getter: GetThingStates) -> Mapping: + @lt.thing_action + def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping: """Metadata summarising the current state of all Things in the server""" return metadata_getter() diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 812c2c32..c9c19627 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -11,21 +11,7 @@ from fastapi.responses import FileResponse import numpy as np 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.outputs.blob import blob_type +import labthings_fastapi as lt from openflexure_microscope_server.utilities import ErrorCapturingThread from openflexure_microscope_server import scan_directories @@ -38,14 +24,16 @@ from .background_detect import BackgroundDetectThing from .camera import CameraDependency as CamDep from .stage import StageDependency as StageDep -CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") -AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") -BackgroundDep = direct_thing_client_dependency( +CSMDep = lt.deps.direct_thing_client_dependency( + CameraStageMapper, "/camera_stage_mapping/" +) +AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") +BackgroundDep = lt.deps.direct_thing_client_dependency( BackgroundDetectThing, "/background_detect/" ) -JPEGBlob = blob_type("image/jpeg") -ZipBlob = blob_type("application/zip") +JPEGBlob = lt.blob.blob_type("image/jpeg") +ZipBlob = lt.blob.blob_type("application/zip") SCAN_DATA_FILENAME = "scan_data.json" STITCHING_CMD = "openflexure-stitch" @@ -76,7 +64,7 @@ def _scan_running(method): return scan_running_wrapper -class SmartScanThing(Thing): +class SmartScanThing(lt.Thing): def __init__(self, scans_folder): self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder) self._preview_stitch_popen = None @@ -87,17 +75,17 @@ class SmartScanThing(Thing): self._latest_scan_name: Optional[str] = None # Scan logger is the invocation logger labthings-fastapi creates - # when the `sample_scan` thing_action is called. It is saved as + # when the `sample_scan` lt.thing_action is called. It is saved as # private class variable along with many others here. # Access to these variables requires a scan to be running, # any method that calls these should be decorrected with # @_scan_running - self._scan_logger: Optional[InvocationLogger] = None - self._cancel: Optional[CancelHook] = None + self._scan_logger: Optional[lt.deps.InvocationLogger] = None + self._cancel: Optional[lt.deps.CancelHook] = None self._autofocus: Optional[AutofocusDep] = None self._stage: Optional[StageDep] = None self._cam: Optional[CamDep] = None - self._metadata_getter: Optional[GetThingStates] = None + self._metadata_getter: Optional[lt.deps.GetThingStates] = None self._csm: Optional[CSMDep] = None self._background_detect: Optional[BackgroundDep] = None self._ongoing_scan: Optional[scan_directories.ScanDirectory] = None @@ -107,15 +95,15 @@ class SmartScanThing(Thing): # TODO Scan data is a dict during refactoring, should become a dataclass self._scan_data: Optional[dict] = None - @thing_action + @lt.thing_action def sample_scan( self, - cancel: CancelHook, - logger: InvocationLogger, + cancel: lt.deps.CancelHook, + logger: lt.deps.InvocationLogger, autofocus: AutofocusDep, stage: StageDep, cam: CamDep, - metadata_getter: GetThingStates, + metadata_getter: lt.deps.GetThingStates, csm: CSMDep, background_detect: BackgroundDep, scan_name: str = "", @@ -210,7 +198,7 @@ class SmartScanThing(Thing): "of motion." ) - @thing_property + @lt.thing_property def latest_scan_name(self) -> Optional[str]: """The name of the last scan to be started.""" return self._latest_scan_name @@ -419,7 +407,7 @@ class SmartScanThing(Thing): self._main_scan_loop() self._update_scan_data_json(scan_result="success") - except InvocationCancelledError: + except lt.exceptions.InvocationCancelledError: scan_successful = False # Reset the cancel event so it can be thrown again self._cancel.clear() @@ -572,7 +560,7 @@ class SmartScanThing(Thing): except SubprocessError as e: self._scan_logger.error(f"Stitching failed: {e}", exc_info=e) - @fastapi_endpoint( + @lt.fastapi_endpoint( "get", "scans/stitched_thumbnail.jpg", responses={ @@ -595,13 +583,13 @@ class SmartScanThing(Thing): raise HTTPException(404, "File not found") return FileResponse(preview_path) - save_resolution = ThingSetting( + save_resolution = lt.ThingSetting( initial_value=(1640, 1232), model=tuple[int, int], description=("A tuple of the image resolution to capture."), ) - max_range = ThingSetting( + max_range = lt.ThingSetting( initial_value=45000, model=int, description=( @@ -609,13 +597,13 @@ class SmartScanThing(Thing): ), ) - stitch_tiff = ThingSetting( + stitch_tiff = lt.ThingSetting( initial_value=False, model=bool, description="Whether or not to also produce a pyramidal tiff", ) - skip_background = ThingSetting( + skip_background = lt.ThingSetting( initial_value=True, model=bool, description="""Whether to detect and skip empty fields of view @@ -623,19 +611,19 @@ class SmartScanThing(Thing): This uses the settings from the `background_detect` Thing.""", ) - autofocus_dz = ThingSetting( + autofocus_dz = lt.ThingSetting( initial_value=1000, model=int, description="The z distance to perform an autofocus in steps", ) - overlap = ThingSetting( + overlap = lt.ThingSetting( initial_value=0.45, model=float, description="The fraction (0-1) that adjacent images should overlap in x or y", ) - stitch_automatically = ThingSetting( + stitch_automatically = lt.ThingSetting( initial_value=True, model=bool, description=( @@ -644,7 +632,7 @@ class SmartScanThing(Thing): ), ) - @thing_property + @lt.thing_property def scans(self) -> list[scan_directories.ScanInfo]: """All the available scans @@ -656,7 +644,7 @@ class SmartScanThing(Thing): """ return self._scan_dir_manager.all_scans_info() - @fastapi_endpoint( + @lt.fastapi_endpoint( "get", "get_stitch/{scan_name}", responses={ @@ -680,7 +668,7 @@ class SmartScanThing(Thing): raise HTTPException(404, "File not found") return FileResponse(stitch_path) - @fastapi_endpoint( + @lt.fastapi_endpoint( "delete", "scans/{scan_name}", responses={ @@ -688,7 +676,7 @@ class SmartScanThing(Thing): 400: {"description": "An error occurred while trying to delete scan"}, }, ) - def delete_scan(self, scan_name: str, logger: InvocationLogger) -> None: + def delete_scan(self, scan_name: str, logger: lt.deps.InvocationLogger) -> None: """Delete the folder for the specified scan. This endpoint allows scans to be deleted from disk. @@ -702,11 +690,11 @@ class SmartScanThing(Thing): if not deleted_scan_success: raise HTTPException(400, "Couldn't delete scan, check log for details") - @fastapi_endpoint( + @lt.fastapi_endpoint( "delete", "scans", ) - def delete_all_scans(self, logger: InvocationLogger) -> None: + def delete_all_scans(self, logger: lt.deps.InvocationLogger) -> None: """Delete all the scans on the microscope **This will irreversibly remove all scanned data from the @@ -716,8 +704,8 @@ class SmartScanThing(Thing): for scan_name in self._scan_dir_manager.all_scans: self._delete_scan(scan_name, logger) - @thing_action - def purge_empty_scans(self, logger: InvocationLogger) -> None: + @lt.thing_action + def purge_empty_scans(self, logger: lt.deps.InvocationLogger) -> None: """ Delete all scan folders containing no images at the top level """ @@ -727,7 +715,7 @@ class SmartScanThing(Thing): if scan_info.number_of_images == 0: self._delete_scan(scan_info.name, logger) - def _delete_scan(self, scan_name, logger: InvocationLogger) -> bool: + def _delete_scan(self, scan_name, logger: lt.deps.InvocationLogger) -> bool: """ A wrapper around scan manager's delete_scan that logs to the invocation logger """ @@ -752,7 +740,7 @@ class SmartScanThing(Thing): scan_name=self.latest_scan_name, filename="preview.jpg", check_exists=True ) - @thing_property + @lt.thing_property def latest_preview_stitch_time(self) -> Optional[float]: """The modification time of the latest preview image, to allow live updating @@ -767,7 +755,7 @@ class SmartScanThing(Thing): return None return os.path.getmtime(self.latest_preview_stitch_path) - @fastapi_endpoint( + @lt.fastapi_endpoint( "get", "latest_preview_stitch.jpg", responses={ @@ -836,8 +824,8 @@ class SmartScanThing(Thing): def run_subprocess( self, - logger: InvocationLogger, - cancel: CancelHook, + logger: lt.deps.InvocationLogger, + cancel: lt.deps.CancelHook, cmd: list[str], ) -> CompletedProcess: """ @@ -873,7 +861,7 @@ class SmartScanThing(Thing): # Note that using cancel.sleep allows the InvocationCancelledError to # be thrown cancel.sleep(0.2) - except InvocationCancelledError as e: + except lt.exceptions.InvocationCancelledError as e: logger.info("Stitching cancelled by user") process.kill() raise e @@ -886,18 +874,18 @@ class SmartScanThing(Thing): else: raise ChildProcessError(f"Subprocess {cmd[0]} exited with an error.") - @thing_action + @lt.thing_action def stitch_scan( self, - logger: InvocationLogger, - cancel: CancelHook, + logger: lt.deps.InvocationLogger, + cancel: lt.deps.CancelHook, scan_name: str, stitch_resize: Optional[float] = None, overlap: float = 0.0, ) -> None: """Generate a stitched image based on stage position metadata - Note that as this is a thing_action it needs the logger passed as + Note that as this is a lt.thing_action it needs the logger passed as a variable if called from another thing action """ json_fpath = self._scan_dir_manager.get_file_path_from_img_dir( @@ -969,12 +957,12 @@ class SmartScanThing(Thing): self._scan_dir_manager.img_dir_for(scan_name), ], ) - except InvocationCancelledError: + except lt.exceptions.InvocationCancelledError: # Sleep for 1 second just to allow invocation logs to pass to user. time.sleep(1) pass - @thing_action + @lt.thing_action def download_zip( self, scan_name: str, diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index f278ef1e..3a40d44d 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -2,14 +2,10 @@ from __future__ import annotations from typing import TypeAlias from collections.abc import Sequence, Mapping -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 -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency +import labthings_fastapi as lt -class BaseStage(Thing): +class BaseStage(lt.Thing): """A base stage class for OpenFlexure translation stages This can't be used directly but should reduce boilerplate code when @@ -20,12 +16,12 @@ class BaseStage(Thing): _axis_names = ("x", "y", "z") - @thing_property + @lt.thing_property def axis_names(self) -> Sequence[str]: """The names of the stage's axes, in order.""" return self._axis_names - position = ThingProperty( + position = lt.ThingProperty( Mapping[str, int], dict.fromkeys(_axis_names, 0), description="Current position of the stage", @@ -33,7 +29,7 @@ class BaseStage(Thing): observable=True, ) - moving = ThingProperty( + moving = lt.ThingProperty( bool, False, description="Whether the stage is in motion", @@ -46,10 +42,10 @@ class BaseStage(Thing): """Summary metadata describing the current state of the stage""" return {"position": self.position} - @thing_action + @lt.thing_action def move_relative( self, - cancel: CancelHook, + cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: Mapping[str, int], ): @@ -58,10 +54,10 @@ class BaseStage(Thing): "StageThings must define their own move_relative method" ) - @thing_action + @lt.thing_action def move_absolute( self, - cancel: CancelHook, + cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: Mapping[str, int], ): @@ -70,7 +66,7 @@ class BaseStage(Thing): "StageThings must define their own move_absolute method" ) - @thing_action + @lt.thing_action def set_zero_position(self): """Make the current position zero in all axes @@ -83,4 +79,6 @@ class BaseStage(Thing): ) -StageDependency: TypeAlias = direct_thing_client_dependency(BaseStage, "/stage/") +StageDependency: TypeAlias = lt.deps.direct_thing_client_dependency( + BaseStage, "/stage/" +) diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index c6df8d25..cd3c6387 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -1,12 +1,10 @@ from __future__ import annotations -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.dependencies.invocation import ( - CancelHook, - InvocationCancelledError, -) + from collections.abc import Mapping import time +import labthings_fastapi as lt + from . import BaseStage @@ -27,10 +25,10 @@ class DummyStage(BaseStage): def __exit__(self, _exc_type, _exc_value, _traceback): pass - @thing_action + @lt.thing_action def move_relative( self, - cancel: CancelHook, + cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: Mapping[str, int], ): @@ -53,7 +51,7 @@ class DummyStage(BaseStage): for k, v in zip(self.axis_names, displacement) } fraction_complete = 1.0 - except InvocationCancelledError as e: + except lt.exceptions.InvocationCancelledError as e: # If the move has been cancelled, stop it but don't handle the exception. # We need the exception to propagate in order to stop any calling tasks, # and to mark the invocation as "cancelled" rather than stopped. @@ -66,10 +64,10 @@ class DummyStage(BaseStage): } self.instantaneous_position = self.position - @thing_action + @lt.thing_action def move_absolute( self, - cancel: CancelHook, + cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: Mapping[str, int], ): @@ -83,7 +81,7 @@ class DummyStage(BaseStage): cancel, block_cancellation=block_cancellation, **displacement ) - @thing_action + @lt.thing_action def set_zero_position(self): """Make the current position zero in all axes diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 7e18c035..35b3b79e 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -7,11 +7,7 @@ from contextlib import contextmanager from collections.abc import Mapping import sangaboard -from labthings_fastapi.decorators import thing_action -from labthings_fastapi.dependencies.invocation import ( - CancelHook, - InvocationCancelledError, -) +import labthings_fastapi as lt from . import BaseStage @@ -57,10 +53,10 @@ class SangaboardThing(BaseStage): with self.sangaboard() as sb: self.position = dict(zip(self.axis_names, sb.position)) - @thing_action + @lt.thing_action def move_relative( self, - cancel: CancelHook, + cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: Mapping[str, int], ) -> None: @@ -75,7 +71,7 @@ class SangaboardThing(BaseStage): else: while sb.query("moving?") == "true": cancel.sleep(0.1) - except InvocationCancelledError as e: + except lt.exceptions.InvocationCancelledError as e: # If the move has been cancelled, stop it but don't handle the exception. # We need the exception to propagate in order to stop any calling tasks, # and to mark the invocation as "cancelled" rather than stopped. @@ -85,10 +81,10 @@ class SangaboardThing(BaseStage): self.moving = False self.update_position() - @thing_action + @lt.thing_action def move_absolute( self, - cancel: CancelHook, + cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: Mapping[str, int], ) -> None: @@ -104,7 +100,7 @@ class SangaboardThing(BaseStage): cancel, block_cancellation=block_cancellation, **displacement ) - @thing_action + @lt.thing_action def set_zero_position(self) -> None: """Make the current position zero in all axes @@ -116,7 +112,7 @@ class SangaboardThing(BaseStage): sb.zero_position() self.update_position() - @thing_action + @lt.thing_action def flash_led( self, number_of_flashes: int = 10, diff --git a/src/openflexure_microscope_server/things/system_control.py b/src/openflexure_microscope_server/things/system_control.py index 0b169c36..cbca06ab 100644 --- a/src/openflexure_microscope_server/things/system_control.py +++ b/src/openflexure_microscope_server/things/system_control.py @@ -6,8 +6,7 @@ This module defines a Thing that can shut down or restart the host computer. import subprocess import os -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_property +import labthings_fastapi as lt from pydantic import BaseModel @@ -16,12 +15,12 @@ class CommandOutput(BaseModel): error: str -class SystemControlThing(Thing): +class SystemControlThing(lt.Thing): """ Attempt to shutdown the device """ - @thing_action + @lt.thing_action def shutdown(self) -> CommandOutput: """ Attempt to shutdown the device @@ -35,14 +34,14 @@ class SystemControlThing(Thing): out, err = p.communicate() return CommandOutput(output=out, error=err) - @thing_property + @lt.thing_property def is_raspberrypi() -> bool: """ Checks if we are running on a Raspberry Pi. """ return os.path.exists("/usr/bin/raspi-config") - @thing_action + @lt.thing_action def reboot(self) -> CommandOutput: """Attempt to reboot the device""" p = subprocess.Popen( diff --git a/tests/test_dummy_server.py b/tests/test_dummy_server.py index 23d139e8..ca286ebd 100644 --- a/tests/test_dummy_server.py +++ b/tests/test_dummy_server.py @@ -3,13 +3,13 @@ import os import tempfile from fastapi.testclient import TestClient -from labthings_fastapi.client import ThingClient from PIL import Image import numpy as np import piexif import pytest -from openflexure_microscope_server.server import ThingServer +import labthings_fastapi as lt + from openflexure_microscope_server.things.camera.simulation import SimulatedCamera from openflexure_microscope_server.things.stage.dummy import DummyStage from openflexure_microscope_server.things.autofocus import AutofocusThing @@ -22,7 +22,7 @@ camera_stage_mapping.DEFAULT_SETTLING_TIME = 0 # skip the settling time for tes @pytest.fixture def thing_server(): temp_folder = tempfile.TemporaryDirectory() - server = ThingServer(settings_folder=temp_folder.name) + server = lt.ThingServer(settings_folder=temp_folder.name) server.add_thing( SimulatedCamera( shape=(240, 320, 3), canvas_shape=(960, 1240, 3), frame_interval=0.01 @@ -52,18 +52,18 @@ def slower_client(thing_server): def test_autofocus(slower_client): client = slower_client - autofocus = ThingClient.from_url("/autofocus/", client) + autofocus = lt.ThingClient.from_url("/autofocus/", client) _ = autofocus.fast_autofocus() def test_grab_jpeg(client): - camera = ThingClient.from_url("/camera/", client) + camera = lt.ThingClient.from_url("/camera/", client) blob = camera.grab_jpeg() _image = Image.open(blob.open()) def test_capture_jpeg_metadata(client): - camera = ThingClient.from_url("/camera/", client) + camera = lt.ThingClient.from_url("/camera/", client) blob = camera.capture_jpeg() image = Image.open(blob.open()) exif_dict = piexif.load(image.info["exif"]) @@ -73,7 +73,7 @@ def test_capture_jpeg_metadata(client): def test_stage(client): - stage = ThingClient.from_url("/stage/", client) + stage = lt.ThingClient.from_url("/stage/", client) start = stage.position move = {"x": 1, "y": 2, "z": 3} stage.move_relative(**move) @@ -87,12 +87,12 @@ def test_stage(client): def test_capture_array(client): - camera = ThingClient.from_url("/camera/", client) + camera = lt.ThingClient.from_url("/camera/", client) array = np.asarray(camera.capture_array()) assert array.shape == (240, 320, 3) # Currently this fails, not yet sure why. def test_camera_stage_mapping_calibration(client): - camera_stage_mapping = ThingClient.from_url("/camera_stage_mapping/", client) + camera_stage_mapping = lt.ThingClient.from_url("/camera_stage_mapping/", client) camera_stage_mapping.calibrate_xy() From df8a9d0e93118f56b2f8145bf468d0726bf2a3e3 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 4 Jul 2025 12:29:53 +0100 Subject: [PATCH 3/6] Renaming picamera context manager to make it clear it is different from using the picamera itself as a context manager --- .../things/camera/picamera.py | 109 +++++++++--------- 1 file changed, 55 insertions(+), 54 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 28c5ffc0..7c15eca5 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -158,7 +158,7 @@ class StreamingPiCamera2(BaseCamera): and only update `self.persistent_controls` if the change is greater than this tolerance. """ - with self.picamera() as cam: + with self._streaming_picamera() as cam: for i in range(discard_frames): # Discard frames, so data is fresh cam.capture_metadata() @@ -242,7 +242,7 @@ class StreamingPiCamera2(BaseCamera): See comment within the function for more detail. """ - with self.picamera() as cam: + with self._streaming_picamera() as cam: # Note: This set a value 1 higher than requested as picamera2 always sets # a lower value than requested, even if the requested is allowed cam.set_controls({"ExposureTime": value + 1}) @@ -253,46 +253,42 @@ class StreamingPiCamera2(BaseCamera): def sensor_modes(self) -> list[SensorMode]: """All the available modes the current sensor supports""" if not self._sensor_modes: - with self.picamera() as cam: + with self._streaming_picamera() as cam: self._sensor_modes = cam.sensor_modes return self._sensor_modes - @lt.thing_property + _sensor_mode: Optional[dict] = None + + @lt.thing_setting def sensor_mode(self) -> Optional[SensorModeSelector]: """The intended sensor mode of the camera""" - return self.thing_settings["sensor_mode"] + if self._sensor_mode is None: + return None + return SensorModeSelector(**self._sensor_mode) @sensor_mode.setter - def sensor_mode(self, new_mode: Optional[SensorModeSelector]): + def sensor_mode(self, new_mode: Optional[SensorModeSelector | dict]): """Change the sensor mode used""" + + if new_mode is None: + self._sensor_mode = None + elif isinstance(new_mode, SensorModeSelector): + self._sensor_mode = new_mode.model_dump() + elif isinstance(new_mode, dict): + self._sensor_mode = new_mode + if isinstance(new_mode, SensorModeSelector): new_mode = new_mode.model_dump() - with self.picamera(pause_stream=True): + with self._streaming_picamera(pause_stream=True): self.thing_settings["sensor_mode"] = new_mode - @lt.thing_property + @lt.thing_setting def sensor_resolution(self) -> tuple[int, int]: """The native resolution of the camera's sensor""" - with self.picamera() as cam: + with self._streaming_picamera() as cam: return cam.sensor_resolution - tuning = lt.ThingProperty(Optional[dict], None, readonly=True) - - def settings_to_properties(self): - """Set the values of properties based on the settings dict""" - try: - props = self.thing_settings["properties"] - except KeyError: - return - for k, v in props.items(): - setattr(self, k, v) - - def properties_to_settings(self): - """Save certain properties to the settings dictionary""" - props = {} - for k in ["mjpeg_bitrate", "stream_resolution"]: - props[k] = getattr(self, k) - self.thing_settings["properties"] = props + tuning = lt.ThingSetting(Optional[dict], None, readonly=True) def initialise_tuning(self): """Read the tuning from the settings, or load default tuning @@ -328,10 +324,10 @@ class StreamingPiCamera2(BaseCamera): "Camera object already exists, closing for reinitialisation" ) self._picamera.close() - print("closed, deleting picamera") + print("Picamera closed, deleting picamera") del self._picamera recalibrate_utils.recreate_camera_manager() - print("[re]creating Picamera2 object") + print("Creating new Picamera2 object") self._picamera = picamera2.Picamera2( camera_num=self.camera_num, tuning=self.tuning, @@ -350,14 +346,19 @@ class StreamingPiCamera2(BaseCamera): return self @contextmanager - def picamera(self, pause_stream=False) -> Iterator[Picamera2]: - """Return the underlying `Picamera2` instance, optionally pausing the stream. + def _streaming_picamera(self, pause_stream=False) -> Iterator[Picamera2]: + """Lock access to picamera and return the underlying `Picamera2` instance. - If pause_stream is True (default is False), we will stop the MJPEG stream - before yielding control of the camera, and restart afterwards. If you make - changes to the camera settings, these may be ignored when the stream is - restarted: you may nened to call `update_persistent_controls()` to ensure - your changes persist after the stream restarts. + Optionally the stream can be paused to allow updating the camera settings. + + :param pause_stream: If False the `Picamera2` instance is simply yielded. + If True: + * Updated persistent controls + * Stop the MJPEG Stream + * Yield the `Picamera2` instance for function calling the context manager to + make changes. If seetings are changed run `update_persistent_controls()` before + closing the context manager. + * On closing of the context manager the stream will restart. """ already_streaming = self.stream_active with self._picamera_lock: @@ -390,7 +391,7 @@ class StreamingPiCamera2(BaseCamera): self.thing_settings.write_to_file() # Shut down the camera self.stop_streaming() - with self.picamera() as cam: + with self._streaming_picamera() as cam: cam.close() del self._picamera @@ -426,7 +427,7 @@ class StreamingPiCamera2(BaseCamera): f"Can't set a buffer count of {buffer_count}. " "Buffer count must be an integer from 1-8" ) - with self.picamera() as picam: + with self._streaming_picamera() as picam: try: if picam.started: picam.stop() @@ -471,7 +472,7 @@ class StreamingPiCamera2(BaseCamera): """ Stop the MJPEG stream """ - with self.picamera() as picam: + with self._streaming_picamera() as picam: try: picam.stop_recording() # This should also stop the extra lores encoder except Exception as e: @@ -502,7 +503,7 @@ class StreamingPiCamera2(BaseCamera): A TimeoutError is raised if this time is exceeded during capture. Default = 0.9s, lower than the 1s timeout default in picamera yaml settings """ - with self.picamera() as cam: + with self._streaming_picamera() as cam: return cam.capture_image(stream_name, wait=wait) @lt.thing_action @@ -526,10 +527,10 @@ class StreamingPiCamera2(BaseCamera): # This was slower than capture_image for our use case, but directly returning # an image as an array is still a useful feature if stream_name == "full": - with self.picamera(pause_stream=True) as picam2: + with self._streaming_picamera(pause_stream=True) as picam2: capture_config = picam2.create_still_configuration() return picam2.switch_mode_and_capture_array(capture_config, wait=wait) - with self.picamera() as cam: + with self._streaming_picamera() as cam: return cam.capture_array(stream_name, wait=wait) @lt.thing_property @@ -544,7 +545,7 @@ class StreamingPiCamera2(BaseCamera): this property refers to whatever configuration is currently in force - usually the one used for the preview stream. """ - with self.picamera() as cam: + with self._streaming_picamera() as cam: return cam.camera_configuration() @lt.thing_action @@ -580,14 +581,14 @@ class StreamingPiCamera2(BaseCamera): # Low-res and main streams are running already - so we don't need # to reconfigure for these if resolution in ("lores", "main") and config[resolution]: - with self.picamera() as cam: + with self._streaming_picamera() as cam: cam.capture_file(path, name=resolution, format="jpeg", wait=wait) else: if resolution != "full": logging.warning( f"There was no {resolution} stream, capturing full resolution" ) - with self.picamera(pause_stream=True) as cam: + with self._streaming_picamera(pause_stream=True) as cam: logging.info("Reconfiguring camera for full resolution capture") cam.configure(cam.create_still_configuration()) cam.start() @@ -606,7 +607,7 @@ class StreamingPiCamera2(BaseCamera): @lt.thing_property def capture_metadata(self) -> dict: """Return the metadata from the camera""" - with self.picamera() as cam: + with self._streaming_picamera() as cam: return cam.capture_metadata() @lt.thing_action @@ -627,7 +628,7 @@ class StreamingPiCamera2(BaseCamera): calculating the brightest pixel, a percentile is used rather than the maximum in order to be robust to a small number of noisy/bright pixels. """ - with self.picamera(pause_stream=True) as cam: + with self._streaming_picamera(pause_stream=True) as cam: recalibrate_utils.adjust_shutter_and_gain_from_raw( cam, target_white_level=target_white_level, @@ -652,7 +653,7 @@ class StreamingPiCamera2(BaseCamera): If `method` is `"centre"`, we will correct the mean of the central 10% of the image. """ - with self.picamera(pause_stream=True) as cam: + with self._streaming_picamera(pause_stream=True) as cam: if self.lens_shading_is_static: lst: LensShading = self.lens_shading_tables recalibrate_utils.adjust_white_balance_from_raw( @@ -679,7 +680,7 @@ class StreamingPiCamera2(BaseCamera): one. This uses the camera's "tuning" file to correct the preview and the processed images. It should not affect raw images. """ - with self.picamera(pause_stream=True) as cam: + with self._streaming_picamera(pause_stream=True) as cam: # Suppress lint warning that L, Cr, and Cb are not lowercase, as these are # the standard mathematical terms for: # luminance (L), red-difference chroma (Cr), and blue-difference chroma @@ -740,7 +741,7 @@ class StreamingPiCamera2(BaseCamera): See page Raspberry Pi Camera Algorithm and Tuning Guide, page 45. """ - with self.picamera(pause_stream=True): + with self._streaming_picamera(pause_stream=True): recalibrate_utils.set_static_ccm(self.tuning, col_corr_matrix) self.initialise_picamera() @@ -755,7 +756,7 @@ class StreamingPiCamera2(BaseCamera): A value of 0 here does nothing, a value of 65535 is maximum correction. """ - with self.picamera(pause_stream=True): + with self._streaming_picamera(pause_stream=True): recalibrate_utils.set_static_geq(self.tuning, offset) self.initialise_picamera() @@ -788,7 +789,7 @@ class StreamingPiCamera2(BaseCamera): This flat table is used to take an image wth no lens shading so that the correct lens shading table can be calibrated. """ - with self.picamera(pause_stream=True): + with self._streaming_picamera(pause_stream=True): # Generate and array of ones of the correct size for each channel flat_array = np.ones((12, 16)) recalibrate_utils.set_static_lst( @@ -833,7 +834,7 @@ class StreamingPiCamera2(BaseCamera): @lens_shading_tables.setter def lens_shading_tables(self, lst: LensShading) -> None: """Set the lens shading tables""" - with self.picamera(pause_stream=True): + with self._streaming_picamera(pause_stream=True): recalibrate_utils.set_static_lst( self.tuning, luminance=lst.luminance, @@ -881,7 +882,7 @@ class StreamingPiCamera2(BaseCamera): flat, i.e. we'll correct vignetting of intensity, but not any change in colour across the image. """ - with self.picamera(pause_stream=True): + with self._streaming_picamera(pause_stream=True): alsc = Picamera2.find_tuning_algo(self.tuning, "rpi.alsc") luminance = alsc["luminance_lut"] flat = np.ones((12, 16)) @@ -895,7 +896,7 @@ class StreamingPiCamera2(BaseCamera): This method will restore the default "adaptive" lens shading method used by the Raspberry Pi camera. """ - with self.picamera(pause_stream=True): + with self._streaming_picamera(pause_stream=True): recalibrate_utils.copy_alsc_section(self.default_tuning, self.tuning) self.initialise_picamera() From 35d6e4589237952ce15966394c18ed64ba066c86 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 4 Jul 2025 16:09:43 +0100 Subject: [PATCH 4/6] Refactor and simplify how picamera settings work --- .../things/camera/picamera.py | 364 ++++++++---------- tests/test_smart_scan.py | 4 +- 2 files changed, 162 insertions(+), 206 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 7c15eca5..aa146e16 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -16,7 +16,7 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf """ from __future__ import annotations -from typing import Annotated, Any, Iterator, Literal, Mapping, Optional +from typing import Annotated, Iterator, Literal, Mapping, Optional from datetime import datetime import json import logging @@ -28,35 +28,18 @@ from threading import RLock from pydantic import BaseModel, BeforeValidator import piexif -import picamera2 import numpy as np from picamera2 import Picamera2 from picamera2.encoders import MJPEGEncoder from picamera2.outputs import Output import labthings_fastapi as lt +from labthings_fastapi.exceptions import NotConnectedToServerError from . import picamera_recalibrate_utils as recalibrate_utils from . import BaseCamera, JPEGBlob, ArrayModel -class PicameraControl(lt.ThingProperty): - def __init__( - self, control_name: str, model: type = float, description: Optional[str] = None - ): - """A property descriptor controlling a picamera control""" - super().__init__(model, observable=False, description=description) - self.control_name = control_name - - def _getter(self, obj: StreamingPiCamera2): - with obj.picamera() as cam: - return cam.capture_metadata()[self.control_name] - - def _setter(self, obj: StreamingPiCamera2, value: Any): - with obj.picamera() as cam: - cam.set_controls({self.control_name: value}) - - class PicameraStreamOutput(Output): """An Output class that sends frames to a stream""" @@ -126,84 +109,23 @@ class StreamingPiCamera2(BaseCamera): """A Thing that represents an OpenCV camera""" def __init__(self, camera_num: int = 0): + self._setting_save_in_progress = False self.camera_num = camera_num self.camera_configs: dict[str, dict] = {} - # Note persistent controls will be updated with settings, in __enter__. - self.persistent_controls = { - "AeEnable": False, - "AnalogueGain": 1.0, - "AwbEnable": False, - "Brightness": 0, - "ColourGains": (1, 1), - "Contrast": 1, - "ExposureTime": 0, - "Saturation": 1, - "Sharpness": 1, - } - self.persistent_control_tolerances = { - "ExposureTime": 30, - } - - def update_persistent_controls(self, discard_frames: int = 1): - """Update the persistent controls dict from the camera - - Query the camera and update the value of `persistent_controls` to - match the current state of the camera. - - There is a work-around here, that will suppress small updates. There - appears to be a bug in the camera code that causes a slight drift in - `ExposureTime` each time the camera is reinitialised: this can - add up over time, particularly if the camera is reconfigured many - times. To get around this, we look in `self.persistent_control_tolerances` - and only update `self.persistent_controls` if the change is greater than - this tolerance. - """ - with self._streaming_picamera() as cam: - for i in range(discard_frames): - # Discard frames, so data is fresh - cam.capture_metadata() - for key, value in cam.capture_metadata().items(): - if key not in self.persistent_controls: - # Skip keys that are not persistent controls - continue - if self._control_value_within_tolerance(key, value): - logging.debug( - "Ignoring a small change in persistent control %s from %s to " - "%s while updating persistent controls.", - key, - self.persistent_controls[key], - value, - ) - else: - self.persistent_controls[key] = value - self.thing_settings.update(self.persistent_controls) - - def _control_value_within_tolerance(self, key: str, value: float) -> bool: - """Return True if the updated value for a persistent control is within - tolerance of current value. This allows ignoring small changes - """ - if key not in self.persistent_control_tolerances: - # Not tolerance range set, so it is not within tolerance - return False - - difference = np.abs(self.persistent_controls[key] - value) - return difference < self.persistent_control_tolerances[key] - - def settings_to_persistent_controls(self): - """Update the persistent controls dict from the settings dict - - NB this must be called **after** self.thing_settings is initialised, - i.e. during or after `__enter__`. - """ + self._picamera_lock = None + self._picamera = None + logging.info("Starting & reconfiguring camera to populate sensor_modes.") + with Picamera2(camera_num=self.camera_num) as cam: + self.default_tuning = recalibrate_utils.load_default_tuning(cam) + logging.info("Done reading sensor modes & default tuning.") + # Set tuning to default tuning. This will be overwritten when the Thing is + # connects to the server if tuning is saved to disk. try: - pc = self.thing_settings["persistent_controls"] - except KeyError: - return # If there are no saved settings, use defaults - for k in self.persistent_controls: - try: - self.persistent_controls[k] = pc[k] - except KeyError: - pass # If controls are missing, leave at default + self.tuning = self.default_tuning + except NotConnectedToServerError: + # This will throw an error after setting as we are not connected to + # a server. But we know this, so we ignore the error. + pass stream_resolution = lt.ThingProperty( tuple[int, int], @@ -225,27 +147,100 @@ class StreamingPiCamera2(BaseCamera): readonly=True, ) - analogue_gain = PicameraControl("AnalogueGain", float) - colour_gains = PicameraControl("ColourGains", tuple[float, float]) + def save_settings(self): + """Override save_settings to ensure that camera properties don't recurse. - exposure_time = PicameraControl( - "ExposureTime", int, description="The exposure time in microseconds" - ) + This method is run by any Thing when a ThingSetting is saved. However, the + method reads the thing_setting. As reading the thing setting talks to the + camera and calls save_settings if the value is not as expected, this could + cause recursion. Aslo this means that saving one setting causes all others + to be read each time. + """ + try: + self._setting_save_in_progress = True + super().save_settings() + finally: + self._setting_save_in_progress = False + + ## Persistent controls! These are settings + + _analogue_gain: float = 1.0 + + @lt.thing_setting + def analogue_gain(self) -> float: + if not self._setting_save_in_progress and self.streaming: + with self._streaming_picamera() as cam: + cam_value = cam.capture_metadata()["AnalogueGain"] + if cam_value != self._analogue_gain: + self._analogue_gain = cam_value + self.save_settings() + return self._analogue_gain + + @analogue_gain.setter + def analogue_gain(self, value: float): + self._analogue_gain = value + if self.streaming: + with self._streaming_picamera() as cam: + cam.set_controls({"AnalogueGain": value}) + + _colour_gains: tuple[float, float] = (1.0, 1.0) + + @lt.thing_setting + def colour_gains(self) -> tuple[float, float]: + if not self._setting_save_in_progress and self.streaming: + with self._streaming_picamera() as cam: + cam_value = cam.capture_metadata()["ColourGains"] + if cam_value != self._colour_gains: + self._colour_gains = cam_value + self.save_settings() + return self._colour_gains + + @colour_gains.setter + def colour_gains(self, value: tuple[float, float]): + self._colour_gains = value + if self.streaming: + with self._streaming_picamera() as cam: + cam.set_controls({"ColourGains": value}) + + _exposure_time: int = 0 + + @lt.thing_setting + def exposure_time(self) -> int: + if not self._setting_save_in_progress and self.streaming: + with self._streaming_picamera() as cam: + cam_value = cam.capture_metadata()["ExposureTime"] + if cam_value != self._exposure_time: + self._exposure_time = cam_value + self.save_settings() + return self._exposure_time @exposure_time.setter def exposure_time(self, value: int): - """ - Custom setter for the above exposure_time PicameraControl. + _exposure_time = value + if self.streaming: + with self._streaming_picamera() as cam: + # Note: This set a value 1 higher than requested as picamera2 always + # sets a lower value than requested, even if the requested is allowed + cam.set_controls({"ExposureTime": value + 1}) - This is overriding the standard setter for a PicameraControl, as - the value needs to be adjusted before setting to behave as expected. - - See comment within the function for more detail. - """ - with self._streaming_picamera() as cam: - # Note: This set a value 1 higher than requested as picamera2 always sets - # a lower value than requested, even if the requested is allowed - cam.set_controls({"ExposureTime": value + 1}) + def _get_persistent_controls(self) -> dict: + discard_frames: int = 1 + if self.streaming: + with self._streaming_picamera() as cam: + # Discard frames, so data is fresh + for i in range(discard_frames): + cam.capture_metadata() + return { + "AeEnable": False, + "AnalogueGain": self.analogue_gain, + "AwbEnable": False, + "Brightness": 0, + "ColourGains": self.colour_gains, + "Contrast": 1, + "ExposureTime": self.exposure_time, + "Saturation": 1, + "Sharpness": 1, + } _sensor_modes = None @@ -259,7 +254,7 @@ class StreamingPiCamera2(BaseCamera): _sensor_mode: Optional[dict] = None - @lt.thing_setting + @lt.thing_property def sensor_mode(self) -> Optional[SensorModeSelector]: """The intended sensor mode of the camera""" if self._sensor_mode is None: @@ -277,74 +272,64 @@ class StreamingPiCamera2(BaseCamera): elif isinstance(new_mode, dict): self._sensor_mode = new_mode - if isinstance(new_mode, SensorModeSelector): - new_mode = new_mode.model_dump() + # By pausing the stream on when accessing, streaming_picamera + # self._sensor_mode will be read and set when the stream restarts + # after the context manager closes. with self._streaming_picamera(pause_stream=True): - self.thing_settings["sensor_mode"] = new_mode + pass - @lt.thing_setting - def sensor_resolution(self) -> tuple[int, int]: + @lt.thing_property + def sensor_resolution(self) -> Optional[tuple[int, int]]: """The native resolution of the camera's sensor""" with self._streaming_picamera() as cam: return cam.sensor_resolution tuning = lt.ThingSetting(Optional[dict], None, readonly=True) - def initialise_tuning(self): - """Read the tuning from the settings, or load default tuning + def _initialise_picamera(self): + """Acquire the picamera device and store it as `self._picamera`. - NB this relies on `self.thing_settings` and `self.default_tuning` - so will fail if it's run before those are populated in `__enter__`. + This duplicates logic in `Picamera2.__init__` to provide a tuning file that + will be read when the camera system initialises. """ - if "tuning" in self.thing_settings: - self.tuning = self.thing_settings["tuning"].dict - else: - logging.info("Did not find tuning in settings, reading from camera...") - self.tuning = self.default_tuning - - def initialise_picamera(self): - """Acquire the picamera device and store it as `self._picamera`""" - if hasattr(self, "_picamera_lock"): + if self._picamera_lock is not None: # Don't close the camera if it's in use self._picamera_lock.acquire() with tempfile.NamedTemporaryFile("w") as tuning_file: - # This duplicates logic in `Picamera2.__init__` to provide a tuning file - # that will be read when the camera system initialises. - # This is a necessary work-around until `picamera2` better supports - # reinitialisation of the camera with new tuning. json.dump(self.tuning, tuning_file) tuning_file.flush() # but leave it open as closing it will delete it os.environ["LIBCAMERA_RPI_TUNING_FILE"] = tuning_file.name - # NB even though we've put the tuning file in the environment, we will - # need to specify the filename in the `Picamera2` initialiser as otherwise - # it will be overwritten with None. - if hasattr(self, "_picamera") and self._picamera: - print("Closing picamera object for reinitialisation") + + if self._picamera is not None: + logging.info("Closing picamera object for reinitialisation") logging.info( "Camera object already exists, closing for reinitialisation" ) self._picamera.close() - print("Picamera closed, deleting picamera") + logging.info("Picamera closed, deleting picamera") del self._picamera recalibrate_utils.recreate_camera_manager() - print("Creating new Picamera2 object") - self._picamera = picamera2.Picamera2( + + logging.info("Creating new Picamera2 object") + # Specify tuning file otherwise it will be overwritten with None. + self._picamera = Picamera2( camera_num=self.camera_num, tuning=self.tuning, ) self._picamera_lock = RLock() def __enter__(self): - self.populate_default_tuning() - self.initialise_tuning() - self.initialise_picamera() + self._initialise_picamera() # populate sensor modes by reading the property self.sensor_modes - self.settings_to_persistent_controls() - self.settings_to_properties() self.start_streaming() return self + @property + def streaming(self) -> bool: + """True if the camera is streaming""" + return self._picamera is not None and self._picamera.started + @contextmanager def _streaming_picamera(self, pause_stream=False) -> Iterator[Picamera2]: """Lock access to picamera and return the underlying `Picamera2` instance. @@ -356,14 +341,12 @@ class StreamingPiCamera2(BaseCamera): * Updated persistent controls * Stop the MJPEG Stream * Yield the `Picamera2` instance for function calling the context manager to - make changes. If seetings are changed run `update_persistent_controls()` before - closing the context manager. + make changes. * On closing of the context manager the stream will restart. """ already_streaming = self.stream_active with self._picamera_lock: if pause_stream and already_streaming: - self.update_persistent_controls() self.stop_streaming(stop_web_stream=False) try: yield self._picamera @@ -371,24 +354,7 @@ class StreamingPiCamera2(BaseCamera): if pause_stream and already_streaming: self.start_streaming() - def populate_default_tuning(self): - """Sensor modes are enumerated and stored, once, on start-up (`__enter__`). - - This opens and closes the camera - must be run before the camera is - initialised. - """ - logging.info("Starting & reconfiguring camera to populate sensor_modes.") - with Picamera2(camera_num=self.camera_num) as cam: - self.default_tuning = recalibrate_utils.load_default_tuning(cam) - logging.info("Done reading sensor modes & default tuning.") - def __exit__(self, exc_type, exc_value, traceback): - # Allow key controls to persist across restarts - self.update_persistent_controls() - self.thing_settings["persistent_controls"] = self.persistent_controls - self.thing_settings["tuning"] = self.tuning - self.properties_to_settings() - self.thing_settings.write_to_file() # Shut down the camera self.stop_streaming() with self._streaming_picamera() as cam: @@ -400,7 +366,7 @@ class StreamingPiCamera2(BaseCamera): self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 6 ) -> None: """ - Start the MJPEG stream + Start the MJPEG stream. This is where persistent controls are sent to camera. Sets the camera resolutions based on input parameters, and sets the low-res resolution to (320, 240). Note: (320, 240) is a standard from the Pi Camera @@ -417,7 +383,7 @@ class StreamingPiCamera2(BaseCamera): buffer_count: the number of frames to hold in the buffer. Higher uses more memory, lower may cause dropped frames. Value must be between 1 and 8, Defaults to 6. """ - + controls = self._get_persistent_controls() # Buffer count can't be negative, zero, or too high. if buffer_count < 1 or buffer_count > 8: # 8 is slightly arbitrary. 6 is the PiCamera default for video @@ -435,8 +401,8 @@ class StreamingPiCamera2(BaseCamera): stream_config = picam.create_video_configuration( main={"size": main_resolution}, lores={"size": (320, 240), "format": "YUV420"}, - sensor=self.thing_settings.get("sensor_mode", None), - controls=self.persistent_controls, + sensor=self._sensor_mode, + controls=controls, ) stream_config["buffer_count"] = buffer_count picam.configure(stream_config) @@ -634,7 +600,6 @@ class StreamingPiCamera2(BaseCamera): target_white_level=target_white_level, percentile=percentile, ) - self.update_persistent_controls() @lt.thing_action def calibrate_white_balance( @@ -669,7 +634,6 @@ class StreamingPiCamera2(BaseCamera): recalibrate_utils.adjust_white_balance_from_raw( cam, percentile=99, method=method ) - self.update_persistent_controls() @lt.thing_action def calibrate_lens_shading(self) -> None: @@ -687,22 +651,31 @@ class StreamingPiCamera2(BaseCamera): # (Cb). L, Cr, Cb = recalibrate_utils.lst_from_camera(cam) # noqa: N806 recalibrate_utils.set_static_lst(self.tuning, L, Cr, Cb) - self.initialise_picamera() + self._initialise_picamera() @lt.thing_property def colour_correction_matrix( self, ) -> tuple[float, float, float, float, float, float, float, float, float]: - """An alias for `colour_correction_matrix` to fit the micromanager API""" - return self.thing_settings.get( - "colour_correction_matrix", - tuple(recalibrate_utils.get_static_ccm(self.tuning)[0]["ccm"]), - ) + """The `colour_correction_matrix` from the tuning file. + + This is broken out into its own property for convenience and compatibility with + the micromanager API + + Ir is a 9 value tuple used to specify the 3x3 matrix that the GPU pipeline uses + to convert from the camera R,G,B vector to the standard R,G,B. + + See page Raspberry Pi Camera Algorithm and Tuning Guide, page 45. + """ + return tuple(recalibrate_utils.get_static_ccm(self.tuning)[0]["ccm"]) @colour_correction_matrix.setter # type: ignore def colour_correction_matrix(self, value) -> None: - self.thing_settings["colour_correction_matrix"] = value - self.calibrate_colour_correction(value) + recalibrate_utils.set_static_ccm(self.tuning, value) + + if self._picamera is not None: + with self._streaming_picamera(pause_stream=True): + self._initialise_picamera() @lt.thing_action def reset_ccm(self): @@ -712,7 +685,7 @@ class StreamingPiCamera2(BaseCamera): These values are from the Raspberry Pi Camera Algorithm and Tuning Guide, page 45. """ - # This is flattened 3x3 matrix. See `calibrate_colour_correction` + # This is flattened 3x3 matrix. See `colour_correction_matrix` col_corr_matrix = [ 1.80439, -0.73699, @@ -726,25 +699,6 @@ class StreamingPiCamera2(BaseCamera): ] self.colour_correction_matrix = col_corr_matrix - @lt.thing_action - def calibrate_colour_correction( - self, - col_corr_matrix: tuple[ - float, float, float, float, float, float, float, float, float - ], - ) -> None: - """Overwrite the colour correction matrix in camera tuning - - col_corr_matrix: This is a 9 value tuple used to specify the 3x3 - matrix that the GPU pipeline uses to convert from the camera R,G,B vector - to the standard R,G,B. - - See page Raspberry Pi Camera Algorithm and Tuning Guide, page 45. - """ - with self._streaming_picamera(pause_stream=True): - recalibrate_utils.set_static_ccm(self.tuning, col_corr_matrix) - self.initialise_picamera() - @lt.thing_action def set_static_green_equalisation(self, offset: int = 65535) -> None: """Set the green equalisation to a static value. @@ -758,7 +712,7 @@ class StreamingPiCamera2(BaseCamera): """ with self._streaming_picamera(pause_stream=True): recalibrate_utils.set_static_geq(self.tuning, offset) - self.initialise_picamera() + self._initialise_picamera() @lt.thing_action def full_auto_calibrate(self) -> None: @@ -795,7 +749,7 @@ class StreamingPiCamera2(BaseCamera): recalibrate_utils.set_static_lst( self.tuning, flat_array, flat_array, flat_array ) - self.initialise_picamera() + self._initialise_picamera() @lt.thing_property def lens_shading_tables(self) -> Optional[LensShading]: @@ -841,7 +795,7 @@ class StreamingPiCamera2(BaseCamera): cr=lst.Cr, cb=lst.Cb, ) - self.initialise_picamera() + self._initialise_picamera() def correct_colour_gains_for_lens_shading( self, colour_gains: tuple[float, float] @@ -887,7 +841,7 @@ class StreamingPiCamera2(BaseCamera): luminance = alsc["luminance_lut"] flat = np.ones((12, 16)) recalibrate_utils.set_static_lst(self.tuning, luminance, flat, flat) - self.initialise_picamera() + self._initialise_picamera() @lt.thing_action def reset_lens_shading(self) -> None: @@ -898,7 +852,7 @@ class StreamingPiCamera2(BaseCamera): """ with self._streaming_picamera(pause_stream=True): recalibrate_utils.copy_alsc_section(self.default_tuning, self.tuning) - self.initialise_picamera() + self._initialise_picamera() @lt.thing_property def lens_shading_is_static(self) -> bool: diff --git a/tests/test_smart_scan.py b/tests/test_smart_scan.py index 8c8e791e..2cd6d267 100644 --- a/tests/test_smart_scan.py +++ b/tests/test_smart_scan.py @@ -256,7 +256,9 @@ def test_outer_scan_wo_sample_skip(): """Test setup and teardown of the scan.""" def _set_skip_background(mock_ss_thing): - mock_ss_thing.thing_settings["skip_background"] = False + # As the Thing is not connected to a server we set the setting via the internal + # __dict__ to avoid triggering property emits that require a server + mock_ss_thing.__dict__["skip_background"] = False mock_ss_thing, exec_info = _run_only_outer_scan(_set_skip_background) From f3ec5a445a900166c6a52a115936cc372842699a Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 7 Jul 2025 13:30:42 +0100 Subject: [PATCH 5/6] Update labthings-fastapi version in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 96f885b9..ba5bd0da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "labthings-fastapi[server] == 0.0.9", + "labthings-fastapi == 0.0.10", "sangaboard", "camera-stage-mapping ~= 0.1.10", "opencv-python ~= 4.11.0", From a8323c4f58283e2784abd3645fc8d6cde97578c5 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 7 Jul 2025 17:58:23 +0000 Subject: [PATCH 6/6] Remove section of docstring that no longer applies --- src/openflexure_microscope_server/things/camera/picamera.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index aa146e16..0f336354 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -338,7 +338,6 @@ class StreamingPiCamera2(BaseCamera): :param pause_stream: If False the `Picamera2` instance is simply yielded. If True: - * Updated persistent controls * Stop the MJPEG Stream * Yield the `Picamera2` instance for function calling the context manager to make changes.