diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 82aa6f28..f05c19fa 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -288,10 +288,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 +390,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 +398,7 @@ class BaseCamera(lt.Thing): self._save_capture( jpeg_path, image, - metadata, + capture_metadata, logger, save_resolution, ) @@ -476,14 +478,63 @@ 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: dict, + ) -> None: + """Return the metadata for a capture, from the thing states, time and known names.""" + return { + "capture_time": datetime.now().timestamp(), + "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"] + + # 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 all relevant EXIF date fields + 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 + + # 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 +563,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. @@ -652,8 +698,8 @@ class BaseCamera(lt.Thing): @property def thing_state(self) -> Mapping[str, Any]: - """Summary metadata describing the current state of the Thing.""" - return {"capture_time": time.time()} + """Empty metadata dict for subclasses to populate.""" + return {} CameraDependency = lt.deps.direct_thing_client_dependency(BaseCamera, "/camera/")