diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 656f0a0f..4e1fb7a2 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -295,12 +295,11 @@ class BaseCamera(lt.Thing): ) return portal.call(stream.next_frame_size) - @lt.thing_action def capture_image( self, stream_name: Literal["main", "lores", "raw"], wait: Optional[float], - ) -> None: + ) -> Image: """Capture a PIL image from stream stream_name with timeout wait.""" raise NotImplementedError( "CameraThings must define their own capture_image method" diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 1f67741f..1e2a98b0 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -28,6 +28,7 @@ from threading import RLock from pydantic import BaseModel, BeforeValidator import piexif import numpy as np +from PIL import Image from picamera2 import Picamera2 from picamera2.encoders import MJPEGEncoder from picamera2.outputs import Output @@ -484,12 +485,11 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera() as cam: cam.capture_metadata() - @lt.thing_action def capture_image( self, stream_name: Literal["main", "lores", "raw"] = "main", wait: Optional[float] = 0.9, - ) -> None: + ) -> Image: """Acquire one image from the camera. Return it as a PIL Image diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 5903891b..c6f30007 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -16,6 +16,7 @@ import time import cv2 import numpy as np +from PIL import Image import piexif from scipy.ndimage import gaussian_filter @@ -51,7 +52,7 @@ class SimulatedCamera(BaseCamera): def __init__( self, - shape: tuple[int, int, int] = (600, 800, 3), + shape: tuple[int, int, int] = (616, 820, 3), glyph_shape: tuple[int, int, int] = (91, 91, 3), canvas_shape: tuple[int, int, int] = (3000, 4000, 3), frame_interval: float = 0.1, @@ -208,9 +209,7 @@ class SimulatedCamera(BaseCamera): def __enter__(self): """Start the capture thread when the Thing context manager is opened.""" - self._capture_enabled = True - self._capture_thread = Thread(target=self._capture_frames) - self._capture_thread.start() + self.start_streaming() return self def __exit__(self, _exc_type, _exc_value, _traceback): @@ -219,6 +218,24 @@ class SimulatedCamera(BaseCamera): self._capture_enabled = False self._capture_thread.join() + @lt.thing_action + def start_streaming( + self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 1 + ) -> None: + """Start the live stream. + + This should be used to adjust the resolution the simulation doesn't yet do + this. + """ + logging.warning( + f"Simulation camera doesn't respect {main_resolution=} or {buffer_count=} " + "arguments." + ) + if not self.stream_active: + self._capture_enabled = True + self._capture_thread = Thread(target=self._capture_frames) + self._capture_thread.start() + @lt.thing_property def stream_active(self) -> bool: """Whether the MJPEG stream is active.""" @@ -236,9 +253,11 @@ class SimulatedCamera(BaseCamera): frame = self.generate_frame() jpeg = cv2.imencode(".jpg", frame)[1].tobytes() self.mjpeg_stream.add_frame(jpeg, portal) - jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[ - 1 - ].tobytes() + # Downsample for lores + ds_frame = cv2.resize( + frame, (320, 240), interpolation=cv2.INTER_NEAREST + ) + jpeg_lores = cv2.imencode(".jpg", ds_frame)[1].tobytes() self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) except Exception as e: logging.error(f"Failed to capture frame: {e}, retrying...") @@ -261,7 +280,7 @@ class SimulatedCamera(BaseCamera): It's likely to be highly inefficient - raw and/or uncompressed captures using binary image formats will be added in due course. """ - logging.warning(f"Simulation camera doesn't respect {resolution} setting") + logging.warning(f"Simulation camera doesn't respect {resolution=} setting") return self.generate_frame() @lt.thing_action @@ -274,7 +293,7 @@ class SimulatedCamera(BaseCamera): This function will produce a JPEG image. """ - logging.warning(f"Simulation camera doesn't respect {resolution} setting") + logging.warning(f"Simulation camera doesn't respect {resolution=} setting") frame = self.capture_array() jpeg = cv2.imencode(".jpg", frame)[1].tobytes() exif_dict = { @@ -292,6 +311,20 @@ class SimulatedCamera(BaseCamera): piexif.insert(piexif.dump(exif_dict), jpeg, output) return JPEGBlob.from_bytes(output.getvalue()) + def capture_image( + self, + stream_name: Literal["main", "lores", "raw"], + wait: Optional[float] = None, + ) -> Image: + """Capture to a PIL image. This is not exposed as a ThingAction. + + It is used for capture to memory. + """ + logging.warning( + f"Simulation camera doesn't respect {stream_name=} or {wait=} arguments." + ) + return Image.fromarray(self.generate_frame()) + @lt.thing_action def remove_sample(self): """Show the simulated background with no sample."""