From 5484b51a1e52de2123678b39a0080008524f2ce8 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 22 Aug 2025 10:24:16 +0100 Subject: [PATCH 1/7] Unify JPEG capture, metadata, and saving; reducing duplication --- .../things/camera/__init__.py | 48 +++++++-- .../things/camera/opencv.py | 33 ++---- .../things/camera/picamera.py | 100 ++++++------------ .../things/camera/simulation.py | 33 +----- .../controlComponents/paneControl.vue | 4 +- 5 files changed, 83 insertions(+), 135 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index cca92ff2..6a83737b 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -8,10 +8,14 @@ See repository root for licensing information. from __future__ import annotations from typing import Literal, Optional, Tuple, Any +from types import EllipsisType import json import io import time import logging +from datetime import datetime +import tempfile +import os import numpy as np from pydantic import RootModel @@ -257,14 +261,42 @@ class BaseCamera(lt.Thing): def capture_jpeg( self, metadata_getter: lt.deps.GetThingStates, - resolution: Literal["lores", "main", "full"] = "main", - wait: Optional[float] = 5, + logger: lt.deps.InvocationLogger, + stream_name: str = "main", + wait: Optional[float | EllipsisType] = ..., ) -> JPEGBlob: - """Acquire one image from the camera and return as a JPEG blob.""" - raise NotImplementedError( - "CameraThings must define their own capture_jpeg method" + """Acquire one image from the camera as a JPEG. + + This will use the internal capture image functionally of capture_image if + the specific camera being used. + + :param metadata_getter: LabThings GetThingStates dependency, automatically + injected. + :param logger: LabThings InvocationLogger dependency, automatically injected. + :param stream_name: A stream name supported by this camera. + :param wait: (Optional, float) Set a timeout in seconds. If not set it will + use the default for the underlying camera. + """ + fname = datetime.now().strftime("%Y-%m-%d-%H%M%S.jpeg") + directory = tempfile.TemporaryDirectory() + jpeg_path = os.path.join(directory.name, fname) + + # Using Ellipsis to specify no input specified. As `None` set for wait may + # have a meaning as it does for the Picamera. If wait is Ellipsis then + # do not specify. + if wait is Ellipsis: + img = self.capture_image(stream_name) + else: + img = self.capture_image(stream_name, wait) + self._save_capture( + jpeg_path=jpeg_path, + image=img, + metadata=metadata_getter(), + logger=logger, ) + return JPEGBlob.from_temporary_directory(directory, fname) + @lt.thing_action def grab_jpeg( self, @@ -331,7 +363,7 @@ class BaseCamera(lt.Thing): def capture_image( self, stream_name: Literal["main", "lores", "raw"], - wait: Optional[float], + wait: Optional[float] = None, ) -> Image: """Capture a PIL image from stream stream_name with timeout wait.""" raise NotImplementedError( @@ -486,10 +518,10 @@ class BaseCamera(lt.Thing): metadata ).encode("utf-8") piexif.insert(piexif.dump(exif_dict), jpeg_path) - except: # noqa: E722 + except Exception: # We need to capture any exception as there are many reasons metadata # might not be added. We warn rather than log the error. - logger.warning(f"Failed to add metadata to {jpeg_path}") + logger.exception(f"Failed to add metadata to {jpeg_path}") except Exception as e: raise IOError(f"An error occurred while saving {jpeg_path}") from e diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 28e8d053..b5352983 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -7,14 +7,13 @@ See repository root for licensing information. """ from __future__ import annotations -import io -import json + import logging from typing import Literal, Optional from threading import Thread import cv2 -import piexif +from PIL import Image import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray @@ -100,30 +99,16 @@ class OpenCVCamera(BaseCamera): ) return frame - @lt.thing_action - def capture_jpeg( + def capture_image( self, - metadata_getter: lt.deps.GetThingStates, - resolution: Literal["main", "full"] = "main", + stream_name: Literal["main", "full"] = "main", + wait: Optional[float] = None, ) -> JPEGBlob: """Acquire one image from the camera and return as a JPEG blob. This function will produce a JPEG image. """ - logging.warning(f"OpenCV camera doesn't respect {resolution} setting") - frame = self.capture_array() - jpeg = cv2.imencode(".jpg", frame)[1].tobytes() - exif_dict = { - "Exif": { - piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode( - "utf-8" - ) - }, - "GPS": {}, - "Interop": {}, - "1st": {}, - "thumbnail": None, - } - output = io.BytesIO() - piexif.insert(piexif.dump(exif_dict), jpeg, output) - return JPEGBlob.from_bytes(output.getvalue()) + logging.warning( + f"Simulation camera doesn't respect {stream_name=} or {wait=} arguments." + ) + return Image.fromarray(self.capture_array()) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index f3282c61..18ef52c9 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -16,7 +16,6 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf from __future__ import annotations from typing import Annotated, Iterator, Literal, Mapping, Optional, overload -from datetime import datetime import json import logging import os @@ -27,7 +26,6 @@ from contextlib import contextmanager from threading import RLock from pydantic import BaseModel, BeforeValidator -import piexif import numpy as np from PIL import Image from picamera2 import Picamera2 @@ -44,7 +42,7 @@ from openflexure_microscope_server.ui import ( property_control_for, ) from . import picamera_recalibrate_utils as recalibrate_utils -from . import BaseCamera, JPEGBlob, ArrayModel +from . import BaseCamera, ArrayModel class MissingCalibrationError(RuntimeError): @@ -528,20 +526,39 @@ class StreamingPiCamera2(BaseCamera): def capture_image( self, - stream_name: Literal["main", "lores", "raw"] = "main", + stream_name: Literal["main", "lores", "raw", "full"] = "main", wait: Optional[float] = 0.9, ) -> Image: - """Acquire one image from the camera. + """Acquire one image from the camera and return it as a PIL Image. - Return it as a PIL Image + If the ``stream_name`` parameter is ``main`` or ``lores``, it will be captured + from the main preview stream, or the low-res preview stream, respectively. This + means the camera won't be reconfigured, and the stream will not pause (though + it may miss one frame). - stream_name: (Optional) The PiCamera2 stream to use, should be one of ["main", "lores", "raw"]. Default = "main" - wait: (Optional, float) Set a timeout in seconds. - A TimeoutError is raised if this time is exceeded during capture. - Default = 0.9s, lower than the 1s timeout default in picamera yaml settings + If ``full`` resolution is requested, we will briefly pause the MJPEG stream and + reconfigure the camera to capture a full resolution image. + + :param stream_name: (Optional) The PiCamera2 stream to use, should be one of + ["main", "lores", "raw", "full"]. Default = "main" + :param wait: (Optional, float) Set a timeout in seconds. + + :rasises TimeoutError: if this time is exceeded during capture. Default = 0.9s, + lower than the 1s timeout for the camera. This ensures that our code times + out and returns before the camera times out. """ - with self._streaming_picamera() as cam: - return cam.capture_image(stream_name, wait=wait) + if stream_name in ["main", "lores", "raw"]: + with self._streaming_picamera() as cam: + return cam.capture_image(stream_name, wait=wait) + elif stream_name == "full": + with self._streaming_picamera(pause_stream=True) as cam: + logging.debug("Reconfiguring camera for full resolution capture") + cam.configure(cam.create_still_configuration()) + cam.start() + time.sleep(0.2) + return cam.capture_image(name="main", wait=wait) + else: + raise ValueError(f'Unknown stream name "{stream_name}"') @lt.thing_action def capture_array( @@ -555,7 +572,8 @@ class StreamingPiCamera2(BaseCamera): It's likely to be highly inefficient - raw and/or uncompressed captures using binary image formats will be added in due course. - stream_name: (Optional) The PiCamera2 stream to use, should be one of ["main", "lores", "raw", "full"]. Default = "main" + stream_name: (Optional) The PiCamera2 stream to use, should be one of ["main", + "lores", "raw", "full"]. Default = "main" wait: (Optional, float) Set a timeout in seconds. A TimeoutError is raised if this time is exceeded during capture. Default = 0.9s, lower than the 1s timeout default in picamera yaml settings @@ -584,62 +602,6 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera() as cam: return cam.camera_configuration() - @lt.thing_action - def capture_jpeg( - self, - metadata_getter: lt.deps.GetThingStates, - resolution: Literal["lores", "main", "full"] = "main", - wait: Optional[float] = 0.9, - ) -> JPEGBlob: - """Acquire one image from the camera as a JPEG. - - The JPEG will be acquired using ``Picamera2.capture_file``. If the - ``resolution`` parameter is ``main`` or ``lores``, it will be captured - from the main preview stream, or the low-res preview stream, - respectively. This means the camera won't be reconfigured, and - the stream will not pause (though it may miss one frame). - - If ``full`` resolution is requested, we will briefly pause the - MJPEG stream and reconfigure the camera to capture a full - resolution image. - - wait: (Optional, float) Set a timeout in seconds. - A TimeoutError is raised if this time is exceeded during capture. - Default = 0.9s, lower than the 1s timeout default in picamera yaml settings - - Note that this always uses the image processing pipeline - to - bypass this, you must use a raw capture. - """ - fname = datetime.now().strftime("%Y-%m-%d-%H%M%S.jpeg") - 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 - # to reconfigure for these - if resolution in ("lores", "main") and config[resolution]: - with self._streaming_picamera() as cam: - cam.capture_file(path, name=resolution, format="jpeg", wait=wait) - else: - if resolution != "full": - logging.warning( - f"There was no {resolution} stream, capturing full resolution" - ) - with self._streaming_picamera(pause_stream=True) as cam: - logging.info("Reconfiguring camera for full resolution capture") - cam.configure(cam.create_still_configuration()) - cam.start() - cam.options["quality"] = 95 - logging.info("capturing") - cam.capture_file(path, name="main", format="jpeg", wait=wait) - logging.info("done") - # After the file is written, add metadata about the current Things - exif_dict = piexif.load(path) - exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps( - metadata_getter() - ).encode("utf-8") - piexif.insert(piexif.dump(exif_dict), path) - return JPEGBlob.from_temporary_directory(folder, fname) - @lt.thing_property def capture_metadata(self) -> dict: """Return the metadata from the camera.""" diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index e1f37560..177eacc5 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -7,8 +7,6 @@ See repository root for licensing information. """ from __future__ import annotations -import io -import json import logging from typing import Literal, Optional from threading import Thread @@ -17,7 +15,6 @@ import time import cv2 import numpy as np from PIL import Image -import piexif from scipy.ndimage import gaussian_filter import labthings_fastapi as lt @@ -29,7 +26,7 @@ from openflexure_microscope_server.ui import ( property_control_for, ) -from . import BaseCamera, JPEGBlob, ArrayModel +from . import BaseCamera, ArrayModel from ..stage import BaseStage # The ratio between "motor" steps and pixels @@ -291,34 +288,6 @@ class SimulatedCamera(BaseCamera): logging.warning(f"Simulation camera doesn't respect {resolution=} setting") return self.generate_frame() - @lt.thing_action - def capture_jpeg( - self, - metadata_getter: lt.deps.GetThingStates, - resolution: Literal["main", "full"] = "main", - ) -> JPEGBlob: - """Acquire one image from the camera and return as a JPEG blob. - - This function will produce a JPEG image. - """ - logging.warning(f"Simulation camera doesn't respect {resolution=} setting") - frame = self.capture_array() - jpeg = cv2.imencode(".jpg", frame)[1].tobytes() - exif_dict = { - "Exif": { - piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode( - "utf-8" - ) - }, - "GPS": {}, - "Interop": {}, - "1st": {}, - "thumbnail": None, - } - output = io.BytesIO() - piexif.insert(piexif.dump(exif_dict), jpeg, output) - return JPEGBlob.from_bytes(output.getvalue()) - def capture_image( self, stream_name: Literal["main", "lores", "raw"], diff --git a/webapp/src/components/tabContentComponents/controlComponents/paneControl.vue b/webapp/src/components/tabContentComponents/controlComponents/paneControl.vue index 93cd35e3..1bb601a4 100644 --- a/webapp/src/components/tabContentComponents/controlComponents/paneControl.vue +++ b/webapp/src/components/tabContentComponents/controlComponents/paneControl.vue @@ -141,7 +141,7 @@ Date: Fri, 22 Aug 2025 11:00:19 +0100 Subject: [PATCH 2/7] Further unification of camera array functionality --- .../things/camera/__init__.py | 14 ++----- .../things/camera/opencv.py | 13 ++++--- .../things/camera/picamera.py | 37 ++++++++++--------- .../things/camera/simulation.py | 13 ++++--- 4 files changed, 40 insertions(+), 37 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 6a83737b..efe9003d 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -8,7 +8,6 @@ See repository root for licensing information. from __future__ import annotations from typing import Literal, Optional, Tuple, Any -from types import EllipsisType import json import io import time @@ -263,7 +262,7 @@ class BaseCamera(lt.Thing): metadata_getter: lt.deps.GetThingStates, logger: lt.deps.InvocationLogger, stream_name: str = "main", - wait: Optional[float | EllipsisType] = ..., + wait: Optional[float] = None, ) -> JPEGBlob: """Acquire one image from the camera as a JPEG. @@ -274,20 +273,15 @@ class BaseCamera(lt.Thing): injected. :param logger: LabThings InvocationLogger dependency, automatically injected. :param stream_name: A stream name supported by this camera. - :param wait: (Optional, float) Set a timeout in seconds. If not set it will + :param wait: (Optional, float) Set a timeout in seconds. If None it will use the default for the underlying camera. """ fname = datetime.now().strftime("%Y-%m-%d-%H%M%S.jpeg") directory = tempfile.TemporaryDirectory() jpeg_path = os.path.join(directory.name, fname) - # Using Ellipsis to specify no input specified. As `None` set for wait may - # have a meaning as it does for the Picamera. If wait is Ellipsis then - # do not specify. - if wait is Ellipsis: - img = self.capture_image(stream_name) - else: - img = self.capture_image(stream_name, wait) + img = self.capture_image(stream_name, wait) + self._save_capture( jpeg_path=jpeg_path, image=img, diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index b5352983..7c42d478 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -83,7 +83,8 @@ class OpenCVCamera(BaseCamera): @lt.thing_action def capture_array( self, - resolution: Literal["main", "full"] = "full", + stream_name: Literal["main", "full"] = "full", + wait: Optional[float] = None, ) -> NDArray: """Acquire one image from the camera and return as an array. @@ -91,7 +92,9 @@ class OpenCVCamera(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"OpenCV camera doesn't respect {resolution} setting") + if wait is not None: + logging.warning("OpenCV camera has no wait option. Use None.") + logging.warning(f"OpenCV camera doesn't respect {stream_name=}") ret, frame = self.cap.read() if not ret: raise RuntimeError( @@ -108,7 +111,7 @@ class OpenCVCamera(BaseCamera): This function will produce a JPEG image. """ - logging.warning( - f"Simulation camera doesn't respect {stream_name=} or {wait=} arguments." - ) + if wait is not None: + logging.warning("OpenCV camera has no wait option. Use None.") + logging.warning(f"OpenCV camera doesn't respect {stream_name=}") return Image.fromarray(self.capture_array()) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 18ef52c9..60ecd582 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -541,12 +541,15 @@ class StreamingPiCamera2(BaseCamera): :param stream_name: (Optional) The PiCamera2 stream to use, should be one of ["main", "lores", "raw", "full"]. Default = "main" - :param wait: (Optional, float) Set a timeout in seconds. - - :rasises TimeoutError: if this time is exceeded during capture. Default = 0.9s, + :param wait: (Optional, float) Set a timeout in seconds. Default = 0.9s, lower than the 1s timeout for the camera. This ensures that our code times - out and returns before the camera times out. + out and returns before the camera times out. If None is set the default + value of 0.9 will be used to prevent the possibility of the camera locking. + + :raises TimeoutError: if this time is exceeded during capture. """ + if wait is None: + wait = 0.9 if stream_name in ["main", "lores", "raw"]: with self._streaming_picamera() as cam: return cam.capture_image(stream_name, wait=wait) @@ -572,20 +575,20 @@ class StreamingPiCamera2(BaseCamera): It's likely to be highly inefficient - raw and/or uncompressed captures using binary image formats will be added in due course. - stream_name: (Optional) The PiCamera2 stream to use, should be one of ["main", - "lores", "raw", "full"]. Default = "main" - wait: (Optional, float) Set a timeout in seconds. - A TimeoutError is raised if this time is exceeded during capture. - Default = 0.9s, lower than the 1s timeout default in picamera yaml settings + :param stream_name: (Optional) The PiCamera2 stream to use, should be one of + ["main", "lores", "raw", "full"]. Default = "main" + :param wait: (Optional, float) Set a timeout in seconds. Default = 0.9s, + lower than the 1s timeout for the camera. This ensures that our code times + out and returns before the camera times out. If None is set the default + value of 0.9 will be used to prevent the possibility of the camera locking. + + :raises TimeoutError: if this time is exceeded during capture. """ - # This was slower than capture_image for our use case, but directly returning - # an image as an array is still a useful feature - if stream_name == "full": - with self._streaming_picamera(pause_stream=True) as picam2: - capture_config = picam2.create_still_configuration() - return picam2.switch_mode_and_capture_array(capture_config, wait=wait) - with self._streaming_picamera() as cam: - return cam.capture_array(stream_name, wait=wait) + # Note that internally the PiCamera creates a PIL image and then converts to + # numpy with ``np.array(Image.open(io.BytesIO(self.make_buffer(name))))``. + # As such we use capture_image to get an Image from the picamera and return + # as array + return np.array(self.capture_image(stream_name, wait)) @lt.thing_property def camera_configuration(self) -> Mapping: diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 177eacc5..7c7a856b 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -277,7 +277,8 @@ class SimulatedCamera(BaseCamera): @lt.thing_action def capture_array( self, - resolution: Literal["main", "full"] = "full", + stream_name: Literal["main", "full"] = "full", + wait: Optional[float] = None, ) -> ArrayModel: """Acquire one image from the camera and return as an array. @@ -285,7 +286,9 @@ 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") + if wait is not None: + logging.warning("Simulation camera has no wait option. Use None.") + logging.warning(f"Simulation camera camera doesn't respect {stream_name=}") return self.generate_frame() def capture_image( @@ -297,9 +300,9 @@ class SimulatedCamera(BaseCamera): It is used for capture to memory. """ - logging.warning( - f"Simulation camera doesn't respect {stream_name=} or {wait=} arguments." - ) + if wait is not None: + logging.warning("Simulation camera has no wait option. Use None.") + logging.warning(f"Simulation camera camera doesn't respect {stream_name=}") return Image.fromarray(self.generate_frame()) @lt.thing_action From 23a3a5b464c85ab319228190ee460df8892d8272 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 22 Aug 2025 11:44:50 +0100 Subject: [PATCH 3/7] Update argument names in picamera tests --- hardware-specific-tests/picamera2/test_acquisition.py | 2 +- .../picamera2/test_exposure_time_drift.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/hardware-specific-tests/picamera2/test_acquisition.py b/hardware-specific-tests/picamera2/test_acquisition.py index 62b41058..3ab404eb 100644 --- a/hardware-specific-tests/picamera2/test_acquisition.py +++ b/hardware-specific-tests/picamera2/test_acquisition.py @@ -40,7 +40,7 @@ def test_jpeg_and_array(client): assert mjpeg_frame.format == "JPEG" # Capture a jpeg - blob = client.capture_jpeg(resolution="main") + blob = client.capture_jpeg(stream_name="main") jpeg_capture = Image.open(blob.open()) jpeg_capture.verify() assert jpeg_capture.format == "JPEG" diff --git a/hardware-specific-tests/picamera2/test_exposure_time_drift.py b/hardware-specific-tests/picamera2/test_exposure_time_drift.py index 9a737622..75700436 100644 --- a/hardware-specific-tests/picamera2/test_exposure_time_drift.py +++ b/hardware-specific-tests/picamera2/test_exposure_time_drift.py @@ -63,7 +63,7 @@ def _test_exposure_time_drift(desired_time: int) -> None: assert abs(pre_capture_et - desired_time) < EXPOSURE_TOL for i in range(10): - client.capture_jpeg(resolution="full") + client.capture_jpeg(stream_name="full") if i == 0: # Exposure can update on first capture, due to frame rate restrictions first_et = client.exposure_time @@ -80,7 +80,7 @@ def _test_exposure_time_drift(desired_time: int) -> None: time.sleep(0.5) # Check before and after capture assert client.exposure_time == frame_et - client.capture_jpeg(resolution="full") + client.capture_jpeg(stream_name="full") assert client.exposure_time == frame_et print("Exposure time didn't change!!") print(f"End of test for exposure target {desired_time}") @@ -106,7 +106,7 @@ def test_exposure_time_on_start_and_stop_stream(): # Take a couple of images to make sure that the exposure is adjusted to # a hardware compatible value. for _i in range(2): - client.capture_jpeg(resolution="full") + client.capture_jpeg(stream_name="full") # Save this time. set_time = client.exposure_time assert abs(set_time - desired_time) < EXPOSURE_TOL @@ -136,7 +136,7 @@ def _load_camera_and_return_exposure(tmpdir: str) -> int: # Take a couple of images to make sure that the exposure is adjusted to # a hardware compatible value. for _i in range(2): - client.capture_jpeg(resolution="full") + client.capture_jpeg(stream_name="full") # Save this time. return client.exposure_time From b124355fed32ef80715e76178ca0b20925e4cd65 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 22 Aug 2025 13:09:46 +0100 Subject: [PATCH 4/7] Fix capture array for raw stream --- .../things/camera/picamera.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 60ecd582..9df2e015 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -524,9 +524,22 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera() as cam: cam.capture_metadata() + @contextmanager + def _switch_to_still_capture_mode(self) -> Iterator[Picamera2]: + """Get the picamera lock, pause stream and switch into still capture config. + + Restarts stream when complete. + """ + with self._streaming_picamera(pause_stream=True) as cam: + logging.debug("Reconfiguring camera for full resolution capture") + cam.configure(cam.create_still_configuration(sensor=self._sensor_mode)) + cam.start() + time.sleep(0.2) + yield cam + def capture_image( self, - stream_name: Literal["main", "lores", "raw", "full"] = "main", + stream_name: Literal["main", "lores", "full"] = "main", wait: Optional[float] = 0.9, ) -> Image: """Acquire one image from the camera and return it as a PIL Image. @@ -540,7 +553,8 @@ class StreamingPiCamera2(BaseCamera): reconfigure the camera to capture a full resolution image. :param stream_name: (Optional) The PiCamera2 stream to use, should be one of - ["main", "lores", "raw", "full"]. Default = "main" + ["main", "lores", "full"]. Default = "main". Note that "raw" images cannot + be captured as PIL images. Use capture_array :param wait: (Optional, float) Set a timeout in seconds. Default = 0.9s, lower than the 1s timeout for the camera. This ensures that our code times out and returns before the camera times out. If None is set the default @@ -554,11 +568,7 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera() as cam: return cam.capture_image(stream_name, wait=wait) elif stream_name == "full": - with self._streaming_picamera(pause_stream=True) as cam: - logging.debug("Reconfiguring camera for full resolution capture") - cam.configure(cam.create_still_configuration()) - cam.start() - time.sleep(0.2) + with self._switch_to_still_capture_mode() as cam: return cam.capture_image(name="main", wait=wait) else: raise ValueError(f'Unknown stream name "{stream_name}"') @@ -584,6 +594,12 @@ class StreamingPiCamera2(BaseCamera): :raises TimeoutError: if this time is exceeded during capture. """ + if stream_name == "raw": + # Raw cannot used capture_image. + if wait is None: + wait = 0.9 + with self._switch_to_still_capture_mode() as cam: + return cam.capture_array(name="raw", wait=wait) # Note that internally the PiCamera creates a PIL image and then converts to # numpy with ``np.array(Image.open(io.BytesIO(self.make_buffer(name))))``. # As such we use capture_image to get an Image from the picamera and return From 5bb742881f00ef8b449641431a560e2a651692d3 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 22 Aug 2025 14:34:57 +0100 Subject: [PATCH 5/7] Fix typehints and docs for opencv camera --- src/openflexure_microscope_server/things/camera/opencv.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 7c42d478..5f179301 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -18,7 +18,7 @@ from PIL import Image import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray -from . import BaseCamera, JPEGBlob +from . import BaseCamera class OpenCVCamera(BaseCamera): @@ -106,8 +106,8 @@ class OpenCVCamera(BaseCamera): self, stream_name: Literal["main", "full"] = "main", wait: Optional[float] = None, - ) -> JPEGBlob: - """Acquire one image from the camera and return as a JPEG blob. + ) -> Image: + """Acquire one image from the camera and return as a PIL image. This function will produce a JPEG image. """ From b46f429c1f089bff535fec61951fd58d9219519a Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 27 Aug 2025 15:32:55 +0000 Subject: [PATCH 6/7] Apply suggestions from code review of branch unify-jpeg-capture Co-authored-by: Richard Bowman --- src/openflexure_microscope_server/things/camera/__init__.py | 2 +- src/openflexure_microscope_server/things/camera/picamera.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index efe9003d..abee8f06 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -266,7 +266,7 @@ class BaseCamera(lt.Thing): ) -> JPEGBlob: """Acquire one image from the camera as a JPEG. - This will use the internal capture image functionally of capture_image if + This will use the internal capture image functionally of capture_image of the specific camera being used. :param metadata_getter: LabThings GetThingStates dependency, automatically diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 9df2e015..5e55ea92 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -550,7 +550,10 @@ class StreamingPiCamera2(BaseCamera): it may miss one frame). If ``full`` resolution is requested, we will briefly pause the MJPEG stream and - reconfigure the camera to capture a full resolution image. + reconfigure the camera to capture a full resolution image. This will capture an + image at the full resolution of the current sensor mode. If the current sensor + mode bins or crops the image, this may not be the native resolution of the + camera sensor. :param stream_name: (Optional) The PiCamera2 stream to use, should be one of ["main", "lores", "full"]. Default = "main". Note that "raw" images cannot From 87815f76781dfba305448e888df24929df3734f9 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 22 Aug 2025 14:08:02 +0100 Subject: [PATCH 7/7] Update picamera_coverage.zip for camera capture changes --- 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 98bad71b9f2e4ffe671d948e6f4394e062ae61fc..eccadaccead003edc27c4e0edec8681f49e0739e 100644 GIT binary patch delta 430 zcmbQalzHY-W}yIYW)=|!5Gd=Cj(+yIV9iD$l?H)!2L7M?ANi;AxAMpGUE^!t>?lyk zH+e^2KsXBO%%})v zRz}W717@aw)zT@E(`GL8U}m_-z`)SJA;Z8XULeSMV3CrCPQfV@~9pqW3} z=|Y-DVp3|VMVf(Gs!_73Ws+%1YLcZvVzQZOs-dBoVUlICxw)~SS#nzH&^HB3`Y zO;ZysjnWLwQ%o!jlZ_0Gk}OP((~^^oQ;f_^j4hKb%n~h9%_d*CU^BV=Vzgb7k-4Qs zvVn10vYDZwk)^SrrKOpLg?Xw$vZlQ>aAIRU!%&?&0|KIE4b7bpoNj2m$D3~$KW@LD<^T&7T$#MOe z;i1f|jGT>H%uIFp%{r%zj72gT4oEXFFbFgB=cO$TFcn*cilnu!wjccHY-k$bl?E_knPpdvdK;t z(lnBj6D^I>(u|T)jnm9hQ_U?*jEu}J&63S5j8iRA%*>6FO_GdE6O$)jxZtdjW^80( zlx$|0mSkaOnQD|`k!)_3YH4g_X^@s=WNcz?k(y$dl4b@{Y%{t1VziyHk%@V#X|ko6 zaiXP>nPqCCS+c2dYI34+qG773v5BRDp^<5tX=;j6ZGbl;lL#|v6igPlWPxyPz$H%r D59*HT