From 8ce06991f96f662f0c11c418f96463619800f491 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 4 Aug 2025 16:23:37 +0100 Subject: [PATCH 1/4] Small tweaks to simulation camera to get scanning working. --- .../things/camera/__init__.py | 3 +- .../things/camera/picamera.py | 4 +- .../things/camera/simulation.py | 51 +++++++++++++++---- 3 files changed, 45 insertions(+), 13 deletions(-) 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.""" From 480af1d938f3a8aa9b367b41e673384c7417c39c Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 5 Aug 2025 23:21:42 +0100 Subject: [PATCH 2/4] Update picamera_coverage.zip as camera source changed --- picamera_coverage.zip | Bin 53913 -> 53913 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/picamera_coverage.zip b/picamera_coverage.zip index 6bc5e66ff959bb29391378b530f16c5edd1bb16b..a77b80a2ed60a3ba20fb2ab3ac22d18322471711 100644 GIT binary patch delta 354 zcmbQalzHY-W}yIYW)=|!5Xj!e8vS#%_P>on-2Il@6T}K8KRkbcr+|&|fAOw$>lwt~ zzP$KPj`yG4-}hUtC-CM(T=*Ureqk-+gYEYi83GRc|M!~v9G`vegXFJ)k`fq{X6gMo?R)uqXM`t8+N3|JT#Bp4Z_nHdt`gXV(_(>XUQPLOoq0J%#eY1W^~P8ZTNOi~TglFSp0%##z14NZ(Kk}Oh8 z(u@p^P11}~QZ0-Pl9LnDj8lwECttYWqMmA$XlapTU}0`-ZkA$dY?x+jkeXyE_ng~*AIH1 delta 354 zcmbQalzHY-W}yIYW)=|!5UAPwKk91eyU>k7-2Il(2HZPNen?MXvuJMkZy9?%yrKKs zmyG&;w)*-1%5P~tYb!_3qy%^=Yr&Dhj3IW5`T(8$c(z|zRT zJjK$?JTcA0GA-HI(8Mw&aq@)=F6wD!=H@1bX{o7+$tkH8DduLT7KW*YX(`6ZDJf|t z#>plY2BsE9Mw2gGu$f$bG1@NC#4Ob!$ Date: Tue, 5 Aug 2025 23:52:20 +0100 Subject: [PATCH 3/4] Update integration test as iamge size for simulation camera changed. --- integration-tests/testfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/testfile.py b/integration-tests/testfile.py index a61fdb64..f9e43361 100755 --- a/integration-tests/testfile.py +++ b/integration-tests/testfile.py @@ -74,7 +74,7 @@ def test_client_connection() -> None: cam_client = lt.ThingClient.from_url("http://localhost:5000/camera/") img = Image.open(cam_client.grab_jpeg().open()) print(f"Successfully grabbed image of size {img.size}") - assert img.size == (800, 600) + assert img.size == (820, 616) print("Successfully grabbed image from camera mjpeg stream") From b672c3c2a7325b4182c04c894cd1dd6488d9b62b Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 6 Aug 2025 11:48:06 +0000 Subject: [PATCH 4/4] Clarify docstring for Simulation start_streaming --- .../things/camera/simulation.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index c6f30007..a453fb3a 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -224,8 +224,13 @@ class SimulatedCamera(BaseCamera): ) -> None: """Start the live stream. - This should be used to adjust the resolution the simulation doesn't yet do - this. + The start_streaming method is used a camera ``Thing`` to begin streaming + images or to adjust the stream resolution if streaming is already active. + + The simulation camera does not currently support the resolution argument. + It will always issue a warning that the resolution is not respected. + If called while already streaming, the warning will be emitted and no other + action will be taken. """ logging.warning( f"Simulation camera doesn't respect {main_resolution=} or {buffer_count=} "