diff --git a/picamera_coverage.zip b/picamera_coverage.zip index dbc0a98d..e3c3946d 100644 Binary files a/picamera_coverage.zip and b/picamera_coverage.zip differ diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index a48dc2c3..bca3c63f 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -7,7 +7,7 @@ See repository root for licensing information. """ from __future__ import annotations -from typing import Literal, Optional, Tuple, Any +from typing import Literal, Optional, Tuple, Any, Mapping from types import TracebackType import json import io @@ -79,7 +79,10 @@ class CameraMemoryBuffer: self._latest_id: int = 0 def add_image( - self, image: Any, metadata: Optional[dict] = None, buffer_max: int = 1 + self, + image: Any, + metadata: Optional[Mapping[str, Any]] = None, + buffer_max: int = 1, ) -> int: """Add an image to the Memory buffer. @@ -288,10 +291,12 @@ class BaseCamera(lt.Thing): img = self.capture_image(stream_name, wait) + capture_metadata = self._capture_metadata(metadata_getter()) + self._save_capture( jpeg_path=jpeg_path, image=img, - metadata=metadata_getter(), + metadata=capture_metadata, logger=logger, ) @@ -388,7 +393,7 @@ class BaseCamera(lt.Thing): :param save_resolution: can be set to resize the image before saving. By default this is None meaning that the image is saved at original resolution. """ - image, metadata = self._robust_image_capture( + image, capture_metadata = self._robust_image_capture( metadata_getter, logger=logger, ) @@ -396,7 +401,7 @@ class BaseCamera(lt.Thing): self._save_capture( jpeg_path, image, - metadata, + capture_metadata, logger, save_resolution, ) @@ -463,7 +468,7 @@ class BaseCamera(lt.Thing): self, metadata_getter: lt.deps.GetThingStates, logger: lt.deps.InvocationLogger, - ) -> Image: + ) -> Tuple[Image, Mapping[str, Any]]: """Capture an image in memory and return it with metadata. This robust capturing method attempts to capture the image five times @@ -476,14 +481,75 @@ class BaseCamera(lt.Thing): for capture_attempts in range(5): try: metadata = metadata_getter() + capture_metadata = self._capture_metadata(metadata) + image = self.capture_image(stream_name="main", wait=5) - return image, metadata + return image, capture_metadata except TimeoutError: logger.warning( f"Attempt {capture_attempts + 1} to capture image timed out. Do you have enough RAM?" ) raise CaptureError("An error occurred while capturing after 5 attempts") + def _capture_metadata( + self, + metadata: Mapping[str, Any], + ) -> dict: + """Return the metadata for a capture, from the thing states, time and known names.""" + current_time = datetime.now() + return { + "capture_time": current_time.timestamp(), + "timezone": current_time.astimezone().utcoffset(), + "make": "OpenFlexure", + "model": "OpenFlexure Microscope", + "things_states": metadata, + } + + def _add_metadata_to_capture(self, jpeg_path: str, capture_metadata: dict) -> None: + """Add the EXIF metadata for a JPEG image. + + This adds: + - UserComment (JSON-encoded metadata from the Things) + - Capture time (DateTimeOriginal, DateTimeDigitized, 0th DateTime) + - Camera Make and Model + """ + # Load existing EXIF + exif_dict = piexif.load(jpeg_path) + + user_metadata = capture_metadata["things_states"] + capture_time = capture_metadata["capture_time"] + timezone = capture_metadata["timezone"] + + # Convert timezone into bytes with required formatting + hours = int(timezone.total_seconds() // 3600) + minutes = int((abs(timezone.total_seconds()) % 3600) // 60) + sign = "+" if hours >= 0 else "-" + offset_str = f"{sign}{abs(hours):02d}:{minutes:02d}" + + # Update UserComment with JSON-encoded metadata + exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps( + user_metadata + ).encode("utf-8") + + capture_time_str = datetime.fromtimestamp(capture_time).strftime( + "%Y:%m:%d %H:%M:%S" + ) + + # Update the three EXIF date fields used as "created" by different platforms + exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = capture_time_str + exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = capture_time_str + exif_dict["0th"][piexif.ImageIFD.DateTime] = capture_time_str + + exif_dict["Exif"][piexif.ExifIFD.OffsetTimeOriginal] = offset_str.encode() + exif_dict["Exif"][piexif.ExifIFD.OffsetTimeDigitized] = offset_str.encode() + + # Update Make and Model + exif_dict["0th"][piexif.ImageIFD.Make] = capture_metadata["make"] + exif_dict["0th"][piexif.ImageIFD.Model] = capture_metadata["model"] + + # Write the updated EXIF back to the file + piexif.insert(piexif.dump(exif_dict), jpeg_path) + def _save_capture( self, jpeg_path: str, @@ -512,12 +578,7 @@ class BaseCamera(lt.Thing): # disabled, file size increases and quality is barely or not affected image.save(jpeg_path, quality=95, subsampling=0) try: - # Load EXIF metadata from image so it can be added to. - exif_dict = piexif.load(jpeg_path) - exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps( - metadata - ).encode("utf-8") - piexif.insert(piexif.dump(exif_dict), jpeg_path) + self._add_metadata_to_capture(jpeg_path, 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. @@ -650,6 +711,11 @@ class BaseCamera(lt.Thing): # Manually save settings as the setter is not called. self.save_settings() + @property + def thing_state(self) -> Mapping[str, Any]: + """Empty metadata dict for subclasses to populate.""" + return {} + CameraDependency = lt.deps.direct_thing_client_dependency(BaseCamera, "/camera/") RawCameraDependency = lt.deps.raw_thing_dependency(BaseCamera) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 92902718..2afc1d7b 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -1093,3 +1093,10 @@ class StreamingPiCamera2(BaseCamera): to be static (except "reset") """ return tf_utils.lst_is_static(self.tuning) + + @property + def thing_state(self) -> Mapping[str, Any]: + """Update generic camera metadata with Picamera-specific data.""" + state = dict(super().thing_state) + state["camera_board"] = self._camera_board + return state diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index a6ab415c..71376362 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -7,7 +7,16 @@ It also controls external processes for live stitching composite images, and the creation of the final stitched images. """ -from typing import Optional, Self, TypeVar, ParamSpec, Callable, Concatenate +from typing import ( + Optional, + Self, + TypeVar, + ParamSpec, + Callable, + Concatenate, + Mapping, + Any, +) import threading import os import time @@ -481,6 +490,13 @@ class SmartScanThing(lt.Thing): **self._scan_data.starting_position, block_cancellation=True ) + @property + def thing_state(self) -> Mapping[str, Any]: + """Return a metadata dict for ongoing scan to populate.""" + if self._ongoing_scan is None: + return {} + return {"scan_name": self._ongoing_scan.name} + @_scan_running def _perform_final_stitch(self) -> None: """Update the scan zip and perform final stitch of the data."""