Merge branch 'camera_modes' into 'v3'

Update camera API to use defined capture modes

Closes #424, #436, #786, #405, and #593

See merge request openflexure/openflexure-microscope-server!601
This commit is contained in:
Joe Knapper 2026-06-16 13:53:04 +00:00
commit d3c4773884
23 changed files with 992 additions and 492 deletions

Binary file not shown.

View file

@ -5,9 +5,13 @@ with other Things and including them in the LabThings-FastAPI config file.
""" """
import os import os
from pathlib import PurePath
from tempfile import TemporaryDirectory
from types import TracebackType from types import TracebackType
from typing import Optional, Self from typing import Optional, Self
from pydantic import PrivateAttr, RootModel, model_validator
import labthings_fastapi as lt import labthings_fastapi as lt
@ -34,6 +38,8 @@ class OFMThing(lt.Thing):
self._data_dir = os.path.join( self._data_dir = os.path.join(
os.path.normpath(str(app_data_dir)), os.path.normpath(self.name) os.path.normpath(str(app_data_dir)), os.path.normpath(self.name)
) )
if not os.path.exists(self.data_dir):
os.makedirs(self.data_dir)
return self return self
def __exit__( def __exit__(
@ -57,3 +63,99 @@ class OFMThing(lt.Thing):
"No data directory set. Has the LabThings server been started?" "No data directory set. Has the LabThings server been started?"
) )
return self._data_dir return self._data_dir
def create_data_path(self, path: str, absolute: bool = False) -> "RelativeDataPath":
"""Create a ``RelativeDataPath`` object with this Thing set as the saving Thing.
:param path: The relative path within the data directory of this Thing's data
dir that the data should be saved to.
:param absolute: Set to True if the current path is absolute. A relative path
will be returned. A validation error will be raised if the absolute path
is not within the data directory.
:return: A ``RelativeDataPath`` object with the saving Thing already set.
"""
if absolute:
path = os.path.relpath(path, self.data_dir)
rel_data_path = RelativeDataPath(path)
rel_data_path.set_saving_thing(self)
return rel_data_path
class RelativeDataPath(RootModel[str]):
"""A relative path that is validated, and can have a Thing assigned to it.
Use ``set_saving_thing`` or ``set_saving_thing_if_unset`` to set the Thing whose
data directory will be used for the final save.
Use the ``abs_data_path`` property to get the final path for saving.
"""
_saving_thing: Optional[OFMThing | TemporaryDirectory] = PrivateAttr(default=None)
@model_validator(mode="before")
@classmethod
def validate_relative_path(cls, value: str) -> str:
"""Validate the relative path is relative and has no parent dir references."""
p = PurePath(value)
if p.is_absolute():
raise ValueError("Absolute paths are not allowed")
if ".." in p.parts:
raise ValueError("Parent directory references are not allowed")
return os.path.normpath(value)
@property
def save_location_set(self) -> bool:
"""Return True if the saving thing is set."""
return self._saving_thing is not None
def set_saving_thing(self, thing: OFMThing | TemporaryDirectory) -> None:
"""Set the Thing that is saving the data.
The thing can also be a ``TemporaryDirectory`` object.
This will set the data directory.
"""
if self.save_location_set:
raise RuntimeError("The saving Thing for the relative path is already set")
self._saving_thing = thing
def set_saving_thing_if_unset(self, thing: OFMThing) -> None:
"""Set the Thing that is saving the data if it is not already set.
Use this in an action to set the Thing for paths set via the API.
"""
if not self.save_location_set:
self._saving_thing = thing
def save_to_tempdir(self) -> TemporaryDirectory:
"""Use a temporary directory to save raher than an ``OFMThing``.
:returns: the ``TemporaryDirectory`` object.
"""
if self.save_location_set:
raise RuntimeError("The saving Thing for the relative path is already set")
self._saving_thing = TemporaryDirectory()
return self._saving_thing
def join(self, sub_path: str) -> "RelativeDataPath":
"""Join a path to the end of this path.
:return: A new ``RelativeDataPath`` object with the path appended.
"""
new_path = RelativeDataPath(os.path.join(self.root, sub_path))
if self._saving_thing is not None:
new_path.set_saving_thing(self._saving_thing)
return new_path
@property
def abs_data_path(self) -> str:
"""The absolute data directory to save to."""
if self._saving_thing is None:
raise RuntimeError("The saving Thing for the relative path was never set")
if isinstance(self._saving_thing, TemporaryDirectory):
return os.path.join(self._saving_thing.name, self.root)
return os.path.join(self._saving_thing.data_dir, self.root)

View file

