Merge branch 'camera-metadata' into 'v3'

Camera metadata with basic data

See merge request openflexure/openflexure-microscope-server!412
This commit is contained in:
Julian Stirling 2025-10-23 16:19:48 +00:00
commit f8e5d877db
4 changed files with 103 additions and 14 deletions

Binary file not shown.

View file

@ -7,7 +7,7 @@ See repository root for licensing information.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Literal, Optional, Tuple, Any from typing import Literal, Optional, Tuple, Any, Mapping
from types import TracebackType from types import TracebackType
import json import json
import io import io
@ -79,7 +79,10 @@ class CameraMemoryBuffer:
self._latest_id: int = 0 self._latest_id: int = 0
def add_image( 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: ) -> int:
"""Add an image to the Memory buffer. """Add an image to the Memory buffer.
@ -288,10 +291,12 @@ class BaseCamera(lt.Thing):
img = self.capture_image(stream_name, wait) img = self.capture_image(stream_name, wait)
capture_metadata = self._capture_metadata(metadata_getter())
self._save_capture( self._save_capture(
jpeg_path=jpeg_path, jpeg_path=jpeg_path,
image=img, image=img,
metadata=metadata_getter(), metadata=capture_metadata,
logger=logger, logger=logger,
) )
@ -388,7 +393,7 @@ class BaseCamera(lt.Thing):
:param save_resolution: can be set to resize the image before saving. By :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. 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, metadata_getter,
logger=logger, logger=logger,
) )
@ -396,7 +401,7 @@ class BaseCamera(lt.Thing):
self._save_capture( self._save_capture(
jpeg_path, jpeg_path,
image, image,
metadata, capture_metadata,
logger, logger,
save_resolution, save_resolution,
) )
@ -463,7 +468,7 @@ class BaseCamera(lt.Thing):
self, self,
metadata_getter: lt.deps.GetThingStates, metadata_getter: lt.deps.GetThingStates,
logger: lt.deps.InvocationLogger, logger: lt.deps.InvocationLogger,
) -> Image: ) -> Tuple[Image, Mapping[str, Any]]:
"""Capture an image in memory and return it with metadata. """Capture an image in memory and return it with metadata.
This robust capturing method attempts to capture the image five times 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): for capture_attempts in range(5):
try: try:
metadata = metadata_getter() metadata = metadata_getter()
capture_metadata = self._capture_metadata(metadata)
image = self.capture_image(stream_name="main", wait=5) image = self.capture_image(stream_name="main", wait=5)
return image, metadata return image, capture_metadata
except TimeoutError: except TimeoutError:
logger.warning( logger.warning(
f"Attempt {capture_attempts + 1} to capture image timed out. Do you have enough RAM?" 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") 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( def _save_capture(
self, self,
jpeg_path: str, jpeg_path: str,
@ -512,12 +578,7 @@ class BaseCamera(lt.Thing):
# disabled, file size increases and quality is barely or not affected # disabled, file size increases and quality is barely or not affected
image.save(jpeg_path, quality=95, subsampling=0) image.save(jpeg_path, quality=95, subsampling=0)
try: try:
# Load EXIF metadata from image so it can be added to. self._add_metadata_to_capture(jpeg_path, metadata)
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)
except Exception: except Exception:
# We need to capture any exception as there are many reasons metadata # We need to capture any exception as there are many reasons metadata
# might not be added. We warn rather than log the error. # 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. # Manually save settings as the setter is not called.
self.save_settings() 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/") CameraDependency = lt.deps.direct_thing_client_dependency(BaseCamera, "/camera/")
RawCameraDependency = lt.deps.raw_thing_dependency(BaseCamera) RawCameraDependency = lt.deps.raw_thing_dependency(BaseCamera)

View file

@ -1093,3 +1093,10 @@ class StreamingPiCamera2(BaseCamera):
to be static (except "reset") to be static (except "reset")
""" """
return tf_utils.lst_is_static(self.tuning) 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

View file

@ -7,7 +7,16 @@ It also controls external processes for live stitching composite images, and
the creation of the final stitched images. 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 threading
import os import os
import time import time
@ -481,6 +490,13 @@ class SmartScanThing(lt.Thing):
**self._scan_data.starting_position, block_cancellation=True **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 @_scan_running
def _perform_final_stitch(self) -> None: def _perform_final_stitch(self) -> None:
"""Update the scan zip and perform final stitch of the data.""" """Update the scan zip and perform final stitch of the data."""