Merge branch 'new-thing-setting-syntax' into 'v3'

Update Thing Settings and import statments for LabThings-FastAPI 0.0.10

Closes #302 and #362

See merge request openflexure/openflexure-microscope-server!296
This commit is contained in:
Julian Stirling 2025-07-07 18:29:01 +00:00
commit ea785cb844
21 changed files with 568 additions and 699 deletions

2
.gitignore vendored
View file

@ -30,7 +30,7 @@ var/
.installed.cfg .installed.cfg
*.egg *.egg
pip-wheel-metadata/ pip-wheel-metadata/
/openflexure/
# PyInstaller # PyInstaller
# Usually these files are written by a python script from a template # Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it. # before PyInstaller builds the exe, so as to inject date/other infos into it.

View file

@ -15,7 +15,7 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
dependencies = [ dependencies = [
"labthings-fastapi[server] == 0.0.9", "labthings-fastapi == 0.0.10",
"sangaboard", "sangaboard",
"camera-stage-mapping ~= 0.1.10", "camera-stage-mapping ~= 0.1.10",
"opencv-python ~= 4.11.0", "opencv-python ~= 4.11.0",

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,9 +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, thing_property
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
@ -189,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
@ -287,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,
@ -324,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,
@ -350,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,
@ -396,39 +394,38 @@ 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()
@thing_property stack_images_to_save = lt.ThingSetting(
def stack_images_to_save(self) -> int: initial_value=1,
"""The number of images to capture and save in a stack model=int,
Defaults to 1 unless you need to see either side of focus""" description="""The number of images to save in a stack.
return self.thing_settings.get("stack_images_to_save", 1)
@stack_images_to_save.setter Defaults to 1 unless you need to see either side of focus""",
def stack_images_to_save(self, value: int) -> None: )
self.thing_settings["stack_images_to_save"] = value
@thing_property stack_min_images_to_test = lt.ThingSetting(
def stack_min_images_to_test(self) -> int: initial_value=9,
"""The number of images to test for successful focusing in a stack model=int,
Defaults to 9, which balances reliability and speed""" description="""The minimum number of images to capture in a stack.
return self.thing_settings.get("stack_min_images_to_test", 9)
@stack_min_images_to_test.setter This many images are captures and tested for focus, if the focus
def stack_min_images_to_test(self, value: int) -> None: is not central enough more images may be captured. After new images
self.thing_settings["stack_min_images_to_test"] = value are captured the number sets the number of images used for checking
if focus is central.
@thing_property Defaults to 9 which balances reliability and speed
def stack_dz(self) -> int: """,
"""Space in steps between images in a z-stack )
Suggested is 50 for 60-100x
100 for 40x
200 for 20x"""
return self.thing_settings.get("stack_dz", 50)
@stack_dz.setter stack_dz = lt.ThingSetting(
def stack_dz(self, value: int) -> None: initial_value=50,
self.thing_settings["stack_dz"] = value model=int,
description="""Space in steps between images in a z-stack
Suggested is 50 for 60-100x
100 for 40x
200 for 20x""",
)
@thing_action @lt.thing_action
def run_smart_stack( def run_smart_stack(
self, self,
cam: WrappedCamera, cam: WrappedCamera,

View file

@ -5,8 +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_property
from .camera import CameraDependency as CamDep from .camera import CameraDependency as CamDep
@ -16,39 +15,45 @@ class ChannelDistributions(BaseModel):
colorspace: str = "LUV" colorspace: str = "LUV"
class BackgroundDetectThing(Thing): class BackgroundDetectThing(lt.Thing):
@thing_property # Requires a getter and a setter to support being a BaseModel but being
# saved to file as a dict
_background_distributions: Optional[ChannelDistributions] = None
@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.thing_settings.get("background_distributions", None) bd = self._background_distributions
if bd: if bd is None:
return ChannelDistributions(**bd) return None
return None return ChannelDistributions(**bd)
@background_distributions.setter @background_distributions.setter
def background_distributions(self, value: Optional[ChannelDistributions]) -> None: def background_distributions(
try: self, value: Optional[ChannelDistributions | dict]
self.thing_settings["background_distributions"] = value.model_dump() ) -> None:
except AttributeError: if value is None:
self.thing_settings["background_distributions"] = None self._background_distributions = None
elif isinstance(value, ChannelDistributions):
self._background_distributions = value.model_dump()
elif isinstance(value, dict):
self._background_distributions = value
else:
raise TypeError(
f"Cannot set background_distributions with an object of type {type(value)}"
)
@thing_property tolerance = lt.ThingSetting(
def tolerance(self) -> float: initial_value=7.0,
"""How many standard deviations to allow for the background""" model=float,
return self.thing_settings.get("tolerance", 7) description="How many standard deviations to allow for the background",
)
@tolerance.setter fraction = lt.ThingSetting(
def tolerance(self, value: float) -> None: initial_value=25.0,
self.thing_settings["tolerance"] = value model=float,
description="How much of the image needs to be not background to label as sample",
@thing_property )
def fraction(self) -> float:
"""How much of the image needs to be not background to label as sample"""
return self.thing_settings.get("fraction", 25)
@fraction.setter
def fraction(self, value: float) -> None:
self.thing_settings["fraction"] = value
def background_mask(self, image: np.ndarray) -> np.ndarray: def background_mask(self, image: np.ndarray) -> np.ndarray:
"""Calculate a binary image, showing whether each pixel is background """Calculate a binary image, showing whether each pixel is background
@ -71,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
@ -89,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)
@ -97,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,59 +16,34 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf
""" """
from __future__ import annotations from __future__ import annotations
from typing import Annotated, 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 PropertyDescriptor
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 numpy as np
import picamera2
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
import labthings_fastapi as lt
from labthings_fastapi.exceptions import NotConnectedToServerError
from . import picamera_recalibrate_utils as recalibrate_utils from . import picamera_recalibrate_utils as recalibrate_utils
from . import BaseCamera, JPEGBlob, ArrayModel from . import BaseCamera, JPEGBlob, ArrayModel
class PicameraControl(PropertyDescriptor):
def __init__(
self, control_name: str, model: type = float, description: Optional[str] = None
):
"""A property descriptor controlling a picamera control"""
PropertyDescriptor.__init__(
self, model, observable=False, description=description
)
self.control_name = control_name
def _getter(self, obj: StreamingPiCamera2):
with obj.picamera() as cam:
return cam.capture_metadata()[self.control_name]
def _setter(self, obj: StreamingPiCamera2, value: Any):
with obj.picamera() as cam:
cam.set_controls({self.control_name: value})
class PicameraStreamOutput(Output): 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
@ -134,98 +109,37 @@ class StreamingPiCamera2(BaseCamera):
"""A Thing that represents an OpenCV camera""" """A Thing that represents an OpenCV camera"""
def __init__(self, camera_num: int = 0): def __init__(self, camera_num: int = 0):
self._setting_save_in_progress = False
self.camera_num = camera_num self.camera_num = camera_num
self.camera_configs: dict[str, dict] = {} self.camera_configs: dict[str, dict] = {}
# Note persistent controls will be updated with settings, in __enter__. self._picamera_lock = None
self.persistent_controls = { self._picamera = None
"AeEnable": False, logging.info("Starting & reconfiguring camera to populate sensor_modes.")
"AnalogueGain": 1.0, with Picamera2(camera_num=self.camera_num) as cam:
"AwbEnable": False, self.default_tuning = recalibrate_utils.load_default_tuning(cam)
"Brightness": 0, logging.info("Done reading sensor modes & default tuning.")
"ColourGains": (1, 1), # Set tuning to default tuning. This will be overwritten when the Thing is
"Contrast": 1, # connects to the server if tuning is saved to disk.
"ExposureTime": 0,
"Saturation": 1,
"Sharpness": 1,
}
self.persistent_control_tolerances = {
"ExposureTime": 30,
}
def update_persistent_controls(self, discard_frames: int = 1):
"""Update the persistent controls dict from the camera
Query the camera and update the value of `persistent_controls` to
match the current state of the camera.
There is a work-around here, that will suppress small updates. There
appears to be a bug in the camera code that causes a slight drift in
`ExposureTime` each time the camera is reinitialised: this can
add up over time, particularly if the camera is reconfigured many
times. To get around this, we look in `self.persistent_control_tolerances`
and only update `self.persistent_controls` if the change is greater than
this tolerance.
"""
with self.picamera() as cam:
for i in range(discard_frames):
# Discard frames, so data is fresh
cam.capture_metadata()
for key, value in cam.capture_metadata().items():
if key not in self.persistent_controls:
# Skip keys that are not persistent controls
continue
if self._control_value_within_tolerance(key, value):
logging.debug(
"Ignoring a small change in persistent control %s from %s to "
"%s while updating persistent controls.",
key,
self.persistent_controls[key],
value,
)
else:
self.persistent_controls[key] = value
self.thing_settings.update(self.persistent_controls)
def _control_value_within_tolerance(self, key: str, value: float) -> bool:
"""Return True if the updated value for a persistent control is within
tolerance of current value. This allows ignoring small changes
"""
if key not in self.persistent_control_tolerances:
# Not tolerance range set, so it is not within tolerance
return False
difference = np.abs(self.persistent_controls[key] - value)
return difference < self.persistent_control_tolerances[key]
def settings_to_persistent_controls(self):
"""Update the persistent controls dict from the settings dict
NB this must be called **after** self.thing_settings is initialised,
i.e. during or after `__enter__`.
"""
try: try:
pc = self.thing_settings["persistent_controls"] self.tuning = self.default_tuning
except KeyError: except NotConnectedToServerError:
return # If there are no saved settings, use defaults # This will throw an error after setting as we are not connected to
for k in self.persistent_controls: # a server. But we know this, so we ignore the error.
try: pass
self.persistent_controls[k] = pc[k]
except KeyError:
pass # If controls are missing, leave at default
stream_resolution = PropertyDescriptor( 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 = PropertyDescriptor( 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 = PropertyDescriptor( 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",
@ -233,144 +147,205 @@ class StreamingPiCamera2(BaseCamera):
readonly=True, readonly=True,
) )
analogue_gain = PicameraControl("AnalogueGain", float) def save_settings(self):
colour_gains = PicameraControl("ColourGains", tuple[float, float]) """Override save_settings to ensure that camera properties don't recurse.
exposure_time = PicameraControl( This method is run by any Thing when a ThingSetting is saved. However, the
"ExposureTime", int, description="The exposure time in microseconds" method reads the thing_setting. As reading the thing setting talks to the
) camera and calls save_settings if the value is not as expected, this could
cause recursion. Aslo this means that saving one setting causes all others
to be read each time.
"""
try:
self._setting_save_in_progress = True
super().save_settings()
finally:
self._setting_save_in_progress = False
## Persistent controls! These are settings
_analogue_gain: float = 1.0
@lt.thing_setting
def analogue_gain(self) -> float:
if not self._setting_save_in_progress and self.streaming:
with self._streaming_picamera() as cam:
cam_value = cam.capture_metadata()["AnalogueGain"]
if cam_value != self._analogue_gain:
self._analogue_gain = cam_value
self.save_settings()
return self._analogue_gain
@analogue_gain.setter
def analogue_gain(self, value: float):
self._analogue_gain = value
if self.streaming:
with self._streaming_picamera() as cam:
cam.set_controls({"AnalogueGain": value})
_colour_gains: tuple[float, float] = (1.0, 1.0)
@lt.thing_setting
def colour_gains(self) -> tuple[float, float]:
if not self._setting_save_in_progress and self.streaming:
with self._streaming_picamera() as cam:
cam_value = cam.capture_metadata()["ColourGains"]
if cam_value != self._colour_gains:
self._colour_gains = cam_value
self.save_settings()
return self._colour_gains
@colour_gains.setter
def colour_gains(self, value: tuple[float, float]):
self._colour_gains = value
if self.streaming:
with self._streaming_picamera() as cam:
cam.set_controls({"ColourGains": value})
_exposure_time: int = 0
@lt.thing_setting
def exposure_time(self) -> int:
if not self._setting_save_in_progress and self.streaming:
with self._streaming_picamera() as cam:
cam_value = cam.capture_metadata()["ExposureTime"]
if cam_value != self._exposure_time:
self._exposure_time = cam_value
self.save_settings()
return self._exposure_time
@exposure_time.setter @exposure_time.setter
def exposure_time(self, value: int): def exposure_time(self, value: int):
""" _exposure_time = value
Custom setter for the above exposure_time PicameraControl. if self.streaming:
with self._streaming_picamera() as cam:
# Note: This set a value 1 higher than requested as picamera2 always
# sets a lower value than requested, even if the requested is allowed
cam.set_controls({"ExposureTime": value + 1})
This is overriding the standard setter for a PicameraControl, as def _get_persistent_controls(self) -> dict:
the value needs to be adjusted before setting to behave as expected. discard_frames: int = 1
if self.streaming:
See comment within the function for more detail. with self._streaming_picamera() as cam:
""" # Discard frames, so data is fresh
with self.picamera() as cam: for i in range(discard_frames):
# Note: This set a value 1 higher than requested as picamera2 always sets cam.capture_metadata()
# a lower value than requested, even if the requested is allowed return {
cam.set_controls({"ExposureTime": value + 1}) "AeEnable": False,
"AnalogueGain": self.analogue_gain,
"AwbEnable": False,
"Brightness": 0,
"ColourGains": self.colour_gains,
"Contrast": 1,
"ExposureTime": self.exposure_time,
"Saturation": 1,
"Sharpness": 1,
}
_sensor_modes = None _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:
with self.picamera() as cam: with self._streaming_picamera() as cam:
self._sensor_modes = cam.sensor_modes self._sensor_modes = cam.sensor_modes
return self._sensor_modes return self._sensor_modes
@thing_property _sensor_mode: Optional[dict] = None
@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"] if self._sensor_mode is None:
return None
return SensorModeSelector(**self._sensor_mode)
@sensor_mode.setter @sensor_mode.setter
def sensor_mode(self, new_mode: Optional[SensorModeSelector]): def sensor_mode(self, new_mode: Optional[SensorModeSelector | dict]):
"""Change the sensor mode used""" """Change the sensor mode used"""
if isinstance(new_mode, SensorModeSelector):
new_mode = new_mode.model_dump()
with self.picamera(pause_stream=True):
self.thing_settings["sensor_mode"] = new_mode
@thing_property if new_mode is None:
def sensor_resolution(self) -> tuple[int, int]: self._sensor_mode = None
elif isinstance(new_mode, SensorModeSelector):
self._sensor_mode = new_mode.model_dump()
elif isinstance(new_mode, dict):
self._sensor_mode = new_mode
# By pausing the stream on when accessing, streaming_picamera
# self._sensor_mode will be read and set when the stream restarts
# after the context manager closes.
with self._streaming_picamera(pause_stream=True):
pass
@lt.thing_property
def sensor_resolution(self) -> Optional[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._streaming_picamera() as cam:
return cam.sensor_resolution return cam.sensor_resolution
tuning = PropertyDescriptor(Optional[dict], None, readonly=True) tuning = lt.ThingSetting(Optional[dict], None, readonly=True)
def settings_to_properties(self): def _initialise_picamera(self):
"""Set the values of properties based on the settings dict""" """Acquire the picamera device and store it as `self._picamera`.
try:
props = self.thing_settings["properties"]
except KeyError:
return
for k, v in props.items():
setattr(self, k, v)
def properties_to_settings(self): This duplicates logic in `Picamera2.__init__` to provide a tuning file that
"""Save certain properties to the settings dictionary""" will be read when the camera system initialises.
props = {}
for k in ["mjpeg_bitrate", "stream_resolution"]:
props[k] = getattr(self, k)
self.thing_settings["properties"] = props
def initialise_tuning(self):
"""Read the tuning from the settings, or load default tuning
NB this relies on `self.thing_settings` and `self.default_tuning`
so will fail if it's run before those are populated in `__enter__`.
""" """
if "tuning" in self.thing_settings: if self._picamera_lock is not None:
self.tuning = self.thing_settings["tuning"].dict
else:
logging.info("Did not find tuning in settings, reading from camera...")
self.tuning = self.default_tuning
def initialise_picamera(self):
"""Acquire the picamera device and store it as `self._picamera`"""
if hasattr(self, "_picamera_lock"):
# Don't close the camera if it's in use # Don't close the camera if it's in use
self._picamera_lock.acquire() self._picamera_lock.acquire()
with tempfile.NamedTemporaryFile("w") as tuning_file: with tempfile.NamedTemporaryFile("w") as tuning_file:
# This duplicates logic in `Picamera2.__init__` to provide a tuning file
# that will be read when the camera system initialises.
# This is a necessary work-around until `picamera2` better supports
# reinitialisation of the camera with new tuning.
json.dump(self.tuning, tuning_file) json.dump(self.tuning, tuning_file)
tuning_file.flush() # but leave it open as closing it will delete it tuning_file.flush() # but leave it open as closing it will delete it
os.environ["LIBCAMERA_RPI_TUNING_FILE"] = tuning_file.name os.environ["LIBCAMERA_RPI_TUNING_FILE"] = tuning_file.name
# NB even though we've put the tuning file in the environment, we will
# need to specify the filename in the `Picamera2` initialiser as otherwise if self._picamera is not None:
# it will be overwritten with None. logging.info("Closing picamera object for reinitialisation")
if hasattr(self, "_picamera") and self._picamera:
print("Closing picamera object for reinitialisation")
logging.info( logging.info(
"Camera object already exists, closing for reinitialisation" "Camera object already exists, closing for reinitialisation"
) )
self._picamera.close() self._picamera.close()
print("closed, deleting picamera") logging.info("Picamera closed, deleting picamera")
del self._picamera del self._picamera
recalibrate_utils.recreate_camera_manager() recalibrate_utils.recreate_camera_manager()
print("[re]creating Picamera2 object")
self._picamera = picamera2.Picamera2( logging.info("Creating new Picamera2 object")
# Specify tuning file otherwise it will be overwritten with None.
self._picamera = Picamera2(
camera_num=self.camera_num, camera_num=self.camera_num,
tuning=self.tuning, tuning=self.tuning,
) )
self._picamera_lock = RLock() self._picamera_lock = RLock()
def __enter__(self): def __enter__(self):
self.populate_default_tuning() self._initialise_picamera()
self.initialise_tuning()
self.initialise_picamera()
# populate sensor modes by reading the property # populate sensor modes by reading the property
self.sensor_modes self.sensor_modes
self.settings_to_persistent_controls()
self.settings_to_properties()
self.start_streaming() self.start_streaming()
return self return self
@contextmanager @property
def picamera(self, pause_stream=False) -> Iterator[Picamera2]: def streaming(self) -> bool:
"""Return the underlying `Picamera2` instance, optionally pausing the stream. """True if the camera is streaming"""
return self._picamera is not None and self._picamera.started
If pause_stream is True (default is False), we will stop the MJPEG stream @contextmanager
before yielding control of the camera, and restart afterwards. If you make def _streaming_picamera(self, pause_stream=False) -> Iterator[Picamera2]:
changes to the camera settings, these may be ignored when the stream is """Lock access to picamera and return the underlying `Picamera2` instance.
restarted: you may nened to call `update_persistent_controls()` to ensure
your changes persist after the stream restarts. Optionally the stream can be paused to allow updating the camera settings.
:param pause_stream: If False the `Picamera2` instance is simply yielded.
If True:
* Stop the MJPEG Stream
* Yield the `Picamera2` instance for function calling the context manager to
make changes.
* On closing of the context manager the stream will restart.
""" """
already_streaming = self.stream_active already_streaming = self.stream_active
with self._picamera_lock: with self._picamera_lock:
if pause_stream and already_streaming: if pause_stream and already_streaming:
self.update_persistent_controls()
self.stop_streaming(stop_web_stream=False) self.stop_streaming(stop_web_stream=False)
try: try:
yield self._picamera yield self._picamera
@ -378,36 +353,19 @@ class StreamingPiCamera2(BaseCamera):
if pause_stream and already_streaming: if pause_stream and already_streaming:
self.start_streaming() self.start_streaming()
def populate_default_tuning(self):
"""Sensor modes are enumerated and stored, once, on start-up (`__enter__`).
This opens and closes the camera - must be run before the camera is
initialised.
"""
logging.info("Starting & reconfiguring camera to populate sensor_modes.")
with Picamera2(camera_num=self.camera_num) as cam:
self.default_tuning = recalibrate_utils.load_default_tuning(cam)
logging.info("Done reading sensor modes & default tuning.")
def __exit__(self, exc_type, exc_value, traceback): def __exit__(self, exc_type, exc_value, traceback):
# Allow key controls to persist across restarts
self.update_persistent_controls()
self.thing_settings["persistent_controls"] = self.persistent_controls
self.thing_settings["tuning"] = self.tuning
self.properties_to_settings()
self.thing_settings.write_to_file()
# Shut down the camera # Shut down the camera
self.stop_streaming() self.stop_streaming()
with self.picamera() as cam: with self._streaming_picamera() as cam:
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:
""" """
Start the MJPEG stream Start the MJPEG stream. This is where persistent controls are sent to camera.
Sets the camera resolutions based on input parameters, and sets the low-res Sets the camera resolutions based on input parameters, and sets the low-res
resolution to (320, 240). Note: (320, 240) is a standard from the Pi Camera resolution to (320, 240). Note: (320, 240) is a standard from the Pi Camera
@ -424,7 +382,7 @@ class StreamingPiCamera2(BaseCamera):
buffer_count: the number of frames to hold in the buffer. Higher uses more memory, buffer_count: the number of frames to hold in the buffer. Higher uses more memory,
lower may cause dropped frames. Value must be between 1 and 8, Defaults to 6. lower may cause dropped frames. Value must be between 1 and 8, Defaults to 6.
""" """
controls = self._get_persistent_controls()
# Buffer count can't be negative, zero, or too high. # Buffer count can't be negative, zero, or too high.
if buffer_count < 1 or buffer_count > 8: if buffer_count < 1 or buffer_count > 8:
# 8 is slightly arbitrary. 6 is the PiCamera default for video # 8 is slightly arbitrary. 6 is the PiCamera default for video
@ -434,7 +392,7 @@ class StreamingPiCamera2(BaseCamera):
f"Can't set a buffer count of {buffer_count}. " f"Can't set a buffer count of {buffer_count}. "
"Buffer count must be an integer from 1-8" "Buffer count must be an integer from 1-8"
) )
with self.picamera() as picam: with self._streaming_picamera() as picam:
try: try:
if picam.started: if picam.started:
picam.stop() picam.stop()
@ -442,8 +400,8 @@ class StreamingPiCamera2(BaseCamera):
stream_config = picam.create_video_configuration( stream_config = picam.create_video_configuration(
main={"size": main_resolution}, main={"size": main_resolution},
lores={"size": (320, 240), "format": "YUV420"}, lores={"size": (320, 240), "format": "YUV420"},
sensor=self.thing_settings.get("sensor_mode", None), sensor=self._sensor_mode,
controls=self.persistent_controls, controls=controls,
) )
stream_config["buffer_count"] = buffer_count stream_config["buffer_count"] = buffer_count
picam.configure(stream_config) picam.configure(stream_config)
@ -453,7 +411,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,
) )
@ -461,7 +419,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",
) )
@ -474,12 +432,12 @@ 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
""" """
with self.picamera() as picam: with self._streaming_picamera() as picam:
try: try:
picam.stop_recording() # This should also stop the extra lores encoder picam.stop_recording() # This should also stop the extra lores encoder
except Exception as e: except Exception as e:
@ -495,7 +453,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",
@ -510,10 +468,10 @@ class StreamingPiCamera2(BaseCamera):
A TimeoutError is raised if this time is exceeded during capture. A TimeoutError is raised if this time is exceeded during capture.
Default = 0.9s, lower than the 1s timeout default in picamera yaml settings Default = 0.9s, lower than the 1s timeout default in picamera yaml settings
""" """
with self.picamera() as cam: with self._streaming_picamera() as cam:
return cam.capture_image(stream_name, wait=wait) 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",
@ -534,13 +492,13 @@ class StreamingPiCamera2(BaseCamera):
# This was slower than capture_image for our use case, but directly returning # This was slower than capture_image for our use case, but directly returning
# an image as an array is still a useful feature # an image as an array is still a useful feature
if stream_name == "full": if stream_name == "full":
with self.picamera(pause_stream=True) as picam2: with self._streaming_picamera(pause_stream=True) as picam2:
capture_config = picam2.create_still_configuration() capture_config = picam2.create_still_configuration()
return picam2.switch_mode_and_capture_array(capture_config, wait=wait) return picam2.switch_mode_and_capture_array(capture_config, wait=wait)
with self.picamera() as cam: with self._streaming_picamera() as cam:
return cam.capture_array(stream_name, wait=wait) 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
@ -552,13 +510,13 @@ class StreamingPiCamera2(BaseCamera):
this property refers to whatever configuration is currently in force - this property refers to whatever configuration is currently in force -
usually the one used for the preview stream. usually the one used for the preview stream.
""" """
with self.picamera() as cam: with self._streaming_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:
@ -582,20 +540,20 @@ 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
# to reconfigure for these # to reconfigure for these
if resolution in ("lores", "main") and config[resolution]: if resolution in ("lores", "main") and config[resolution]:
with self.picamera() as cam: with self._streaming_picamera() as cam:
cam.capture_file(path, name=resolution, format="jpeg", wait=wait) cam.capture_file(path, name=resolution, format="jpeg", wait=wait)
else: else:
if resolution != "full": if resolution != "full":
logging.warning( logging.warning(
f"There was no {resolution} stream, capturing full resolution" f"There was no {resolution} stream, capturing full resolution"
) )
with self.picamera(pause_stream=True) as cam: with self._streaming_picamera(pause_stream=True) as cam:
logging.info("Reconfiguring camera for full resolution capture") logging.info("Reconfiguring camera for full resolution capture")
cam.configure(cam.create_still_configuration()) cam.configure(cam.create_still_configuration())
cam.start() cam.start()
@ -611,13 +569,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._streaming_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,
@ -635,15 +593,14 @@ class StreamingPiCamera2(BaseCamera):
calculating the brightest pixel, a percentile is used rather than the calculating the brightest pixel, a percentile is used rather than the
maximum in order to be robust to a small number of noisy/bright pixels. maximum in order to be robust to a small number of noisy/bright pixels.
""" """
with self.picamera(pause_stream=True) as cam: with self._streaming_picamera(pause_stream=True) as cam:
recalibrate_utils.adjust_shutter_and_gain_from_raw( recalibrate_utils.adjust_shutter_and_gain_from_raw(
cam, cam,
target_white_level=target_white_level, target_white_level=target_white_level,
percentile=percentile, percentile=percentile,
) )
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",
@ -660,7 +617,7 @@ class StreamingPiCamera2(BaseCamera):
If `method` is `"centre"`, we will correct the mean of the central 10% If `method` is `"centre"`, we will correct the mean of the central 10%
of the image. of the image.
""" """
with self.picamera(pause_stream=True) as cam: with self._streaming_picamera(pause_stream=True) as cam:
if self.lens_shading_is_static: if self.lens_shading_is_static:
lst: LensShading = self.lens_shading_tables lst: LensShading = self.lens_shading_tables
recalibrate_utils.adjust_white_balance_from_raw( recalibrate_utils.adjust_white_balance_from_raw(
@ -676,9 +633,8 @@ class StreamingPiCamera2(BaseCamera):
recalibrate_utils.adjust_white_balance_from_raw( recalibrate_utils.adjust_white_balance_from_raw(
cam, percentile=99, method=method cam, percentile=99, method=method
) )
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.
@ -687,31 +643,40 @@ class StreamingPiCamera2(BaseCamera):
one. This uses the camera's "tuning" file to correct the preview and one. This uses the camera's "tuning" file to correct the preview and
the processed images. It should not affect raw images. the processed images. It should not affect raw images.
""" """
with self.picamera(pause_stream=True) as cam: with self._streaming_picamera(pause_stream=True) as cam:
# Suppress lint warning that L, Cr, and Cb are not lowercase, as these are # Suppress lint warning that L, Cr, and Cb are not lowercase, as these are
# the standard mathematical terms for: # the standard mathematical terms for:
# luminance (L), red-difference chroma (Cr), and blue-difference chroma # luminance (L), red-difference chroma (Cr), and blue-difference chroma
# (Cb). # (Cb).
L, Cr, Cb = recalibrate_utils.lst_from_camera(cam) # noqa: N806 L, Cr, Cb = recalibrate_utils.lst_from_camera(cam) # noqa: N806
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]:
"""An alias for `colour_correction_matrix` to fit the micromanager API""" """The `colour_correction_matrix` from the tuning file.
return self.thing_settings.get(
"colour_correction_matrix", This is broken out into its own property for convenience and compatibility with
tuple(recalibrate_utils.get_static_ccm(self.tuning)[0]["ccm"]), the micromanager API
)
Ir is a 9 value tuple used to specify the 3x3 matrix that the GPU pipeline uses
to convert from the camera R,G,B vector to the standard R,G,B.
See page Raspberry Pi Camera Algorithm and Tuning Guide, page 45.
"""
return tuple(recalibrate_utils.get_static_ccm(self.tuning)[0]["ccm"])
@colour_correction_matrix.setter # type: ignore @colour_correction_matrix.setter # type: ignore
def colour_correction_matrix(self, value) -> None: def colour_correction_matrix(self, value) -> None:
self.thing_settings["colour_correction_matrix"] = value recalibrate_utils.set_static_ccm(self.tuning, value)
self.calibrate_colour_correction(value)
@thing_action if self._picamera is not None:
with self._streaming_picamera(pause_stream=True):
self._initialise_picamera()
@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.
@ -719,7 +684,7 @@ class StreamingPiCamera2(BaseCamera):
These values are from the Raspberry Pi Camera Algorithm and Tuning Guide, page These values are from the Raspberry Pi Camera Algorithm and Tuning Guide, page
45. 45.
""" """
# This is flattened 3x3 matrix. See `calibrate_colour_correction` # This is flattened 3x3 matrix. See `colour_correction_matrix`
col_corr_matrix = [ col_corr_matrix = [
1.80439, 1.80439,
-0.73699, -0.73699,
@ -733,26 +698,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(
self,
col_corr_matrix: tuple[
float, float, float, float, float, float, float, float, float
],
) -> None:
"""Overwrite the colour correction matrix in camera tuning
col_corr_matrix: This is a 9 value tuple used to specify the 3x3
matrix that the GPU pipeline uses to convert from the camera R,G,B vector
to the standard R,G,B.
See page Raspberry Pi Camera Algorithm and Tuning Guide, page 45.
"""
with self.picamera(pause_stream=True):
recalibrate_utils.set_static_ccm(self.tuning, col_corr_matrix)
self.initialise_picamera()
@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.
@ -763,11 +709,11 @@ class StreamingPiCamera2(BaseCamera):
A value of 0 here does nothing, a value of 65535 is maximum correction. A value of 0 here does nothing, a value of 65535 is maximum correction.
""" """
with self.picamera(pause_stream=True): with self._streaming_picamera(pause_stream=True):
recalibrate_utils.set_static_geq(self.tuning, offset) 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
@ -785,7 +731,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
@ -796,15 +742,15 @@ class StreamingPiCamera2(BaseCamera):
This flat table is used to take an image wth no lens shading so that the This flat table is used to take an image wth no lens shading so that the
correct lens shading table can be calibrated. correct lens shading table can be calibrated.
""" """
with self.picamera(pause_stream=True): with self._streaming_picamera(pause_stream=True):
# Generate and array of ones of the correct size for each channel # Generate and array of ones of the correct size for each channel
flat_array = np.ones((12, 16)) flat_array = np.ones((12, 16))
recalibrate_utils.set_static_lst( recalibrate_utils.set_static_lst(
self.tuning, flat_array, flat_array, flat_array self.tuning, flat_array, flat_array, flat_array
) )
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)
@ -841,14 +787,14 @@ class StreamingPiCamera2(BaseCamera):
@lens_shading_tables.setter @lens_shading_tables.setter
def lens_shading_tables(self, lst: LensShading) -> None: def lens_shading_tables(self, lst: LensShading) -> None:
"""Set the lens shading tables""" """Set the lens shading tables"""
with self.picamera(pause_stream=True): with self._streaming_picamera(pause_stream=True):
recalibrate_utils.set_static_lst( recalibrate_utils.set_static_lst(
self.tuning, self.tuning,
luminance=lst.luminance, luminance=lst.luminance,
cr=lst.Cr, cr=lst.Cr,
cb=lst.Cb, cb=lst.Cb,
) )
self.initialise_picamera() self._initialise_picamera()
def correct_colour_gains_for_lens_shading( def correct_colour_gains_for_lens_shading(
self, colour_gains: tuple[float, float] self, colour_gains: tuple[float, float]
@ -881,7 +827,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
@ -889,25 +835,25 @@ class StreamingPiCamera2(BaseCamera):
flat, i.e. we'll correct vignetting of intensity, but not any change in flat, i.e. we'll correct vignetting of intensity, but not any change in
colour across the image. colour across the image.
""" """
with self.picamera(pause_stream=True): with self._streaming_picamera(pause_stream=True):
alsc = Picamera2.find_tuning_algo(self.tuning, "rpi.alsc") alsc = Picamera2.find_tuning_algo(self.tuning, "rpi.alsc")
luminance = alsc["luminance_lut"] luminance = alsc["luminance_lut"]
flat = np.ones((12, 16)) flat = np.ones((12, 16))
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
This method will restore the default "adaptive" lens shading method used This method will restore the default "adaptive" lens shading method used
by the Raspberry Pi camera. by the Raspberry Pi camera.
""" """
with self.picamera(pause_stream=True): with self._streaming_picamera(pause_stream=True):
recalibrate_utils.copy_alsc_section(self.default_tuning, self.tuning) 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,
@ -115,9 +112,11 @@ class SimulatedCamera(BaseCamera):
) )
return image return image
def attach_to_server(self, server: ThingServer, path: str): def attach_to_server(
self, server: lt.ThingServer, path: str, setting_storage_path: str
):
self._server = server self._server = server
return super().attach_to_server(server, path) return super().attach_to_server(server, path, setting_storage_path)
def get_stage_position(self): def get_stage_position(self):
if not self._stage and self._server: if not self._stage and self._server:
@ -144,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:
@ -152,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:
@ -166,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",
@ -180,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,13 +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, denumpify, DenumpifyingDict
from labthings_fastapi.decorators import thing_action, thing_property
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
@ -178,21 +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"""
def __enter__(self): @lt.thing_action
pass
def __exit__(self, exc_type, exc_value, traceback):
self.thing_settings.write_to_file()
@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"""
@ -207,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)
@ -220,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
@ -244,8 +235,6 @@ class CameraStageMapper(Thing):
corrected_resolution = tuple( corrected_resolution = tuple(
r * hw.grab_image_downsampling for r in cal_x["image_resolution"] r * hw.grab_image_downsampling for r in cal_x["image_resolution"]
) )
self.thing_settings.update(denumpify(cal_xy))
self.thing_settings["image_resolution"] = corrected_resolution
csm_matrix = cal_xy["image_to_stage_displacement"] csm_matrix = cal_xy["image_to_stage_displacement"]
csm_as_string = f"[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]" csm_as_string = f"[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]"
@ -260,11 +249,11 @@ class CameraStageMapper(Thing):
"downsampling": hw.grab_image_downsampling, "downsampling": hw.grab_image_downsampling,
} }
self.thing_settings["last_calibration"] = DenumpifyingDict(data).model_dump() self.last_calibration = DenumpifyingDict(data).model_dump()
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,15 +274,26 @@ class CameraStageMapper(Thing):
) )
``` ```
""" """
displacement_matrix = self.thing_settings.get("image_to_stage_displacement") if self.last_calibration is None:
if not displacement_matrix:
return None return None
displacement_matrix = self.last_calibration["camera_stage_mapping_calibration"][
"image_to_stage_displacement"
]
return np.array(displacement_matrix).tolist() return np.array(displacement_matrix).tolist()
@thing_property last_calibration = lt.ThingSetting(
initial_value=None,
model=Optional[dict],
readonly=True,
description="The most recent CSM calibration",
)
@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"""
return self.thing_settings.get("image_resolution", None) if self.last_calibration is None:
return None
return self.last_calibration["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"""
@ -302,12 +302,7 @@ class CameraStageMapper(Thing):
# added by CSMUncalibratedError # added by CSMUncalibratedError
raise CSMUncalibratedError() # noqa: RSE102 raise CSMUncalibratedError() # noqa: RSE102
@thing_property @lt.thing_action
def last_calibration(self) -> Optional[Dict]:
"""The results of the last calibration that was run"""
return self.thing_settings.get("last_calibration", None)
@thing_action
def move_in_image_coordinates( def move_in_image_coordinates(
self, self,
stage: Stage, stage: Stage,
@ -331,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
from labthings_fastapi.dependencies.thing_server import find_thing_server
from labthings_fastapi.server import ThingServer
from labthings_fastapi.dependencies.invocation import InvocationLogger
def thing_server_from_request(request: Request) -> ThingServer: 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,13 +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):
@thing_property external_metadata = lt.ThingSetting(
def external_metadata(self) -> Mapping: initial_value={},
"""External metadata stored in the server's settings""" model=Mapping,
return self.thing_settings.get("external_metadata", {}) description="External metadata stored in the server's settings",
)
@thing_action external_metadata_in_state = lt.ThingSetting(
initial_value=[],
model=Sequence[str],
description='A list of strings that are included in the "state" metadata',
)
@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:
@ -75,9 +77,9 @@ class SettingsManager(Thing):
if key: if key:
subdict = nested_dict_get(subdict, key.split("/"), create=True) subdict = nested_dict_get(subdict, key.split("/"), create=True)
recursive_update(subdict, data) recursive_update(subdict, data)
self.thing_settings["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.
@ -93,35 +95,32 @@ class SettingsManager(Thing):
raise HTTPException( raise HTTPException(
status_code=404, detail="The specified key '{key}' was not found" status_code=404, detail="The specified key '{key}' was not found"
) )
self.thing_settings["external_metadata"] = metadata self.external_metadata = metadata
@thing_property _microscope_id: Optional[str] = None
@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 "microscope_id" not in self.thing_settings: if self._microscope_id is None:
self.thing_settings["microscope_id"] = str(uuid4()) self._microscope_id = str(uuid4())
return UUID(self.thing_settings["microscope_id"]) return UUID(self._microscope_id)
@thing_property @microscope_id.setter
def microscope_id(self, uuid: UUID):
# TODO make read only but still settable from disk
self._microscope_id = uuid
@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()
@thing_property
def external_metadata_in_state(self) -> Sequence[str]:
"""A list of strings that are included in the "state" metadata"""
return self.thing_settings.get("external_metadata_in_state", [])
@external_metadata_in_state.setter
def external_metadata_in_state(self, keys: Sequence[str]):
"""Set the keys from external metadata that are returned in state"""
self.thing_settings["external_metadata_in_state"] = keys
@property @property
def thing_state(self) -> Mapping: def thing_state(self) -> Mapping:
state = { state = {
@ -138,29 +137,3 @@ class SettingsManager(Thing):
except KeyError: except KeyError:
continue continue
return state return state
@thing_action
def save_all_thing_settings(
self, thing_server: ThingServerDep, logger: InvocationLogger
) -> None:
"""Ensure all the Things sync their settings to disk.
Normally, each Thing has a `thing_settings` attribute that can be
used to save settings. That dictionary is saved to a JSON file
when the server shuts down cleanly.
This action causes all the `thing_settings` dictionaries to be
saved to files immediately, meaning that settings will not be
lost in the event of a server crash, power outage, etc.
"""
for name, thing in thing_server.things.items():
try:
if thing._labthings_thing_settings:
thing._labthings_thing_settings.write_to_file()
logger.info(f"Wrote {name} settings to disk.")
except PermissionError:
logger.warning(
f"Could not write {name} settings to disk: permission error."
)
except FileNotFoundError:
logger.warning(f"Could not write {name} settings, folder not found")

View file

@ -11,16 +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.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
@ -33,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"
@ -71,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
@ -82,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
@ -102,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 = "",
@ -205,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
@ -414,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()
@ -567,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={
@ -590,74 +583,56 @@ class SmartScanThing(Thing):
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
return FileResponse(preview_path) return FileResponse(preview_path)
@thing_property save_resolution = lt.ThingSetting(
def save_resolution(self) -> tuple[int, int]: initial_value=(1640, 1232),
"""A tuple of the image resolution to capture. Should be in a model=tuple[int, int],
4:3 aspect ratio""" description=("A tuple of the image resolution to capture."),
return self.thing_settings.get("save_resolution", ((1640, 1232))) )
@save_resolution.setter max_range = lt.ThingSetting(
def save_resolution(self, value: tuple[int, int]) -> None: initial_value=45000,
self.thing_settings["save_resolution"] = value model=int,
description=(
"The maximum distance from the centre of the scan before we break in steps"
),
)
@thing_property stitch_tiff = lt.ThingSetting(
def max_range(self) -> int: initial_value=False,
"""The maximum distance from the centre of the scan before we break in steps""" model=bool,
return self.thing_settings.get("max_range", 45000) description="Whether or not to also produce a pyramidal tiff",
)
@max_range.setter skip_background = lt.ThingSetting(
def max_range(self, value: int) -> None: initial_value=True,
self.thing_settings["max_range"] = value model=bool,
description="""Whether to detect and skip empty fields of view
@thing_property This uses the settings from the `background_detect` Thing.""",
def stitch_tiff(self) -> bool: )
"""Whether or not to also produce a pyramidal tiff"""
return self.thing_settings.get("stitch_tiff", False)
@stitch_tiff.setter autofocus_dz = lt.ThingSetting(
def stitch_tiff(self, value: bool) -> None: initial_value=1000,
self.thing_settings["stitch_tiff"] = value model=int,
description="The z distance to perform an autofocus in steps",
)
@thing_property overlap = lt.ThingSetting(
def skip_background(self) -> bool: initial_value=0.45,
"""Whether to detect and skip empty fields of view model=float,
description="The fraction (0-1) that adjacent images should overlap in x or y",
)
This uses the settings from the `background_detect` Thing. stitch_automatically = lt.ThingSetting(
""" initial_value=True,
return self.thing_settings.get("skip_background", True) model=bool,
description=(
"Whether to run a final stitch at the end of the scan (assuming scan "
"success)"
),
)
@skip_background.setter @lt.thing_property
def skip_background(self, value: bool) -> None:
self.thing_settings["skip_background"] = value
@thing_property
def autofocus_dz(self) -> int:
"""The z distance to perform an autofocus in steps"""
return self.thing_settings.get("autofocus_dz", 1000)
@autofocus_dz.setter
def autofocus_dz(self, value: int) -> None:
self.thing_settings["autofocus_dz"] = value
@thing_property
def overlap(self) -> float:
"""The fraction (0-1) that adjacent images should overlap in x or y"""
return self.thing_settings.get("overlap", 0.45)
@overlap.setter
def overlap(self, value: float) -> None:
self.thing_settings["overlap"] = value
@thing_property
def stitch_automatically(self) -> bool:
"""Whether to run a final stitch at the end of the scan (assuming scan success)"""
return self.thing_settings.get("stitch_automatically", True)
@stitch_automatically.setter
def stitch_automatically(self, value: bool) -> None:
self.thing_settings["stitch_automatically"] = value
@thing_property
def scans(self) -> list[scan_directories.ScanInfo]: def scans(self) -> list[scan_directories.ScanInfo]:
"""All the available scans """All the available scans
@ -669,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={
@ -693,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={
@ -701,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.
@ -715,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
@ -729,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
""" """
@ -740,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
""" """
@ -765,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
@ -780,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={
@ -849,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:
""" """
@ -886,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
@ -899,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(
@ -982,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 PropertyDescriptor 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 = PropertyDescriptor( 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 = PropertyDescriptor( 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()

View file

@ -256,7 +256,9 @@ def test_outer_scan_wo_sample_skip():
"""Test setup and teardown of the scan.""" """Test setup and teardown of the scan."""
def _set_skip_background(mock_ss_thing): def _set_skip_background(mock_ss_thing):
mock_ss_thing.thing_settings["skip_background"] = False # As the Thing is not connected to a server we set the setting via the internal
# __dict__ to avoid triggering property emits that require a server
mock_ss_thing.__dict__["skip_background"] = False
mock_ss_thing, exec_info = _run_only_outer_scan(_set_skip_background) mock_ss_thing, exec_info = _run_only_outer_scan(_set_skip_background)

View file

@ -58,11 +58,6 @@
</tabIcon> </tabIcon>
</li> </li>
</ul> </ul>
<action-button
thing="settings"
action="save_all_thing_settings"
submit-label="Save All Settings"
/>
</div> </div>
<div class="view-component uk-width-expand uk-padding-small"> <div class="view-component uk-width-expand uk-padding-small">
<tabContent <tabContent
@ -118,7 +113,6 @@ import stageSettings from "./settingsComponents/stageSettings.vue";
// Import generic components // Import generic components
import tabIcon from "../genericComponents/tabIcon"; import tabIcon from "../genericComponents/tabIcon";
import tabContent from "../genericComponents/tabContent"; import tabContent from "../genericComponents/tabContent";
import ActionButton from "../labThingsComponents/actionButton.vue";
// Export main app // Export main app
export default { export default {
@ -131,8 +125,7 @@ export default {
CSMSettings, CSMSettings,
appSettings, appSettings,
tabIcon, tabIcon,
tabContent, tabContent
ActionButton
}, },
data: function() { data: function() {