@ -9,7 +9,6 @@ See repository root for licensing information.
import enum import enum
import logging import logging
import os
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from types import TracebackType from types import TracebackType
@ -737,11 +736,9 @@ class AutofocusThing(lt.Thing):
# Loop through the range, saving each capture to disk # Loop through the range, saving each capture to disk
for capture in captures[slice_to_save]: for capture in captures[slice_to_save]:
self._cam.save_from_memory( path = capture_parameters.images_dir.join(capture.filename)
jpeg_path=os.path.join(capture_parameters.images_dir, capture.filename), self._cam.save_from_memory(path=path, buffer_id=capture.buffer_id)
save_resolution=capture_parameters.save_resolution,
buffer_id=capture.buffer_id,
)
self._cam.clear_buffers() self._cam.clear_buffers()
return sharpest_index return sharpest_index
@ -823,7 +820,9 @@ class AutofocusThing(lt.Thing):
camera buffer_id needed for saving. camera buffer_id needed for saving.
""" """
stage_location = self._stage.position stage_location = self._stage.position
buffer_id = self._cam.capture_to_memory(buffer_max=buffer_max) buffer_id = self._cam.capture_to_memory(
capture_mode="standard", buffer_max=buffer_max
)
return CaptureInfo( return CaptureInfo(
buffer_id=buffer_id, buffer_id=buffer_id,
position=stage_location, position=stage_location,
@ -956,14 +955,8 @@ class AutofocusThing(lt.Thing):
# Save all captures # Save all captures
for capture in captures: for capture in captures:
self._cam.save_from_memory( path = capture_parameters.images_dir.join(capture.filename)
jpeg_path=os.path.join( self._cam.save_from_memory(path=path, buffer_id=capture.buffer_id)
capture_parameters.images_dir,
capture.filename,
),
save_resolution=capture_parameters.save_resolution,
buffer_id=capture.buffer_id,
)
self._cam.clear_buffers() self._cam.clear_buffers()

View file

@ -11,22 +11,22 @@ from __future__ import annotations
import io import io
import json import json
import os import os
import tempfile
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from copy import deepcopy
from datetime import datetime from datetime import datetime
from types import TracebackType from types import TracebackType
from typing import Annotated, Any, Literal, Mapping, Optional, Self, Tuple from typing import Any, Literal, Mapping, Optional, Self
import numpy as np import numpy as np
import piexif import piexif
from PIL import Image from PIL import Image
from pydantic import BaseModel, Field from pydantic import BaseModel
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
from openflexure_microscope_server.things import OFMThing from openflexure_microscope_server.things import OFMThing, RelativeDataPath
from openflexure_microscope_server.things.background_detect import ( from openflexure_microscope_server.things.background_detect import (
BackgroundDetectAlgorithm, BackgroundDetectAlgorithm,
) )
@ -34,31 +34,42 @@ from openflexure_microscope_server.ui import ActionButton, PropertyControl
from openflexure_microscope_server.utilities import coerce_thing_selector from openflexure_microscope_server.utilities import coerce_thing_selector
class JPEGBlob(lt.blob.Blob): class ImageFormatInfo(BaseModel):
"""A class representing a JPEG image as a LabThings FastAPI Blob.""" """Basic data for image formats."""
media_type: str = "image/jpeg" 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)
class PNGBlob(lt.blob.Blob): BASE_IMAGE_FORMATS: dict[str, ImageFormatInfo] = {
"""A class representing a PNG image as a LabThings FastAPI Blob.""" "jpeg": ImageFormatInfo(
media_type="image/jpeg",
media_type: str = "image/png" extension=".jpeg",
supported_extensions=(".jpeg", ".jpg"),
),
"png": ImageFormatInfo(
media_type="image/png",
extension=".png",
supported_extensions=(".png",),
),
}
class CaptureError(RuntimeError): class CaptureError(RuntimeError):
"""An error trying to capture from a CameraThing.""" """An error trying to capture from a CameraThing."""
PositiveInt = Annotated[int, Field(ge=1)]
NonEmptyString = Annotated[str, Field(min_length=1)]
class CaptureParams(BaseModel): class CaptureParams(BaseModel):
"""A class for capturing at least a single image.""" """A class for capturing at least a single image."""
images_dir: NonEmptyString images_dir: RelativeDataPath
save_resolution: tuple[PositiveInt, PositiveInt] capture_mode: str
class NoImageInMemoryError(RuntimeError): class NoImageInMemoryError(RuntimeError):
@ -71,7 +82,7 @@ class CameraMemoryBuffer:
However subclasses of BaseCamera can use this class to store other object types. However subclasses of BaseCamera can use this class to store other object types.
""" """
_storage: dict[int, tuple[Any, Mapping[str, Any]]] _storage: dict[int, tuple[Any, Mapping[str, Any], str]]
def __init__(self) -> None: def __init__(self) -> None:
"""Create the buffer instance.""" """Create the buffer instance."""
@ -86,6 +97,7 @@ class CameraMemoryBuffer:
self, self,
image: Any, image: Any,
metadata: Mapping[str, Any], metadata: Mapping[str, Any],
mode: str,
buffer_max: int = 1, buffer_max: int = 1,
) -> int: ) -> int:
"""Add an image to the Memory buffer. """Add an image to the Memory buffer.
@ -104,12 +116,12 @@ class CameraMemoryBuffer:
""" """
self._latest_id += 1 self._latest_id += 1
self._create_space(buffer_max) self._create_space(buffer_max)
self._storage[self._latest_id] = (image, metadata) self._storage[self._latest_id] = (image, metadata, mode)
return self._latest_id return self._latest_id
def get_image( def get_image(
self, buffer_id: Optional[int] = None, remove: bool = True self, buffer_id: Optional[int] = None, remove: bool = True
) -> tuple[Any, Mapping[str, Any]]: ) -> tuple[Any, Mapping[str, Any], str]:
"""Return the image with the given id. """Return the image with the given id.
If no id is given the most recent image is returned. However, the If no id is given the most recent image is returned. However, the
@ -169,12 +181,23 @@ class CameraMemoryBuffer:
class StreamingMode(BaseModel): class StreamingMode(BaseModel):
"""Description of streaming modes for the camera. """Description of streaming modes for the camera.
Cameras can sub class this to record camera specific information about the mode. Cameras can sub class this to store camera specific information about the mode.
""" """
description: str 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 BaseCamera(OFMThing, ABC): class BaseCamera(OFMThing, ABC):
"""The base class for all cameras. All cameras must directly inherit from this class. """The base class for all cameras. All cameras must directly inherit from this class.
@ -191,6 +214,8 @@ class BaseCamera(OFMThing, ABC):
_memory_buffer = CameraMemoryBuffer() _memory_buffer = CameraMemoryBuffer()
supports_focus_fom: bool = False supports_focus_fom: bool = False
supported_image_formats = deepcopy(BASE_IMAGE_FORMATS)
def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None: def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None:
"""Initialise the base camera, this creates the background detectors. """Initialise the base camera, this creates the background detectors.
@ -205,8 +230,10 @@ class BaseCamera(OFMThing, ABC):
# would be ideal. # would be ideal.
self._default_background_detector = "bg_channel_deviations_luv" self._default_background_detector = "bg_channel_deviations_luv"
self._background_detector_name: Optional[str] = None self._background_detector_name: Optional[str] = None
self._framerate_monitor_running = False
if "default" and "full_resolution" not in self.streaming_modes: required_modes = ("default", "full_resolution")
if not all(mode in self.streaming_modes for mode in required_modes):
raise KeyError( raise KeyError(
f"Camera {type(self).__name__} doesn't define both a 'default' and a " f"Camera {type(self).__name__} doesn't define both a 'default' and a "
"'full_resolution' streaming mode." "'full_resolution' streaming mode."
@ -429,97 +456,11 @@ class BaseCamera(OFMThing, ABC):
def discard_frames(self) -> None: def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh.""" """Discard frames so that the next frame captured is fresh."""
@abstractmethod
@lt.action
def capture_array(
self,
stream_name: Literal["main", "lores", "raw", "full"] = "main",
wait: Optional[float] = 5,
) -> 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 downsamples 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_array()
return downsample(self.downsampled_array_factor, img)
@lt.action
def capture_jpeg(
self,
stream_name: Literal["main", "lores", "full"] = "main",
wait: Optional[float] = None,
) -> JPEGBlob:
"""Acquire one image from the camera as a JPEG.
This will use the internal capture image functionally of capture_image of
the specific camera being used.
:param stream_name: A stream name supported by this camera.
:param wait: (Optional, float) Set a timeout in seconds. If None it will
use the default for the underlying camera.
"""
fname = datetime.now().strftime("%Y-%m-%d-%H%M%S.jpeg")
directory = tempfile.TemporaryDirectory()
jpeg_path = os.path.join(directory.name, fname)
img = self.capture_image(stream_name, wait)
capture_metadata = self._capture_metadata()
self._save_capture(
path=jpeg_path,
image=img,
metadata=capture_metadata,
)
return JPEGBlob.from_temporary_directory(directory, fname)
@lt.action
def capture_png(
self,
stream_name: Literal["main", "lores", "full"] = "main",
wait: Optional[float] = None,
) -> PNGBlob:
"""Acquire one image from the camera as a PNG.
This will use the internal capture image functionally of capture_image of
the specific camera being used.
:param stream_name: A stream name supported by this camera.
:param wait: (Optional, float) Set a timeout in seconds. If None it will
use the default for the underlying camera.
"""
fname = datetime.now().strftime("%Y-%m-%d-%H%M%S.jpeg")
directory = tempfile.TemporaryDirectory()
png_path = os.path.join(directory.name, fname)
img = self.capture_image(stream_name, wait)
capture_metadata = self._capture_metadata()
self._save_capture(
path=png_path,
image=img,
metadata=capture_metadata,
)
return PNGBlob.from_temporary_directory(directory, fname)
@lt.action @lt.action
def grab_jpeg( def grab_jpeg(
self, self,
stream_name: Literal["main", "lores"] = "main", stream_name: Literal["main", "lores"] = "main",
) -> JPEGBlob: ) -> lt.blob.Blob:
"""Acquire one image from the preview stream and return as blob of JPEG data. """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 Note: in rare cases the JPEG stream may be broken. This can cause an OS error
@ -527,7 +468,7 @@ class BaseCamera(OFMThing, ABC):
complete, this error will not be raised until the data is accessed. Consider complete, this error will not be raised until the data is accessed. Consider
using ``grab_jpeg_as_array`` instead. using ``grab_jpeg_as_array`` instead.
This differs from ``capture_jpeg`` in that it does not pause the MJPEG This differs from ``capture`` in that it does not pause the MJPEG
preview stream. Instead, we simply return the next frame from that preview stream. Instead, we simply return the next frame from that
stream (either "main" for the preview stream, or "lores" for the low stream (either "main" for the preview stream, or "lores" for the low
resolution preview). No metadata is returned. resolution preview). No metadata is returned.
@ -536,7 +477,9 @@ class BaseCamera(OFMThing, ABC):
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
) )
frame = self._thing_server_interface.call_async_task(stream.grab_frame) frame = self._thing_server_interface.call_async_task(stream.grab_frame)
return JPEGBlob.from_bytes(frame) blob = lt.blob.Blob.from_bytes(frame)
blob.media_type = BASE_IMAGE_FORMATS["jpeg"].media_type
return blob
@lt.action @lt.action
def grab_as_array( def grab_as_array(
@ -549,7 +492,7 @@ class BaseCamera(OFMThing, ABC):
this method over directly grabbing the frame and converting to a numpy array this method over directly grabbing the frame and converting to a numpy array
via PIL. via PIL.
This differs from ``capture_array`` in that it does not pause the MJPEG This differs from ``capture_as_array`` in that it does not pause the MJPEG
preview stream. preview stream.
""" """
stream = ( stream = (
@ -575,32 +518,118 @@ class BaseCamera(OFMThing, ABC):
) )
return self._thing_server_interface.call_async_task(stream.next_frame_size) 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."""
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 @abstractmethod
def capture_image( @lt.action
def capture_as_array(
self, self,
stream_name: Literal["main", "lores", "full"], capture_mode: str = "standard",
wait: Optional[float] = None, raw: bool = False,
) -> Image.Image: ) -> NDArray:
"""Capture a PIL image from stream stream_name with timeout wait.""" """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 @lt.action
def capture_and_save( 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, self,
jpeg_path: str, capture_mode: str = "standard",
save_resolution: Optional[Tuple[int, int]] = None, 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: ) -> None:
"""Capture an image and save it to disk. """Capture an image and save it to disk.
:param jpeg_path: The path to save the file to This is not an action as it exposes a direct path for saving
: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, capture_metadata = self._robust_image_capture()
self._save_capture(jpeg_path, image, capture_metadata, save_resolution) :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 @lt.action
def capture_to_memory(self, buffer_max: int = 1) -> int: 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``. """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 Note that only one image is held in memory so this will overwrite any image
@ -608,66 +637,102 @@ class BaseCamera(OFMThing, ABC):
:param buffer_max: The maximum number of images that should be in the buffer :param buffer_max: The maximum number of images that should be in the buffer
once this images is added. Default is 1. 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 :returns: the buffer id of the image captured
""" """
image, metadata = self._robust_image_capture() success = False
return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max) 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 @lt.action
def save_from_memory( def save_from_memory(
self, self,
jpeg_path: str, path: RelativeDataPath,
save_resolution: Optional[Tuple[int, int]] = None,
buffer_id: Optional[int] = None, buffer_id: Optional[int] = None,
) -> None: ) -> None:
"""Save an image that has been captured to memory. """Save an image that has been captured to memory.
:param jpeg_path: The path to save the file to Note this is not exposed as an action as it allows arbitrary paths on disk to
:param save_resolution: can be set to resize the image before saving. By be written to.
default this is None meaning that the image is saved at original
resolution. :param path: The path to save the file to
:param buffer_id: The buffer id of the image to save, this was returned by :param buffer_id: The buffer id of the image to save, this was returned by
``capture_to_memory`` ``capture_to_memory``
""" """
image, metadata = self._memory_buffer.get_image(buffer_id) image, metadata, mode = self._memory_buffer.get_image(buffer_id)
self._save_capture( mode_info = self.capture_modes[mode]
path=jpeg_path, save_resolution = mode_info.save_resolution
image=image,
metadata=metadata, path.set_saving_thing_if_unset(self)
save_resolution=save_resolution, 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 @lt.action
def clear_buffers(self) -> None: def clear_buffers(self) -> None:
"""Clear all images in memory.""" """Clear all images in memory."""
self._memory_buffer.clear() self._memory_buffer.clear()
def _robust_image_capture(self) -> Tuple[Image.Image, Mapping[str, Any]]: def _collect_ofm_metadata(self) -> dict:
"""Capture an image in memory and return it with metadata. """Return the metadata for a capture.
This robust capturing method attempts to capture the image five times This is information from the thing states, the time, and make/model names.
each time with a 5 second timeout set.
:raises CaptureError: if the capture fails for any reason
:returns: tuple with PIL Image, and dictionary of metadata.
""" """
for capture_attempts in range(5):
try:
capture_metadata = self._capture_metadata()
image = self.capture_image(stream_name="main", wait=5)
return image, capture_metadata
except TimeoutError:
self.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) -> dict:
"""Return the metadata for a capture, from the thing states, time and known names."""
metadata = self._thing_server_interface.get_thing_states() metadata = self._thing_server_interface.get_thing_states()
current_time = datetime.now() current_time = datetime.now()
return { return {
@ -678,7 +743,7 @@ class BaseCamera(OFMThing, ABC):
"things_states": metadata, "things_states": metadata,
} }
def _add_metadata_to_capture(self, jpeg_path: str, capture_metadata: dict) -> None: def _add_metadata_to_capture(self, path: str, capture_metadata: dict) -> None:
"""Add the EXIF metadata for a JPEG image. """Add the EXIF metadata for a JPEG image.
This adds: This adds:
@ -687,7 +752,7 @@ class BaseCamera(OFMThing, ABC):
- Camera Make and Model - Camera Make and Model
""" """
# Load existing EXIF # Load existing EXIF
exif_dict = piexif.load(jpeg_path) exif_dict = piexif.load(path)
user_metadata = capture_metadata["things_states"] user_metadata = capture_metadata["things_states"]
capture_time = capture_metadata["capture_time"] capture_time = capture_metadata["capture_time"]
@ -721,45 +786,7 @@ class BaseCamera(OFMThing, ABC):
exif_dict["0th"][piexif.ImageIFD.Model] = capture_metadata["model"] exif_dict["0th"][piexif.ImageIFD.Model] = capture_metadata["model"]
# Write the updated EXIF back to the file # Write the updated EXIF back to the file
piexif.insert(piexif.dump(exif_dict), jpeg_path) piexif.insert(piexif.dump(exif_dict), path)
def _save_capture(
self,
path: str,
image: Image.Image,
metadata: Mapping[str, Any],
save_resolution: Optional[Tuple[int, int]] = None,
) -> None:
"""Save the captured image and metadata to disk.
A warning is logged if metadata cannot be added.
:raises IOError: if the file cannot be saved
nothing is returned on success
"""
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 path.endswith("jpg"):
# 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}
image.save(path, **save_kwargs)
try:
self._add_metadata_to_capture(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 {path}")
except Exception as e:
raise IOError(f"An error occurred while saving {path}") from e
settling_time: float = lt.setting(default=0.2, ge=0) settling_time: float = lt.setting(default=0.2, ge=0)
"""The settling time when calling the ``settle()`` method.""" """The settling time when calling the ``settle()`` method."""

View file

@ -11,7 +11,7 @@ from __future__ import annotations
import logging import logging
from threading import Thread from threading import Thread
from types import TracebackType from types import TracebackType
from typing import Literal, Optional, Self from typing import Optional, Self
import cv2 import cv2
from PIL import Image from PIL import Image
@ -136,41 +136,33 @@ class OpenCVCamera(BaseCamera):
@lt.action @lt.action
def discard_frames(self) -> None: def discard_frames(self) -> None:
"""Discard frames so that the next frame captured is fresh.""" """Discard frames so that the next frame captured is fresh."""
self.capture_array() self.capture_as_array()
@lt.action @lt.action
def capture_array( def capture_as_array(
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "full", capture_mode: str = "standard",
wait: Optional[float] = None, raw: bool = False,
) -> NDArray: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array."""
if raw is True:
This function will produce a nested list containing an uncompressed RGB image. raise NotImplementedError(
It's likely to be highly inefficient - raw and/or uncompressed captures using "OpenCV camera camera doesn't support raw capture."
binary image formats will be added in due course. )
""" # Warn if the capture mode is incorrect, but don't read the coerced value as
if wait is not None: # this camera only supports one mode.
LOGGER.warning("OpenCV camera has no wait option. Use None.") self._validate_capture_mode(capture_mode)
LOGGER.warning(f"OpenCV camera doesn't respect {stream_name=}")
ret, frame = self.cap.read() ret, frame = self.cap.read()
if not ret: if not ret:
raise RuntimeError("Failed to capture frame from camera.") raise RuntimeError("Failed to capture frame from camera.")
return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
def capture_image( def _capture_image(self, capture_mode: str = "standard") -> Image.Image:
self, """Acquire one image from the camera and return as a PIL image."""
stream_name: Literal["main", "lores", "full"] = "main", # Warn if the capture mode is incorrect, but don't read the coerced value as
wait: Optional[float] = None, # this camera only supports one mode.
) -> Image.Image: self._validate_capture_mode(capture_mode)
"""Acquire one image from the camera and return as a PIL image. return Image.fromarray(self.capture_as_array())
This function will produce a JPEG image.
"""
if wait is not None:
LOGGER.warning("OpenCV camera has no wait option. Use None.")
LOGGER.warning(f"OpenCV camera doesn't respect {stream_name=}")
return Image.fromarray(self.capture_array())
@lt.property @lt.property
def manual_camera_settings(self) -> list[PropertyControl]: def manual_camera_settings(self) -> list[PropertyControl]:

View file

@ -54,7 +54,7 @@ from openflexure_microscope_server.ui import (
property_control_for, property_control_for,
) )
from . import BaseCamera, StreamingMode from . import BaseCamera, CaptureMode, StreamingMode
from . import picamera_recalibrate_utils as recalibrate_utils from . import picamera_recalibrate_utils as recalibrate_utils
from . import picamera_tuning_file_utils as tf_utils from . import picamera_tuning_file_utils as tf_utils
@ -112,6 +112,20 @@ class PiCamera2StreamingMode(StreamingMode):
return {"output_size": self.sensor_mode_resolution, "bit_depth": self.bit_depth} return {"output_size": self.sensor_mode_resolution, "bit_depth": self.bit_depth}
class PiCamera2CaptureMode(CaptureMode):
"""Capture mode configuration for the PiCamera2."""
streaming_mode: Optional[str]
"""The streaming mode the camera should be in for capture.
Use None to use the active mode.
"""
stream_name: Literal["lores", "main"]
"""The Picamera stream name to capture."""
timeout: float = 5.0
"""The timeout. A number above 10 risks hardlocking the Pi."""
class StreamingPiCamera2(BaseCamera, ABC): class StreamingPiCamera2(BaseCamera, ABC):
"""A Thing that provides and interface to the Raspberry Pi Camera. """A Thing that provides and interface to the Raspberry Pi Camera.
@ -382,6 +396,7 @@ class StreamingPiCamera2(BaseCamera, ABC):
* On closing of the context manager the stream will restart. * On closing of the context manager the stream will restart.
""" """
already_streaming = self.stream_active already_streaming = self.stream_active
streaming_mode = self.streaming_mode
with self._picamera_lock: with self._picamera_lock:
if pause_stream and already_streaming: if pause_stream and already_streaming:
self._stop_streaming(stop_web_stream=False) self._stop_streaming(stop_web_stream=False)
@ -389,7 +404,7 @@ class StreamingPiCamera2(BaseCamera, ABC):
yield self._picamera yield self._picamera
finally: finally:
if pause_stream and already_streaming: if pause_stream and already_streaming:
self._start_streaming() self._start_streaming(streaming_mode)
def __exit__( def __exit__(
self, self,
@ -409,6 +424,35 @@ class StreamingPiCamera2(BaseCamera, ABC):
def streaming_modes(self) -> Mapping[str, PiCamera2StreamingMode]: def streaming_modes(self) -> Mapping[str, PiCamera2StreamingMode]:
"""Modes the camera can stream in.""" """Modes the camera can stream in."""
@abstractmethod
@lt.property
def capture_modes(self) -> Mapping[str, PiCamera2CaptureMode]:
"""Modes the camera can use for capturing."""
def _create_picam_config_from_mode_info(
self,
picam: Picamera2,
controls: dict[str, Any],
mode_info: PiCamera2StreamingMode,
) -> dict[str, Any]:
"""Create the dictionary to pass to ``Picamera2.configure``.
:param controls: The controls for the camera. Note that running
``_get_persistent_controls()`` should be done before getting the picamera
lock to ensure that the current settings are read from the camera.
"""
if mode_info.scaler_crop is not None:
controls["ScalerCrop"] = mode_info.scaler_crop
stream_config = picam.create_video_configuration(
main={"size": mode_info.main_resolution},
lores={"size": mode_info.lores_resolution, "format": "YUV420"},
sensor=mode_info.sensor_mode_dict,
controls=controls,
)
stream_config["buffer_count"] = mode_info.buffer_count
return stream_config
def _start_streaming(self, mode: str = "default") -> None: def _start_streaming(self, mode: str = "default") -> None:
"""Start the MJPEG stream. This is where persistent controls are sent to camera. """Start the MJPEG stream. This is where persistent controls are sent to camera.
@ -425,24 +469,21 @@ class StreamingPiCamera2(BaseCamera, ABC):
raise ValueError(f"Unknown mode {mode}") raise ValueError(f"Unknown mode {mode}")
self.streaming_mode = mode self.streaming_mode = mode
mode_info = self.streaming_modes[mode] # This must be before getting the picamera hardware lock.
controls = self._get_persistent_controls() controls = self._get_persistent_controls()
if mode_info.scaler_crop is not None: mode_info = self.streaming_modes[mode]
controls["ScalerCrop"] = mode_info.scaler_crop
with self._streaming_picamera() as picam: with self._streaming_picamera() as picam:
try: try:
if picam.started: if picam.started:
picam.stop() picam.stop()
picam.stop_encoder() # make sure there are no other encoders going picam.stop_encoder() # make sure there are no other encoders going
stream_config = picam.create_video_configuration( stream_config = self._create_picam_config_from_mode_info(
main={"size": mode_info.main_resolution}, picam=picam,
lores={"size": mode_info.lores_resolution, "format": "YUV420"},
sensor=mode_info.sensor_mode_dict,
controls=controls, controls=controls,
mode_info=mode_info,
) )
stream_config["buffer_count"] = mode_info.buffer_count
picam.configure(stream_config) picam.configure(stream_config)
LOGGER.info("Starting picamera MJPEG stream...") LOGGER.info("Starting picamera MJPEG stream...")
stream_name = "lores" if mode_info.use_lores_as_preview else "main" stream_name = "lores" if mode_info.use_lores_as_preview else "main"
@ -487,65 +528,63 @@ class StreamingPiCamera2(BaseCamera, ABC):
cam.capture_metadata() cam.capture_metadata()
@contextmanager @contextmanager
def _switch_to_single_capture_mode(self) -> Iterator[Picamera2]: def _ensure_mode_for_capture(
"""Get the picamera lock, pause stream and switch into still capture config. self, capture_mode_info: PiCamera2CaptureMode
) -> Iterator[Picamera2]:
"""Ensure in correct mode for capture.
Restarts stream when complete. If the camera is already in the correct mode, the stream isn't paused and
this is the same as using ``self._streaming_picamera()``.
Otherwise, pause stream, and switch mode. Mode is reset and stream
restarts after the context manager closes.
""" """
mode_info = self.streaming_modes["full_resolution"] required_streaming_mode = capture_mode_info.streaming_mode
with self._streaming_picamera(pause_stream=True) as cam: if (
LOGGER.debug("Reconfiguring camera for full resolution capture") required_streaming_mode is None
cam.configure( or required_streaming_mode == self.streaming_mode
cam.create_still_configuration(sensor=mode_info.sensor_mode_dict) ):
) with self._streaming_picamera() as cam:
cam.start() yield cam
time.sleep(self._sensor_info.short_pause) else:
yield cam streaming_mode_info = self.streaming_modes[required_streaming_mode]
def capture_image( # This must be before getting the picamera hardware lock.
self, controls = self._get_persistent_controls()
stream_name: Literal["main", "lores", "full"] = "main",
wait: Optional[float] = 0.9, with self._streaming_picamera(pause_stream=True) as cam:
) -> Image.Image: LOGGER.debug("Reconfiguring camera for full resolution capture")
stream_config = self._create_picam_config_from_mode_info(
picam=cam,
controls=controls,
mode_info=streaming_mode_info,
)
cam.configure(stream_config)
cam.start()
time.sleep(self._sensor_info.short_pause)
yield cam
def _capture_image(self, capture_mode: str = "standard") -> Image.Image:
"""Acquire one image from the camera and return it as a PIL Image. """Acquire one image from the camera and return it as a PIL Image.
If the ``stream_name`` parameter is ``main`` or ``lores``, it will be captured :param capture_mode: The capture mode to use. See the description field of each
from the main preview stream, or the low-res preview stream, respectively. This mode in ``capture_modes`` for more detail.
means the camera won't be reconfigured, and the stream will not pause (though
it may miss one frame).
If ``full`` resolution is requested, we will briefly pause the MJPEG stream and
reconfigure the camera to capture a full resolution image. This will capture an
image at the full resolution of the current sensor mode. If the current sensor
mode bins or crops the image, this may not be the native resolution of the
camera sensor.
:param stream_name: (Optional) The PiCamera2 stream to use, should be one of
["main", "lores", "full"]. Default = "main". Note that "raw" images cannot
be captured as PIL images. Use capture_array
:param wait: (Optional, float) Set a timeout in seconds. Default = 0.9s,
lower than the 1s timeout for the camera. This ensures that our code times
out and returns before the camera times out. If None is set the default
value of 0.9 will be used to prevent the possibility of the camera locking.
:raises TimeoutError: if this time is exceeded during capture. :raises TimeoutError: if this time is exceeded during capture.
""" """
if wait is None: capture_mode = self._validate_capture_mode(capture_mode)
wait = 0.9 capture_mode_info = self.capture_modes[capture_mode]
if stream_name in ["main", "lores", "raw"]:
with self._streaming_picamera() as cam: with self._ensure_mode_for_capture(capture_mode_info) as cam:
return cam.capture_image(stream_name, wait=wait) return cam.capture_image(
elif stream_name == "full": capture_mode_info.stream_name, wait=capture_mode_info.timeout
with self._switch_to_single_capture_mode() as cam: )
return cam.capture_image(name="main", wait=wait)
else:
raise ValueError(f'Unknown stream name "{stream_name}"')
@lt.action @lt.action
def capture_array( def capture_as_array(
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "main", capture_mode: str = "standard",
wait: Optional[float] = 0.9, raw: bool = False,
) -> NDArray: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array.
@ -553,26 +592,26 @@ class StreamingPiCamera2(BaseCamera, ABC):
It's likely to be highly inefficient - raw and/or uncompressed captures using It's likely to be highly inefficient - raw and/or uncompressed captures using
binary image formats will be added in due course. binary image formats will be added in due course.
:param stream_name: (Optional) The PiCamera2 stream to use, should be one of :param capture_mode: (Optional) The name of the capture mode as defined by the
["main", "lores", "raw", "full"]. Default = "main" camera.
:param wait: (Optional, float) Set a timeout in seconds. Default = 0.9s, :param raw: Whether to capture RAW data. Capturing RAW data may ignore some
lower than the 1s timeout for the camera. This ensures that our code times of the camera mode settings.
out and returns before the camera times out. If None is set the default
value of 0.9 will be used to prevent the possibility of the camera locking.
:raises TimeoutError: if this time is exceeded during capture. :raises TimeoutError: if this time is exceeded during capture.
""" """
if stream_name == "raw": if raw:
# Raw cannot used capture_image. # Raw cannot use _capture_image.
if wait is None: capture_mode = self._validate_capture_mode(capture_mode)
wait = 0.9 capture_mode_info = self.capture_modes[capture_mode]
with self._switch_to_single_capture_mode() as cam:
return cam.capture_array(name="raw", wait=wait) with self._ensure_mode_for_capture(capture_mode_info) as cam:
return cam.capture_array(name="raw", wait=capture_mode_info.timeout)
# Note that internally the PiCamera creates a PIL image and then converts to # Note that internally the PiCamera creates a PIL image and then converts to
# numpy with ``np.array(Image.open(io.BytesIO(self.make_buffer(name))))``. # numpy with ``np.array(Image.open(io.BytesIO(self.make_buffer(name))))``.
# As such we use capture_image to get an Image from the picamera and return # As such we use _capture_image to get an Image from the picamera and return
# as array # as array
return np.array(self.capture_image(stream_name, wait)) return np.array(self._capture_image(capture_mode))
@lt.property @lt.property
def camera_configuration(self) -> Mapping: def camera_configuration(self) -> Mapping:
@ -946,6 +985,31 @@ class PiCameraV2(StreamingPiCamera2):
), ),
} }
@lt.property
def capture_modes(self) -> Mapping[str, PiCamera2CaptureMode]:
"""Modes the camera can use for capturing."""
return {
"standard": PiCamera2CaptureMode(
description=(
"The standard mode, 2MP image created from downsampling an 8MP "
"capture."
),
save_resolution=(1640, 1232),
streaming_mode="full_resolution",
stream_name="main",
),
"full": PiCamera2CaptureMode(
description="A full resolution 8MP capture.",
streaming_mode="full_resolution",
stream_name="main",
),
"quick": PiCamera2CaptureMode(
description="Capture without altering the stream settings.",
streaming_mode=None,
stream_name="main",
),
}
class PiCameraHQ(StreamingPiCamera2): class PiCameraHQ(StreamingPiCamera2):
"""A Thing that provides and interface to the Raspberry Pi Camera HQ.""" """A Thing that provides and interface to the Raspberry Pi Camera HQ."""
@ -971,9 +1035,10 @@ class PiCameraHQ(StreamingPiCamera2):
), ),
"full_resolution": PiCamera2StreamingMode( "full_resolution": PiCamera2StreamingMode(
description=( description=(
"Streaming the camera in 12MP full resolution. The preview stream " "Streaming the camera with an 8MP area in full-resolution area "
"sent to the UI will be the low resolution (lores) stream. " "from the centre of the 12MP sensor.. The preview stream sent to "
"This allows better image capture at expense of preview quality." "the UI will be the low resolution (lores) stream. This allows "
"better image capture at expense of preview quality."
), ),
main_resolution=(2800, 2800), main_resolution=(2800, 2800),
lores_resolution=(350, 350), lores_resolution=(350, 350),
@ -983,3 +1048,30 @@ class PiCameraHQ(StreamingPiCamera2):
scaler_crop=(628, 120, 2800, 2800), scaler_crop=(628, 120, 2800, 2800),
), ),
} }
@lt.property
def capture_modes(self) -> Mapping[str, PiCamera2CaptureMode]:
"""Modes the camera can use for capturing."""
return {
"standard": PiCamera2CaptureMode(
description=(
"The standard mode, 2MP image created from downsampling an 8MP "
"capture from the centre of the 12MP sensor."
),
save_resolution=(1400, 1400),
streaming_mode="full_resolution",
stream_name="main",
),
"full": PiCamera2CaptureMode(
description=(
"An 8MP full resolution capture from the centre of the 12MP sensor."
),
streaming_mode="full_resolution",
stream_name="main",
),
"quick": PiCamera2CaptureMode(
description="Capture without altering the stream settings.",
streaming_mode=None,
stream_name="main",
),
}

