From 2268425bf39ad24517967777db506df38a156e29 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 9 Aug 2024 02:41:39 +0100 Subject: [PATCH] Depend only on generic Camera and Stage I've now defined parent classes for camera and stage, and depend only on those. This allows proper typing while enabling the actual hardware classes to be swapped. The interfaces definitely aren't final - this is a first draft, largely to enable me to test out the concept of a configuration server. --- .../things/auto_recentre_stage.py | 9 +- .../things/autofocus.py | 10 +- .../things/camera/__init__.py | 106 ++++++++++++++++++ .../{opencv_camera.py => camera/opencv.py} | 54 ++------- .../things/camera_stage_mapping.py | 13 ++- .../things/smart_scan.py | 9 +- .../things/stage/__init__.py | 63 +++++++++++ .../things/{dummy_stage.py => stage/dummy.py} | 40 +------ 8 files changed, 204 insertions(+), 100 deletions(-) create mode 100644 src/openflexure_microscope_server/things/camera/__init__.py rename src/openflexure_microscope_server/things/{opencv_camera.py => camera/opencv.py} (66%) create mode 100644 src/openflexure_microscope_server/things/stage/__init__.py rename src/openflexure_microscope_server/things/{dummy_stage.py => stage/dummy.py} (71%) 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."""