And adjust for new as lt import

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

View file

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

View file

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

View file

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

View file

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