openflexure-microscope-server/src/openflexure_microscope_server/things/camera/__init__.py
2026-07-10 09:39:09 +01:00

1042 lines
37 KiB
Python

"""OpenFlexure Microscope Camera.
This module defines the interface for cameras. Any compatible lt.Thing
should enable the server to work.
See repository root for licensing information.
"""
from __future__ import annotations
import io
import json
import os
import time
from abc import ABC, abstractmethod
from copy import deepcopy
from datetime import datetime
from types import TracebackType
from typing import Any, Literal, Mapping, Optional, Self
import numpy as np
import piexif
from fastapi import HTTPException, Response
from PIL import Image
from pydantic import BaseModel
import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray
from openflexure_microscope_server.things import OFMThing, RelativeDataPath
from openflexure_microscope_server.things.background_detect import (
BackgroundDetectAlgorithm,
)
from openflexure_microscope_server.ui import ActionButton, PropertyControl
from openflexure_microscope_server.utilities import coerce_thing_selector
class ImageFormatInfo(BaseModel):
"""Basic data for image formats."""
media_type: str
extension: str
supported_extensions: tuple[str, ...]
"""All supported extension (lowercase)."""
def path_matches(self, path: str) -> bool:
"""Return True if path matches one of the supported extensions."""
return path.lower().endswith(self.supported_extensions)
BASE_IMAGE_FORMATS: dict[str, ImageFormatInfo] = {
"jpeg": ImageFormatInfo(
media_type="image/jpeg",
extension=".jpeg",
supported_extensions=(".jpeg", ".jpg"),
),
"png": ImageFormatInfo(
media_type="image/png",
extension=".png",
supported_extensions=(".png",),
),
}
def _file_is_capture(filename: str) -> bool:
"""Return whether this filename is a capture."""
return BASE_IMAGE_FORMATS["jpeg"].path_matches(filename) or BASE_IMAGE_FORMATS[
"png"
].path_matches(filename)
class CaptureError(RuntimeError):
"""An error trying to capture from a CameraThing."""
class CaptureParams(BaseModel):
"""A class for capturing at least a single image."""
images_dir: RelativeDataPath
capture_mode: str
class NoImageInMemoryError(RuntimeError):
"""An error called if no image is in memory when accessed."""
class CameraMemoryBuffer:
"""A class that holds images in memory. The images are by default PIL images.
However subclasses of BaseCamera can use this class to store other object types.
"""
_storage: dict[int, tuple[Any, Mapping[str, Any], str]]
def __init__(self) -> None:
"""Create the buffer instance."""
# This dictionary is the main store for data. Dictionaries are ordered since
# Python 3.6, so the order in the dictionary is the capture order
self._storage = {}
# A simple id system where each capture id is just the number of captures since
# the server starts
self._latest_id: int = 0
def add_image(
self,
image: Any,
metadata: Mapping[str, Any],
mode: str,
buffer_max: int = 1,
) -> int:
"""Add an image to the Memory buffer.
This will add an image to the memory buffer. By default the buffer will
be cleared. To allow saving multiple images the buffer_max must be set
every time an image is added.
:param image: The image to add. A PIL image is recommended, but cameras
can choose to use other formats
:param metadata: Optional, a dictionary of the image metadata.
:param buffer_max: The maximum number of images that should be in the buffer
once this images is added. Default is 1.
:returns: The id in the buffer for this image
"""
self._latest_id += 1
self._create_space(buffer_max)
self._storage[self._latest_id] = (image, metadata, mode)
return self._latest_id
def get_image(
self, buffer_id: Optional[int] = None, remove: bool = True
) -> tuple[Any, Mapping[str, Any], str]:
"""Return the image with the given id.
If no id is given the most recent image is returned. However, the
buffer is also cleared, otherwise it would be possible to accidentally
retrieve images out of order.
:param buffer_id: The buffer id of the image to retrieve
:param remove: True (default) to remove this image from the buffer, False
to leave the image in the buffer.
"""
# No id given
if buffer_id is None:
# Get the latest image and metadata tuple from storage
try:
image_tuple = list(self._storage.values())[-1]
except IndexError as e:
raise NoImageInMemoryError("No image in memory to retrieve.") from e
# Clear the storage so images don't get retrieved out of order
self._storage.clear()
return image_tuple
try:
if remove:
return self._storage.pop(buffer_id)
return self._storage[buffer_id]
except KeyError as e:
raise NoImageInMemoryError(
"No image with matching id in memory to retrieve."
) from e
def clear(self) -> None:
"""Clear all images from memory."""
self._storage.clear()
def _create_space(self, buffer_max: int) -> None:
"""Create space to add an image.
:param buffer_max: The maximum number of images that should be in the buffer
once another images is added.
"""
# If only one image to be stored just clear the storage and return
if buffer_max <= 1:
self._storage.clear()
return
# Number to remove to get the storage down to 1 less than the buffer length
to_remove = len(self._storage) - (buffer_max - 1)
# If if there is space. Nothing to do, just return
if to_remove < 1:
return
keys_to_remove = list(self._storage.keys())[:to_remove]
for key in keys_to_remove:
del self._storage[key]
class StreamingMode(BaseModel):
"""Description of streaming modes for the camera.
Cameras can sub class this to store camera specific information about the mode.
"""
description: str
class CaptureMode(BaseModel):
"""Description of still capture modes for the camera.
Cameras can sub class this to store camera specific information about the mode.
"""
description: str
save_resolution: Optional[tuple[int, int]] = None
"""The resolution to save the image. Use None to save as captured."""
class CaptureGalleryInfo(BaseModel):
"""Summary information for the UI about an image."""
name: str
created: float
modified: float
thing: str
card_type: Literal["Capture"] = "Capture"
class MJPEGStreamWithTimestamp(lt.outputs.MJPEGStream):
"""An MJPEG stream where the frame time can be recorded with the frame.
This can be used to capture when the image was taken by the sensor if known
rather than take the time it is added into the ring buffer.
"""
def add_frame(self, frame: bytes, timestamp: Optional[datetime]) -> None:
"""Add a JPEG to the MJPEG stream.
Modify the standard function to have an option to send in the capture time.
:param frame: The frame to add
:param timestamp: The time the frame was captured.
:raise ValueError: if the supplied frame does not start with the JPEG
start bytes and end with the end bytes.
"""
if not (
frame[0] == 0xFF
and frame[1] == 0xD8
and frame[-2] == 0xFF
and frame[-1] == 0xD9
):
raise ValueError("Invalid JPEG")
with self._lock:
entry = self._ringbuffer[(self.last_frame_i + 1) % len(self._ringbuffer)]
entry.timestamp = timestamp if timestamp is not None else datetime.now()
entry.frame = frame
entry.index = self.last_frame_i + 1
self._thing_server_interface.start_async_task_soon(
self.notify_new_frame, entry.index
)
class BaseCamera(OFMThing, ABC):
"""The base class for all cameras. All cameras must directly inherit from this class.
The connection to the camera hardware should be added to the ``__enter__`` method not
``__init__`` method of the subclass.
"""
_class_settings = {"validate_properties_on_set": True}
_all_background_detectors: Mapping[str, BackgroundDetectAlgorithm] = lt.thing_slot()
mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
_memory_buffer = CameraMemoryBuffer()
supports_focus_fom: bool = False
supported_image_formats = deepcopy(BASE_IMAGE_FORMATS)
def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None:
"""Initialise the base camera, this creates the background detectors.
This must be run by all child camera classes.
To add a new background detector to the server it must be added to the
dictionary in this function. Configuration will be added at a later date.
"""
super().__init__(thing_server_interface)
# Default is never updated but is used if the value set from settings is
# incorrect. In the future a better way to set defaults for thing slot mappings
# would be ideal.
self._default_background_detector = "bg_channel_deviations_luv"
self._background_detector_name: Optional[str] = None
self._framerate_monitor_running = False
required_modes = ("default", "full_resolution")
if not all(mode in self.streaming_modes for mode in required_modes):
raise KeyError(
f"Camera {type(self).__name__} doesn't define both a 'default' and a "
"'full_resolution' streaming mode."
)
def __enter__(self) -> Self:
"""Open hardware connection when the Thing context manager is opened."""
super().__enter__()
self._background_detector_name = coerce_thing_selector(
thing_mapping=self._all_background_detectors,
selected=self._background_detector_name,
default=self._default_background_detector,
)
return self
def __exit__(
self,
_exc_type: type[BaseException],
_exc_value: Optional[BaseException],
_traceback: Optional[TracebackType],
) -> None:
"""Close hardware connection when the Thing context manager is closed."""
pass
@lt.endpoint(
"get",
"snapshot",
responses={
200: {
"description": "A snapshot of the microscope stream",
"content": {"image/jpeg": {}},
},
},
)
async def snapshot(self) -> Response:
"""Return a snapshot from the microscope."""
jpeg_data = await self.lores_mjpeg_stream.grab_frame()
return Response(content=jpeg_data, media_type="image/jpeg")
# Register with gallery.
_show_data_in_gallery = True
@property
def gallery_data_schema(self) -> type[CaptureGalleryInfo]:
"""The schema (BaseModel) for passing data to the gallery."""
return CaptureGalleryInfo
def _all_captures(self) -> list[str]:
"""Return the full path for all captures on disk."""
files = os.listdir(self._data_dir)
captures = []
for filename in files:
full_path = os.path.join(self.data_dir, filename)
if os.path.isfile(full_path) and _file_is_capture(filename):
captures.append(full_path)
return captures
def get_data_for_gallery(self) -> list[CaptureGalleryInfo]:
"""Return all the information about the saved captures."""
return [
CaptureGalleryInfo(
name=os.path.basename(capture),
created=os.path.getctime(capture),
modified=os.path.getmtime(capture),
thing=self.name,
)
for capture in self._all_captures()
]
def delete_all_gallery_items(self) -> None:
"""Delete all the captures on the microscope.
Use with extreme caution.
"""
for capture in self._all_captures():
lt.raise_if_cancelled()
self.logger.info(f"Deleting: {capture}")
os.remove(capture)
def get_gallery_bulk_actions(self) -> list[ActionButton]:
"""Return the bulk gallery actions for cameras.
By default there are no bulk actions.
"""
return []
@lt.endpoint(
"delete",
"capture/{name}",
responses={
200: {"description": "Successfully deleted capture"},
400: {"description": "An error occurred while trying to delete capture"},
},
)
def delete_capture(self, name: str) -> None:
"""Delete the specified capture.
This endpoint allows captures to be deleted from disk.
:param name: The name of the capture to delete
"""
if not _file_is_capture(name):
self.logger.warning(f"{name} is not an image file.")
raise HTTPException(400, f"{name} is not an image file.")
full_path = os.path.normpath(os.path.join(self.data_dir, name))
try:
os.remove(full_path)
except IOError as e:
self.logger.warning(f"Failed to delete {name}.")
raise HTTPException(
400, "Couldn't delete capture, check log for details"
) from e
@property
def focus_fom(self) -> int:
"""Return the focus figure of merit.
This returns a NotImplementedError if not supported by the camera.
To use, requires self.supports_focus_fom to be set to True.
"""
raise NotImplementedError(
"This camera does not support Figure of Merit focusing."
)
@lt.property
def calibration_required(self) -> bool:
"""Whether the camera needs calibrating.
This always returns False in BaseCamera. It should be reimplemented by child
classes if calibration is required.
"""
return False
@lt.property
def streaming_modes(self) -> Mapping[str, StreamingMode]:
"""Modes the camera can stream in."""
return {
"default": StreamingMode(
description=(
"The standard mode that balances capture resolution and stream "
"size."
)
),
"full_resolution": StreamingMode(
description=(
"Streaming the camera in full resolution. For this camera, this is "
"identical to the default mode."
)
),
}
streaming_mode: str = lt.property(default="default", readonly=True)
@lt.action
def change_streaming_mode(self, mode: str = "default") -> None:
"""Change the mode the camera is streaming in.
:param mode: Must be a key from ``streaming_modes``
When creating a subclass. Subclass the method ``_start_streaming`` rather than
this method.
"""
if not self.stream_active:
raise RuntimeError(
"Cannot change streaming mode before streaming is started."
)
if mode not in self.streaming_modes:
self.logger.warning(
f"Streaming mode {mode}, is not known. Expecting a mode from "
f"{self.streaming_modes.keys()}. Streaming in default mode."
)
mode = "default"
if mode != self.streaming_mode:
self._start_streaming(mode=mode)
@abstractmethod
def _start_streaming(self, mode: str = "default") -> None:
"""Start (or stop and restart) the camera."""
def kill_mjpeg_streams(self) -> None:
"""Kill the streams now as the server is shutting down.
This is called when uvicorn gets the a shutdown signal. As this is called from
the event loop it cannot interact with the our ThingProperties or run
``self.mjpeg_stream.stop()`` as the portal cannot be called from this loop.
Instead we just set the ``_streaming`` value to False. This stops the async frame
generator when the next frame notifies.
"""
if self.stream_active:
self.mjpeg_stream._streaming = False
self.lores_mjpeg_stream._streaming = False
async def _monitor_framerate(
self,
duration: float,
sample_interval: float = 0.1,
) -> tuple[float, int, list[dict[str, float | int]]]:
"""Asynchronously monitor the timing on incoming frames."""
start_time = time.time()
last_sample_time = start_time
last_sample_frames = 0
frames = 0
samples = []
async for frame in self.mjpeg_stream.frame_async_generator():
if not self._framerate_monitor_running:
break
now = time.time()
frames += 1
if now - last_sample_time >= sample_interval:
interval = now - last_sample_time
fps = (frames - last_sample_frames) / interval
samples.append(
{
"timestamp": now,
"frame_count": frames,
"frame_size_bytes": len(frame),
"instant_fps": fps,
}
)
last_sample_time = now
last_sample_frames = frames
if now - start_time >= duration:
break
total_time = time.time() - start_time
return total_time, frames, samples
@lt.action
def record_framerate(
self,
duration: float = 5.0,
) -> str:
"""Record MJPEG stream framerate statistics."""
output_dir = os.path.join(
self.data_dir,
"characterisation",
"framerate",
)
os.makedirs(output_dir, exist_ok=True)
timestamp = time.strftime("%Y%m%d_%H%M%S")
datafile_path = os.path.join(
output_dir,
f"framerate_{timestamp}.json",
)
self.logger.info(
"Framerate monitor started -> %s",
datafile_path,
)
self._framerate_monitor_running = True
# This runs as an async task, which we wait to complete
try:
total_time, frames, samples = self._thing_server_interface.call_async_task(
self._monitor_framerate,
duration,
)
finally:
self._framerate_monitor_running = False
avg_fps = frames / total_time if total_time > 0 else 0
data = {
"summary": {
"total_duration": total_time,
"total_frames": frames,
"avg_fps": avg_fps,
},
"samples": samples,
}
self.logger.info(
("Framerate monitor results: duration=%.2fs, frames=%d, avg_fps=%.2f"),
total_time,
frames,
avg_fps,
)
with open(datafile_path, "w") as f:
json.dump(data, f, indent=2)
self.logger.info(
"Framerate monitor complete -> %s",
datafile_path,
)
return datafile_path
@abstractmethod
@lt.property
def stream_active(self) -> bool:
"""Whether the MJPEG stream is active."""
@abstractmethod
@lt.action
def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh."""
@lt.action
def grab_jpeg(
self,
stream_name: Literal["main", "lores"] = "main",
) -> lt.blob.Blob:
"""Acquire one image from the preview stream and return as blob of JPEG data.
Note: in rare cases the JPEG stream may be broken. This can cause an OS error
when loading the image. If loading with PIL, as long as the header data is
complete, this error will not be raised until the data is accessed. Consider
using ``grab_jpeg_as_array`` instead.
This differs from ``capture`` in that it does not pause the MJPEG
preview stream. Instead, we simply return the next frame from that
stream (either "main" for the preview stream, or "lores" for the low
resolution preview). No metadata is returned.
"""
stream = (
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
)
frame = self._thing_server_interface.call_async_task(stream.grab_frame)
blob = lt.blob.Blob.from_bytes(frame)
blob.media_type = BASE_IMAGE_FORMATS["jpeg"].media_type
return blob
@lt.action
def grab_as_array(
self,
stream_name: Literal["main", "lores"] = "main",
) -> NDArray:
"""Acquire one image from the preview stream and return as an array.
It works like ``grab_jpeg`` but reliably handles broken streams. Prefer using
this method over directly grabbing the frame and converting to a numpy array
via PIL.
This differs from ``capture_as_array`` in that it does not pause the MJPEG
preview stream.
"""
stream = (
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
)
tries = 0
while tries < 3:
try:
frame = self._thing_server_interface.call_async_task(stream.grab_frame)
return np.asarray(Image.open(io.BytesIO(frame)))
except OSError:
tries += 1
raise OSError("Could not open frames from MJPEG stream.")
@lt.action
def grab_jpeg_size(
self,
stream_name: Literal["main", "lores"] = "main",
) -> int:
"""Acquire one image from the preview stream and return its size."""
stream = (
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
)
return self._thing_server_interface.call_async_task(stream.next_frame_size)
@lt.property
def capture_modes(self) -> Mapping[str, CaptureMode]:
"""Modes the camera can use for capturing.
Cameras can override this to get more specific modes.
"""
return {
"quick": CaptureMode(
description="Capture without altering the stream settings.",
),
"standard": CaptureMode(description="The standard capture mode."),
}
def _validate_capture_mode(self, capture_mode: str) -> str:
"""Check input capture mode exists, always returns a valid mode.
:param capture_mode: The capture mode to check. If this isn't valid a warning
will be logged.
:return: The input capture mode if it is supported, or "standard".
"""
if capture_mode not in self.capture_modes:
name = type(self).__name__
self.logger.warning(
f"{name} has no capture mode {capture_mode}. Using the 'standard' "
"capture mode instead."
)
capture_mode = "standard"
return capture_mode
@abstractmethod
@lt.action
def capture_as_array(
self,
capture_mode: str = "standard",
raw: bool = False,
) -> NDArray:
"""Acquire one image from the camera and return as an array."""
downsampled_array_factor: int = lt.property(default=2, ge=1)
"""The downsampling factor when calling capture_downsampled_array."""
@lt.action
def capture_downsampled_array(self) -> NDArray:
"""Acquire one image from the camera, downsample, and return as an array.
* The array is downsampled by the thing property `downsampled_array_factor`.
* The default capture array arguments are used.
This method provides the interface expected by the camera_stage_mapping.
"""
img = self.capture_as_array(capture_mode="quick")
return downsample(self.downsampled_array_factor, img)
@lt.action
def capture(
self,
capture_mode: str = "standard",
image_format: str = "jpeg",
retain_image: bool = True,
) -> lt.blob.Blob:
"""Acquire one image from the camera.
This will use the internal capture image functionally of _capture_image of
the specific camera being used.
:param capture_mode: The mode to use, must be one of ``capture_modes``.
:param image_format: The image format to use, must be one of
``supported_image_formats``
:param retain_image: (Default True) True to save image to the microscope,
False to only save temporarily for transfer.
:returns: A LabThings Blob that with access to the captured file.
"""
format_info = self.supported_image_formats[image_format]
fname = datetime.now().strftime("%Y-%m-%d-%H%M%S") + format_info.extension
path = RelativeDataPath(fname)
tmpdir = None
if retain_image:
path.set_saving_thing(self)
else:
tmpdir = path.save_to_tempdir()
self.capture_and_save_to_path(path, capture_mode)
if tmpdir is None:
blob = lt.blob.Blob.from_file(path.abs_data_path)
else:
blob = lt.blob.Blob.from_temporary_directory(tmpdir, fname)
blob.media_type = format_info.media_type
return blob
def capture_and_save_to_path(
self,
path: RelativeDataPath,
capture_mode: str = "standard",
) -> None:
"""Capture an image and save it to disk.
This is not an action as it exposes a direct path for saving
:param path: The path to save the file to, this should be a ``RelativeDataPath``
object. If the saving Thing is not set for the path, the camera's data
directory will be used.
:param capture_mode: (Optional) The name of the capture mode as defined by the
camera.
"""
buffer_id = self.capture_to_memory(capture_mode=capture_mode)
self.save_from_memory(path=path, buffer_id=buffer_id)
@lt.action
def capture_to_memory(
self, capture_mode: str = "standard", buffer_max: int = 1, max_attempts: int = 5
) -> int:
"""Capture an image to memory. This can be saved later with ``save_from_memory``.
Note that only one image is held in memory so this will overwrite any image
in memory.
:param buffer_max: The maximum number of images that should be in the buffer
once this images is added. Default is 1.
:param max_attempts: The maximum number of times to attempt the capture.
:returns: the buffer id of the image captured
"""
success = False
for capture_attempts in range(max_attempts):
try:
ofm_metadata = self._collect_ofm_metadata()
image = self._capture_image(capture_mode=capture_mode)
success = True
break
except TimeoutError:
self.logger.warning(
f"Attempt {capture_attempts + 1} to capture image timed out. "
"Do you have enough RAM?"
)
if not success:
raise CaptureError(
f"An error occurred while capturing after {max_attempts} attempts"
)
return self._memory_buffer.add_image(
image, ofm_metadata, capture_mode, buffer_max=buffer_max
)
@lt.action
def save_from_memory(
self,
path: RelativeDataPath,
buffer_id: Optional[int] = None,
) -> None:
"""Save an image that has been captured to memory.
Note this is not exposed as an action as it allows arbitrary paths on disk to
be written to.
:param path: The path to save the file to
:param buffer_id: The buffer id of the image to save, this was returned by
``capture_to_memory``
"""
image, metadata, mode = self._memory_buffer.get_image(buffer_id)
mode_info = self.capture_modes[mode]
save_resolution = mode_info.save_resolution
path.set_saving_thing_if_unset(self)
resolved_path = path.abs_data_path
if save_resolution is not None and image.size != save_resolution:
image = image.resize(save_resolution, Image.Resampling.BOX)
try:
save_kwargs: dict[str, Any] = {}
if BASE_IMAGE_FORMATS["jpeg"].path_matches(resolved_path):
# Per PIL documentation,
# (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg)
# there are two factors when saving a JPEG. Subsampling affects the colour,
# quality affects the pixels.
# subsampling = 0 disables subsampling of colour
# quality = 95 is the maximum recommended - above this, JPEG compression is
# disabled, file size increases and quality is barely or not affected
save_kwargs = {"quality": 95, "subsampling": 0}
if (
BASE_IMAGE_FORMATS["png"].path_matches(resolved_path)
and image.mode == "RGBX"
):
image = image.convert("RGB")
image.save(resolved_path, **save_kwargs)
try:
self._add_metadata_to_capture(resolved_path, dict(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.
self.logger.exception(f"Failed to add metadata to {resolved_path}")
except Exception as e:
raise IOError(f"An error occurred while saving {resolved_path}") from e
@abstractmethod
def _capture_image(self, capture_mode: str = "standard") -> Image.Image:
"""Capture a PIL image from the camera.
This unlike the ``grab_*`` methods this may pause the stream or temporarily
switch streaming mode to capture the image if required by the mode.
"""
@lt.action
def clear_buffers(self) -> None:
"""Clear all images in memory."""
self._memory_buffer.clear()
def _collect_ofm_metadata(self) -> dict:
"""Return the metadata for a capture.
This is information from the thing states, the time, and make/model names.
"""
metadata = self._thing_server_interface.get_thing_states()
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, 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(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), path)
settling_time: float = lt.setting(default=0.2, ge=0)
"""The settling time when calling the ``settle()`` method."""
@lt.action
def settle(self) -> None:
"""Sleep for the settling time, ready to provide a fresh frame.
This function will sleep for the given time, and clear the buffer after sleeping.
As such, the next frame captured from the camera after running this function will
always be captured after settling.
This method provides the interface expected by the camera_stage_mapping.
"""
time.sleep(self.settling_time)
self.discard_frames()
@lt.property
def primary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions for both calibration wizard and settings panel."""
return []
@lt.property
def secondary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions that appear only in settings panel."""
return []
@lt.property
def manual_camera_settings(self) -> list[PropertyControl]:
"""The camera settings to expose as property controls in the settings panel."""
return []
# Note that the default detector name is set at init. This is over written if
# setting is loaded from disk.
@lt.setting
def background_detector_name(self) -> Optional[str]:
"""The name of the active background selector."""
return self._background_detector_name
@background_detector_name.setter
def _set_background_detector_name(self, name: Optional[str]) -> None:
"""Validate and set background_detector_name."""
if name not in self._all_background_detectors:
self.logger.warning(f"{name} is not a valid background detector name.")
return
self._background_detector_name = name
@property
def background_detector(self) -> Optional[BackgroundDetectAlgorithm]:
"""The active background detector instance."""
if self.background_detector_name is None:
return None
return self._all_background_detectors[self.background_detector_name]
@lt.action
def image_is_sample(self) -> tuple[bool, str]:
"""Label the current image as either background or sample."""
if self.background_detector is None:
raise RuntimeError("No background detectors available.")
current_image = self.grab_as_array(stream_name="lores")
return self.background_detector.image_is_sample(current_image)
@lt.action
def set_background(self) -> None:
"""Grab an image, and use its statistics to set the background.
This should be run when the microscope is looking at an empty region,
and will calculate the mean and standard deviation of the pixel values
in the LUV colourspace. These values will then be used to compare
future images to the distribution, to determine if each pixel is
foreground or background.
"""
if self.background_detector is None:
raise RuntimeError("No background detectors available.")
background = self.grab_as_array(stream_name="lores")
self.background_detector.set_background(background)
@property
def thing_state(self) -> Mapping[str, Any]:
"""Return camera-specific metadata.
By default, this just adds the subclass name as the camera type.
Subclasses can extend by overriding this property and calling super().thing_state.
"""
return {"camera": self.__class__.__name__}
def downsample(factor: int, image: np.ndarray) -> np.ndarray:
"""Downsample an image by taking the mean of each nxn region.
This should be very efficient:
* calculate each pixel as the mean of each ``factor * factor`` square without
interpolation.
* If the image is not an integer multiple of the resampling factor, discard
the left-over pixels to avoid odd edge effects and keep performance quick.
"""
if factor == 1:
return image
new_size = [d // factor for d in image.shape[:2]]
# First, we ensure we have something that's an integer multiple
# of `factor`
cropped = image[: new_size[0] * factor, : new_size[1] * factor, ...]
reshaped = cropped.reshape(
(new_size[0], factor, new_size[1], factor) + image.shape[2:]
)
return reshaped.mean(axis=(1, 3))