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 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)

View file

@ -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

View file

@ -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."""