From ee6911f4a7b7f2e796ba5edc3938cf740f27f156 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 22 Apr 2026 23:22:11 +0100 Subject: [PATCH 1/3] PNG Capture This adds an additional action to BaseCamera to capture and return a PNG Blob. It makes the PNG using Pillow, in a very similar way to `capture_jpeg`. I have parametrized the test for `capture_jpeg` so it now tests both jpeg and png with the same code. --- .../things/camera/__init__.py | 60 +++++++++++++++---- tests/integration_tests/test_actions.py | 5 +- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 049f2a29..99eb88da 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -437,13 +437,44 @@ class BaseCamera(OFMThing): capture_metadata = self._capture_metadata() self._save_capture( - jpeg_path=jpeg_path, + path=jpeg_path, image=img, metadata=capture_metadata, ) return JPEGBlob.from_temporary_directory(directory, fname) + @lt.action + def capture_png( + self, + stream_name: Literal["main", "lores", "full"] = "main", + wait: Optional[float] = None, + ) -> PNGBlob: + """Acquire one image from the camera as a PNG. + + This will use the internal capture image functionally of capture_image of + the specific camera being used. + + :param stream_name: A stream name supported by this camera. + :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() + png_path = os.path.join(directory.name, fname) + + img = self.capture_image(stream_name, wait) + + capture_metadata = self._capture_metadata() + + self._save_capture( + path=png_path, + image=img, + metadata=capture_metadata, + ) + + return PNGBlob.from_temporary_directory(directory, fname) + @lt.action def grab_jpeg( self, @@ -656,7 +687,7 @@ class BaseCamera(OFMThing): def _save_capture( self, - jpeg_path: str, + path: str, image: Image.Image, metadata: Mapping[str, Any], save_resolution: Optional[Tuple[int, int]] = None, @@ -672,22 +703,25 @@ class BaseCamera(OFMThing): if save_resolution is not None and image.size != save_resolution: image = image.resize(save_resolution, Image.Resampling.BOX) try: - # Per PIL documentation, - # (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg) - # there are two factors when saving a JPEG. Subsampling affects the colour, - # quality affects the pixels. - # subsampling = 0 disables subsampling of colour - # quality = 95 is the maximum recommended - above this, JPEG compression is - # disabled, file size increases and quality is barely or not affected - image.save(jpeg_path, quality=95, subsampling=0) + save_kwargs: dict[str, Any] = {} + if path.endswith("jpg"): + # Per PIL documentation, + # (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg) + # there are two factors when saving a JPEG. Subsampling affects the colour, + # quality affects the pixels. + # subsampling = 0 disables subsampling of colour + # quality = 95 is the maximum recommended - above this, JPEG compression is + # disabled, file size increases and quality is barely or not affected + save_kwargs = {"quality": 95, "subsampling": 0} + image.save(path, **save_kwargs) try: - self._add_metadata_to_capture(jpeg_path, dict(metadata)) + self._add_metadata_to_capture(path, dict(metadata)) 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. - self.logger.exception(f"Failed to add metadata to {jpeg_path}") + self.logger.exception(f"Failed to add metadata to {path}") except Exception as e: - raise IOError(f"An error occurred while saving {jpeg_path}") from e + raise IOError(f"An error occurred while saving {path}") from e settling_time: float = lt.setting(default=0.2, ge=0) """The settling time when calling the ``settle()`` method.""" diff --git a/tests/integration_tests/test_actions.py b/tests/integration_tests/test_actions.py index 80ec2573..5de893a0 100644 --- a/tests/integration_tests/test_actions.py +++ b/tests/integration_tests/test_actions.py @@ -40,10 +40,11 @@ def test_grab_jpeg(simulation_test_env): assert image.size == (820, 616) -def test_capture_jpeg_metadata(simulation_test_env): +@pytest.mark.parametrize("fmt", ["jpeg", "png"]) +def test_capture_and_metadata(simulation_test_env, fmt): """Check that the position is encoded into the image metadata.""" camera = simulation_test_env.get_thing_client("camera") - blob = camera.capture_jpeg() + blob = getattr(camera, f"capture_{fmt}")() image = Image.open(blob.open()) exif_dict = piexif.load(image.info["exif"]) encoded_metadata = exif_dict["Exif"][piexif.ExifIFD.UserComment] From a66428b1b2f0fd6848cba1edc837056a1fc61319 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 22 Apr 2026 23:43:30 +0100 Subject: [PATCH 2/3] Fix a typo when calling `_save_capture` I renamed `jpeg_path` to `path` as it's no longer just used for jpegs. --- src/openflexure_microscope_server/things/camera/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 99eb88da..747342fb 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -595,7 +595,7 @@ class BaseCamera(OFMThing): image, metadata = self._memory_buffer.get_image(buffer_id) self._save_capture( - jpeg_path=jpeg_path, + path=jpeg_path, image=image, metadata=metadata, save_resolution=save_resolution, From 3aa257196402cedb12ae47850f138efd80715856 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 15 May 2026 13:24:37 +0100 Subject: [PATCH 3/3] Update picamera test results --- picamera_coverage.zip | Bin 54038 -> 54038 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/picamera_coverage.zip b/picamera_coverage.zip index fb57989b62a11e8cc42bc1ee8f9b3665abc9a709..0038331dfa843dccb226d2d153db10bb247ac012 100644 GIT binary patch delta 1386 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 LJ{P1XT^0iX`Z$})