From b1413ca66c7949cc108982df1c09a7876bd76e83 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Mon, 20 Oct 2025 17:07:10 +0100 Subject: [PATCH 01/10] Camera metadata with basic data --- .../things/camera/__init__.py | 7 ++++++- .../things/camera/picamera.py | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index a48dc2c3..82aa6f28 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 @@ -650,6 +650,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]: + """Summary metadata describing the current state of the Thing.""" + return {"capture_time": time.time()} + 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..a6b851c9 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -1093,3 +1093,9 @@ class StreamingPiCamera2(BaseCamera): to be static (except "reset") """ return tf_utils.lst_is_static(self.tuning) + + def get_thing_state(self) -> Mapping[str, Any]: + """Update generic camera metadata with Picamera-specific data.""" + state = super().get_thing_state() + state["camera_board"] = self._camera_board + return state From e0ce40607b4842f044c7f78a88147197b363ccc7 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 20 Oct 2025 17:21:47 +0100 Subject: [PATCH 02/10] Fix property name and call --- src/openflexure_microscope_server/things/camera/picamera.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index a6b851c9..79c19c1f 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -1094,8 +1094,9 @@ class StreamingPiCamera2(BaseCamera): """ return tf_utils.lst_is_static(self.tuning) - def get_thing_state(self) -> Mapping[str, Any]: + @property + def thing_state(self) -> Mapping[str, Any]: """Update generic camera metadata with Picamera-specific data.""" - state = super().get_thing_state() + state = super().thing_state state["camera_board"] = self._camera_board return state From f8ee71b9c046fa46afa5460c6cd4da9bdc6efc81 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 21 Oct 2025 16:52:52 +0100 Subject: [PATCH 03/10] Make thing state mutable within the method --- src/openflexure_microscope_server/things/camera/picamera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 79c19c1f..2afc1d7b 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -1097,6 +1097,6 @@ class StreamingPiCamera2(BaseCamera): @property def thing_state(self) -> Mapping[str, Any]: """Update generic camera metadata with Picamera-specific data.""" - state = super().thing_state + state = dict(super().thing_state) state["camera_board"] = self._camera_board return state From 057376a79971276f7a936f8d7b208722a97af8ec Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 21 Oct 2025 18:59:46 +0100 Subject: [PATCH 04/10] Capture metadata with more info, including default time tags --- .../things/camera/__init__.py | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) 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/") From c2d8160ff4494fc44a076acb06ffb8f24b6a8d11 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 21 Oct 2025 19:00:15 +0100 Subject: [PATCH 05/10] Smart Scan metadata containing scan name if scan is running --- .../things/smart_scan.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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.""" From 4f0d4536abf41f9fe507a8c2fb5c9d9d31041692 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 22 Oct 2025 09:43:56 +0000 Subject: [PATCH 06/10] Apply suggestions from code review of branch camera-metadata --- 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 f05c19fa..a76914d6 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -491,7 +491,7 @@ class BaseCamera(lt.Thing): def _capture_metadata( self, metadata: dict, - ) -> None: + ) -> dict: """Return the metadata for a capture, from the thing states, time and known names.""" return { "capture_time": datetime.now().timestamp(), From 9db2cb9545c8901e141e12c33c5e2040b24e8e7b Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 22 Oct 2025 11:21:21 +0000 Subject: [PATCH 07/10] Apply suggestions from code review of branch camera-metadata --- 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 a76914d6..1a20f65e 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -523,7 +523,7 @@ class BaseCamera(lt.Thing): "%Y:%m:%d %H:%M:%S" ) - # Update all relevant EXIF date fields + # 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 From 24be993478355cbd03efdd3d8da5901daff6bb1b Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 22 Oct 2025 11:26:36 +0000 Subject: [PATCH 08/10] Apply suggestions from code review of branch camera-metadata --- src/openflexure_microscope_server/things/camera/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 1a20f65e..ac1958c2 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -465,7 +465,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 @@ -490,7 +490,7 @@ class BaseCamera(lt.Thing): def _capture_metadata( self, - metadata: dict, + metadata: Mapping[str, Any], ) -> dict: """Return the metadata for a capture, from the thing states, time and known names.""" return { From 602040426bf2e4e8e3347334421f8f6137b97809 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 22 Oct 2025 15:43:59 +0100 Subject: [PATCH 09/10] Add timezone to capture metadata --- .../things/camera/__init__.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index ac1958c2..bca3c63f 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -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. @@ -493,8 +496,10 @@ class BaseCamera(lt.Thing): 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": datetime.now().timestamp(), + "capture_time": current_time.timestamp(), + "timezone": current_time.astimezone().utcoffset(), "make": "OpenFlexure", "model": "OpenFlexure Microscope", "things_states": metadata, @@ -513,6 +518,13 @@ class BaseCamera(lt.Thing): 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( @@ -528,6 +540,9 @@ class BaseCamera(lt.Thing): 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"] From de0021852f78826cd71f04a586590cdbfe2bb0b6 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 23 Oct 2025 10:49:34 +0100 Subject: [PATCH 10/10] Update picamera test data --- 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 dbc0a98d71f670f4c9192eb32d30761e582c23eb..e3c3946d9d355a95105f6cf9b8f3a894ff8f2164 100644 GIT binary patch delta 773 zcmbQXjCtBJW}yIYW)=|!5V#&19)06?cjZPQX#-|MJ%h~#2GRoZ^LVp)4SD|Y9Omia zapU3PzQEnb?Z?f{b(U)qmmintW=DbBoUG=O%nYTI9etE0Z}1XgbCO_TXyk-)*(aBH ziB3M`WzA|M#>`MZ+0jRH^4|;WlRtQKv5K-VG_u1*^nG}l>_jFzdfPEM2v7d+<-lYm zG`Z2+iq%4pnV}q{OenFmBtI=bxwKfXpwdNvg`trbBs_VUj~|mI-(-1z)yeaH*e7TC z@G=?lPG0D%I{BwB&*W6!TqaYl$p*frn_2y=n6!;}=W~g1P3H3F&EcQM!^ zUlboZHxG|H&k=5a?u*?0JiYvyJpVU43f$pjwqP!ud@x2C1zC=cqjC>v&5 z=K9GG;|wQ%@aAL{b7E;^2lMoOco{_}J4D+vicJ0&<;W;Jxgpw`MaYu5eDc8)|Wni{BjBu!oObfR&Yzvq@pHNPkyKSt1K7BWI%%Gt5l}71YP0HKl>dWI6z6~LBgl`lbtT4X(S~l z8Yi0>r$6kv;nh$p4nyt18D*IMZEdErab?6j_~yI zxbyIEpXBc5_UGp3I?FYQE0Rlev!lRmP9`VG$qimktmYET45gDDy_F{CcnM8D-+FB*$7Ym?`6v~tYbBhkbnCD|z1*xcMCE!Du#z#!4U)X2ml#URDl(kRKm#LO_w z$js8*eDZ}0&Kj0RmIg-Vh8BtD#z`rbMy5%Y2Bt}=$(H5@Muv%I$;l}umWGyQDT$LW zT(Fs3e({E#xe-XanUO(Sl8K?2QJO`vd9qomk-24Ja*}0YN>YlEahkDliiJ{bfHxzP S2s3I5o!ooL0^!=TmplQ;LDu^K