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