Merge branch 'camera-metadata' into 'v3'
Camera metadata with basic data See merge request openflexure/openflexure-microscope-server!412
This commit is contained in:
commit
f8e5d877db
4 changed files with 103 additions and 14 deletions
Binary file not shown.
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue