diff --git a/src/openflexure_microscope_server/things/auto_recentre_stage.py b/src/openflexure_microscope_server/things/auto_recentre_stage.py index f06a148b..a36feae2 100644 --- a/src/openflexure_microscope_server/things/auto_recentre_stage.py +++ b/src/openflexure_microscope_server/things/auto_recentre_stage.py @@ -1,17 +1,16 @@ import numpy as np import logging -import time from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.decorators import thing_action -from labthings_sangaboard import SangaboardThing -from labthings_picamera2.thing import StreamingPiCamera2 +from .stage import Stage +from .camera import Camera from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper -StageDep = direct_thing_client_dependency(SangaboardThing, "/stage/") -CamDep = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") +StageDep = direct_thing_client_dependency(Stage, "/stage/") +CamDep = direct_thing_client_dependency(Camera, "/camera/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index ab1325dd..c010a3f6 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -19,14 +19,14 @@ from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.blocking_portal import BlockingPortal from labthings_fastapi.decorators import thing_action from labthings_fastapi.types.numpy import NDArray -from labthings_picamera2.thing import StreamingPiCamera2 -from labthings_sangaboard import SangaboardThing +from .camera import Camera as CameraThing +from .stage import Stage as StageThing import numpy as np from pydantic import BaseModel -Stage = direct_thing_client_dependency(SangaboardThing, "/stage/") -Camera = raw_thing_dependency(StreamingPiCamera2) -WrappedCamera = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") +Stage = direct_thing_client_dependency(StageThing, "/stage/") +Camera = raw_thing_dependency(CameraThing) +WrappedCamera = direct_thing_client_dependency(CameraThing, "/camera/") ### Autofocus utilities diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py new file mode 100644 index 00000000..14e386fa --- /dev/null +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -0,0 +1,106 @@ +"""OpenFlexure Microscope Camera + +This module defines the interface for cameras. Any compatible Thing +should enabe the server to work. + +See repository root for licensing information. +""" +from __future__ import annotations +import logging +from typing import Literal + +from labthings_fastapi.thing import Thing +from labthings_fastapi.decorators import thing_action, thing_property +from labthings_fastapi.dependencies.metadata import GetThingStates +from labthings_fastapi.dependencies.blocking_portal import BlockingPortal +from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor +from labthings_fastapi.outputs.blob import BlobOutput +from labthings_fastapi.types.numpy import NDArray + + +class JPEGBlob(BlobOutput): + media_type = "image/jpeg" + + +class Camera(Thing): + """A Thing representing a camera""" + + def __enter__(self): + raise NotImplementedError("Subclasses must implement __enter__") + + def __exit__(self, _exc_type, _exc_value, _traceback): + raise NotImplementedError("Subclasses must implement __exit__") + + @thing_property + def stream_active(self) -> bool: + "Whether the MJPEG stream is active" + raise NotImplementedError("Subclasses must implement stream_active") + mjpeg_stream = MJPEGStreamDescriptor() + lores_mjpeg_stream = MJPEGStreamDescriptor() + + @thing_action + def snap_image(self) -> NDArray: + """Acquire one image from the camera. + + This action cannot run if the camera is in use by a background thread, for + example if a preview stream is running. + """ + return self.capture_array() + + @thing_action + def capture_array( + self, + resolution: Literal["lores", "main", "full"] = "main", + ) -> NDArray: + """Acquire one image from the camera and return as an array + + This function will produce a nested list containing an uncompressed RGB image. + It's likely to be highly inefficient - raw and/or uncompressed captures using + binary image formats will be added in due course. + """ + raise NotImplementedError("Subclasses must implement capture_array") + + @thing_action + def capture_jpeg( + self, + metadata_getter: GetThingStates, + resolution: Literal["lores", "main", "full"] = "main", + ) -> JPEGBlob: + """Acquire one image from the camera and return as a JPEG blob + + This function will produce a JPEG image. + """ + raise NotImplementedError("Subclasses must implement capture_jpeg") + + @thing_action + def grab_jpeg( + self, + portal: BlockingPortal, + stream_name: Literal["main", "lores"] = "main", + ) -> JPEGBlob: + """Acquire one image from the preview stream and return as an array + + This differs from `capture_jpeg` in that it does not pause the MJPEG + preview stream. Instead, we simply return the next frame from that + stream (either "main" for the preview stream, or "lores" for the low + resolution preview). No metadata is returned. + """ + logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting") + stream = ( + self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream + ) + frame = portal.call(stream.grab_frame) + logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame") + return JPEGBlob.from_bytes(frame) + + @thing_action + def grab_jpeg_size( + self, + portal: BlockingPortal, + stream_name: Literal["main", "lores"] = "main", + ) -> int: + """Acquire one image from the preview stream and return its size""" + stream = ( + self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream + ) + return portal.call(stream.next_frame_size) diff --git a/src/openflexure_microscope_server/things/opencv_camera.py b/src/openflexure_microscope_server/things/camera/opencv.py similarity index 66% rename from src/openflexure_microscope_server/things/opencv_camera.py rename to src/openflexure_microscope_server/things/camera/opencv.py index 13f3c3ee..4732bfda 100644 --- a/src/openflexure_microscope_server/things/opencv_camera.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -15,21 +15,21 @@ from threading import Thread import cv2 import piexif -from labthings_fastapi.thing import Thing from labthings_fastapi.utilities import get_blocking_portal from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.dependencies.metadata import GetThingStates -from labthings_fastapi.dependencies.blocking_portal import BlockingPortal from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor from labthings_fastapi.outputs.blob import BlobOutput from labthings_fastapi.types.numpy import NDArray +from . import Camera + class JPEGBlob(BlobOutput): media_type = "image/jpeg" -class OpenCVCamera(Thing): +class OpenCVCamera(Camera): """A Thing representing an OpenCV camera""" def __init__(self, camera_index: int=0): self.camera_index = camera_index @@ -68,16 +68,10 @@ class OpenCVCamera(Thing): self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) @thing_action - def snap_image(self) -> NDArray: - """Acquire one image from the camera. - - This action cannot run if the camera is in use by a background thread, for - example if a preview stream is running. - """ - return self.capture_array() - - @thing_action - def capture_array(self) -> NDArray: + def capture_array( + self, + resolution: Literal["main", "full"] = "full", + ) -> NDArray: """Acquire one image from the camera and return as an array This function will produce a nested list containing an uncompressed RGB image. @@ -93,6 +87,7 @@ class OpenCVCamera(Thing): def capture_jpeg( self, metadata_getter: GetThingStates, + resolution: Literal["main", "full"] = "main", ) -> JPEGBlob: """Acquire one image from the camera and return as a JPEG blob @@ -112,36 +107,3 @@ class OpenCVCamera(Thing): output = io.BytesIO() piexif.insert(piexif.dump(exif_dict), jpeg, output) return JPEGBlob.from_bytes(output.getvalue()) - - @thing_action - def grab_jpeg( - self, - portal: BlockingPortal, - stream_name: Literal["main", "lores"] = "main", - ) -> JPEGBlob: - """Acquire one image from the preview stream and return as an array - - This differs from `capture_jpeg` in that it does not pause the MJPEG - preview stream. Instead, we simply return the next frame from that - stream (either "main" for the preview stream, or "lores" for the low - resolution preview). No metadata is returned. - """ - logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting") - stream = ( - self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream - ) - frame = portal.call(stream.grab_frame) - logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame") - return JPEGBlob.from_bytes(frame) - - @thing_action - def grab_jpeg_size( - self, - portal: BlockingPortal, - stream_name: Literal["main", "lores"] = "main", - ) -> int: - """Acquire one image from the preview stream and return its size""" - stream = ( - self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream - ) - return portal.call(stream.next_frame_size) diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index abe1d7e1..395c9e6d 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -22,8 +22,8 @@ from camera_stage_mapping.camera_stage_calibration_1d import ( image_to_stage_displacement_from_1d, ) from camera_stage_mapping.camera_stage_tracker import Tracker -from labthings_picamera2.thing import StreamingPiCamera2 -from labthings_sangaboard import SangaboardThing +from .camera import Camera as CameraThing +from .stage import Stage as StageThing from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.invocation import InvocationCancelledError, InvocationLogger @@ -31,8 +31,8 @@ from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.thing import Thing -Camera = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") -Stage = direct_thing_client_dependency(SangaboardThing, "/stage/") +Camera = direct_thing_client_dependency(CameraThing, "/camera/") +Stage = direct_thing_client_dependency(StageThing, "/stage/") CoordinateType = Tuple[float, float, float] XYCoordinateType = Tuple[float, float] @@ -87,7 +87,10 @@ def make_hardware_interface( return downsample(downsample_factor, img) def settle() -> None: time.sleep(0.2) - camera.capture_metadata + try: + camera.capture_metadata # This discards frames on a picamera + except AttributeError: + pass # Don't raise an error for other cameras (may consider grabbing a frame) return HardwareInterfaceModel( move=move, get_position=get_position, grab_image=grab_image, settle=settle, grab_image_downsampling=downsample_factor ) diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 600bab4a..3ffa976b 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -28,14 +28,15 @@ 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 BlobOutput -from labthings_sangaboard import SangaboardThing -from labthings_picamera2.thing import StreamingPiCamera2 +from .camera import Camera +from .stage import Stage from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper from openflexure_microscope_server.things.auto_recentre_stage import RecentringThing -StageDep = direct_thing_client_dependency(SangaboardThing, "/stage/") -CamDep = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") + +CamDep = direct_thing_client_dependency(Camera, "/camera/") +StageDep = direct_thing_client_dependency(Stage, "/stage/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") RecentreStage = direct_thing_client_dependency(RecentringThing, "/auto_recentre_stage/") diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py new file mode 100644 index 00000000..f600f58e --- /dev/null +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -0,0 +1,63 @@ +from __future__ import annotations +from labthings_fastapi.descriptors.property import PropertyDescriptor +from labthings_fastapi.thing import Thing +from labthings_fastapi.decorators import thing_action, thing_property +from labthings_fastapi.dependencies.invocation import CancelHook +from collections.abc import Sequence, Mapping + + +class Stage(Thing): + """A dummy stage for testing purposes + + This stage should work similarly to a Sangaboard stage, but without any + hardware attached. + """ + _axis_names = ("x", "y", "z") + + @thing_property + def axis_names(self) -> Sequence[str]: + """The names of the stage's axes, in order.""" + return self._axis_names + + position = PropertyDescriptor( + Mapping[str, int], + {k: 0 for k in _axis_names}, + description="Current position of the stage", + readonly=True, + observable=True, + ) + + moving = PropertyDescriptor( + bool, + False, + description="Whether the stage is in motion", + readonly=True, + observable=True, + ) + + @property + def thing_state(self): + """Summary metadata describing the current state of the stage""" + return { + "position": self.position + } + + @thing_action + def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): + """Make a relative move. Keyword arguments should be axis names.""" + raise NotImplementedError("Subclasses should implement this method") + + @thing_action + def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): + """Make an absolute move. Keyword arguments should be axis names.""" + raise NotImplementedError("Subclasses should implement this method") + + @thing_action + def set_zero_position(self): + """Make the current position zero in all axes + + This action does not move the stage, but resets the position to zero. + It is intended for use after manually or automatically recentring the + stage. + """ + raise NotImplementedError("Subclasses should implement this method") diff --git a/src/openflexure_microscope_server/things/dummy_stage.py b/src/openflexure_microscope_server/things/stage/dummy.py similarity index 71% rename from src/openflexure_microscope_server/things/dummy_stage.py rename to src/openflexure_microscope_server/things/stage/dummy.py index 6ce16e7d..ca1f7f63 100644 --- a/src/openflexure_microscope_server/things/dummy_stage.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -1,54 +1,24 @@ from __future__ import annotations -from labthings_fastapi.descriptors.property import PropertyDescriptor -from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_property +from labthings_fastapi.decorators import thing_action from labthings_fastapi.dependencies.invocation import CancelHook, InvocationCancelledError -from collections.abc import Sequence, Mapping +from collections.abc import Mapping import time +from . import Stage -class DummyStage(Thing): + +class DummyStage(Stage): """A dummy stage for testing purposes This stage should work similarly to a Sangaboard stage, but without any hardware attached. """ - _axis_names = ("x", "y", "z") - def __enter__(self): pass def __exit__(self, _exc_type, _exc_value, _traceback): pass - @thing_property - def axis_names(self) -> Sequence[str]: - """The names of the stage's axes, in order.""" - return self._axis_names - - position = PropertyDescriptor( - Mapping[str, int], - {k: 0 for k in _axis_names}, - description="Current position of the stage", - readonly=True, - observable=True, - ) - - moving = PropertyDescriptor( - bool, - False, - description="Whether the stage is in motion", - readonly=True, - observable=True, - ) - - @property - def thing_state(self): - """Summary metadata describing the current state of the stage""" - return { - "position": self.position - } - @thing_action def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): """Make a relative move. Keyword arguments should be axis names."""