And adjust for new as lt import

This commit is contained in:
Julian Stirling 2025-06-28 17:57:31 +01:00
parent 840ed7f20e
commit 52a80ef66b
17 changed files with 240 additions and 293 deletions

View file

@ -3,14 +3,16 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from copy import copy from copy import copy
from labthings_fastapi.server import cli, ThingServer import labthings_fastapi as lt
import uvicorn import uvicorn
from .serve_static_files import add_static_files from .serve_static_files import add_static_files
from .legacy_api import add_v2_endpoints from .legacy_api import add_v2_endpoints
from ..logging import configure_logging, retrieve_log, retrieve_log_from_file 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.""" """Customise the server with additional endpoints, etc."""
configure_logging(log_folder) configure_logging(log_folder)
add_v2_endpoints(server) 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): def serve_from_cli(argv: Optional[list[str]] = None):
"""Start the server from the command line""" """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 = copy(uvicorn.config.LOGGING_CONFIG)
log_config["loggers"]["uvicorn"]["propagate"] = True log_config["loggers"]["uvicorn"]["propagate"] = True
@ -50,10 +52,10 @@ def serve_from_cli(argv: Optional[list[str]] = None):
config = None config = None
server = None server = None
try: try:
config = cli.config_from_args(args) config = lt.cli.config_from_args(args)
log_folder = config.get("log_folder", "./openflexure/logs") log_folder = config.get("log_folder", "./openflexure/logs")
scans_folder = _get_scans_dir(config) 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) customise_server(server, log_folder, scans_folder)
uvicorn.run( uvicorn.run(
server.app, server.app,
@ -68,7 +70,7 @@ def serve_from_cli(argv: Optional[list[str]] = None):
print(f"Error: {e}") print(f"Error: {e}")
fallback_server = "labthings_fastapi.server.fallback:app" fallback_server = "labthings_fastapi.server.fallback:app"
print(f"Starting fallback server {fallback_server}.") 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_config = config
app.labthings_server = server app.labthings_server = server
app.labthings_error = e app.labthings_error = e

View file

@ -1,9 +1,9 @@
from . import ThingServer import labthings_fastapi as lt
from fastapi import Response from fastapi import Response
from socket import gethostname from socket import gethostname
def add_v2_endpoints(thing_server: ThingServer): def add_v2_endpoints(thing_server: lt.ThingServer):
app = thing_server.app app = thing_server.app
# TODO: update openflexure connect to make this unnecessary!! # TODO: update openflexure connect to make this unnecessary!!

View file

@ -1,19 +1,20 @@
import numpy as np import numpy as np
import logging import logging
from labthings_fastapi.thing import Thing import labthings_fastapi as lt
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
from labthings_fastapi.decorators import thing_action
from .stage import StageDependency as StageDep from .stage import StageDependency as StageDep
from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.autofocus import AutofocusThing
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") CSMDep = lt.deps.direct_thing_client_dependency(
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") CameraStageMapper, "/camera_stage_mapping/"
)
AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/")
class RecentringThing(Thing): class RecentringThing(lt.Thing):
@thing_action @lt.thing_action
def recentre( def recentre(
self, self,
autofocus: AutofocusDep, autofocus: AutofocusDep,

View file

@ -17,10 +17,7 @@ from fastapi import Depends
import numpy as np import numpy as np
from pydantic import BaseModel from pydantic import BaseModel
from labthings_fastapi.thing import Thing import labthings_fastapi as lt
from labthings_fastapi.dependencies.blocking_portal import BlockingPortal
from labthings_fastapi.decorators import thing_action
from labthings_fastapi.descriptors import ThingSetting
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
from .camera import RawCameraDependency as Camera from .camera import RawCameraDependency as Camera
@ -190,7 +187,7 @@ class SharpnessDataArrays(BaseModel):
class JPEGSharpnessMonitor: 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.camera = camera
self.stage = stage self.stage = stage
self.portal = portal self.portal = portal
@ -288,14 +285,14 @@ class JPEGSharpnessMonitor:
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()] SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
class AutofocusThing(Thing): class AutofocusThing(lt.Thing):
"""The Thing concerned with combinations of z axis movements and the camera. """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 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 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)""" field of view to assess focus (autofocus and testing the success of a z-stack)"""
@thing_action @lt.thing_action
def fast_autofocus( def fast_autofocus(
self, self,
sharpness_monitor: SharpnessMonitorDep, sharpness_monitor: SharpnessMonitorDep,
@ -325,7 +322,7 @@ class AutofocusThing(Thing):
# Return all focus data # Return all focus data
return sharpness_monitor.data_dict() return sharpness_monitor.data_dict()
@thing_action @lt.thing_action
def z_move_and_measure_sharpness( def z_move_and_measure_sharpness(
self, self,
sharpness_monitor: SharpnessMonitorDep, sharpness_monitor: SharpnessMonitorDep,
@ -351,7 +348,7 @@ class AutofocusThing(Thing):
sharpness_monitor.focus_rel(current_dz) sharpness_monitor.focus_rel(current_dz)
return sharpness_monitor.data_dict() return sharpness_monitor.data_dict()
@thing_action @lt.thing_action
def looping_autofocus( def looping_autofocus(
self, self,
stage: Stage, stage: Stage,
@ -397,7 +394,7 @@ class AutofocusThing(Thing):
stage.move_absolute(z=peak_height) stage.move_absolute(z=peak_height)
return heights.tolist(), sizes.tolist() return heights.tolist(), sizes.tolist()
stack_images_to_save = ThingSetting( stack_images_to_save = lt.ThingSetting(
initial_value=1, initial_value=1,
model=int, model=int,
description="""The number of images to save in a stack. 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""", 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, initial_value=9,
model=int, model=int,
description="""The minimum number of images to capture in a stack. 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, initial_value=50,
model=int, model=int,
description="""Space in steps between images in a z-stack description="""Space in steps between images in a z-stack
@ -428,7 +425,7 @@ class AutofocusThing(Thing):
200 for 20x""", 200 for 20x""",
) )
@thing_action @lt.thing_action
def run_smart_stack( def run_smart_stack(
self, self,
cam: WrappedCamera, cam: WrappedCamera,

View file

@ -5,9 +5,7 @@ from PIL import Image
from pydantic import BaseModel from pydantic import BaseModel
from scipy.stats import norm from scipy.stats import norm
from labthings_fastapi.thing import Thing import labthings_fastapi as lt
from labthings_fastapi.decorators import thing_action, thing_setting
from labthings_fastapi.descriptors import ThingSetting
from .camera import CameraDependency as CamDep from .camera import CameraDependency as CamDep
@ -17,12 +15,12 @@ class ChannelDistributions(BaseModel):
colorspace: str = "LUV" colorspace: str = "LUV"
class BackgroundDetectThing(Thing): class BackgroundDetectThing(lt.Thing):
# Requires a getter and a setter to support being a BaseModel but being # Requires a getter and a setter to support being a BaseModel but being
# saved to file as a dict # saved to file as a dict
_background_distributions: Optional[ChannelDistributions] = None _background_distributions: Optional[ChannelDistributions] = None
@thing_setting @lt.thing_setting
def background_distributions(self) -> Optional[ChannelDistributions]: def background_distributions(self) -> Optional[ChannelDistributions]:
"""The statistics of the background image""" """The statistics of the background image"""
bd = self._background_distributions bd = self._background_distributions
@ -45,13 +43,13 @@ class BackgroundDetectThing(Thing):
f"Cannot set background_distributions with an object of type {type(value)}" f"Cannot set background_distributions with an object of type {type(value)}"
) )
tolerance = ThingSetting( tolerance = lt.ThingSetting(
initial_value=7.0, initial_value=7.0,
model=float, model=float,
description="How many standard deviations to allow for the background", description="How many standard deviations to allow for the background",
) )
fraction = ThingSetting( fraction = lt.ThingSetting(
initial_value=25.0, initial_value=25.0,
model=float, model=float,
description="How much of the image needs to be not background to label as sample", description="How much of the image needs to be not background to label as sample",
@ -78,7 +76,7 @@ class BackgroundDetectThing(Thing):
axis=2, axis=2,
) )
@thing_action @lt.thing_action
def background_fraction(self, cam: CamDep) -> float: def background_fraction(self, cam: CamDep) -> float:
"""Determine what fraction of the current image is background """Determine what fraction of the current image is background
@ -96,7 +94,7 @@ class BackgroundDetectThing(Thing):
mask = self.background_mask(current_image_luv) mask = self.background_mask(current_image_luv)
return np.count_nonzero(mask) / np.prod(mask.shape) * 100 return np.count_nonzero(mask) / np.prod(mask.shape) * 100
@thing_action @lt.thing_action
def image_is_sample(self, cam: CamDep) -> bool: def image_is_sample(self, cam: CamDep) -> bool:
"""Label the current image as either background or sample""" """Label the current image as either background or sample"""
b_fraction = self.background_fraction(cam) b_fraction = self.background_fraction(cam)
@ -104,7 +102,7 @@ class BackgroundDetectThing(Thing):
return (100 - b_fraction) > fraction_threshold return (100 - b_fraction) > fraction_threshold
@thing_action @lt.thing_action
def set_background(self, cam: CamDep): def set_background(self, cam: CamDep):
"""Grab an image, and use its statistics to set the background """Grab an image, and use its statistics to set the background

View file

@ -1,6 +1,6 @@
"""OpenFlexure Microscope Camera """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. should enabe the server to work.
See repository root for licensing information. See repository root for licensing information.
@ -14,23 +14,15 @@ from pydantic import RootModel
from PIL import Image from PIL import Image
import piexif import piexif
from labthings_fastapi.thing import Thing import labthings_fastapi as lt
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
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
class JPEGBlob(Blob): class JPEGBlob(lt.blob.Blob):
media_type: str = "image/jpeg" media_type: str = "image/jpeg"
class PNGBlob(Blob): class PNGBlob(lt.blob.Blob):
"""A class representing a PNG image as a LabThings FastAPI Blob""" """A class representing a PNG image as a LabThings FastAPI Blob"""
media_type: str = "image/png" media_type: str = "image/png"
@ -154,11 +146,11 @@ class CameraMemoryBuffer:
del self._storage[key] 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""" """The base class for all cameras. All cameras must directly inherit from this class"""
mjpeg_stream = MJPEGStreamDescriptor() mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
lores_mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
_memory_buffer = CameraMemoryBuffer() _memory_buffer = CameraMemoryBuffer()
def __enter__(self) -> None: def __enter__(self) -> None:
@ -167,7 +159,7 @@ class BaseCamera(Thing):
def __exit__(self, _exc_type, _exc_value, _traceback) -> None: def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
raise NotImplementedError("CameraThings must define their own __exit__ method") raise NotImplementedError("CameraThings must define their own __exit__ method")
@thing_action @lt.thing_action
def start_streaming( def start_streaming(
self, main_resolution: tuple[int, int], buffer_count: int self, main_resolution: tuple[int, int], buffer_count: int
) -> None: ) -> None:
@ -177,14 +169,14 @@ class BaseCamera(Thing):
"CameraThings must define their own start_streaming method" "CameraThings must define their own start_streaming method"
) )
@thing_property @lt.thing_property
def stream_active(self) -> bool: def stream_active(self) -> bool:
"Whether the MJPEG stream is active" "Whether the MJPEG stream is active"
raise NotImplementedError( raise NotImplementedError(
"CameraThings must define their own stream_active method" "CameraThings must define their own stream_active method"
) )
@thing_action @lt.thing_action
def capture_array( def capture_array(
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "main", stream_name: Literal["main", "lores", "raw", "full"] = "main",
@ -194,10 +186,10 @@ class BaseCamera(Thing):
"CameraThings must define their own capture_array method" "CameraThings must define their own capture_array method"
) )
@thing_action @lt.thing_action
def capture_jpeg( def capture_jpeg(
self, self,
metadata_getter: GetThingStates, metadata_getter: lt.deps.GetThingStates,
resolution: Literal["lores", "main", "full"] = "main", resolution: Literal["lores", "main", "full"] = "main",
wait: Optional[float] = 5, wait: Optional[float] = 5,
) -> JPEGBlob: ) -> JPEGBlob:
@ -206,10 +198,10 @@ class BaseCamera(Thing):
"CameraThings must define their own capture_jpeg method" "CameraThings must define their own capture_jpeg method"
) )
@thing_action @lt.thing_action
def grab_jpeg( def grab_jpeg(
self, self,
portal: BlockingPortal, portal: lt.deps.BlockingPortal,
stream_name: Literal["main", "lores"] = "main", stream_name: Literal["main", "lores"] = "main",
) -> JPEGBlob: ) -> JPEGBlob:
"""Acquire one image from the preview stream and return as an array """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) frame = portal.call(stream.grab_frame)
return JPEGBlob.from_bytes(frame) return JPEGBlob.from_bytes(frame)
@thing_action @lt.thing_action
def grab_jpeg_size( def grab_jpeg_size(
self, self,
portal: BlockingPortal, portal: lt.deps.BlockingPortal,
stream_name: Literal["main", "lores"] = "main", stream_name: Literal["main", "lores"] = "main",
) -> int: ) -> int:
"""Acquire one image from the preview stream and return its size""" """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) return portal.call(stream.next_frame_size)
@thing_action @lt.thing_action
def capture_image( def capture_image(
self, self,
stream_name: Literal["main", "lores", "raw"], stream_name: Literal["main", "lores", "raw"],
@ -248,12 +240,12 @@ class BaseCamera(Thing):
"CameraThings must define their own capture_image method" "CameraThings must define their own capture_image method"
) )
@thing_action @lt.thing_action
def capture_and_save( def capture_and_save(
self, self,
jpeg_path: str, jpeg_path: str,
logger: InvocationLogger, logger: lt.deps.InvocationLogger,
metadata_getter: GetThingStates, metadata_getter: lt.deps.GetThingStates,
save_resolution: Optional[Tuple[int, int]] = None, save_resolution: Optional[Tuple[int, int]] = None,
) -> None: ) -> None:
"""Capture an image and save it to disk """Capture an image and save it to disk
@ -279,11 +271,11 @@ class BaseCamera(Thing):
save_resolution, save_resolution,
) )
@thing_action @lt.thing_action
def capture_to_memory( def capture_to_memory(
self, self,
logger: InvocationLogger, logger: lt.deps.InvocationLogger,
metadata_getter: GetThingStates, metadata_getter: lt.deps.GetThingStates,
buffer_max: int = 1, buffer_max: int = 1,
) -> None: ) -> None:
""" """
@ -304,11 +296,11 @@ class BaseCamera(Thing):
image, metadata = self._robust_image_capture(metadata_getter, logger) image, metadata = self._robust_image_capture(metadata_getter, logger)
return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max) return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max)
@thing_action @lt.thing_action
def save_from_memory( def save_from_memory(
self, self,
jpeg_path: str, jpeg_path: str,
logger: InvocationLogger, logger: lt.deps.InvocationLogger,
save_resolution: Optional[Tuple[int, int]] = None, save_resolution: Optional[Tuple[int, int]] = None,
buffer_id: Optional[int] = None, buffer_id: Optional[int] = None,
) -> None: ) -> None:
@ -333,15 +325,15 @@ class BaseCamera(Thing):
save_resolution=save_resolution, save_resolution=save_resolution,
) )
@thing_action @lt.thing_action
def clear_buffers(self) -> None: def clear_buffers(self) -> None:
"""Clear all images in memory""" """Clear all images in memory"""
self._memory_buffer.clear() self._memory_buffer.clear()
def _robust_image_capture( def _robust_image_capture(
self, self,
metadata_getter: GetThingStates, metadata_getter: lt.deps.GetThingStates,
logger: InvocationLogger, logger: lt.deps.InvocationLogger,
) -> Image: ) -> Image:
"""Capture an image in memory and return it with metadata """Capture an image in memory and return it with metadata
CaptureError raised if the capture fails for any reason CaptureError raised if the capture fails for any reason
@ -366,7 +358,7 @@ class BaseCamera(Thing):
jpeg_path: str, jpeg_path: str,
image: Image, image: Image,
metadata: dict, metadata: dict,
logger: InvocationLogger, logger: lt.deps.InvocationLogger,
save_resolution: Optional[Tuple[int, int]] = None, save_resolution: Optional[Tuple[int, int]] = None,
) -> None: ) -> None:
"""Saving the captured image and metadata to disk """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 raise IOError(f"An error occurred while saving {jpeg_path}") from e
CameraDependency = direct_thing_client_dependency(BaseCamera, "/camera/") CameraDependency = lt.deps.direct_thing_client_dependency(BaseCamera, "/camera/")
RawCameraDependency = raw_thing_dependency(BaseCamera) RawCameraDependency = lt.deps.raw_thing_dependency(BaseCamera)

View file

@ -16,9 +16,7 @@ from threading import Thread
import cv2 import cv2
import piexif import piexif
from labthings_fastapi.utilities import get_blocking_portal import labthings_fastapi as lt
from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.dependencies.metadata import GetThingStates
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
from . import BaseCamera, JPEGBlob from . import BaseCamera, JPEGBlob
@ -45,7 +43,7 @@ class OpenCVCamera(BaseCamera):
self._capture_thread.join() self._capture_thread.join()
self.cap.release() self.cap.release()
@thing_property @lt.thing_property
def stream_active(self) -> bool: def stream_active(self) -> bool:
"Whether the MJPEG stream is active" "Whether the MJPEG stream is active"
if self._capture_enabled and self._capture_thread: if self._capture_enabled and self._capture_thread:
@ -53,7 +51,7 @@ class OpenCVCamera(BaseCamera):
return False return False
def _capture_frames(self): def _capture_frames(self):
portal = get_blocking_portal(self) portal = lt.get_blocking_portal(self)
while self._capture_enabled: while self._capture_enabled:
ret, frame = self.cap.read() ret, frame = self.cap.read()
if not ret: if not ret:
@ -68,7 +66,7 @@ class OpenCVCamera(BaseCamera):
].tobytes() ].tobytes()
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
@thing_action @lt.thing_action
def capture_array( def capture_array(
self, self,
resolution: Literal["main", "full"] = "full", resolution: Literal["main", "full"] = "full",
@ -87,10 +85,10 @@ class OpenCVCamera(BaseCamera):
) )
return frame return frame
@thing_action @lt.thing_action
def capture_jpeg( def capture_jpeg(
self, self,
metadata_getter: GetThingStates, metadata_getter: lt.deps.GetThingStates,
resolution: Literal["main", "full"] = "main", resolution: Literal["main", "full"] = "main",
) -> JPEGBlob: ) -> JPEGBlob:
"""Acquire one image from the camera and return as a JPEG blob """Acquire one image from the camera and return as a JPEG blob

View file

@ -16,37 +16,31 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf
""" """
from __future__ import annotations from __future__ import annotations
from typing import Annotated, Any, Iterator, Literal, Mapping, Optional
from datetime import datetime from datetime import datetime
import json import json
import logging import logging
import os import os
import tempfile import tempfile
import time import time
from tempfile import TemporaryDirectory from contextlib import contextmanager
from threading import RLock
from pydantic import BaseModel, BeforeValidator 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 import piexif
from threading import RLock
import picamera2 import picamera2
import numpy as np
from picamera2 import Picamera2 from picamera2 import Picamera2
from picamera2.encoders import MJPEGEncoder from picamera2.encoders import MJPEGEncoder
from picamera2.outputs import Output 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 from . import BaseCamera, JPEGBlob, ArrayModel
class PicameraControl(ThingProperty): class PicameraControl(lt.ThingProperty):
def __init__( def __init__(
self, control_name: str, model: type = float, description: Optional[str] = None self, control_name: str, model: type = float, description: Optional[str] = None
): ):
@ -66,7 +60,7 @@ class PicameraControl(ThingProperty):
class PicameraStreamOutput(Output): class PicameraStreamOutput(Output):
"""An Output class that sends frames to a stream""" """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 """Create an output that puts frames in an MJPEGStream
We need to pass the stream object, and also the blocking portal, because We need to pass the stream object, and also the blocking portal, because
@ -211,19 +205,19 @@ class StreamingPiCamera2(BaseCamera):
except KeyError: except KeyError:
pass # If controls are missing, leave at default pass # If controls are missing, leave at default
stream_resolution = ThingProperty( stream_resolution = lt.ThingProperty(
tuple[int, int], tuple[int, int],
initial_value=(820, 616), initial_value=(820, 616),
description="Resolution to use for the MJPEG stream", description="Resolution to use for the MJPEG stream",
) )
mjpeg_bitrate = ThingProperty( mjpeg_bitrate = lt.ThingProperty(
Optional[int], Optional[int],
initial_value=100000000, initial_value=100000000,
description="Bitrate for MJPEG stream (None for default)", description="Bitrate for MJPEG stream (None for default)",
) )
stream_active = ThingProperty( stream_active = lt.ThingProperty(
bool, bool,
initial_value=False, initial_value=False,
description="Whether the MJPEG stream is active", description="Whether the MJPEG stream is active",
@ -255,7 +249,7 @@ class StreamingPiCamera2(BaseCamera):
_sensor_modes = None _sensor_modes = None
@thing_property @lt.thing_property
def sensor_modes(self) -> list[SensorMode]: def sensor_modes(self) -> list[SensorMode]:
"""All the available modes the current sensor supports""" """All the available modes the current sensor supports"""
if not self._sensor_modes: if not self._sensor_modes:
@ -263,7 +257,7 @@ class StreamingPiCamera2(BaseCamera):
self._sensor_modes = cam.sensor_modes self._sensor_modes = cam.sensor_modes
return self._sensor_modes return self._sensor_modes
@thing_property @lt.thing_property
def sensor_mode(self) -> Optional[SensorModeSelector]: def sensor_mode(self) -> Optional[SensorModeSelector]:
"""The intended sensor mode of the camera""" """The intended sensor mode of the camera"""
return self.thing_settings["sensor_mode"] return self.thing_settings["sensor_mode"]
@ -276,13 +270,13 @@ class StreamingPiCamera2(BaseCamera):
with self.picamera(pause_stream=True): with self.picamera(pause_stream=True):
self.thing_settings["sensor_mode"] = new_mode self.thing_settings["sensor_mode"] = new_mode
@thing_property @lt.thing_property
def sensor_resolution(self) -> tuple[int, int]: def sensor_resolution(self) -> tuple[int, int]:
"""The native resolution of the camera's sensor""" """The native resolution of the camera's sensor"""
with self.picamera() as cam: with self.picamera() as cam:
return cam.sensor_resolution return cam.sensor_resolution
tuning = ThingProperty(Optional[dict], None, readonly=True) tuning = lt.ThingProperty(Optional[dict], None, readonly=True)
def settings_to_properties(self): def settings_to_properties(self):
"""Set the values of properties based on the settings dict""" """Set the values of properties based on the settings dict"""
@ -400,7 +394,7 @@ class StreamingPiCamera2(BaseCamera):
cam.close() cam.close()
del self._picamera del self._picamera
@thing_action @lt.thing_action
def start_streaming( def start_streaming(
self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 6 self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 6
) -> None: ) -> None:
@ -451,7 +445,7 @@ class StreamingPiCamera2(BaseCamera):
MJPEGEncoder(self.mjpeg_bitrate), MJPEGEncoder(self.mjpeg_bitrate),
PicameraStreamOutput( PicameraStreamOutput(
self.mjpeg_stream, self.mjpeg_stream,
get_blocking_portal(self), lt.get_blocking_portal(self),
), ),
name=stream_name, name=stream_name,
) )
@ -459,7 +453,7 @@ class StreamingPiCamera2(BaseCamera):
MJPEGEncoder(100000000), MJPEGEncoder(100000000),
PicameraStreamOutput( PicameraStreamOutput(
self.lores_mjpeg_stream, self.lores_mjpeg_stream,
get_blocking_portal(self), lt.get_blocking_portal(self),
), ),
name="lores", name="lores",
) )
@ -472,7 +466,7 @@ class StreamingPiCamera2(BaseCamera):
"Started MJPEG stream at %s on port %s", self.stream_resolution, 1 "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: def stop_streaming(self, stop_web_stream: bool = True) -> None:
""" """
Stop the MJPEG stream Stop the MJPEG stream
@ -493,7 +487,7 @@ class StreamingPiCamera2(BaseCamera):
# Adding a sleep to prevent camera getting confused by rapid commands # Adding a sleep to prevent camera getting confused by rapid commands
time.sleep(0.2) time.sleep(0.2)
@thing_action @lt.thing_action
def capture_image( def capture_image(
self, self,
stream_name: Literal["main", "lores", "raw"] = "main", stream_name: Literal["main", "lores", "raw"] = "main",
@ -511,7 +505,7 @@ class StreamingPiCamera2(BaseCamera):
with self.picamera() as cam: with self.picamera() as cam:
return cam.capture_image(stream_name, wait=wait) return cam.capture_image(stream_name, wait=wait)
@thing_action @lt.thing_action
def capture_array( def capture_array(
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "main", stream_name: Literal["main", "lores", "raw", "full"] = "main",
@ -538,7 +532,7 @@ class StreamingPiCamera2(BaseCamera):
with self.picamera() as cam: with self.picamera() as cam:
return cam.capture_array(stream_name, wait=wait) return cam.capture_array(stream_name, wait=wait)
@thing_property @lt.thing_property
def camera_configuration(self) -> Mapping: def camera_configuration(self) -> Mapping:
"""The "configuration" dictionary of the picamera2 object """The "configuration" dictionary of the picamera2 object
@ -553,10 +547,10 @@ class StreamingPiCamera2(BaseCamera):
with self.picamera() as cam: with self.picamera() as cam:
return cam.camera_configuration() return cam.camera_configuration()
@thing_action @lt.thing_action
def capture_jpeg( def capture_jpeg(
self, self,
metadata_getter: GetThingStates, metadata_getter: lt.deps.GetThingStates,
resolution: Literal["lores", "main", "full"] = "main", resolution: Literal["lores", "main", "full"] = "main",
wait: Optional[float] = 0.9, wait: Optional[float] = 0.9,
) -> JPEGBlob: ) -> JPEGBlob:
@ -580,7 +574,7 @@ class StreamingPiCamera2(BaseCamera):
bypass this, you must use a raw capture. bypass this, you must use a raw capture.
""" """
fname = datetime.now().strftime("%Y-%m-%d-%H%M%S.jpeg") fname = datetime.now().strftime("%Y-%m-%d-%H%M%S.jpeg")
folder = TemporaryDirectory() folder = tempfile.TemporaryDirectory()
path = os.path.join(folder.name, fname) path = os.path.join(folder.name, fname)
config = self.camera_configuration config = self.camera_configuration
# Low-res and main streams are running already - so we don't need # 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) piexif.insert(piexif.dump(exif_dict), path)
return JPEGBlob.from_temporary_directory(folder, fname) return JPEGBlob.from_temporary_directory(folder, fname)
@thing_property @lt.thing_property
def capture_metadata(self) -> dict: def capture_metadata(self) -> dict:
"""Return the metadata from the camera""" """Return the metadata from the camera"""
with self.picamera() as cam: with self.picamera() as cam:
return cam.capture_metadata() return cam.capture_metadata()
@thing_action @lt.thing_action
def auto_expose_from_minimum( def auto_expose_from_minimum(
self, self,
target_white_level: int = 700, target_white_level: int = 700,
@ -641,7 +635,7 @@ class StreamingPiCamera2(BaseCamera):
) )
self.update_persistent_controls() self.update_persistent_controls()
@thing_action @lt.thing_action
def calibrate_white_balance( def calibrate_white_balance(
self, self,
method: Literal["percentile", "centre"] = "centre", method: Literal["percentile", "centre"] = "centre",
@ -676,7 +670,7 @@ class StreamingPiCamera2(BaseCamera):
) )
self.update_persistent_controls() self.update_persistent_controls()
@thing_action @lt.thing_action
def calibrate_lens_shading(self) -> None: def calibrate_lens_shading(self) -> None:
"""Take an image and use it for flat-field correction. """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) recalibrate_utils.set_static_lst(self.tuning, L, Cr, Cb)
self.initialise_picamera() self.initialise_picamera()
@thing_property @lt.thing_property
def colour_correction_matrix( def colour_correction_matrix(
self, self,
) -> tuple[float, float, float, float, float, float, float, float, float]: ) -> 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.thing_settings["colour_correction_matrix"] = value
self.calibrate_colour_correction(value) self.calibrate_colour_correction(value)
@thing_action @lt.thing_action
def reset_ccm(self): def reset_ccm(self):
""" """
Overwrite the colour correction matrix in camera tuning with default values. 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 self.colour_correction_matrix = col_corr_matrix
@thing_action @lt.thing_action
def calibrate_colour_correction( def calibrate_colour_correction(
self, self,
col_corr_matrix: tuple[ col_corr_matrix: tuple[
@ -750,7 +744,7 @@ class StreamingPiCamera2(BaseCamera):
recalibrate_utils.set_static_ccm(self.tuning, col_corr_matrix) recalibrate_utils.set_static_ccm(self.tuning, col_corr_matrix)
self.initialise_picamera() self.initialise_picamera()
@thing_action @lt.thing_action
def set_static_green_equalisation(self, offset: int = 65535) -> None: def set_static_green_equalisation(self, offset: int = 65535) -> None:
"""Set the green equalisation to a static value. """Set the green equalisation to a static value.
@ -765,7 +759,7 @@ class StreamingPiCamera2(BaseCamera):
recalibrate_utils.set_static_geq(self.tuning, offset) recalibrate_utils.set_static_geq(self.tuning, offset)
self.initialise_picamera() self.initialise_picamera()
@thing_action @lt.thing_action
def full_auto_calibrate(self) -> None: def full_auto_calibrate(self) -> None:
"""Perform a full auto-calibration """Perform a full auto-calibration
@ -783,7 +777,7 @@ class StreamingPiCamera2(BaseCamera):
self.calibrate_lens_shading() self.calibrate_lens_shading()
self.calibrate_white_balance() self.calibrate_white_balance()
@thing_action @lt.thing_action
def flat_lens_shading(self) -> None: def flat_lens_shading(self) -> None:
"""Disable flat-field correction """Disable flat-field correction
@ -802,7 +796,7 @@ class StreamingPiCamera2(BaseCamera):
) )
self.initialise_picamera() self.initialise_picamera()
@thing_property @lt.thing_property
def lens_shading_tables(self) -> Optional[LensShading]: def lens_shading_tables(self) -> Optional[LensShading]:
"""The current lens shading (i.e. flat-field correction) """The current lens shading (i.e. flat-field correction)
@ -879,7 +873,7 @@ class StreamingPiCamera2(BaseCamera):
float(gain_b / np.max(lst.Cb)), float(gain_b / np.max(lst.Cb)),
) )
@thing_action @lt.thing_action
def flat_lens_shading_chrominance(self) -> None: def flat_lens_shading_chrominance(self) -> None:
"""Disable flat-field correction """Disable flat-field correction
@ -894,7 +888,7 @@ class StreamingPiCamera2(BaseCamera):
recalibrate_utils.set_static_lst(self.tuning, luminance, flat, flat) recalibrate_utils.set_static_lst(self.tuning, luminance, flat, flat)
self.initialise_picamera() self.initialise_picamera()
@thing_action @lt.thing_action
def reset_lens_shading(self) -> None: def reset_lens_shading(self) -> None:
"""Revert to default lens shading settings """Revert to default lens shading settings
@ -905,7 +899,7 @@ class StreamingPiCamera2(BaseCamera):
recalibrate_utils.copy_alsc_section(self.default_tuning, self.tuning) recalibrate_utils.copy_alsc_section(self.default_tuning, self.tuning)
self.initialise_picamera() self.initialise_picamera()
@thing_property @lt.thing_property
def lens_shading_is_static(self) -> bool: def lens_shading_is_static(self) -> bool:
"""Whether the lens shading is static """Whether the lens shading is static

View file

@ -19,10 +19,7 @@ import numpy as np
import piexif import piexif
from scipy.ndimage import gaussian_filter from scipy.ndimage import gaussian_filter
from labthings_fastapi.utilities import get_blocking_portal import labthings_fastapi as lt
from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.dependencies.metadata import GetThingStates
from labthings_fastapi.server import ThingServer
from . import BaseCamera, JPEGBlob, ArrayModel from . import BaseCamera, JPEGBlob, ArrayModel
from ..stage import BaseStage from ..stage import BaseStage
@ -36,7 +33,7 @@ class SimulatedCamera(BaseCamera):
"""A Thing representing an OpenCV camera""" """A Thing representing an OpenCV camera"""
_stage: Optional[BaseStage] = None _stage: Optional[BaseStage] = None
_server: Optional[ThingServer] = None _server: Optional[lt.ThingServer] = None
def __init__( def __init__(
self, self,
@ -116,7 +113,7 @@ class SimulatedCamera(BaseCamera):
return image return image
def attach_to_server( 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 self._server = server
return super().attach_to_server(server, path, setting_storage_path) return super().attach_to_server(server, path, setting_storage_path)
@ -146,7 +143,7 @@ class SimulatedCamera(BaseCamera):
self._capture_enabled = False self._capture_enabled = False
self._capture_thread.join() self._capture_thread.join()
@thing_property @lt.thing_property
def stream_active(self) -> bool: def stream_active(self) -> bool:
"Whether the MJPEG stream is active" "Whether the MJPEG stream is active"
if self._capture_enabled and self._capture_thread: if self._capture_enabled and self._capture_thread:
@ -154,7 +151,7 @@ class SimulatedCamera(BaseCamera):
return False return False
def _capture_frames(self): def _capture_frames(self):
portal = get_blocking_portal(self) portal = lt.get_blocking_portal(self)
while self._capture_enabled: while self._capture_enabled:
time.sleep(self.frame_interval) time.sleep(self.frame_interval)
try: try:
@ -168,7 +165,7 @@ class SimulatedCamera(BaseCamera):
except Exception as e: except Exception as e:
logging.error(f"Failed to capture frame: {e}, retrying...") logging.error(f"Failed to capture frame: {e}, retrying...")
@thing_action @lt.thing_action
def capture_array( def capture_array(
self, self,
resolution: Literal["main", "full"] = "full", resolution: Literal["main", "full"] = "full",
@ -182,10 +179,10 @@ class SimulatedCamera(BaseCamera):
logging.warning(f"Simulation camera doen't respect {resolution} setting") logging.warning(f"Simulation camera doen't respect {resolution} setting")
return self.generate_frame() return self.generate_frame()
@thing_action @lt.thing_action
def capture_jpeg( def capture_jpeg(
self, self,
metadata_getter: GetThingStates, metadata_getter: lt.deps.GetThingStates,
resolution: Literal["main", "full"] = "main", resolution: Literal["main", "full"] = "main",
) -> JPEGBlob: ) -> JPEGBlob:
"""Acquire one image from the camera and return as a JPEG blob """Acquire one image from the camera and return as a JPEG blob

View file

@ -33,14 +33,10 @@ from camera_stage_mapping.camera_stage_calibration_1d import (
image_to_stage_displacement_from_1d, image_to_stage_displacement_from_1d,
) )
from camera_stage_mapping.exceptions import MappingError from camera_stage_mapping.exceptions import MappingError
from labthings_fastapi.dependencies.invocation import (
InvocationCancelledError, import labthings_fastapi as lt
InvocationLogger,
)
from labthings_fastapi.types.numpy import NDArray, 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_stage_mapping.camera_stage_tracker import Tracker
from .camera import CameraDependency as Camera from .camera import CameraDependency as Camera
from .stage import StageDependency as Stage 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""" """A Thing to manage mapping between image and stage coordinates"""
@thing_action @lt.thing_action
def calibrate_1d( def calibrate_1d(
self, self,
hw: HardwareInterfaceDep, hw: HardwareInterfaceDep,
stage: Stage, stage: Stage,
logger: InvocationLogger, logger: lt.deps.InvocationLogger,
direction: Tuple[float, float, float], direction: Tuple[float, float, float],
) -> DenumpifyingDict: ) -> DenumpifyingDict:
"""Move a microscope's stage in 1D, and figure out the relationship with the camera""" """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( result: dict = calibrate_backlash_1d(
tracker, move, direction_array, logger=logger 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("User cancelled the camera stage mapping calibration")
logger.info("Returning to starting position") logger.info("Returning to starting position")
stage.move_absolute(**starting_position, block_cancellation=True) stage.move_absolute(**starting_position, block_cancellation=True)
@ -215,9 +211,9 @@ class CameraStageMapper(Thing):
result["image_resolution"] = hw.grab_image().shape[:2] result["image_resolution"] = hw.grab_image().shape[:2]
return result return result
@thing_action @lt.thing_action
def calibrate_xy( def calibrate_xy(
self, hw: HardwareInterfaceDep, stage: Stage, logger: InvocationLogger self, hw: HardwareInterfaceDep, stage: Stage, logger: lt.deps.InvocationLogger
) -> DenumpifyingDict: ) -> DenumpifyingDict:
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera """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 return data
@thing_property @lt.thing_property
def image_to_stage_displacement_matrix( def image_to_stage_displacement_matrix(
self, self,
) -> Optional[List[List[float]]]: # 2x2 integer array ) -> Optional[List[List[float]]]: # 2x2 integer array
@ -285,21 +281,19 @@ class CameraStageMapper(Thing):
] ]
return np.array(displacement_matrix).tolist() return np.array(displacement_matrix).tolist()
last_calibration = ThingSetting( last_calibration = lt.ThingSetting(
initial_value=None, initial_value=None,
model=Optional[dict], model=Optional[dict],
readonly=True, readonly=True,
description="The most recent CSM calibration", description="The most recent CSM calibration",
) )
@thing_property @lt.thing_property
def image_resolution(self) -> Optional[Tuple[float, float]]: def image_resolution(self) -> Optional[Tuple[float, float]]:
"""The image size used to calibrate the image_to_stage_displacement_matrix""" """The image size used to calibrate the image_to_stage_displacement_matrix"""
if self.last_calibration is None: if self.last_calibration is None:
return None return None
return self.last_calibration["camera_stage_mapping_calibration"][ return self.last_calibration["image_resolution"]
"image_resolution"
]
def assert_calibrated(self): def assert_calibrated(self):
"""Raise an exception if the image_to_stage_displacement matrix is not set""" """Raise an exception if the image_to_stage_displacement matrix is not set"""
@ -308,7 +302,7 @@ class CameraStageMapper(Thing):
# added by CSMUncalibratedError # added by CSMUncalibratedError
raise CSMUncalibratedError() # noqa: RSE102 raise CSMUncalibratedError() # noqa: RSE102
@thing_action @lt.thing_action
def move_in_image_coordinates( def move_in_image_coordinates(
self, self,
stage: Stage, stage: Stage,
@ -332,7 +326,7 @@ class CameraStageMapper(Thing):
) )
stage.move_relative(x=relative_move[0], y=relative_move[1]) stage.move_relative(x=relative_move[0], y=relative_move[1])
@thing_property @lt.thing_property
def thing_state(self) -> dict[str, Any]: def thing_state(self) -> dict[str, Any]:
"""Summary metadata describing the current state of the Thing""" """Summary metadata describing the current state of the Thing"""
return { return {

View file

@ -11,19 +11,14 @@ from socket import gethostname
from typing import Annotated, Any, MutableMapping, Optional, Sequence from typing import Annotated, Any, MutableMapping, Optional, Sequence
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
from labthings_fastapi.dependencies.metadata import GetThingStates import labthings_fastapi as lt
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
def thing_server_from_request(request: Request) -> ThingServer: def thing_server_from_request(request: Request) -> lt.ThingServer:
return find_thing_server(request.app) 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): 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 return subdict
class SettingsManager(Thing): class SettingsManager(lt.Thing):
external_metadata = ThingSetting( external_metadata = lt.ThingSetting(
initial_value={}, initial_value={},
model=Mapping, model=Mapping,
description="External metadata stored in the server's settings", description="External metadata stored in the server's settings",
) )
external_metadata_in_state = ThingSetting( external_metadata_in_state = lt.ThingSetting(
initial_value=[], initial_value=[],
model=Sequence[str], model=Sequence[str],
description='A list of strings that are included in the "state" metadata', description='A list of strings that are included in the "state" metadata',
) )
@thing_action @lt.thing_action
def update_external_metadata( def update_external_metadata(
self, data: Mapping, key: Optional[str] = None self, data: Mapping, key: Optional[str] = None
) -> None: ) -> None:
@ -84,7 +79,7 @@ class SettingsManager(Thing):
recursive_update(subdict, data) recursive_update(subdict, data)
self.external_metadata = metadata self.external_metadata = metadata
@thing_action @lt.thing_action
def delete_external_metadata(self, key: str) -> None: def delete_external_metadata(self, key: str) -> None:
"""Delete a key from the stored metadata. """Delete a key from the stored metadata.
@ -104,7 +99,7 @@ class SettingsManager(Thing):
_microscope_id: Optional[str] = None _microscope_id: Optional[str] = None
@thing_setting @lt.thing_setting
def microscope_id(self) -> UUID: def microscope_id(self) -> UUID:
"""A unique identifier for this microscope""" """A unique identifier for this microscope"""
if self._microscope_id is None: if self._microscope_id is None:
@ -116,13 +111,13 @@ class SettingsManager(Thing):
# TODO make read only but still settable from disk # TODO make read only but still settable from disk
self._microscope_id = uuid self._microscope_id = uuid
@thing_property @lt.thing_property
def hostname(self) -> str: def hostname(self) -> str:
"""The hostname of the microscope, as reported by its operating system.""" """The hostname of the microscope, as reported by its operating system."""
return gethostname() return gethostname()
@thing_action @lt.thing_action
def get_things_state(self, metadata_getter: GetThingStates) -> Mapping: def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping:
"""Metadata summarising the current state of all Things in the server""" """Metadata summarising the current state of all Things in the server"""
return metadata_getter() return metadata_getter()

View file

@ -11,21 +11,7 @@ from fastapi.responses import FileResponse
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from labthings_fastapi.thing import Thing import labthings_fastapi as lt
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
from openflexure_microscope_server.utilities import ErrorCapturingThread from openflexure_microscope_server.utilities import ErrorCapturingThread
from openflexure_microscope_server import scan_directories from openflexure_microscope_server import scan_directories
@ -38,14 +24,16 @@ from .background_detect import BackgroundDetectThing
from .camera import CameraDependency as CamDep from .camera import CameraDependency as CamDep
from .stage import StageDependency as StageDep from .stage import StageDependency as StageDep
CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") CSMDep = lt.deps.direct_thing_client_dependency(
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") CameraStageMapper, "/camera_stage_mapping/"
BackgroundDep = direct_thing_client_dependency( )
AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/")
BackgroundDep = lt.deps.direct_thing_client_dependency(
BackgroundDetectThing, "/background_detect/" BackgroundDetectThing, "/background_detect/"
) )
JPEGBlob = blob_type("image/jpeg") JPEGBlob = lt.blob.blob_type("image/jpeg")
ZipBlob = blob_type("application/zip") ZipBlob = lt.blob.blob_type("application/zip")
SCAN_DATA_FILENAME = "scan_data.json" SCAN_DATA_FILENAME = "scan_data.json"
STITCHING_CMD = "openflexure-stitch" STITCHING_CMD = "openflexure-stitch"
@ -76,7 +64,7 @@ def _scan_running(method):
return scan_running_wrapper return scan_running_wrapper
class SmartScanThing(Thing): class SmartScanThing(lt.Thing):
def __init__(self, scans_folder): def __init__(self, scans_folder):
self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder) self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder)
self._preview_stitch_popen = None self._preview_stitch_popen = None
@ -87,17 +75,17 @@ class SmartScanThing(Thing):
self._latest_scan_name: Optional[str] = None self._latest_scan_name: Optional[str] = None
# Scan logger is the invocation logger labthings-fastapi creates # 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. # private class variable along with many others here.
# Access to these variables requires a scan to be running, # Access to these variables requires a scan to be running,
# any method that calls these should be decorrected with # any method that calls these should be decorrected with
# @_scan_running # @_scan_running
self._scan_logger: Optional[InvocationLogger] = None self._scan_logger: Optional[lt.deps.InvocationLogger] = None
self._cancel: Optional[CancelHook] = None self._cancel: Optional[lt.deps.CancelHook] = None
self._autofocus: Optional[AutofocusDep] = None self._autofocus: Optional[AutofocusDep] = None
self._stage: Optional[StageDep] = None self._stage: Optional[StageDep] = None
self._cam: Optional[CamDep] = 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._csm: Optional[CSMDep] = None
self._background_detect: Optional[BackgroundDep] = None self._background_detect: Optional[BackgroundDep] = None
self._ongoing_scan: Optional[scan_directories.ScanDirectory] = 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 # TODO Scan data is a dict during refactoring, should become a dataclass
self._scan_data: Optional[dict] = None self._scan_data: Optional[dict] = None
@thing_action @lt.thing_action
def sample_scan( def sample_scan(
self, self,
cancel: CancelHook, cancel: lt.deps.CancelHook,
logger: InvocationLogger, logger: lt.deps.InvocationLogger,
autofocus: AutofocusDep, autofocus: AutofocusDep,
stage: StageDep, stage: StageDep,
cam: CamDep, cam: CamDep,
metadata_getter: GetThingStates, metadata_getter: lt.deps.GetThingStates,
csm: CSMDep, csm: CSMDep,
background_detect: BackgroundDep, background_detect: BackgroundDep,
scan_name: str = "", scan_name: str = "",
@ -210,7 +198,7 @@ class SmartScanThing(Thing):
"of motion." "of motion."
) )
@thing_property @lt.thing_property
def latest_scan_name(self) -> Optional[str]: def latest_scan_name(self) -> Optional[str]:
"""The name of the last scan to be started.""" """The name of the last scan to be started."""
return self._latest_scan_name return self._latest_scan_name
@ -419,7 +407,7 @@ class SmartScanThing(Thing):
self._main_scan_loop() self._main_scan_loop()
self._update_scan_data_json(scan_result="success") self._update_scan_data_json(scan_result="success")
except InvocationCancelledError: except lt.exceptions.InvocationCancelledError:
scan_successful = False scan_successful = False
# Reset the cancel event so it can be thrown again # Reset the cancel event so it can be thrown again
self._cancel.clear() self._cancel.clear()
@ -572,7 +560,7 @@ class SmartScanThing(Thing):
except SubprocessError as e: except SubprocessError as e:
self._scan_logger.error(f"Stitching failed: {e}", exc_info=e) self._scan_logger.error(f"Stitching failed: {e}", exc_info=e)
@fastapi_endpoint( @lt.fastapi_endpoint(
"get", "get",
"scans/stitched_thumbnail.jpg", "scans/stitched_thumbnail.jpg",
responses={ responses={
@ -595,13 +583,13 @@ class SmartScanThing(Thing):
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
return FileResponse(preview_path) return FileResponse(preview_path)
save_resolution = ThingSetting( save_resolution = lt.ThingSetting(
initial_value=(1640, 1232), initial_value=(1640, 1232),
model=tuple[int, int], model=tuple[int, int],
description=("A tuple of the image resolution to capture."), description=("A tuple of the image resolution to capture."),
) )
max_range = ThingSetting( max_range = lt.ThingSetting(
initial_value=45000, initial_value=45000,
model=int, model=int,
description=( description=(
@ -609,13 +597,13 @@ class SmartScanThing(Thing):
), ),
) )
stitch_tiff = ThingSetting( stitch_tiff = lt.ThingSetting(
initial_value=False, initial_value=False,
model=bool, model=bool,
description="Whether or not to also produce a pyramidal tiff", description="Whether or not to also produce a pyramidal tiff",
) )
skip_background = ThingSetting( skip_background = lt.ThingSetting(
initial_value=True, initial_value=True,
model=bool, model=bool,
description="""Whether to detect and skip empty fields of view 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.""", This uses the settings from the `background_detect` Thing.""",
) )
autofocus_dz = ThingSetting( autofocus_dz = lt.ThingSetting(
initial_value=1000, initial_value=1000,
model=int, model=int,
description="The z distance to perform an autofocus in steps", description="The z distance to perform an autofocus in steps",
) )
overlap = ThingSetting( overlap = lt.ThingSetting(
initial_value=0.45, initial_value=0.45,
model=float, model=float,
description="The fraction (0-1) that adjacent images should overlap in x or y", description="The fraction (0-1) that adjacent images should overlap in x or y",
) )
stitch_automatically = ThingSetting( stitch_automatically = lt.ThingSetting(
initial_value=True, initial_value=True,
model=bool, model=bool,
description=( description=(
@ -644,7 +632,7 @@ class SmartScanThing(Thing):
), ),
) )
@thing_property @lt.thing_property
def scans(self) -> list[scan_directories.ScanInfo]: def scans(self) -> list[scan_directories.ScanInfo]:
"""All the available scans """All the available scans
@ -656,7 +644,7 @@ class SmartScanThing(Thing):
""" """
return self._scan_dir_manager.all_scans_info() return self._scan_dir_manager.all_scans_info()
@fastapi_endpoint( @lt.fastapi_endpoint(
"get", "get",
"get_stitch/{scan_name}", "get_stitch/{scan_name}",
responses={ responses={
@ -680,7 +668,7 @@ class SmartScanThing(Thing):
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
return FileResponse(stitch_path) return FileResponse(stitch_path)
@fastapi_endpoint( @lt.fastapi_endpoint(
"delete", "delete",
"scans/{scan_name}", "scans/{scan_name}",
responses={ responses={
@ -688,7 +676,7 @@ class SmartScanThing(Thing):
400: {"description": "An error occurred while trying to delete scan"}, 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. """Delete the folder for the specified scan.
This endpoint allows scans to be deleted from disk. This endpoint allows scans to be deleted from disk.
@ -702,11 +690,11 @@ class SmartScanThing(Thing):
if not deleted_scan_success: if not deleted_scan_success:
raise HTTPException(400, "Couldn't delete scan, check log for details") raise HTTPException(400, "Couldn't delete scan, check log for details")
@fastapi_endpoint( @lt.fastapi_endpoint(
"delete", "delete",
"scans", "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 """Delete all the scans on the microscope
**This will irreversibly remove all scanned data from the **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: for scan_name in self._scan_dir_manager.all_scans:
self._delete_scan(scan_name, logger) self._delete_scan(scan_name, logger)
@thing_action @lt.thing_action
def purge_empty_scans(self, logger: InvocationLogger) -> None: def purge_empty_scans(self, logger: lt.deps.InvocationLogger) -> None:
""" """
Delete all scan folders containing no images at the top level 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: if scan_info.number_of_images == 0:
self._delete_scan(scan_info.name, logger) 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 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 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]: def latest_preview_stitch_time(self) -> Optional[float]:
"""The modification time of the latest preview image, to allow live updating """The modification time of the latest preview image, to allow live updating
@ -767,7 +755,7 @@ class SmartScanThing(Thing):
return None return None
return os.path.getmtime(self.latest_preview_stitch_path) return os.path.getmtime(self.latest_preview_stitch_path)
@fastapi_endpoint( @lt.fastapi_endpoint(
"get", "get",
"latest_preview_stitch.jpg", "latest_preview_stitch.jpg",
responses={ responses={
@ -836,8 +824,8 @@ class SmartScanThing(Thing):
def run_subprocess( def run_subprocess(
self, self,
logger: InvocationLogger, logger: lt.deps.InvocationLogger,
cancel: CancelHook, cancel: lt.deps.CancelHook,
cmd: list[str], cmd: list[str],
) -> CompletedProcess: ) -> CompletedProcess:
""" """
@ -873,7 +861,7 @@ class SmartScanThing(Thing):
# Note that using cancel.sleep allows the InvocationCancelledError to # Note that using cancel.sleep allows the InvocationCancelledError to
# be thrown # be thrown
cancel.sleep(0.2) cancel.sleep(0.2)
except InvocationCancelledError as e: except lt.exceptions.InvocationCancelledError as e:
logger.info("Stitching cancelled by user") logger.info("Stitching cancelled by user")
process.kill() process.kill()
raise e raise e
@ -886,18 +874,18 @@ class SmartScanThing(Thing):
else: else:
raise ChildProcessError(f"Subprocess {cmd[0]} exited with an error.") raise ChildProcessError(f"Subprocess {cmd[0]} exited with an error.")
@thing_action @lt.thing_action
def stitch_scan( def stitch_scan(
self, self,
logger: InvocationLogger, logger: lt.deps.InvocationLogger,
cancel: CancelHook, cancel: lt.deps.CancelHook,
scan_name: str, scan_name: str,
stitch_resize: Optional[float] = None, stitch_resize: Optional[float] = None,
overlap: float = 0.0, overlap: float = 0.0,
) -> None: ) -> None:
"""Generate a stitched image based on stage position metadata """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 a variable if called from another thing action
""" """
json_fpath = self._scan_dir_manager.get_file_path_from_img_dir( 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), 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. # Sleep for 1 second just to allow invocation logs to pass to user.
time.sleep(1) time.sleep(1)
pass pass
@thing_action @lt.thing_action
def download_zip( def download_zip(
self, self,
scan_name: str, scan_name: str,

View file

@ -2,14 +2,10 @@ from __future__ import annotations
from typing import TypeAlias from typing import TypeAlias
from collections.abc import Sequence, Mapping from collections.abc import Sequence, Mapping
from labthings_fastapi.descriptors.property import ThingProperty import labthings_fastapi as lt
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
class BaseStage(Thing): class BaseStage(lt.Thing):
"""A base stage class for OpenFlexure translation stages """A base stage class for OpenFlexure translation stages
This can't be used directly but should reduce boilerplate code when This can't be used directly but should reduce boilerplate code when
@ -20,12 +16,12 @@ class BaseStage(Thing):
_axis_names = ("x", "y", "z") _axis_names = ("x", "y", "z")
@thing_property @lt.thing_property
def axis_names(self) -> Sequence[str]: def axis_names(self) -> Sequence[str]:
"""The names of the stage's axes, in order.""" """The names of the stage's axes, in order."""
return self._axis_names return self._axis_names
position = ThingProperty( position = lt.ThingProperty(
Mapping[str, int], Mapping[str, int],
dict.fromkeys(_axis_names, 0), dict.fromkeys(_axis_names, 0),
description="Current position of the stage", description="Current position of the stage",
@ -33,7 +29,7 @@ class BaseStage(Thing):
observable=True, observable=True,
) )
moving = ThingProperty( moving = lt.ThingProperty(
bool, bool,
False, False,
description="Whether the stage is in motion", description="Whether the stage is in motion",
@ -46,10 +42,10 @@ class BaseStage(Thing):
"""Summary metadata describing the current state of the stage""" """Summary metadata describing the current state of the stage"""
return {"position": self.position} return {"position": self.position}
@thing_action @lt.thing_action
def move_relative( def move_relative(
self, self,
cancel: CancelHook, cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: Mapping[str, int], **kwargs: Mapping[str, int],
): ):
@ -58,10 +54,10 @@ class BaseStage(Thing):
"StageThings must define their own move_relative method" "StageThings must define their own move_relative method"
) )
@thing_action @lt.thing_action
def move_absolute( def move_absolute(
self, self,
cancel: CancelHook, cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: Mapping[str, int], **kwargs: Mapping[str, int],
): ):
@ -70,7 +66,7 @@ class BaseStage(Thing):
"StageThings must define their own move_absolute method" "StageThings must define their own move_absolute method"
) )
@thing_action @lt.thing_action
def set_zero_position(self): def set_zero_position(self):
"""Make the current position zero in all axes """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/"
)

View file

@ -1,12 +1,10 @@
from __future__ import annotations from __future__ import annotations
from labthings_fastapi.decorators import thing_action
from labthings_fastapi.dependencies.invocation import (
CancelHook,
InvocationCancelledError,
)
from collections.abc import Mapping from collections.abc import Mapping
import time import time
import labthings_fastapi as lt
from . import BaseStage from . import BaseStage
@ -27,10 +25,10 @@ class DummyStage(BaseStage):
def __exit__(self, _exc_type, _exc_value, _traceback): def __exit__(self, _exc_type, _exc_value, _traceback):
pass pass
@thing_action @lt.thing_action
def move_relative( def move_relative(
self, self,
cancel: CancelHook, cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: Mapping[str, int], **kwargs: Mapping[str, int],
): ):
@ -53,7 +51,7 @@ class DummyStage(BaseStage):
for k, v in zip(self.axis_names, displacement) for k, v in zip(self.axis_names, displacement)
} }
fraction_complete = 1.0 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. # 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, # We need the exception to propagate in order to stop any calling tasks,
# and to mark the invocation as "cancelled" rather than stopped. # and to mark the invocation as "cancelled" rather than stopped.
@ -66,10 +64,10 @@ class DummyStage(BaseStage):
} }
self.instantaneous_position = self.position self.instantaneous_position = self.position
@thing_action @lt.thing_action
def move_absolute( def move_absolute(
self, self,
cancel: CancelHook, cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: Mapping[str, int], **kwargs: Mapping[str, int],
): ):
@ -83,7 +81,7 @@ class DummyStage(BaseStage):
cancel, block_cancellation=block_cancellation, **displacement cancel, block_cancellation=block_cancellation, **displacement
) )
@thing_action @lt.thing_action
def set_zero_position(self): def set_zero_position(self):
"""Make the current position zero in all axes """Make the current position zero in all axes

View file

@ -7,11 +7,7 @@ from contextlib import contextmanager
from collections.abc import Mapping from collections.abc import Mapping
import sangaboard import sangaboard
from labthings_fastapi.decorators import thing_action import labthings_fastapi as lt
from labthings_fastapi.dependencies.invocation import (
CancelHook,
InvocationCancelledError,
)
from . import BaseStage from . import BaseStage
@ -57,10 +53,10 @@ class SangaboardThing(BaseStage):
with self.sangaboard() as sb: with self.sangaboard() as sb:
self.position = dict(zip(self.axis_names, sb.position)) self.position = dict(zip(self.axis_names, sb.position))
@thing_action @lt.thing_action
def move_relative( def move_relative(
self, self,
cancel: CancelHook, cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: Mapping[str, int], **kwargs: Mapping[str, int],
) -> None: ) -> None:
@ -75,7 +71,7 @@ class SangaboardThing(BaseStage):
else: else:
while sb.query("moving?") == "true": while sb.query("moving?") == "true":
cancel.sleep(0.1) 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. # 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, # We need the exception to propagate in order to stop any calling tasks,
# and to mark the invocation as "cancelled" rather than stopped. # and to mark the invocation as "cancelled" rather than stopped.
@ -85,10 +81,10 @@ class SangaboardThing(BaseStage):
self.moving = False self.moving = False
self.update_position() self.update_position()
@thing_action @lt.thing_action
def move_absolute( def move_absolute(
self, self,
cancel: CancelHook, cancel: lt.deps.CancelHook,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: Mapping[str, int], **kwargs: Mapping[str, int],
) -> None: ) -> None:
@ -104,7 +100,7 @@ class SangaboardThing(BaseStage):
cancel, block_cancellation=block_cancellation, **displacement cancel, block_cancellation=block_cancellation, **displacement
) )
@thing_action @lt.thing_action
def set_zero_position(self) -> None: def set_zero_position(self) -> None:
"""Make the current position zero in all axes """Make the current position zero in all axes
@ -116,7 +112,7 @@ class SangaboardThing(BaseStage):
sb.zero_position() sb.zero_position()
self.update_position() self.update_position()
@thing_action @lt.thing_action
def flash_led( def flash_led(
self, self,
number_of_flashes: int = 10, number_of_flashes: int = 10,

View file

@ -6,8 +6,7 @@ This module defines a Thing that can shut down or restart the host computer.
import subprocess import subprocess
import os import os
from labthings_fastapi.thing import Thing import labthings_fastapi as lt
from labthings_fastapi.decorators import thing_action, thing_property
from pydantic import BaseModel from pydantic import BaseModel
@ -16,12 +15,12 @@ class CommandOutput(BaseModel):
error: str error: str
class SystemControlThing(Thing): class SystemControlThing(lt.Thing):
""" """
Attempt to shutdown the device Attempt to shutdown the device
""" """
@thing_action @lt.thing_action
def shutdown(self) -> CommandOutput: def shutdown(self) -> CommandOutput:
""" """
Attempt to shutdown the device Attempt to shutdown the device
@ -35,14 +34,14 @@ class SystemControlThing(Thing):
out, err = p.communicate() out, err = p.communicate()
return CommandOutput(output=out, error=err) return CommandOutput(output=out, error=err)
@thing_property @lt.thing_property
def is_raspberrypi() -> bool: def is_raspberrypi() -> bool:
""" """
Checks if we are running on a Raspberry Pi. Checks if we are running on a Raspberry Pi.
""" """
return os.path.exists("/usr/bin/raspi-config") return os.path.exists("/usr/bin/raspi-config")
@thing_action @lt.thing_action
def reboot(self) -> CommandOutput: def reboot(self) -> CommandOutput:
"""Attempt to reboot the device""" """Attempt to reboot the device"""
p = subprocess.Popen( p = subprocess.Popen(

View file

@ -3,13 +3,13 @@ import os
import tempfile import tempfile
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from labthings_fastapi.client import ThingClient
from PIL import Image from PIL import Image
import numpy as np import numpy as np
import piexif import piexif
import pytest 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.camera.simulation import SimulatedCamera
from openflexure_microscope_server.things.stage.dummy import DummyStage from openflexure_microscope_server.things.stage.dummy import DummyStage
from openflexure_microscope_server.things.autofocus import AutofocusThing 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 @pytest.fixture
def thing_server(): def thing_server():
temp_folder = tempfile.TemporaryDirectory() temp_folder = tempfile.TemporaryDirectory()
server = ThingServer(settings_folder=temp_folder.name) server = lt.ThingServer(settings_folder=temp_folder.name)
server.add_thing( server.add_thing(
SimulatedCamera( SimulatedCamera(
shape=(240, 320, 3), canvas_shape=(960, 1240, 3), frame_interval=0.01 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): def test_autofocus(slower_client):
client = slower_client client = slower_client
autofocus = ThingClient.from_url("/autofocus/", client) autofocus = lt.ThingClient.from_url("/autofocus/", client)
_ = autofocus.fast_autofocus() _ = autofocus.fast_autofocus()
def test_grab_jpeg(client): def test_grab_jpeg(client):
camera = ThingClient.from_url("/camera/", client) camera = lt.ThingClient.from_url("/camera/", client)
blob = camera.grab_jpeg() blob = camera.grab_jpeg()
_image = Image.open(blob.open()) _image = Image.open(blob.open())
def test_capture_jpeg_metadata(client): def test_capture_jpeg_metadata(client):
camera = ThingClient.from_url("/camera/", client) camera = lt.ThingClient.from_url("/camera/", client)
blob = camera.capture_jpeg() blob = camera.capture_jpeg()
image = Image.open(blob.open()) image = Image.open(blob.open())
exif_dict = piexif.load(image.info["exif"]) exif_dict = piexif.load(image.info["exif"])
@ -73,7 +73,7 @@ def test_capture_jpeg_metadata(client):
def test_stage(client): def test_stage(client):
stage = ThingClient.from_url("/stage/", client) stage = lt.ThingClient.from_url("/stage/", client)
start = stage.position start = stage.position
move = {"x": 1, "y": 2, "z": 3} move = {"x": 1, "y": 2, "z": 3}
stage.move_relative(**move) stage.move_relative(**move)
@ -87,12 +87,12 @@ def test_stage(client):
def test_capture_array(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()) array = np.asarray(camera.capture_array())
assert array.shape == (240, 320, 3) assert array.shape == (240, 320, 3)
# Currently this fails, not yet sure why. # Currently this fails, not yet sure why.
def test_camera_stage_mapping_calibration(client): 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() camera_stage_mapping.calibrate_xy()