View file

@ -14,7 +14,7 @@ import re
import time import time
from threading import Thread from threading import Thread
from types import TracebackType from types import TracebackType
from typing import Literal, Optional, Self, overload from typing import Optional, Self, overload
import numpy as np import numpy as np
from PIL import Image, ImageFilter from PIL import Image, ImageFilter
@ -479,10 +479,10 @@ class SimulatedCamera(BaseCamera):
""" """
@lt.action @lt.action
def capture_array( def capture_as_array(
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "full", capture_mode: str = "standard",
wait: Optional[float] = None, raw: bool = False,
) -> NDArray: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array.
@ -490,29 +490,26 @@ class SimulatedCamera(BaseCamera):
It's likely to be highly inefficient - raw and/or uncompressed captures using It's likely to be highly inefficient - raw and/or uncompressed captures using
binary image formats will be added in due course. binary image formats will be added in due course.
:param stream_name: Currently ignored, this argument exists to ensure consistent API across camera Things. :param capture_mode: (Optional) The name of the capture mode as defined by the
:param wait: Currently ignored, this argument exists to ensure consistent API across camera Things. camera.
:param raw: Raw Capture is not implemented for the simulation microscope.
Setting this to True will result in an error.
""" """
if wait is not None: if raw is True:
LOGGER.warning("Simulation camera has no wait option. Use None.") raise NotImplementedError("Simulation camera doesn't support raw capture.")
LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}") # Warn if the capture mode is incorrect, but don't read the coerced value as
# this camera only supports one mode.
self._validate_capture_mode(capture_mode)
return np.array(self.generate_frame()) return np.array(self.generate_frame())
def capture_image( def _capture_image(self, capture_mode: str = "standard") -> Image.Image:
self,
stream_name: Literal["main", "lores", "full"],
wait: Optional[float] = None,
) -> Image.Image:
"""Capture to a PIL image. This is not exposed as a ThingAction. """Capture to a PIL image. This is not exposed as a ThingAction.
It is used for capture to memory. It is used for capture to memory.
:param stream_name: Currently ignored, this argument exists to ensure consistent API across camera Things.
:param wait: Currently ignored, this argument exists to ensure consistent API across camera Things.
""" """
if wait is not None: # Warn if the capture mode is incorrect, but don't read the coerced value as
LOGGER.warning("Simulation camera has no wait option. Use None.") # this camera only supports one mode.
LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}") self._validate_capture_mode(capture_mode)
return self.generate_frame() return self.generate_frame()
@lt.action @lt.action

View file

@ -4,7 +4,6 @@ This module contains the base ``ScanWorkflow`` class that all workflows should s
as well as specific workflows. as well as specific workflows.
""" """
import os
from typing import ( from typing import (
Generic, Generic,
Literal, Literal,
@ -27,6 +26,7 @@ from openflexure_microscope_server.stitching import (
TARGET_STITCHING_DIMENSION, TARGET_STITCHING_DIMENSION,
StitchingSettings, StitchingSettings,
) )
from openflexure_microscope_server.things import RelativeDataPath
from openflexure_microscope_server.things.autofocus import ( from openflexure_microscope_server.things.autofocus import (
MAX_TEST_IMAGE_COUNT, MAX_TEST_IMAGE_COUNT,
MIN_TEST_IMAGE_COUNT, MIN_TEST_IMAGE_COUNT,
@ -55,6 +55,10 @@ from openflexure_microscope_server.ui import (
SettingModelType = TypeVar("SettingModelType", bound=BaseModel) SettingModelType = TypeVar("SettingModelType", bound=BaseModel)
class WorkflowStartError(lt.exceptions.InvocationError):
"""The scan workflow cannot start, as the requested configuration is invalid."""
class ScanWorkflow(Generic[SettingModelType], lt.Thing): class ScanWorkflow(Generic[SettingModelType], lt.Thing):
"""A base class for all Scanworkflows. """A base class for all Scanworkflows.
@ -75,7 +79,7 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
_planner_cls: type[ScanPlanner] _planner_cls: type[ScanPlanner]
# All workflows set a save resolution # All workflows set a save resolution
save_resolution: tuple[int, int] = lt.setting(default=(1640, 1232)) capture_mode: str = lt.setting(default="standard")
"""A tuple of the image resolution to capture.""" """A tuple of the image resolution to capture."""
# CSM may not be set, and isn't required for a workflow. Allow for it to exist or be None # CSM may not be set, and isn't required for a workflow. Allow for it to exist or be None
@ -85,15 +89,19 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
_stage: BaseStage = lt.thing_slot() _stage: BaseStage = lt.thing_slot()
_autofocus: AutofocusThing = lt.thing_slot() _autofocus: AutofocusThing = lt.thing_slot()
def check_before_start(self, scan_name: str) -> None: # The noqa statement is because scan_name is unused but is needed for equivalence
# with other workflows that may want to validate the scan name.
def check_before_start(self, scan_name: str) -> None: # noqa: ARG002
"""Check before the scan starts. Throw an error if the scan shouldn't start. """Check before the scan starts. Throw an error if the scan shouldn't start.
The scan_name is passed to this function to enable workflows to validate the The scan_name is passed to this function to enable workflows to validate the
scan name if needed. scan name if needed.
""" """
raise NotImplementedError( if self.capture_mode not in self._cam.capture_modes:
"Each specific ScanWorkflow must implement a check_before_start." cam_name = type(self._cam).__name__
) raise WorkflowStartError(
f"{cam_name} has no capure mode {self.capture_mode}"
)
@lt.property @lt.property
def ready(self) -> bool: def ready(self) -> bool:
@ -103,19 +111,27 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
) )
def all_settings( def all_settings(
self, images_dir: str self, images_dir: RelativeDataPath
) -> tuple[SettingModelType, Optional[StitchingSettings]]: ) -> tuple[SettingModelType, Optional[StitchingSettings], tuple[int, int]]:
"""Return the scan settings and the stitching settings. """Return the scan settings and the stitching settings.
- The specific settings for this scan workflow are returned as a Base Model of - The specific settings for this scan workflow are returned as a Base Model of
the type set when defining the class. the type set when defining the class.
- Stitiching settings are returned either as a StitchingSettings object or None - Stitiching settings are returned either as a StitchingSettings object or None
is returned if it is not possible to stitch the scan. is returned if it is not possible to stitch the scan.
- The save resolution as determined by a test image.
""" """
raise NotImplementedError( raise NotImplementedError(
"Each specific ScanWorkflow must implement a `all_settings` method." "Each specific ScanWorkflow must implement a `all_settings` method."
) )
def _get_save_resolution(self) -> tuple[int, int]:
"""Return the save resolution as determined by a test image."""
# Capture an example image.
image = self._cam._capture_image(capture_mode=self.capture_mode)
# Check size to create a unit faction for downsampling.
return image.size
def pre_scan_routine(self, settings: SettingModelType) -> None: def pre_scan_routine(self, settings: SettingModelType) -> None:
"""Overload to set the routine that happens before each scan.""" """Overload to set the routine that happens before each scan."""
raise NotImplementedError( raise NotImplementedError(
@ -148,14 +164,14 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
self, self,
xyz_pos: tuple[int, int, int], xyz_pos: tuple[int, int, int],
dz: int, dz: int,
images_dir: str, images_dir: RelativeDataPath,
save_resolution: tuple[int, int], capture_mode: str,
) -> tuple[bool, Optional[int]]: ) -> tuple[bool, Optional[int]]:
"""Autofocus and then capture, this can be used as an acquisition routine. """Autofocus and then capture, this can be used as an acquisition routine.
:param dz: The dz for autofocus. :param dz: The dz for autofocus.
:param images_dir: The path to the directory for saving images.. :param images_dir: The path to the directory for saving images.
:param save_resolution: The resolution to save images at. :param capture_mode: The name of the camera capture mode.
:return: A tuple ready to pass out of acquisition routine. In this method, :return: A tuple ready to pass out of acquisition routine. In this method,
image is always taken, so first return is True. image is always taken, so first return is True.
@ -164,9 +180,9 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
self._autofocus.fast_autofocus(dz=dz) self._autofocus.fast_autofocus(dz=dz)
focus_height = self._stage.get_xyz_position()[2] focus_height = self._stage.get_xyz_position()[2]
filename = f"img_{xyz_pos[0]}_{xyz_pos[1]}_{focus_height}.jpeg" filename = f"img_{xyz_pos[0]}_{xyz_pos[1]}_{focus_height}.jpeg"
self._cam.capture_and_save( self._cam.capture_and_save_to_path(
jpeg_path=os.path.join(images_dir, filename), path=images_dir.join(filename),
save_resolution=save_resolution, capture_mode=capture_mode,
) )
return True, focus_height return True, focus_height
@ -217,16 +233,15 @@ class RectGridWorkflow(
must be above this. 3000 is a sensible limit for 20x objectives. must be above this. 3000 is a sensible limit for 20x objectives.
""" """
# The noqa statement is because scan_name is unused but is needed for equivalence def check_before_start(self, scan_name: str) -> None:
# with other workflows that may want to validate the scan name.
def check_before_start(self, scan_name: str) -> None: # noqa: ARG002
"""Before starting a scan, check that camera-stage-mapping is set. """Before starting a scan, check that camera-stage-mapping is set.
Raise error if: Raise error if:
- camera stage mapping is not set - camera stage mapping is not set
""" """
super().check_before_start(scan_name)
if self._csm.calibration_required: if self._csm.calibration_required:
raise RuntimeError("Camera Stage Mapping is not calibrated.") raise WorkflowStartError("Camera Stage Mapping is not calibrated.")
def _calc_displacement_from_overlap(self, overlap: float) -> tuple[int, int]: def _calc_displacement_from_overlap(self, overlap: float) -> tuple[int, int]:
"""Use camera stage mapping to calculate x and y displacement from given overlap. """Use camera stage mapping to calculate x and y displacement from given overlap.
@ -261,11 +276,11 @@ class RectGridWorkflow(
) )
return y_move_stage["x"], x_move_stage["y"] return y_move_stage["x"], x_move_stage["y"]
def _get_stitching_settings_model(self) -> StitchingSettings: def _get_stitching_settings_model(
self, save_resolution: tuple[int, int]
) -> StitchingSettings:
"""Return a stitching settings model based on current settings.""" """Return a stitching settings model based on current settings."""
# Use the save resolution and target stitch resolution to choose a unit fraction, width, height = save_resolution
# which makes correlating faster
width, height = self.save_resolution
# Target area in pixels # Target area in pixels
target_area = TARGET_STITCHING_DIMENSION**2 target_area = TARGET_STITCHING_DIMENSION**2
# Find N so that (width/N) * (height/N) ~ target_area # Find N so that (width/N) * (height/N) ~ target_area
@ -285,17 +300,18 @@ class RectGridWorkflow(
return self._settings_model(**base_kwargs) return self._settings_model(**base_kwargs)
def all_settings( def all_settings(
self, images_dir: str self, images_dir: RelativeDataPath
) -> tuple[RectGridSettingModelType, Optional[StitchingSettings]]: ) -> tuple[RectGridSettingModelType, Optional[StitchingSettings], tuple[int, int]]:
"""Return scan settings and the stitching settings. """Return scan settings and the stitching settings.
:param images_dir: The directory that images are to be written to. :param images_dir: The directory that images are to be written to.
:return: A tuple containing the settings model for this workflow and the :return: A tuple containing the settings model for this workflow, the
settings model for stitching. settings model for stitching, and the save resolution.
""" """
save_resolution = self._get_save_resolution()
# Developer Note: When subclassing RectGridWorkflow rather than override # Developer Note: When subclassing RectGridWorkflow rather than override
# this method first consider overriding _build_scan_settings # this method first consider overriding _build_scan_settings
stitching_settings = self._get_stitching_settings_model() stitching_settings = self._get_stitching_settings_model(save_resolution)
dx, dy = self._calc_displacement_from_overlap(self.overlap) dx, dy = self._calc_displacement_from_overlap(self.overlap)
base_kwargs = { base_kwargs = {
@ -303,14 +319,14 @@ class RectGridWorkflow(
"dx": dx, "dx": dx,
"dy": dy, "dy": dy,
"capture_params": CaptureParams( "capture_params": CaptureParams(
images_dir=images_dir, save_resolution=self.save_resolution images_dir=images_dir, capture_mode=self.capture_mode
), ),
"autofocus_params": AutofocusParams(dz=self.autofocus_dz), "autofocus_params": AutofocusParams(dz=self.autofocus_dz),
} }
scan_settings = self._build_scan_settings(base_kwargs) scan_settings = self._build_scan_settings(base_kwargs)
return scan_settings, stitching_settings return scan_settings, stitching_settings, save_resolution
@lt.property @lt.property
def ready(self) -> bool: def ready(self) -> bool:
@ -510,20 +526,22 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel], SmartStackMixi
# The noqa statement is because scan_name is unused but is needed for equivalence # The noqa statement is because scan_name is unused but is needed for equivalence
# with other workflows that may want to validate the scan name. # with other workflows that may want to validate the scan name.
def check_before_start(self, scan_name: str) -> None: # noqa: ARG002 def check_before_start(self, scan_name: str) -> None: # noqa: ARG002
"""Before starting a scan, check that background and camera-stage-mapping are set. """Before starting a scan, check that background and CSM are set.
Raise error if: Raise error if:
- background is to be skipped but is not set - background is to be skipped but is not set
- camera stage mapping is not set - camera stage mapping is not set
Raise warning if not using background detect that scan will go on until max steps reached Raise warning if not using background detect that scan will go on until max
steps reached.
""" """
super().check_before_start(scan_name)
if self._csm.calibration_required: if self._csm.calibration_required:
raise RuntimeError("Camera Stage Mapping is not calibrated.") raise WorkflowStartError("Camera Stage Mapping is not calibrated.")
if self.skip_background: if self.skip_background:
if not self._background_detector.ready: if not self._background_detector.ready:
raise RuntimeError( raise WorkflowStartError(
"Background is not set: you need to calibrate background detection." "Background is not set: you need to calibrate background detection."
) )
else: else:

View file

@ -304,8 +304,8 @@ class SmartScanThing(OFMThing):
# Ensure any PreviewStitcher created cannot be reused. # Ensure any PreviewStitcher created cannot be reused.
self._preview_stitcher = None self._preview_stitcher = None
# Remove any scan folders containing zero images. # Remove any scan folders containing zero images.
self.purge_empty_scans() self.purge_empty_scans()
@_scan_running @_scan_running
def _move_to_next_point( def _move_to_next_point(
@ -343,8 +343,8 @@ class SmartScanThing(OFMThing):
if images_dir is None: if images_dir is None:
raise RuntimeError("Couldn't run scan, images directory was not created.") raise RuntimeError("Couldn't run scan, images directory was not created.")
workflow_settings, stitching_settings = workflow.all_settings( workflow_settings, stitching_settings, save_resolution = workflow.all_settings(
images_dir=images_dir images_dir=self.create_data_path(images_dir, absolute=True)
) )
# If stitching settings is None then this workflow doesn't support stitching. # If stitching settings is None then this workflow doesn't support stitching.
@ -356,7 +356,7 @@ class SmartScanThing(OFMThing):
starting_position=starting_position, starting_position=starting_position,
start_time=datetime.now(), start_time=datetime.now(),
stitch_automatically=auto_stitch, stitch_automatically=auto_stitch,
save_resolution=workflow.save_resolution, save_resolution=save_resolution,
workflow=type(workflow).__name__, workflow=type(workflow).__name__,
workflow_settings=workflow_settings, workflow_settings=workflow_settings,
stitching_settings=stitching_settings, stitching_settings=stitching_settings,
@ -392,6 +392,9 @@ class SmartScanThing(OFMThing):
starting x,y,z position. starting x,y,z position.
""" """
try: try:
# Change into streaming mode instantly so mode is correct when collecting
# Scan Data
self._cam.change_streaming_mode(mode="full_resolution")
self._scan_data = self._collect_scan_data(workflow) self._scan_data = self._collect_scan_data(workflow)
images_dir = self.ongoing_scan.images_dir images_dir = self.ongoing_scan.images_dir
# Type narrowing # Type narrowing
@ -401,7 +404,6 @@ class SmartScanThing(OFMThing):
) )
self.ongoing_scan.save_scan_data(self._scan_data) self.ongoing_scan.save_scan_data(self._scan_data)
self._cam.change_streaming_mode(mode="full_resolution")
workflow.pre_scan_routine(self._scan_data.workflow_settings) workflow.pre_scan_routine(self._scan_data.workflow_settings)
# If stitching settings are None then this type of scan can't be stitched # If stitching settings are None then this type of scan can't be stitched

View file

@ -197,9 +197,6 @@ class RangeofMotionThing(OFMThing):
"time": total_time, "time": total_time,
} }
if not os.path.exists(self.data_dir):
os.makedirs(self.data_dir)
timestamp = datetime.now().strftime("%Y_%m_%d_%H_%M") timestamp = datetime.now().strftime("%Y_%m_%d_%H_%M")
datafile_path = os.path.join(self.data_dir, f"rom_data_{timestamp}.json") datafile_path = os.path.join(self.data_dir, f"rom_data_{timestamp}.json")
with open(datafile_path, "w", encoding="utf-8") as f: with open(datafile_path, "w", encoding="utf-8") as f:

View file

@ -7,11 +7,8 @@ import numpy as np
from PIL import Image from PIL import Image
def test_jpeg_and_array(picamera_client): def test_quick_capture_size(picamera_client):
"""Check that a jpeg grabbed from the stream is the same size as other captures. """Check that a jpeg grabbed from the stream is the same size as a quick capture."""
Compare it to an array capture and a jpeg capture.
"""
# Grab a jpeg from the stream # Grab a jpeg from the stream
blob = picamera_client.grab_jpeg() blob = picamera_client.grab_jpeg()
mjpeg_frame = Image.open(blob.open()) mjpeg_frame = Image.open(blob.open())
@ -20,13 +17,17 @@ def test_jpeg_and_array(picamera_client):
assert mjpeg_frame.format == "JPEG" assert mjpeg_frame.format == "JPEG"
# Capture a jpeg # Capture a jpeg
blob = picamera_client.capture_jpeg(stream_name="main") blob = picamera_client.capture(
capture_mode="quick",
image_format="jpeg",
retain_image=True,
)
jpeg_capture = Image.open(blob.open()) jpeg_capture = Image.open(blob.open())
jpeg_capture.verify() jpeg_capture.verify()
assert jpeg_capture.format == "JPEG" assert jpeg_capture.format == "JPEG"
# Capture an array # Capture an array
arrlist = picamera_client.capture_array(stream_name="main") arrlist = picamera_client.capture_as_array(capture_mode="quick")
array_main = np.array(arrlist) array_main = np.array(arrlist)
# Verify image sizes are the same # Verify image sizes are the same
@ -34,6 +35,41 @@ def test_jpeg_and_array(picamera_client):
assert array_main.shape[1::-1] == jpeg_capture.size assert array_main.shape[1::-1] == jpeg_capture.size
def test_format(picamera_client):
"""Check capture format is as requested."""
# Capture a jpeg
blob = picamera_client.capture(
capture_mode="quick",
image_format="jpeg",
retain_image=True,
)
jpeg_capture = Image.open(blob.open())
jpeg_capture.verify()
assert jpeg_capture.format == "JPEG"
blob = picamera_client.capture(
capture_mode="quick",
image_format="png",
retain_image=True,
)
png_capture = Image.open(blob.open())
png_capture.verify()
assert png_capture.format == "PNG"
def test_standard_capture_size(picamera_client):
"""Check standard capture mode captures at expected size."""
# Capture a jpeg
blob = picamera_client.capture(
capture_mode="standard",
image_format="jpeg",
retain_image=True,
)
jpeg_capture = Image.open(blob.open())
jpeg_capture.verify()
assert jpeg_capture.size == (1640, 1232)
def test_record_framerate(picamera_client): def test_record_framerate(picamera_client):
"""Check that framerate monitoring creates a valid JSON log with good data.""" """Check that framerate monitoring creates a valid JSON log with good data."""
log_file = Path(picamera_client.record_framerate(duration=1.0)) log_file = Path(picamera_client.record_framerate(duration=1.0))

View file

@ -39,7 +39,7 @@ def _test_exposure_time_drift(desired_time: int) -> None:
assert abs(pre_capture_et - desired_time) < EXPOSURE_TOL assert abs(pre_capture_et - desired_time) < EXPOSURE_TOL
for i in range(10): for i in range(10):
client.capture_jpeg(stream_name="full") client.capture(capture_mode="full")
if i == 0: if i == 0:
# Exposure can update on first capture, due to frame rate restrictions # Exposure can update on first capture, due to frame rate restrictions
first_et = client.exposure_time first_et = client.exposure_time
@ -56,7 +56,7 @@ def _test_exposure_time_drift(desired_time: int) -> None:
time.sleep(0.5) time.sleep(0.5)
# Check before and after capture # Check before and after capture
assert client.exposure_time == frame_et assert client.exposure_time == frame_et
client.capture_jpeg(stream_name="full") client.capture(capture_mode="full")
assert client.exposure_time == frame_et assert client.exposure_time == frame_et
print("Exposure time didn't change!!") print("Exposure time didn't change!!")
print(f"End of test for exposure target {desired_time}") print(f"End of test for exposure target {desired_time}")
@ -82,7 +82,7 @@ def test_exposure_time_on_start_and_stop_stream():
# Take a couple of images to make sure that the exposure is adjusted to # Take a couple of images to make sure that the exposure is adjusted to
# a hardware compatible value. # a hardware compatible value.
for _i in range(2): for _i in range(2):
client.capture_jpeg(stream_name="full") client.capture(capture_mode="full")
# Save this time. # Save this time.
set_time = client.exposure_time set_time = client.exposure_time
assert abs(set_time - desired_time) < EXPOSURE_TOL assert abs(set_time - desired_time) < EXPOSURE_TOL
@ -112,7 +112,7 @@ def _load_camera_and_return_exposure(tmpdir: str) -> int:
# Take a couple of images to make sure that the exposure is adjusted to # Take a couple of images to make sure that the exposure is adjusted to
# a hardware compatible value. # a hardware compatible value.
for _i in range(2): for _i in range(2):
client.capture_jpeg(stream_name="full") client.capture(capture_mode="full")
# Save this time. # Save this time.
return client.exposure_time return client.exposure_time

View file

@ -14,7 +14,7 @@ def test_streaming_mode():
with camera_test_client() as client: with camera_test_client() as client:
for mode, res in ["default", (820, 616)], ["full_resolution", (3280, 2464)]: for mode, res in ["default", (820, 616)], ["full_resolution", (3280, 2464)]:
client.change_streaming_mode(mode=mode) client.change_streaming_mode(mode=mode)
arr = np.array(client.capture_array(stream_name="main")) arr = np.array(client.capture_as_array(capture_mode="quick"))
# Check that the array dimensions match the requested image size. # Check that the array dimensions match the requested image size.
# Note: Numpy array shape is (y,x), but the sensor is set with (x,y) # Note: Numpy array shape is (y,x), but the sensor is set with (x,y)
# hence the need to compare index 0 with index 1. # hence the need to compare index 0 with index 1.

View file

@ -12,6 +12,7 @@ it has been moved as it artificaially inflated coverage.
""" """
import json import json
import logging
import numpy as np import numpy as np
import piexif import piexif
@ -40,11 +41,26 @@ def test_grab_jpeg(simulation_test_env):
assert image.size == (820, 616) assert image.size == (820, 616)
@pytest.mark.parametrize("fmt", ["jpeg", "png"]) @pytest.mark.parametrize(
def test_capture_and_metadata(simulation_test_env, fmt): "image_format",
"""Check that the position is encoded into the image metadata.""" [
"jpeg",
pytest.param(
"png", marks=pytest.mark.xfail(reason="piexif does not support PNG EXIF")
),
],
)
def test_capture_and_metadata(simulation_test_env, image_format, caplog):
"""Capture an image and check a attributes.
- Check that the position is encoded into the image metadata
- Check the dimensions
- Check the format
"""
camera = simulation_test_env.get_thing_client("camera") camera = simulation_test_env.get_thing_client("camera")
blob = getattr(camera, f"capture_{fmt}")() with caplog.at_level(logging.WARNING):
blob = camera.capture(image_format=image_format)
assert len(caplog.messages) == 0
image = Image.open(blob.open()) image = Image.open(blob.open())
exif_dict = piexif.load(image.info["exif"]) exif_dict = piexif.load(image.info["exif"])
encoded_metadata = exif_dict["Exif"][piexif.ExifIFD.UserComment] encoded_metadata = exif_dict["Exif"][piexif.ExifIFD.UserComment]
@ -52,6 +68,7 @@ def test_capture_and_metadata(simulation_test_env, fmt):
assert "position" in metadata["stage"] assert "position" in metadata["stage"]
assert metadata["stage"]["position"] == {"x": 0, "y": 0, "z": 0} assert metadata["stage"]["position"] == {"x": 0, "y": 0, "z": 0}
assert image.size == (820, 616) assert image.size == (820, 616)
assert image.format == image_format.upper()
def test_stage(simulation_test_env): def test_stage(simulation_test_env):
@ -77,10 +94,10 @@ def test_stage(simulation_test_env):
assert start["z"] == pos["z"] assert start["z"] == pos["z"]
def test_capture_array(simulation_test_env): def test_capture_as_array(simulation_test_env):
"""Capture array from simulation and check the size is as expected.""" """Capture array from simulation and check the size is as expected."""
camera = simulation_test_env.get_thing_client("camera") camera = simulation_test_env.get_thing_client("camera")
array = np.asarray(camera.capture_array()) array = np.asarray(camera.capture_as_array())
assert array.shape == (616, 820, 3) assert array.shape == (616, 820, 3)

View file

@ -5,10 +5,16 @@ test_simulated_camera.py and for testing the consistency of camera APIs see
test_cameras.py. test_cameras.py.
""" """
import os
from dataclasses import dataclass, field
from typing import Optional
import numpy as np import numpy as np
import pytest import pytest
from PIL import Image from PIL import Image
from openflexure_microscope_server.things import RelativeDataPath
from openflexure_microscope_server.things.camera import CaptureMode
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
from openflexure_microscope_server.things.stage.dummy import DummyStage from openflexure_microscope_server.things.stage.dummy import DummyStage
@ -61,3 +67,73 @@ def test_handle_broken_frame(test_env):
for _i in range(15): for _i in range(15):
array = camera.grab_as_array() array = camera.grab_as_array()
assert isinstance(array, np.ndarray) assert isinstance(array, np.ndarray)
@dataclass
class MemorySaveTestCase:
"""Inputs and expected outputs for testing ``save_from_memory``.
The default save kwargs assume a jpeg.
"""
filename: str = "foobar.jpeg"
save_resolution: Optional[tuple[int, int]] = None
resize_needed: bool = False
convert_needed: bool = False
save_kwargs: dict[str, int] = field(
default_factory=lambda: {"quality": 95, "subsampling": 0}
)
SAVE_TEST_CASES = [
# Default test case is a jpeg, check it works with all extensions.
MemorySaveTestCase("foobar.jpeg"),
MemorySaveTestCase("foobar.jpg"),
MemorySaveTestCase("foobar.JPEG"),
MemorySaveTestCase("foobar.JPG"),
MemorySaveTestCase("foobar.png.jpeg"),
MemorySaveTestCase("foobar.png", save_kwargs={}, convert_needed=True),
MemorySaveTestCase("foobar.PNG", save_kwargs={}, convert_needed=True),
MemorySaveTestCase("foobar.jpeg.png", save_kwargs={}, convert_needed=True),
MemorySaveTestCase(save_resolution=None, resize_needed=False),
MemorySaveTestCase(save_resolution=(1000, 1200), resize_needed=False),
MemorySaveTestCase(save_resolution=(2000, 2400), resize_needed=True),
]
@pytest.mark.parametrize("test_case", SAVE_TEST_CASES)
def test_save_from_memory(test_case, test_env, mocker):
"""Check the correct image is retrieved and saved with correct settings."""
camera = test_env.get_thing_by_type(SimulatedCamera)
camera._memory_buffer = mocker.Mock()
camera._add_metadata_to_capture = mocker.Mock()
mode = CaptureMode(description="foo", save_resolution=test_case.save_resolution)
capture_modes_mock = mocker.PropertyMock(return_value={"standard": mode})
mocker.patch.object(type(camera), "capture_modes", capture_modes_mock)
mock_image = mocker.Mock()
# Make resize and convert return itself so we can track further calls of the Image
# object after a resize
mock_image.resize.return_value = mock_image
mock_image.convert.return_value = mock_image
mock_image.size = (1000, 1200)
mock_image.mode = "RGBX"
camera._memory_buffer.get_image.return_value = (
mock_image,
{"meta": "data"},
"standard",
)
camera._data_dir = os.path.normpath("/fake/data/dir")
camera.save_from_memory(RelativeDataPath(test_case.filename), 33)
assert camera._memory_buffer.get_image.call_count == 1
assert camera._memory_buffer.get_image.call_args.args == (33,)
assert camera._add_metadata_to_capture.call_count == 1
assert mock_image.resize.call_count == (1 if test_case.resize_needed else 0)
assert mock_image.convert.call_count == (1 if test_case.convert_needed else 0)
assert mock_image.save.call_count == 1
assert mock_image.save.call_args.kwargs == test_case.save_kwargs

View file

@ -33,8 +33,8 @@ def test_add_and_get_image():
"""Check images can be captured and retrieved.""" """Check images can be captured and retrieved."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image = random_image() misc_image = random_image()
buffer_id = mem_buf.add_image(misc_image, random_metadata()) buffer_id = mem_buf.add_image(misc_image, random_metadata(), "standard")
returned_image, _ = mem_buf.get_image(buffer_id) returned_image, _, _ = mem_buf.get_image(buffer_id)
# It is the same image # It is the same image
assert misc_image is returned_image assert misc_image is returned_image
# It is now removed from memory # It is now removed from memory
@ -46,12 +46,12 @@ def test_add_and_get_image_twice():
"""Check images can be retrieved twice if remove flag set false.""" """Check images can be retrieved twice if remove flag set false."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image = random_image() misc_image = random_image()
buffer_id = mem_buf.add_image(misc_image, random_metadata()) buffer_id = mem_buf.add_image(misc_image, random_metadata(), "standard")
returned_image, _ = mem_buf.get_image(buffer_id, remove=False) returned_image, _, _ = mem_buf.get_image(buffer_id, remove=False)
# It is the same image # It is the same image
assert misc_image is returned_image assert misc_image is returned_image
# It is still in memory # It is still in memory
returned_image, _ = mem_buf.get_image(buffer_id) returned_image, _, _ = mem_buf.get_image(buffer_id)
assert misc_image is returned_image assert misc_image is returned_image
# It is now removed from memory # It is now removed from memory
with pytest.raises(NoImageInMemoryError): with pytest.raises(NoImageInMemoryError):
@ -62,8 +62,8 @@ def test_get_without_id():
"""Check images can be captured and retrieved without ID.""" """Check images can be captured and retrieved without ID."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image = random_image() misc_image = random_image()
mem_buf.add_image(misc_image, random_metadata()) mem_buf.add_image(misc_image, random_metadata(), "standard")
returned_image, _ = mem_buf.get_image() returned_image, _, _ = mem_buf.get_image()
# It is the same image # It is the same image
assert misc_image is returned_image assert misc_image is returned_image
# It is now removed from memory # It is now removed from memory
@ -76,11 +76,21 @@ def test_get_two_images():
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image1 = random_image() misc_image1 = random_image()
misc_image2 = random_image() misc_image2 = random_image()
buffer_id1 = mem_buf.add_image(misc_image1, random_metadata(), buffer_max=2) buffer_id1 = mem_buf.add_image(
buffer_id2 = mem_buf.add_image(misc_image2, random_metadata(), buffer_max=2) misc_image1, random_metadata(), "standard", buffer_max=2
returned_image1, _ = mem_buf.get_image(buffer_id1) )
returned_image2, _ = mem_buf.get_image(buffer_id2) buffer_id1 = mem_buf.add_image(
# It they the same images misc_image1, random_metadata(), "standard", buffer_max=2
)
buffer_id1 = mem_buf.add_image(
misc_image1, random_metadata(), "standard", buffer_max=2
)
buffer_id2 = mem_buf.add_image(
misc_image2, random_metadata(), "standard", buffer_max=2
)
returned_image1, _, _ = mem_buf.get_image(buffer_id1)
returned_image2, _, _ = mem_buf.get_image(buffer_id2)
# Assert they are the same images
assert misc_image1 is returned_image1 assert misc_image1 is returned_image1
assert misc_image2 is returned_image2 assert misc_image2 is returned_image2
# They are removed from memory # They are removed from memory
@ -95,11 +105,11 @@ def test_get_two_images_without_setting_buffer_size():
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image1 = random_image() misc_image1 = random_image()
misc_image2 = random_image() misc_image2 = random_image()
buffer_id1 = mem_buf.add_image(misc_image1, random_metadata()) buffer_id1 = mem_buf.add_image(misc_image1, random_metadata(), "standard")
buffer_id2 = mem_buf.add_image(misc_image2, random_metadata()) buffer_id2 = mem_buf.add_image(misc_image2, random_metadata(), "standard")
with pytest.raises(NoImageInMemoryError): with pytest.raises(NoImageInMemoryError):
mem_buf.get_image(buffer_id1) mem_buf.get_image(buffer_id1)
returned_image2, _ = mem_buf.get_image(buffer_id2) returned_image2, _, _ = mem_buf.get_image(buffer_id2)
# Image 2 the expected image # Image 2 the expected image
assert misc_image2 is returned_image2 assert misc_image2 is returned_image2
@ -110,17 +120,21 @@ def test_buffer_size_changing():
misc_image1 = random_image() misc_image1 = random_image()
misc_image2 = random_image() misc_image2 = random_image()
misc_image3 = random_image() misc_image3 = random_image()
buffer_id1 = mem_buf.add_image(misc_image1, random_metadata(), buffer_max=3) buffer_id1 = mem_buf.add_image(
buffer_id2 = mem_buf.add_image(misc_image2, random_metadata(), buffer_max=3) misc_image1, random_metadata(), "standard", buffer_max=3
)
buffer_id2 = mem_buf.add_image(
misc_image2, random_metadata(), "standard", buffer_max=3
)
# Third capture doesn't set buffer size, so it will be reset # Third capture doesn't set buffer size, so it will be reset
buffer_id3 = mem_buf.add_image(misc_image3, random_metadata()) buffer_id3 = mem_buf.add_image(misc_image3, random_metadata(), "standard")
# As buffer size was reset, images 1 and 2 are deleted # As buffer size was reset, images 1 and 2 are deleted
with pytest.raises(NoImageInMemoryError): with pytest.raises(NoImageInMemoryError):
mem_buf.get_image(buffer_id1) mem_buf.get_image(buffer_id1)
with pytest.raises(NoImageInMemoryError): with pytest.raises(NoImageInMemoryError):
mem_buf.get_image(buffer_id2) mem_buf.get_image(buffer_id2)
returned_image3, _ = mem_buf.get_image(buffer_id3) returned_image3, _, _ = mem_buf.get_image(buffer_id3)
# Image 3 the expected image # Image 3 is the expected image
assert misc_image3 is returned_image3 assert misc_image3 is returned_image3
@ -129,9 +143,9 @@ def test_capture_two_images_get_without_id():
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image1 = random_image() misc_image1 = random_image()
misc_image2 = random_image() misc_image2 = random_image()
mem_buf.add_image(misc_image1, random_metadata(), buffer_max=2) mem_buf.add_image(misc_image1, random_metadata(), "standard", buffer_max=2)
mem_buf.add_image(misc_image2, random_metadata(), buffer_max=2) mem_buf.add_image(misc_image2, random_metadata(), "standard", buffer_max=2)
returned_image, _ = mem_buf.get_image() returned_image, _, _ = mem_buf.get_image()
# When buffer_id is not specified, the most recent image (image2) is expected to # When buffer_id is not specified, the most recent image (image2) is expected to
# be retrieved # be retrieved
assert returned_image is misc_image2 assert returned_image is misc_image2
@ -148,7 +162,9 @@ def test_buffer_size_respected():
buffer_ids = [] buffer_ids = []
for _i in range(10): for _i in range(10):
image = random_image() image = random_image()
buffer_id = mem_buf.add_image(image, random_metadata(), buffer_max=5) buffer_id = mem_buf.add_image(
image, random_metadata(), "standard", buffer_max=5
)
images.append(image) images.append(image)
buffer_ids.append(buffer_id) buffer_ids.append(buffer_id)
@ -157,7 +173,7 @@ def test_buffer_size_respected():
with pytest.raises(NoImageInMemoryError): with pytest.raises(NoImageInMemoryError):
mem_buf.get_image(buffer_id) mem_buf.get_image(buffer_id)
else: else:
returned_image, _ = mem_buf.get_image(buffer_id) returned_image, _, _ = mem_buf.get_image(buffer_id)
assert image is returned_image assert image is returned_image
@ -169,7 +185,9 @@ def test_clear_buffer():
buffer_ids = [] buffer_ids = []
for _i in range(10): for _i in range(10):
image = random_image() image = random_image()
buffer_id = mem_buf.add_image(image, random_metadata(), buffer_max=10) buffer_id = mem_buf.add_image(
image, random_metadata(), "standard", buffer_max=10
)
images.append(image) images.append(image)
buffer_ids.append(buffer_id) buffer_ids.append(buffer_id)
@ -192,7 +210,7 @@ def test_get_metadata_too():
for _i in range(10): for _i in range(10):
image = random_image() image = random_image()
metadata = random_metadata() metadata = random_metadata()
buffer_id = mem_buf.add_image(image, metadata, buffer_max=10) buffer_id = mem_buf.add_image(image, metadata, "standard", buffer_max=10)
images.append(image) images.append(image)
metadatas.append(metadata) metadatas.append(metadata)
buffer_ids.append(buffer_id) buffer_ids.append(buffer_id)
@ -202,6 +220,19 @@ def test_get_metadata_too():
# Check both image and metadata # Check both image and metadata
for image, metadata, buffer_id in zipped: for image, metadata, buffer_id in zipped:
returned_image, returned_metadata = mem_buf.get_image(buffer_id) returned_image, returned_metadata, _ = mem_buf.get_image(buffer_id)
assert image is returned_image assert image is returned_image
assert metadata is returned_metadata assert metadata is returned_metadata
def test_mode_is_returned():
"""Check that the correct mode name is returned with the image from the buffer."""
mem_buf = CameraMemoryBuffer()
misc_image = random_image()
buffer_id = mem_buf.add_image(misc_image, random_metadata(), "standard")
_, _, mode = mem_buf.get_image(buffer_id)
assert mode == "standard"
buffer_id = mem_buf.add_image(misc_image, random_metadata(), "foobar")
_, _, mode = mem_buf.get_image(buffer_id)
assert mode == "foobar"

View file

@ -169,9 +169,9 @@ def test_picamera_metadata_written_to_exif(mock_picam_thing, temp_jpeg, mocker):
mock_interface.get_thing_states.return_value = camera.thing_state mock_interface.get_thing_states.return_value = camera.thing_state
camera._thing_server_interface = mock_interface camera._thing_server_interface = mock_interface
capture_metadata = camera._capture_metadata() ofm_metadata = camera._collect_ofm_metadata()
camera._add_metadata_to_capture(str(temp_jpeg), capture_metadata) camera._add_metadata_to_capture(str(temp_jpeg), ofm_metadata)
exif_dict = piexif.load(str(temp_jpeg)) exif_dict = piexif.load(str(temp_jpeg))
user_comment = json.loads(exif_dict["Exif"][piexif.ExifIFD.UserComment].decode()) user_comment = json.loads(exif_dict["Exif"][piexif.ExifIFD.UserComment].decode())

View file

@ -0,0 +1,128 @@
"""Tests for the for thing defined in the base __init__ of the things dir."""
import os
from tempfile import TemporaryDirectory
import pytest
from pydantic import ValidationError
from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.things import OFMThing, RelativeDataPath
@pytest.mark.parametrize(
("path", "valid"),
[
("foo.png", True),
("foo/bar/", True),
("./foo/bar", True),
("/foo/bar", False),
("../foo/bar", False),
("foo/../bar", False),
],
)
def test_rel_data_path_validation(path, valid):
"""Check validation for a number of cases."""
if valid:
path_obj = RelativeDataPath(path)
assert path_obj.root == os.path.normpath(path)
else:
with pytest.raises(ValidationError):
RelativeDataPath(path)
def test_setting_saving_things():
"""Check that the saving thing can be set."""
thing1 = create_thing_without_server(OFMThing)
thing1._data_dir = os.path.join("data", "thing1")
thing2 = create_thing_without_server(OFMThing)
thing2._data_dir = os.path.join("data", "thing2")
path = RelativeDataPath("foo.png")
assert not path.save_location_set
with pytest.raises(
RuntimeError, match="The saving Thing for the relative path was never set"
):
path.abs_data_path
# Set the saving thing and check the response is as expected
path.set_saving_thing(thing1)
assert path.save_location_set
assert path.abs_data_path == os.path.join("data", "thing1", "foo.png")
assert path._saving_thing is thing1
# Check there is an error is trying to overwrite it with a new thing...
with pytest.raises(
RuntimeError, match="The saving Thing for the relative path is already set"
):
path.set_saving_thing(thing2)
# or a temporary dir
with pytest.raises(
RuntimeError, match="The saving Thing for the relative path is already set"
):
path.save_to_tempdir()
# Check no error when using set_saving_thing_if_unset
path.set_saving_thing_if_unset(thing2)
assert path.save_location_set
assert path.abs_data_path == os.path.join("data", "thing1", "foo.png")
assert path._saving_thing is thing1
# Check set_saving_thing_if_unset works on a new path.
path2 = RelativeDataPath("foo2.png")
assert not path2.save_location_set
path2.set_saving_thing_if_unset(thing2)
assert path2.save_location_set
assert path2.abs_data_path == os.path.join("data", "thing2", "foo2.png")
assert path2._saving_thing is thing2
def test_saving_thing_propagates_on_join():
"""Check when joining to a path the saving thing propagates to the new path."""
thing = create_thing_without_server(OFMThing)
thing._data_dir = os.path.join("data", "thing")
path = RelativeDataPath("foo")
# No thing set
assert not path.save_location_set
# After joining no thing set
path2 = path.join("bar")
assert not path2.save_location_set
# Set thing and check joined path is as expected
path2.set_saving_thing(thing)
assert path2.save_location_set
assert path2.abs_data_path == os.path.join("data", "thing", "foo", "bar")
# Do another join. Set thing remains
path3 = path2.join("file.png")
assert path3.save_location_set
assert path3.abs_data_path == os.path.join(
"data", "thing", "foo", "bar", "file.png"
)
def test_temp_dir_for_saving_thing():
"""Check that the saving thing can actually be a temporary directory."""
path = RelativeDataPath("foo.png")
tmpdir = path.save_to_tempdir()
assert isinstance(tmpdir, TemporaryDirectory)
assert path.save_location_set
assert path.abs_data_path == os.path.join(tmpdir.name, "foo.png")
def test_create_data_path_from_thing():
"""Check the create_data_path method of ``OFMThing``.
It should create a RelativeDataPath with itself set as the saving thing.
"""
thing = create_thing_without_server(OFMThing)
thing._data_dir = os.path.join("data", "thing")
path = thing.create_data_path("file.png")
assert path.save_location_set
assert path.abs_data_path == os.path.join("data", "thing", "file.png")
assert path._saving_thing is thing

View file

@ -4,6 +4,7 @@ import itertools
import logging import logging
import pytest import pytest
from PIL import Image
from pydantic import BaseModel from pydantic import BaseModel
import labthings_fastapi as lt import labthings_fastapi as lt
@ -11,6 +12,7 @@ from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.scan_planners import SmartSpiral from openflexure_microscope_server.scan_planners import SmartSpiral
from openflexure_microscope_server.stitching import StitchingSettings from openflexure_microscope_server.stitching import StitchingSettings
from openflexure_microscope_server.things import RelativeDataPath
from openflexure_microscope_server.things.autofocus import SmartStackParams from openflexure_microscope_server.things.autofocus import SmartStackParams
from openflexure_microscope_server.things.camera_stage_mapping import csm_img_to_stage from openflexure_microscope_server.things.camera_stage_mapping import csm_img_to_stage
from openflexure_microscope_server.things.scan_workflows import ( from openflexure_microscope_server.things.scan_workflows import (
@ -44,8 +46,6 @@ def test_partial_base_classes():
bad_workflow = create_thing_without_server(BadWorkflow) bad_workflow = create_thing_without_server(BadWorkflow)
settings = MinimalSettings() settings = MinimalSettings()
with pytest.raises(NotImplementedError):
bad_workflow.check_before_start(settings)
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
bad_workflow.ready bad_workflow.ready
@ -69,7 +69,17 @@ def test_partial_base_classes():
@pytest.fixture @pytest.fixture
def histo_workflow(): def histo_workflow():
"""Return a HistoScanWorkflow thing with slots mocked.""" """Return a HistoScanWorkflow thing with slots mocked."""
return create_thing_without_server(HistoScanWorkflow, mock_all_slots=True) workflow = create_thing_without_server(HistoScanWorkflow, mock_all_slots=True)
workflow._cam.capture_modes = {"standard": "Mock"}
workflow._cam._capture_image.return_value = Image.new("RGB", (1111, 1222))
return workflow
def test_histo_workflow_save_resolution(histo_workflow):
"""Check that the camera is used to get the save resolution."""
width, height = histo_workflow._get_save_resolution()
assert width == 1111
assert height == 1222
# Use itertools to iterate over every true/false permutation # Use itertools to iterate over every true/false permutation
@ -117,7 +127,12 @@ def test_histo_workflow_settings_generation(histo_workflow, mocker):
mocker.patch.object( mocker.patch.object(
histo_workflow, "_calc_displacement_from_overlap", return_value=(123, 456) histo_workflow, "_calc_displacement_from_overlap", return_value=(123, 456)
) )
workflow_settings, stitching_settings = histo_workflow.all_settings("/this/img_dir")
img_dir = RelativeDataPath("this/img_dir")
workflow_settings, stitching_settings, save_res = histo_workflow.all_settings(
img_dir
)
assert save_res == (1111, 1222)
## Check type ## Check type
assert isinstance(workflow_settings, HistoScanSettingsModel) assert isinstance(workflow_settings, HistoScanSettingsModel)
assert isinstance(stitching_settings, StitchingSettings) assert isinstance(stitching_settings, StitchingSettings)
@ -137,7 +152,7 @@ def test_histo_workflow_settings_generation(histo_workflow, mocker):
assert workflow_settings.dx == 123 assert workflow_settings.dx == 123
assert workflow_settings.dy == 456 assert workflow_settings.dy == 456
# And that the input image dir is passed to stack the stack parameter for saving # And that the input image dir is passed to stack the stack parameter for saving
assert workflow_settings.capture_params.images_dir == "/this/img_dir" assert workflow_settings.capture_params.images_dir.root == "this/img_dir"
def test_histo_workflow_settings_generation_equal_overlap(histo_workflow, mocker): def test_histo_workflow_settings_generation_equal_overlap(histo_workflow, mocker):
@ -148,8 +163,9 @@ def test_histo_workflow_settings_generation_equal_overlap(histo_workflow, mocker
# Different when False # Different when False
histo_workflow.equal_distances = False histo_workflow.equal_distances = False
workflow_settings, _stitching_settings = histo_workflow.all_settings( img_dir = RelativeDataPath("this/img_dir")
"/this/img_dir" workflow_settings, _stitching_settings, _save_res = histo_workflow.all_settings(
img_dir
) )
assert workflow_settings.dx == 123 assert workflow_settings.dx == 123
@ -157,8 +173,8 @@ def test_histo_workflow_settings_generation_equal_overlap(histo_workflow, mocker
# Same when set True # Same when set True
histo_workflow.equal_distances = True histo_workflow.equal_distances = True
workflow_settings, _stitching_settings = histo_workflow.all_settings( workflow_settings, _stitching_settings, _save_res = histo_workflow.all_settings(
"/this/img_dir" img_dir
) )
assert workflow_settings.dx == 123 assert workflow_settings.dx == 123
@ -455,9 +471,8 @@ def test_correlation_resize(histo_workflow, save_res, expected_resize):
target area, taking the square root, and rounding to the nearest integer N. Then target area, taking the square root, and rounding to the nearest integer N. Then
correlation_resize = 1 / N. correlation_resize = 1 / N.
""" """
histo_workflow.save_resolution = save_res
histo_workflow.overlap = 0.1 histo_workflow.overlap = 0.1
settings = histo_workflow._get_stitching_settings_model() settings = histo_workflow._get_stitching_settings_model(save_res)
assert isinstance(settings, StitchingSettings) assert isinstance(settings, StitchingSettings)
assert settings.correlation_resize == expected_resize assert settings.correlation_resize == expected_resize

View file

@ -159,12 +159,12 @@ def test_infinite_sample(camera, stage):
camera.noise_level = 0 camera.noise_level = 0
assert not camera.repeating assert not camera.repeating
cached_canvas = camera.canvas cached_canvas = camera.canvas
array_not_repeating = camera.capture_array() array_not_repeating = camera.capture_as_array()
camera.repeating = True camera.repeating = True
time.sleep(0.2) # Ensure frame regenerates time.sleep(0.2) # Ensure frame regenerates
# Canvas shouldn't regenerate # Canvas shouldn't regenerate
assert camera.canvas is cached_canvas assert camera.canvas is cached_canvas
array_repeating = camera.capture_array() array_repeating = camera.capture_as_array()
# Images are identical whether or not repeating # Images are identical whether or not repeating
assert np.array_equal(array_not_repeating, array_repeating) assert np.array_equal(array_not_repeating, array_repeating)
@ -174,13 +174,13 @@ def test_infinite_sample(camera, stage):
camera.repeating = False camera.repeating = False
time.sleep(0.2) # Ensure frame regenerates time.sleep(0.2) # Ensure frame regenerates
# If not repeating the array is just background # If not repeating the array is just background
assert np.all(camera.capture_array() == simulation.BG_COLOR) assert np.all(camera.capture_as_array() == simulation.BG_COLOR)
# Turn on repeating # Turn on repeating
camera.repeating = True camera.repeating = True
time.sleep(0.2) # Ensure frame regenerates time.sleep(0.2) # Ensure frame regenerates
# Sample is now infinite, so not all background # Sample is now infinite, so not all background
assert not np.all(camera.capture_array() == simulation.BG_COLOR) assert not np.all(camera.capture_as_array() == simulation.BG_COLOR)
def test_simulation_cam_calibration(camera): def test_simulation_cam_calibration(camera):

View file

@ -413,13 +413,14 @@ def scan_thing_mocked_for_scan_data(smart_scan_thing, mocker):
smart_scan_thing._workflow.all_settings.return_value = ( smart_scan_thing._workflow.all_settings.return_value = (
MockWorkflowSettingModel(), MockWorkflowSettingModel(),
StitchingSettings(correlation_resize=0.5, overlap=0.45), StitchingSettings(correlation_resize=0.5, overlap=0.45),
(1640, 1232),
) )
smart_scan_thing._workflow.save_resolution = (1640, 1232)
mock_ongoing_scan = mocker.Mock() mock_ongoing_scan = mocker.Mock()
mock_ongoing_scan.name = MOCK_SCAN_NAME mock_ongoing_scan.name = MOCK_SCAN_NAME
mock_ongoing_scan.images_dir = MOCK_SCAN_DIR mock_ongoing_scan.images_dir = MOCK_SCAN_DIR
smart_scan_thing._ongoing_scan = mock_ongoing_scan smart_scan_thing._ongoing_scan = mock_ongoing_scan
smart_scan_thing._data_dir = "scans"
yield smart_scan_thing yield smart_scan_thing

View file

@ -8,10 +8,13 @@ import numpy as np
import pytest import pytest
from hypothesis import given from hypothesis import given
from hypothesis import strategies as st from hypothesis import strategies as st
from PIL import Image
from pydantic import ValidationError
from labthings_fastapi.testing import create_thing_without_server from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.scan_directories import IMAGE_REGEX from openflexure_microscope_server.scan_directories import IMAGE_REGEX
from openflexure_microscope_server.things import RelativeDataPath
from openflexure_microscope_server.things.autofocus import ( from openflexure_microscope_server.things.autofocus import (
EXTRA_STACK_CAPTURES, EXTRA_STACK_CAPTURES,
AutofocusThing, AutofocusThing,
@ -262,6 +265,10 @@ def histo_scan_workflow():
workflow._csm.calibration_required = False workflow._csm.calibration_required = False
workflow._csm.convert_image_to_stage_coordinates = lambda x, y: {"x": x, "y": y} workflow._csm.convert_image_to_stage_coordinates = lambda x, y: {"x": x, "y": y}
# And set up camera
workflow._cam.capture_modes = {"standard": "Mock"}
workflow._cam._capture_image.return_value = Image.new("RGB", (1000, 1000))
return workflow return workflow
@ -341,7 +348,7 @@ def test_coercing_stack_save_ims(
@pytest.mark.parametrize("pass_on", [1, 2, 3, 4]) @pytest.mark.parametrize("pass_on", [1, 2, 3, 4])
def test_run_smart_stack(pass_on, histo_scan_workflow, autofocus_thing, mocker): def test_run_smart_stack(pass_on, histo_scan_workflow, autofocus_thing, mocker):
"""Test Running smart stack with the stack passing on different attempts.""" """Test Running smart stack with the stack passing on different attempts."""
scan_settings, _ = histo_scan_workflow.all_settings(images_dir="dummy") scan_settings, _, _ = histo_scan_workflow.all_settings(RelativeDataPath("dummy"))
assert scan_settings.smart_stack_params.max_attempts == 3 assert scan_settings.smart_stack_params.max_attempts == 3
# Set up returns from z-stack # Set up returns from z-stack
@ -878,35 +885,15 @@ def test_invalid_stack_settling_raises():
@pytest.mark.parametrize( @pytest.mark.parametrize(
("bad_path", "match_err"), ("bad_path", "error_type"),
[ [
("", "String should have at least 1 character"), (None, TypeError),
(None, "Input should be a valid string"), (67, TypeError),
(67, "Input should be a valid string"), ("../dangerous", ValidationError),
("/usr/bin/bash", ValidationError),
], ],
) )
def test_invalid_capture_dir_raises(bad_path, match_err): def test_invalid_capture_dir_raises(bad_path, error_type):
"""Test basic stack raises expected error for bad image dir paths.""" """Test basic stack raises expected error for bad image dir paths."""
with pytest.raises(ValueError, match=match_err): with pytest.raises(error_type):
CaptureParams(images_dir=bad_path, save_resolution=(20, 20)) CaptureParams(images_dir=bad_path, capture_mode="foo")
@pytest.mark.parametrize(
("bad_res", "match_err"),
[
((-100, 50), "Input should be greater than or equal to 1"),
((20, 0), "Input should be greater than or equal to 1"),
("", "Input should be a valid tuple"),
(None, "Input should be a valid tuple"),
(67, "Input should be a valid tuple"),
(
["path"],
"Input should be a valid integer, unable to parse string as an integer",
),
((20, 20, 20), "Tuple should have at most 2 items"),
],
)
def test_invalid_capture_res_raises(bad_res, match_err):
"""Test basic stack raises expected error for invalid save resolutions."""
with pytest.raises(ValueError, match=match_err):
CaptureParams(images_dir="dummy", save_resolution=bad_res)

View file

@ -7,20 +7,9 @@
<div class="uk-margin"> <div class="uk-margin">
<action-button <action-button
thing="camera" thing="camera"
action="capture_jpeg" action="capture"
:submit-data="{ stream_name: 'main' }" :submit-data="{ capture_mode: 'standard' }"
:submit-label="'Low Resolution'" submit-label="Capture"
@response="handleCaptureResponse"
@error="modalError"
/>
</div>
<div class="uk-margin">
<action-button
thing="camera"
action="capture_jpeg"
:submit-data="{ stream_name: 'full' }"
submit-label="Full Resolution"
:submit-on-event="'globalCaptureEvent'" :submit-on-event="'globalCaptureEvent'"
@response="handleCaptureResponse" @response="handleCaptureResponse"
@error="modalError" @error="modalError"