Merge branch 'type-fixes' into 'v3'

Type fixes

See merge request openflexure/openflexure-microscope-server!455
This commit is contained in:
Julian Stirling 2026-01-12 16:10:25 +00:00
commit 1e36811796
32 changed files with 521 additions and 483 deletions

3
.gitignore vendored
View file

@ -9,6 +9,9 @@ __pycache__/
# Mypy cache # Mypy cache
.mypy_cache* .mypy_cache*
.dmypy.json
mypy/
mypy-out.txt
# C extensions # C extensions
*.so *.so

View file

@ -95,12 +95,15 @@ mypy:
extends: .python extends: .python
script: script:
- mkdir -p mypy - mkdir -p mypy
# "|| true" is used for preventing job failure when mypy find errors - mypy src --cobertura-xml-report mypy --no-error-summary > mypy-out.txt
- mypy src --cobertura-xml-report mypy --no-error-summary > mypy-out.txt || true after_script:
# Installed here rather than with [dev] as the after_script runs in a new shell
- pip install mypy-gitlab-code-quality
- mypy-gitlab-code-quality < mypy-out.txt > codequality.json - mypy-gitlab-code-quality < mypy-out.txt > codequality.json
allow_failure: true - cat mypy-out.txt
artifacts: artifacts:
when: always
reports: reports:
codequality: codequality.json codequality: codequality.json
coverage_report: coverage_report:

Binary file not shown.

View file

@ -18,7 +18,7 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
dependencies = [ dependencies = [
"labthings-fastapi==0.0.13", "labthings-fastapi==0.0.14",
"sangaboard", "sangaboard",
"camera-stage-mapping ~= 0.1.10", "camera-stage-mapping ~= 0.1.10",
"opencv-python ~= 4.11.0", "opencv-python ~= 4.11.0",
@ -37,7 +37,6 @@ dependencies = [
dev = [ dev = [
"ruff~=0.13.0", "ruff~=0.13.0",
"mypy[reports]", "mypy[reports]",
"mypy-gitlab-code-quality",
# "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings # "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings
"pytest", "pytest",
"pytest-cov", "pytest-cov",

View file

@ -5,15 +5,17 @@ for analysis. Information from these images is used to detect whether an image f
current camera field of view contains sample. current camera field of view contains sample.
""" """
from typing import Any, Optional from typing import Any, Generic, Optional, TypeVar
import cv2 import cv2
import numpy as np import numpy as np
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from pydantic.errors import PydanticUserError
from labthings_fastapi.thing_description import type_to_dataschema from labthings_fastapi.thing_description import type_to_dataschema
SettingsType = TypeVar("SettingsType", bound=BaseModel)
BackgroundType = TypeVar("BackgroundType", bound=BaseModel)
class MissingBackgroundDataError(RuntimeError): class MissingBackgroundDataError(RuntimeError):
"""An error raised if checking for sample without background data set.""" """An error raised if checking for sample without background data set."""
@ -56,22 +58,24 @@ class BackgroundDetectorStatus(BaseModel):
""" """
class BackgroundDetectAlgorithm: class BackgroundDetectAlgorithm(Generic[SettingsType, BackgroundType]):
"""The base class for defining background detect algorithms.""" """The base class for defining background detect algorithms."""
background_data_model: BaseModel = BaseModel background_data_model: type[BackgroundType]
"""The data model of the background data. This must be set by child classes""" """The data model of the background data. This must be set by child classes"""
settings_data_model: BaseModel = BaseModel settings_data_model: type[SettingsType]
"""The data model of algorithm settings. This must be set by child classes""" """The data model of algorithm settings. This must be set by child classes"""
def __init__(self) -> None: def __init__(self) -> None:
"""Initialise the algorithm settings.""" """Initialise the algorithm settings."""
try: if not hasattr(self, "background_data_model") or not hasattr(
self._settings: BaseModel = self.settings_data_model() self, "settings_data_model"
except PydanticUserError as e: ):
raise NotImplementedError( raise NotImplementedError(
"BackgroundDetectAlgorithms must set their own settings data model." "All BackgroundDetectAlgorithm subclesses must set their own settings "
) from e "and background data models."
)
self._settings: SettingsType = self.settings_data_model()
@property @property
def status(self) -> BackgroundDetectorStatus: def status(self) -> BackgroundDetectorStatus:
@ -86,10 +90,10 @@ class BackgroundDetectAlgorithm:
# Requires a getter and a setter to support being a BaseModel but being # Requires a getter and a setter to support being a BaseModel but being
# saved to file as a dict # saved to file as a dict
_background_data: Optional[BaseModel] = None _background_data: Optional[BackgroundType] = None
@property @property
def background_data(self) -> Optional[BaseModel]: def background_data(self) -> Optional[BackgroundType]:
"""The statistics of the background image.""" """The statistics of the background image."""
bd = self._background_data bd = self._background_data
if bd is None: if bd is None:
@ -97,36 +101,31 @@ class BackgroundDetectAlgorithm:
return bd return bd
@background_data.setter @background_data.setter
def background_data(self, value: Optional[BaseModel | dict]) -> None: def background_data(self, value: Optional[BackgroundType | dict]) -> None:
"""Set the statistics for the background image. """Set the statistics for the background image.
This should be None, of no data is available. It can be set from either This should be None, of no data is available. It can be set from either
a dictionary or a base model of the type specified in a dictionary or a base model of the type specified in
``self.background_data_model``. ``self.background_data_model``.
""" """
try: if value is None:
if value is None: self._background_data = None
self._background_data = None elif isinstance(value, self.background_data_model):
elif isinstance(value, self.background_data_model): self._background_data = value
self._background_data = value elif isinstance(value, dict):
elif isinstance(value, dict): self._background_data = self.background_data_model(**value)
self._background_data = self.background_data_model(**value) else:
else: raise TypeError(
raise TypeError( f"Cannot set background_data with an object of type {type(value)}"
f"Cannot set background_data with an object of type {type(value)}" )
)
except PydanticUserError as e:
raise NotImplementedError(
"BackgroundDetectAlgorithms must set their own background data model."
) from e
@property @property
def settings(self) -> BaseModel: def settings(self) -> SettingsType:
"""The statistics of the background image.""" """The statistics of the background image."""
return self._settings return self._settings
@settings.setter @settings.setter
def settings(self, value: BaseModel | dict) -> None: def settings(self, value: SettingsType | dict) -> None:
if isinstance(value, self.settings_data_model): if isinstance(value, self.settings_data_model):
self._settings = value self._settings = value
elif isinstance(value, dict): elif isinstance(value, dict):
@ -186,7 +185,12 @@ class ColourChannelDetectSettings(BaseModel):
""" """
class ColourChannelDetectLUV(BackgroundDetectAlgorithm): class ColourChannelDetectLUV(
BackgroundDetectAlgorithm[
ColourChannelDetectSettings,
ChannelDistributions,
]
):
"""Compare images with a known background in LUV colourspace. """Compare images with a known background in LUV colourspace.
This uses an LUV colour space checking only the mean and standard deviation of the This uses an LUV colour space checking only the mean and standard deviation of the
@ -194,8 +198,8 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
intuitive way. intuitive way.
""" """
background_data_model: BaseModel = ChannelDistributions background_data_model: type[ChannelDistributions] = ChannelDistributions
settings_data_model: BaseModel = ColourChannelDetectSettings settings_data_model: type[ColourChannelDetectSettings] = ColourChannelDetectSettings
# These are the same as those used for ChannelDeviationLUV. More detail is # These are the same as those used for ChannelDeviationLUV. More detail is
# provided there. # provided there.
@ -209,6 +213,10 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
The image should be in LUV format, the output will be binary with the The image should be in LUV format, the output will be binary with the
same shape in the first two dimensions. same shape in the first two dimensions.
""" """
if self.background_data is None:
raise RuntimeError(
"Cannot calculated background mask if no background is set."
)
# The ``[1:]`` selects only the U and V channels of the image. # The ``[1:]`` selects only the U and V channels of the image.
# Only U and V are used as brightness (L) often changes as # Only U and V are used as brightness (L) often changes as
# the height of the sample changes. # the height of the sample changes.
@ -240,7 +248,7 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV) image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
mask = self.background_mask(image_luv) mask = self.background_mask(image_luv)
return (1 - np.count_nonzero(mask) / np.prod(mask.shape)) * 100 return float(1 - np.count_nonzero(mask) / np.prod(mask.shape)) * 100
def image_is_sample(self, image: np.ndarray) -> tuple[bool, str]: def image_is_sample(self, image: np.ndarray) -> tuple[bool, str]:
"""Label the current image as either background or sample. """Label the current image as either background or sample.
@ -274,7 +282,12 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
) )
class ChannelDeviationLUV(BackgroundDetectAlgorithm): class ChannelDeviationLUV(
BackgroundDetectAlgorithm[
ColourChannelDetectSettings,
ChannelDistributions,
]
):
"""Compare the standard deviations of the LUV channels in a grid to background data. """Compare the standard deviations of the LUV channels in a grid to background data.
Using an LUV colour space, each image is divided into an 8x8 grid of images. Using an LUV colour space, each image is divided into an 8x8 grid of images.
@ -284,8 +297,8 @@ class ChannelDeviationLUV(BackgroundDetectAlgorithm):
# Note we don't use the means in this algorithm but we use the same channel # Note we don't use the means in this algorithm but we use the same channel
# distributions model # distributions model
background_data_model: BaseModel = ChannelDistributions background_data_model: type[ChannelDistributions] = ChannelDistributions
settings_data_model: BaseModel = ColourChannelDetectSettings settings_data_model: type[ColourChannelDetectSettings] = ColourChannelDetectSettings
# Empirically, 0.5 seems to be approximate the standard deviation for a good image # Empirically, 0.5 seems to be approximate the standard deviation for a good image
# in L and V. U appears to be about 60% of this value. U is about 65% of V when # in L and V. U appears to be about 60% of this value. U is about 65% of V when

View file

@ -14,12 +14,13 @@ output to STDOUT/STDERR are captured by ``systemd``. These can be viewed by usin
import logging import logging
import os import os
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from typing import Optional
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
OFM_LOG_FILE = None OFM_LOG_FILE: Optional[str] = None
def configure_logging(log_folder: str) -> None: def configure_logging(log_folder: str) -> None:
@ -128,7 +129,7 @@ class OFMHandler(logging.Handler):
how many can be returned over HTTP. how many can be returned over HTTP.
""" """
super().__init__(level=level) super().__init__(level=level)
self._log = [] self._log: list[str] = []
self._max_logs = max_logs self._max_logs = max_logs
def append_record(self, record: logging.LogRecord) -> None: def append_record(self, record: logging.LogRecord) -> None:

View file

@ -304,7 +304,14 @@ class ScanDirectoryManager:
else: else:
last_matching_scan = sorted(matching_scans)[-1] last_matching_scan = sorted(matching_scans)[-1]
# Get the first group from the regex, turn to int, and add 1 # Get the first group from the regex, turn to int, and add 1
scan_num = int(scan_regex.match(last_matching_scan)[1]) + 1 scan_match = scan_regex.match(last_matching_scan)
if scan_match is None: # pragma: no cover
# Type narrow, we know it is a match but mypy doesn't. This code is
# Not reachable hence the no cover.
raise RuntimeError("Internal error: regex No longer matches")
scan_num = int(scan_match[1]) + 1
# Set a sensible limit for the number of scans of one name # Set a sensible limit for the number of scans of one name
# based on our zero padding. # based on our zero padding.
@ -346,7 +353,7 @@ class ScanDirectoryManager:
shutil.rmtree(self.path_for(scan_name)) shutil.rmtree(self.path_for(scan_name))
@requires_lock @requires_lock
def zip_scan(self, scan_name: str, final_version: bool = False) -> "ScanDirectory": def zip_scan(self, scan_name: str, final_version: bool = False) -> str:
"""Zips any images from the scan not yet zipped, return full path to zip. """Zips any images from the scan not yet zipped, return full path to zip.
``final_version`` Set true to stitch all files not just the scan images ``final_version`` Set true to stitch all files not just the scan images

View file

@ -13,10 +13,17 @@ from uvicorn.main import Server
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.server import fallback from labthings_fastapi.server import fallback
from labthings_fastapi.server.config_model import ThingServerConfig
from openflexure_microscope_server.things.camera import BaseCamera
from openflexure_microscope_server.utilities import load_patched_config from openflexure_microscope_server.utilities import load_patched_config
from ..logging import configure_logging, retrieve_log, retrieve_log_from_file from ..logging import (
OFM_HANDLER,
configure_logging,
retrieve_log,
retrieve_log_from_file,
)
from .legacy_api import add_v2_endpoints from .legacy_api import add_v2_endpoints
from .serve_static_files import add_static_files from .serve_static_files import add_static_files
@ -44,7 +51,9 @@ def set_shutdown_function(shutdown_function: Callable[[], None]) -> None:
shutdown_function() shutdown_function()
original_handler(*args, **kwargs) original_handler(*args, **kwargs)
Server.handle_exit = handle_exit # Ignore the MyPy doesn't want us monkey patching. We have to unless the
# FastAPI lifecycle is fixed.
Server.handle_exit = handle_exit # type: ignore[method-assign]
def customise_server( def customise_server(
@ -82,21 +91,24 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None:
log_config["loggers"]["uvicorn"]["propagate"] = True log_config["loggers"]["uvicorn"]["propagate"] = True
log_config["loggers"]["uvicorn.access"]["propagate"] = True log_config["loggers"]["uvicorn.access"]["propagate"] = True
# Create server and config vars before trying to configure so they are defined # Create server and lt_config vars before trying to configure so they are defined
# if fallback is needed before they are set. # if fallback is needed before they are set.
config = None lt_config = None
server = None server = None
try: try:
config = _full_config_from_args(args) lt_config, internal_config = _full_config_from_args(args)
log_folder = config.pop("log_folder", "./openflexure/logs")
scans_folder = _get_scans_dir(config) server = lt.ThingServer.from_config(lt_config)
server = lt.ThingServer.from_config(config) customise_server(
customise_server(server, log_folder, scans_folder) server, internal_config["log_folder"], internal_config["scans_folder"]
)
def shutdown_call() -> None: def shutdown_call() -> None:
try: try:
# Kill any mjpeg streams so that StreamingResponses close. camera_thing = server.things["camera"]
server.things["camera"].kill_mjpeg_streams() if not isinstance(camera_thing, BaseCamera):
raise RuntimeError("Camera thing is not a BaseCamera")
camera_thing.kill_mjpeg_streams()
except BaseException as e: except BaseException as e:
# Catch anything and log as it is essential that this # Catch anything and log as it is essential that this
# function cannot raise an unhandled exception or Uvicorn # function cannot raise an unhandled exception or Uvicorn
@ -121,10 +133,18 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None:
# presented in the fallback logs. # presented in the fallback logs.
print(f"Error: {e}") # noqa: T201 print(f"Error: {e}") # noqa: T201
print("Starting fallback server.") # noqa: T201 print("Starting fallback server.") # noqa: T201
try:
log_history = OFM_HANDLER.log_history
except BaseException:
# If log history fails for any reason carry on.
log_history = None
app = fallback.app app = fallback.app
app.labthings_config = config app.set_context(
app.labthings_server = server fallback.FallbackContext(
app.labthings_error = e server=server, config=lt_config, error=e, log_history=log_history
)
)
uvicorn.run( uvicorn.run(
app, app,
host=args.host, host=args.host,
@ -135,14 +155,25 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None:
raise e raise e
def _full_config_from_args(args: Namespace) -> dict: def _full_config_from_args(args: Namespace) -> tuple[ThingServerConfig, dict[str, Any]]:
"""Load configuration from LabThings args allowing patching. """Load configuration from LabThings args allowing patching.
This returns the labthings ThingServerConfig model and a dictionary of the config
for the microscope.
This provides similar functionarlity to lt.cli.config_from_args except allows the This provides similar functionarlity to lt.cli.config_from_args except allows the
configuration file to specify a base config, and optionally patches. configuration file to specify a base config, and optionally patches.
""" """
internal_config = {"log_folder": "./openflexure/logs", "scans_folder": None}
# If no config file specified let LabThings handle it. # If no config file specified let LabThings handle it.
if not args.config: if not args.config:
return lt.cli.config_from_args(args) return lt.cli.config_from_args(args), internal_config
return load_patched_config(args.config) patched_config = load_patched_config(args.config)
log_folder = patched_config.pop("log_folder", None)
if log_folder is not None:
internal_config["log_folder"] = log_folder
scans_folder = _get_scans_dir(patched_config)
if scans_folder is not None:
internal_config["log_folder"] = log_folder
return ThingServerConfig(**patched_config), internal_config

View file

@ -6,6 +6,8 @@ from fastapi import Response
import labthings_fastapi as lt import labthings_fastapi as lt
from openflexure_microscope_server.things.camera import BaseCamera
FAKE_ROUTES = [ FAKE_ROUTES = [
"/api/v2/", "/api/v2/",
"/api/v2/streams/snapshot", "/api/v2/streams/snapshot",
@ -38,7 +40,10 @@ def add_v2_endpoints(thing_server: lt.ThingServer) -> None:
@app.head("/api/v2/streams/snapshot") @app.head("/api/v2/streams/snapshot")
async def thumbnail() -> JPEGResponse: async def thumbnail() -> JPEGResponse:
"""Return a low-resolution snapshot, for compatibility with OF connect.""" """Return a low-resolution snapshot, for compatibility with OF connect."""
blob = await thing_server.things["camera"].lores_mjpeg_stream.grab_frame() camera_thing = thing_server.things["camera"]
if not isinstance(camera_thing, BaseCamera):
raise RuntimeError("Camera thing is not a BaseCamera")
blob = await camera_thing.lores_mjpeg_stream.grab_frame()
return JPEGResponse(blob) return JPEGResponse(blob)
@app.get("/api/v2/instrument/settings/name") @app.get("/api/v2/instrument/settings/name")

View file

@ -12,8 +12,7 @@ import shlex
import signal import signal
import subprocess import subprocess
import threading import threading
from io import TextIOWrapper from typing import IO, Any, Optional
from typing import Any, Optional
import labthings_fastapi as lt import labthings_fastapi as lt
@ -87,7 +86,7 @@ class BaseStitcher:
self.min_overlap = round(overlap * 0.9, 2) self.min_overlap = round(overlap * 0.9, 2)
self.correlation_resize = float(correlation_resize) self.correlation_resize = float(correlation_resize)
self._mode = "all" self._mode = "all"
self._extra_args = [] self._extra_args: list[str] = []
@property @property
def command(self) -> list[str]: def command(self) -> list[str]:
@ -236,8 +235,8 @@ class FinalStitcher(BaseStitcher):
def _process_inputs( def _process_inputs(
self, self,
overlap: float, overlap: Optional[float],
correlation_resize: float, correlation_resize: Optional[float],
scan_data_dict: Optional[dict[str, Any]], scan_data_dict: Optional[dict[str, Any]],
) -> tuple[float, float]: ) -> tuple[float, float]:
"""Process inputs to ensure ``overlap`` and ``correlation_resize`` have values. """Process inputs to ensure ``overlap`` and ``correlation_resize`` have values.
@ -296,13 +295,15 @@ class FinalStitcher(BaseStitcher):
# Run the command piping stdout into the process for reading and # Run the command piping stdout into the process for reading and
# forwarding the stdrerr to stdout # forwarding the stdrerr to stdout
process = subprocess.Popen( process: subprocess.Popen[str] = subprocess.Popen(
cmd, cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
bufsize=1, bufsize=1,
text=True, text=True,
) )
if process.stdout is None: # pragma: no cover - impossible with stdout=PIPE
raise RuntimeError("stdout pipe was not created")
# Stop opening pipe blocking writing to it # Stop opening pipe blocking writing to it
os.set_blocking(process.stdout.fileno(), False) os.set_blocking(process.stdout.fileno(), False)
@ -320,8 +321,11 @@ class FinalStitcher(BaseStitcher):
f"Subprocess {STITCHING_CMD} errored with exit code {process.poll()}." f"Subprocess {STITCHING_CMD} errored with exit code {process.poll()}."
) )
def _log_ongoing(self, process: subprocess.Popen) -> None: def _log_ongoing(self, process: subprocess.Popen[str]) -> None:
"""Log the ongoing process unless it is cancelled.""" """Log the ongoing process unless it is cancelled."""
if process.stdout is None: # pragma: no cover - impossible with stdout=PIPE
raise RuntimeError("stdout pipe was not created")
# Poll returns None while running, will return the error code when finished # Poll returns None while running, will return the error code when finished
while process.poll() is None: while process.poll() is None:
self.log_buffer(process.stdout) self.log_buffer(process.stdout)
@ -340,7 +344,7 @@ class FinalStitcher(BaseStitcher):
# Print everything in the buffer when program finishes # Print everything in the buffer when program finishes
self.log_buffer(process.stdout) self.log_buffer(process.stdout)
def log_buffer(self, buffer: TextIOWrapper) -> None: def log_buffer(self, buffer: IO[str]) -> None:
"""Log everything in the buffer at INFO level.""" """Log everything in the buffer at INFO level."""
# Use rstrip to remove newlines from each line, as logging provides its own # Use rstrip to remove newlines from each line, as logging provides its own
# new lines # new lines

View file

@ -148,7 +148,7 @@ class CaptureInfo:
"""The information from a capture in a z_stack.""" """The information from a capture in a z_stack."""
buffer_id: int buffer_id: int
position: dict[str, int] position: Mapping[str, int]
sharpness: int sharpness: int
@property @property
@ -264,7 +264,7 @@ class JPEGSharpnessMonitor:
"""Clean up after context manager is closed.""" """Clean up after context manager is closed."""
self.running = False self.running = False
def focus_rel(self, dz: int, block_cancellation: int = False) -> tuple[int, int]: def focus_rel(self, dz: int, block_cancellation: bool = False) -> tuple[int, int]:
"""Move the stage by dz, monitoring the position over time. """Move the stage by dz, monitoring the position over time.
This performs exactly one move. Multiple calls of this method This performs exactly one move. Multiple calls of this method
@ -426,7 +426,7 @@ class AutofocusThing(lt.Thing):
while attempt < 10: while attempt < 10:
attempt += 1 attempt += 1
if start == "centre": if start == "centre":
self._stage.move_relative(x=0, y=0, z=-(backlash + dz / 2)) self._stage.move_relative(x=0, y=0, z=-int(backlash + dz / 2))
self._stage.move_relative(x=0, y=0, z=backlash) self._stage.move_relative(x=0, y=0, z=backlash)
# Always start centrally for future runs # Always start centrally for future runs
@ -555,7 +555,7 @@ class AutofocusThing(lt.Thing):
stack_parameters: StackParams, stack_parameters: StackParams,
save_on_failure: bool = False, save_on_failure: bool = False,
check_turning_points: bool = True, check_turning_points: bool = True,
) -> tuple[bool, Optional[int]]: ) -> tuple[bool, int]:
"""Run a smart stack. """Run a smart stack.
A smart stack captures images offset in z, testing whether the sharpest image A smart stack captures images offset in z, testing whether the sharpest image
@ -612,7 +612,7 @@ class AutofocusThing(lt.Thing):
def reset_stack( def reset_stack(
self, self,
initial_z_pos: list[int], initial_z_pos: int,
autofocus_dz: int, autofocus_dz: int,
) -> None: ) -> None:
"""Return to the initial z position and run a looping autofocus. """Return to the initial z position and run a looping autofocus.
@ -629,7 +629,7 @@ class AutofocusThing(lt.Thing):
def save_stack( def save_stack(
self, self,
sharpest_id: int, sharpest_id: int,
captures: list[list], captures: list[CaptureInfo],
stack_parameters: StackParams, stack_parameters: StackParams,
) -> int: ) -> int:
"""Save the required captures to disk. """Save the required captures to disk.
@ -681,7 +681,7 @@ class AutofocusThing(lt.Thing):
# Move down by the height of the z stack, plus an overshoot # Move down by the height of the z stack, plus an overshoot
# Better to start too low and take too many images than too high and need to refocus # Better to start too low and take too many images than too high and need to refocus
self._stage.move_relative( self._stage.move_relative(
z=-( z=-int(
stack_parameters.steps_undershoot stack_parameters.steps_undershoot
+ stack_parameters.backlash_correction + stack_parameters.backlash_correction
+ stack_parameters.stack_z_range / 2 + stack_parameters.stack_z_range / 2
@ -689,7 +689,7 @@ class AutofocusThing(lt.Thing):
) )
self._stage.move_relative(z=stack_parameters.backlash_correction) self._stage.move_relative(z=stack_parameters.backlash_correction)
captures = [] captures: list[CaptureInfo] = []
# Always check for focus using the the last `min_images_to_test` in the # Always check for focus using the the last `min_images_to_test` in the
# stack so check is fair # stack so check is fair
ims_to_check = slice(-stack_parameters.min_images_to_test, None) ims_to_check = slice(-stack_parameters.min_images_to_test, None)

View file

@ -15,12 +15,11 @@ import tempfile
import time import time
from datetime import datetime from datetime import datetime
from types import TracebackType from types import TracebackType
from typing import Any, Literal, Mapping, Optional, Tuple from typing import Any, Literal, Mapping, Optional, Self, Tuple
import numpy as np import numpy as np
import piexif import piexif
from PIL import Image from PIL import Image
from pydantic import RootModel
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
@ -46,12 +45,6 @@ class PNGBlob(lt.blob.Blob):
media_type: str = "image/png" media_type: str = "image/png"
class ArrayModel(RootModel):
"""A model for an array."""
root: NDArray
class CaptureError(RuntimeError): class CaptureError(RuntimeError):
"""An error trying to capture from a CameraThing.""" """An error trying to capture from a CameraThing."""
@ -66,7 +59,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, Optional[dict]]] _storage: dict[int, tuple[Any, Mapping[str, Any]]]
def __init__(self) -> None: def __init__(self) -> None:
"""Create the buffer instance.""" """Create the buffer instance."""
@ -80,7 +73,7 @@ class CameraMemoryBuffer:
def add_image( def add_image(
self, self,
image: Any, image: Any,
metadata: Optional[Mapping[str, Any]] = None, metadata: Mapping[str, Any],
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,7 +97,7 @@ class CameraMemoryBuffer:
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, Optional[dict]]: ) -> tuple[Any, Mapping[str, Any]]:
"""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
@ -187,7 +180,7 @@ class BaseCamera(lt.Thing):
} }
self._detector_name = "Channel Deviations (LUV)" self._detector_name = "Channel Deviations (LUV)"
def __enter__(self) -> None: def __enter__(self) -> Self:
"""Open hardware connection when the Thing context manager is opened.""" """Open hardware connection when the Thing context manager is opened."""
raise NotImplementedError("CameraThings must define their own __enter__ method") raise NotImplementedError("CameraThings must define their own __enter__ method")
@ -211,12 +204,15 @@ class BaseCamera(lt.Thing):
@lt.action @lt.action
def start_streaming( def start_streaming(
self, main_resolution: tuple[int, int], buffer_count: int self, main_resolution: tuple[int, int] = (800, 800), buffer_count: int = 1
) -> None: ) -> None:
"""Start (or stop and restart) the camera. """Start (or stop and restart) the camera.
:param main_resolution: the resolution to use for the main stream. :param main_resolution: the resolution to use for the main stream.
:param buffer_count: number of images in the stream buffer. :param buffer_count: number of images in the stream buffer.
Note that the default values for both parameters should be set appropriately
for the specific camera when defining a new Camera Thing.
""" """
raise NotImplementedError( raise NotImplementedError(
"CameraThings must define their own start_streaming method" "CameraThings must define their own start_streaming method"
@ -255,7 +251,7 @@ class BaseCamera(lt.Thing):
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "main", stream_name: Literal["main", "lores", "raw", "full"] = "main",
wait: Optional[float] = 5, wait: Optional[float] = 5,
) -> ArrayModel: ) -> NDArray:
"""Acquire one image from the camera and return as an array.""" """Acquire one image from the camera and return as an array."""
raise NotImplementedError( raise NotImplementedError(
"CameraThings must define their own capture_array method" "CameraThings must define their own capture_array method"
@ -265,7 +261,7 @@ class BaseCamera(lt.Thing):
"""The downsampling factor when calling capture_downsampled_array.""" """The downsampling factor when calling capture_downsampled_array."""
@lt.action @lt.action
def capture_downsampled_array(self) -> ArrayModel: def capture_downsampled_array(self) -> NDArray:
"""Acquire one image from the camera, downsample, and return as an array. """Acquire one image from the camera, downsample, and return as an array.
* The array is downsamples by the thing property `downsampled_array_factor`. * The array is downsamples by the thing property `downsampled_array_factor`.
@ -279,7 +275,7 @@ class BaseCamera(lt.Thing):
@lt.action @lt.action
def capture_jpeg( def capture_jpeg(
self, self,
stream_name: str = "main", stream_name: Literal["main", "lores", "full"] = "main",
wait: Optional[float] = None, wait: Optional[float] = None,
) -> JPEGBlob: ) -> JPEGBlob:
"""Acquire one image from the camera as a JPEG. """Acquire one image from the camera as a JPEG.
@ -334,7 +330,7 @@ class BaseCamera(lt.Thing):
def grab_as_array( def grab_as_array(
self, self,
stream_name: Literal["main", "lores"] = "main", stream_name: Literal["main", "lores"] = "main",
) -> ArrayModel: ) -> NDArray:
"""Acquire one image from the preview stream and return as an array. """Acquire one image from the preview stream and return as an array.
It works like ``grab_jpeg`` but reliably handles broken streams. Prefer using It works like ``grab_jpeg`` but reliably handles broken streams. Prefer using
@ -369,9 +365,9 @@ class BaseCamera(lt.Thing):
def capture_image( def capture_image(
self, self,
stream_name: Literal["main", "lores", "raw"], stream_name: Literal["main", "lores", "full"],
wait: Optional[float] = None, wait: Optional[float] = None,
) -> Image: ) -> Image.Image:
"""Capture a PIL image from stream stream_name with timeout wait.""" """Capture a PIL image from stream stream_name with timeout wait."""
raise NotImplementedError( raise NotImplementedError(
"CameraThings must define their own capture_image method" "CameraThings must define their own capture_image method"
@ -438,7 +434,7 @@ class BaseCamera(lt.Thing):
"""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, Mapping[str, Any]]: def _robust_image_capture(self) -> Tuple[Image.Image, Mapping[str, Any]]:
"""Capture an image in memory and return it with metadata. """Capture an image in memory and return it with metadata.
This robust capturing method attempts to capture the image five times This robust capturing method attempts to capture the image five times
@ -520,8 +516,8 @@ class BaseCamera(lt.Thing):
def _save_capture( def _save_capture(
self, self,
jpeg_path: str, jpeg_path: str,
image: Image, image: Image.Image,
metadata: dict, metadata: Mapping[str, Any],
save_resolution: Optional[Tuple[int, int]] = None, save_resolution: Optional[Tuple[int, int]] = None,
) -> None: ) -> None:
"""Save the captured image and metadata to disk. """Save the captured image and metadata to disk.
@ -533,7 +529,7 @@ class BaseCamera(lt.Thing):
nothing is returned on success nothing is returned on success
""" """
if save_resolution is not None and image.size != save_resolution: if save_resolution is not None and image.size != save_resolution:
image = image.resize(save_resolution, Image.BOX) image = image.resize(save_resolution, Image.Resampling.BOX)
try: try:
# Per PIL documentation, # Per PIL documentation,
# (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg) # (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg)
@ -544,7 +540,7 @@ class BaseCamera(lt.Thing):
# disabled, file size increases and quality is barely or not affected # disabled, file size increases and quality is barely or not affected
image.save(jpeg_path, quality=95, subsampling=0) image.save(jpeg_path, quality=95, subsampling=0)
try: try:
self._add_metadata_to_capture(jpeg_path, metadata) self._add_metadata_to_capture(jpeg_path, dict(metadata))
except Exception: except Exception:
# We need to capture any exception as there are many reasons metadata # We need to capture any exception as there are many reasons metadata
# might not be added. We warn rather than log the error. # might not be added. We warn rather than log the error.
@ -591,7 +587,7 @@ class BaseCamera(lt.Thing):
return self._detector_name return self._detector_name
@detector_name.setter @detector_name.setter
def detector_name(self, name: str) -> None: def _set_detector_name(self, name: str) -> None:
"""Validate and set detector_name.""" """Validate and set detector_name."""
if name not in self.background_detectors: if name not in self.background_detectors:
self.logger.warning(f"{name} is not a valid background detector name.") self.logger.warning(f"{name} is not a valid background detector name.")
@ -640,7 +636,7 @@ class BaseCamera(lt.Thing):
return data return data
@background_detector_data.setter @background_detector_data.setter
def background_detector_data(self, data: dict) -> None: def _set_background_detector_data(self, data: dict) -> None:
"""Set the data for each detector. Only to be used as settings are loaded from disk. """Set the data for each detector. Only to be used as settings are loaded from disk.
Do not call over HTTP. This needs to be updated once LbaThings Settings can be Do not call over HTTP. This needs to be updated once LbaThings Settings can be

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 from typing import Literal, Optional, Self
import cv2 import cv2
from PIL import Image from PIL import Image
@ -39,7 +39,7 @@ class OpenCVCamera(BaseCamera):
self._capture_thread: Optional[Thread] = None self._capture_thread: Optional[Thread] = None
self._capture_enabled = False self._capture_enabled = False
def __enter__(self) -> None: def __enter__(self) -> Self:
"""Start the capture thread when the Thing context manager is opened.""" """Start the capture thread when the Thing context manager is opened."""
self.cap = cv2.VideoCapture(self.camera_index) self.cap = cv2.VideoCapture(self.camera_index)
self._capture_enabled = True self._capture_enabled = True
@ -59,7 +59,8 @@ class OpenCVCamera(BaseCamera):
""" """
if self.stream_active: if self.stream_active:
self._capture_enabled = False self._capture_enabled = False
self._capture_thread.join() if self._capture_thread is not None:
self._capture_thread.join()
self.cap.release() self.cap.release()
@lt.property @lt.property
@ -90,7 +91,7 @@ class OpenCVCamera(BaseCamera):
@lt.action @lt.action
def capture_array( def capture_array(
self, self,
stream_name: Literal["main", "full"] = "full", stream_name: Literal["main", "lores", "raw", "full"] = "full",
wait: Optional[float] = None, wait: Optional[float] = None,
) -> NDArray: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array.
@ -111,9 +112,9 @@ class OpenCVCamera(BaseCamera):
def capture_image( def capture_image(
self, self,
stream_name: Literal["main", "full"] = "main", stream_name: Literal["main", "lores", "full"] = "main",
wait: Optional[float] = None, wait: Optional[float] = None,
) -> Image: ) -> Image.Image:
"""Acquire one image from the camera and return as a PIL image. """Acquire one image from the camera and return as a PIL image.
This function will produce a JPEG image. This function will produce a JPEG image.

View file

@ -25,7 +25,7 @@ import time
from contextlib import contextmanager from contextlib import contextmanager
from threading import RLock from threading import RLock
from types import TracebackType from types import TracebackType
from typing import Annotated, Any, Iterator, Literal, Mapping, Optional from typing import Annotated, Any, Iterator, Literal, Mapping, Optional, Self
import numpy as np import numpy as np
from picamera2 import Picamera2 from picamera2 import Picamera2
@ -36,6 +36,7 @@ from pydantic import BaseModel, BeforeValidator
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.exceptions import ServerNotRunningError from labthings_fastapi.exceptions import ServerNotRunningError
from labthings_fastapi.types.numpy import NDArray
from openflexure_microscope_server.background_detect import ChannelBlankError from openflexure_microscope_server.background_detect import ChannelBlankError
from openflexure_microscope_server.ui import ( from openflexure_microscope_server.ui import (
@ -45,7 +46,7 @@ from openflexure_microscope_server.ui import (
property_control_for, property_control_for,
) )
from . import ArrayModel, BaseCamera from . import BaseCamera
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
@ -122,6 +123,9 @@ class StreamingPiCamera2(BaseCamera):
generalisation. generalisation.
""" """
tuning: dict = lt.setting(default_factory=dict, readonly=True)
"""The Raspberry PiCamera Tuning File JSON."""
def __init__( def __init__(
self, self,
thing_server_interface: lt.ThingServerInterface, thing_server_interface: lt.ThingServerInterface,
@ -147,7 +151,7 @@ class StreamingPiCamera2(BaseCamera):
f"are {SUPPORTED_CAMS_SENSOR_INFO.keys()}." f"are {SUPPORTED_CAMS_SENSOR_INFO.keys()}."
) )
self._sensor_info = SUPPORTED_CAMS_SENSOR_INFO[camera_board] self._sensor_info = SUPPORTED_CAMS_SENSOR_INFO[camera_board]
self._picamera_lock = None self._picamera_lock = RLock()
self._picamera = None self._picamera = None
# Load the tuning file for the specified sensor mode. # Load the tuning file for the specified sensor mode.
@ -159,10 +163,12 @@ class StreamingPiCamera2(BaseCamera):
# connected to the server if tuning is saved to disk. # connected to the server if tuning is saved to disk.
try: try:
self.tuning = copy.deepcopy(self.default_tuning) self.tuning = copy.deepcopy(self.default_tuning)
except ServerNotRunningError: except ServerNotRunningError as e:
# This will throw an error after setting as we are not connected to # This will throw an error after setting as we are not connected to
# a server. But we know this, so we ignore the error. # a server. But we know this, so we ignore the error as long as the
pass # tuning data is set.
if "version" not in self.tuning:
raise RuntimeError("Tuning file could not be set.") from e
# Also set the colour gains based on the tuning. Set to _colour_gains to not # Also set the colour gains based on the tuning. Set to _colour_gains to not
# trigger a ServerNotRunningError # trigger a ServerNotRunningError
@ -214,7 +220,7 @@ class StreamingPiCamera2(BaseCamera):
return self._analogue_gain return self._analogue_gain
@analogue_gain.setter @analogue_gain.setter
def analogue_gain(self, value: float) -> None: def _set_analogue_gain(self, value: float) -> None:
self._analogue_gain = value self._analogue_gain = value
if self.streaming: if self.streaming:
with self._streaming_picamera() as cam: with self._streaming_picamera() as cam:
@ -234,7 +240,7 @@ class StreamingPiCamera2(BaseCamera):
return self._colour_gains return self._colour_gains
@colour_gains.setter @colour_gains.setter
def colour_gains(self, value: tuple[float, float]) -> None: def _set_colour_gains(self, value: tuple[float, float]) -> None:
self._colour_gains = value self._colour_gains = value
if self.streaming: if self.streaming:
with self._streaming_picamera() as cam: with self._streaming_picamera() as cam:
@ -258,7 +264,7 @@ class StreamingPiCamera2(BaseCamera):
return self._exposure_time return self._exposure_time
@exposure_time.setter @exposure_time.setter
def exposure_time(self, value: int) -> None: def _set_exposure_time(self, value: int) -> None:
self._exposure_time = value self._exposure_time = value
if self.streaming: if self.streaming:
with self._streaming_picamera() as cam: with self._streaming_picamera() as cam:
@ -282,7 +288,7 @@ class StreamingPiCamera2(BaseCamera):
"Sharpness": 1, "Sharpness": 1,
} }
_sensor_modes = None _sensor_modes: Optional[list[SensorMode]] = None
@lt.property @lt.property
def sensor_modes(self) -> list[SensorMode]: def sensor_modes(self) -> list[SensorMode]:
@ -302,7 +308,7 @@ class StreamingPiCamera2(BaseCamera):
return SensorModeSelector(**self._sensor_mode) return SensorModeSelector(**self._sensor_mode)
@sensor_mode.setter @sensor_mode.setter
def sensor_mode(self, new_mode: Optional[SensorModeSelector | dict]) -> None: def _set_sensor_mode(self, new_mode: Optional[SensorModeSelector | dict]) -> None:
"""Change the sensor mode used.""" """Change the sensor mode used."""
if new_mode is None: if new_mode is None:
self._sensor_mode = None self._sensor_mode = None
@ -323,9 +329,6 @@ class StreamingPiCamera2(BaseCamera):
with self._streaming_picamera() as cam: with self._streaming_picamera() as cam:
return cam.sensor_resolution return cam.sensor_resolution
tuning: Optional[dict] = lt.setting(default=None, readonly=True)
"""The Raspberry PiCamera Tuning File JSON."""
def _initialise_picamera(self, check_sensor_model: bool = False) -> None: def _initialise_picamera(self, check_sensor_model: bool = False) -> None:
"""Acquire the picamera device and store it as ``self._picamera``. """Acquire the picamera device and store it as ``self._picamera``.
@ -339,10 +342,7 @@ class StreamingPiCamera2(BaseCamera):
:raises PicameraModelError: If check_sensor_model is True and the real :raises PicameraModelError: If check_sensor_model is True and the real
camera sensor model doesn't match the expected sensor model. camera sensor model doesn't match the expected sensor model.
""" """
if self._picamera_lock is not None: with self._picamera_lock, tempfile.NamedTemporaryFile("w") as tuning_file:
# Don't close the camera if it's in use
self._picamera_lock.acquire()
with tempfile.NamedTemporaryFile("w") as tuning_file:
json.dump(self.tuning, tuning_file) json.dump(self.tuning, tuning_file)
tuning_file.flush() # but leave it open as closing it will delete it tuning_file.flush() # but leave it open as closing it will delete it
os.environ["LIBCAMERA_RPI_TUNING_FILE"] = tuning_file.name os.environ["LIBCAMERA_RPI_TUNING_FILE"] = tuning_file.name
@ -363,6 +363,9 @@ class StreamingPiCamera2(BaseCamera):
camera_num=self._camera_num, camera_num=self._camera_num,
tuning=self.tuning, tuning=self.tuning,
) )
if self._picamera is None:
# Type narrow (error if failure)
raise RuntimeError("Failed to start Picamera")
if check_sensor_model: if check_sensor_model:
hw_sensor_model = self._picamera.camera_properties["Model"] hw_sensor_model = self._picamera.camera_properties["Model"]
if hw_sensor_model != self._sensor_info.sensor_model: if hw_sensor_model != self._sensor_info.sensor_model:
@ -370,9 +373,8 @@ class StreamingPiCamera2(BaseCamera):
f"Wrong Picamera model. Expecting {self._sensor_info.sensor_model}, " f"Wrong Picamera model. Expecting {self._sensor_info.sensor_model}, "
f"but found {hw_sensor_model}." f"but found {hw_sensor_model}."
) )
self._picamera_lock = RLock()
def __enter__(self) -> None: def __enter__(self) -> Self:
"""Start streaming when the Thing context manager is opened. """Start streaming when the Thing context manager is opened.
This opens the picamera connection, initialises the camera, sets the This opens the picamera connection, initialises the camera, sets the
@ -533,7 +535,7 @@ class StreamingPiCamera2(BaseCamera):
self, self,
stream_name: Literal["main", "lores", "full"] = "main", stream_name: Literal["main", "lores", "full"] = "main",
wait: Optional[float] = 0.9, wait: Optional[float] = 0.9,
) -> Image: ) -> 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 If the ``stream_name`` parameter is ``main`` or ``lores``, it will be captured
@ -573,7 +575,7 @@ class StreamingPiCamera2(BaseCamera):
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "main", stream_name: Literal["main", "lores", "raw", "full"] = "main",
wait: Optional[float] = 0.9, wait: Optional[float] = 0.9,
) -> ArrayModel: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array.
This function will produce a nested list containing an uncompressed RGB image. This function will produce a nested list containing an uncompressed RGB image.
@ -773,7 +775,8 @@ class StreamingPiCamera2(BaseCamera):
"""The calibration actions for both calibration wizard and settings panel.""" """The calibration actions for both calibration wizard and settings panel."""
return [ return [
action_button_for( action_button_for(
self.full_auto_calibrate, self,
"full_auto_calibrate",
submit_label="Full Auto-Calibrate", submit_label="Full Auto-Calibrate",
can_terminate=False, can_terminate=False,
requires_confirmation=True, requires_confirmation=True,
@ -791,13 +794,15 @@ class StreamingPiCamera2(BaseCamera):
"""The calibration actions that appear only in settings panel.""" """The calibration actions that appear only in settings panel."""
return [ return [
action_button_for( action_button_for(
self.auto_expose_from_minimum, self,
"auto_expose_from_minimum",
submit_label="Auto Gain & Shutter Speed", submit_label="Auto Gain & Shutter Speed",
can_terminate=False, can_terminate=False,
button_primary=False, button_primary=False,
), ),
action_button_for( action_button_for(
self.calibrate_lens_shading, self,
"calibrate_lens_shading",
submit_label="Auto Flat Field Correction", submit_label="Auto Flat Field Correction",
can_terminate=False, can_terminate=False,
button_primary=False, button_primary=False,
@ -809,19 +814,22 @@ class StreamingPiCamera2(BaseCamera):
), ),
), ),
action_button_for( action_button_for(
self.flat_lens_shading, self,
"flat_lens_shading",
submit_label="Disable Flat Field Correction", submit_label="Disable Flat Field Correction",
can_terminate=False, can_terminate=False,
button_primary=False, button_primary=False,
), ),
action_button_for( action_button_for(
self.flat_lens_shading_chrominance, self,
"flat_lens_shading_chrominance",
submit_label="Disable Flat Field Chrominance", submit_label="Disable Flat Field Chrominance",
can_terminate=False, can_terminate=False,
button_primary=False, button_primary=False,
), ),
action_button_for( action_button_for(
self.reset_lens_shading, self,
"reset_lens_shading",
submit_label="Reset Flat Field Correction", submit_label="Reset Flat Field Correction",
can_terminate=False, can_terminate=False,
button_primary=False, button_primary=False,
@ -856,7 +864,7 @@ class StreamingPiCamera2(BaseCamera):
] ]
@lt.property @lt.property
def lens_shading_tables(self) -> Optional[tf_utils.LensShading]: def lens_shading_tables(self) -> Optional[tf_utils.LensShadingModel]:
"""The current lens shading (i.e. flat-field correction). """The current lens shading (i.e. flat-field correction).
Return the current lens shading correction, as three 2D lists each with Return the current lens shading correction, as three 2D lists each with
@ -869,19 +877,6 @@ class StreamingPiCamera2(BaseCamera):
""" """
return tf_utils.get_lst(self.tuning) return tf_utils.get_lst(self.tuning)
@lens_shading_tables.setter
def lens_shading_tables(self, lst: tf_utils.LensShading) -> None:
"""Set the lens shading tables."""
with self._streaming_picamera(pause_stream=True):
self.tuning = tf_utils.set_lst(
self.tuning,
luminance=lst.luminance,
cr=lst.Cr,
cb=lst.Cb,
colour_temp=lst.colour_temp,
)
self._initialise_picamera()
@lt.action @lt.action
def flat_lens_shading(self) -> None: def flat_lens_shading(self) -> None:
"""Disable flat-field correction. """Disable flat-field correction.

View file

@ -51,7 +51,6 @@ import numpy as np
import picamera2 import picamera2
from picamera2 import Picamera2 from picamera2 import Picamera2
from pydantic import BaseModel from pydantic import BaseModel
from scipy.ndimage import zoom
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -322,18 +321,7 @@ def _get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray:
return np.reshape(np.array(grid), (12, 16)) return np.reshape(np.array(grid), (12, 16))
def _upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray: def _downsampled_channels(channels: np.ndarray, blacklevel: int) -> np.ndarray:
"""Zoom an image in the last two dimensions.
This is effectively the inverse operation of ``_get_16x12_grid``
"""
zoom_factors = [
1,
] + list(np.ceil(np.array(shape) / np.array(grids.shape[1:])))
return zoom(grids, zoom_factors, order=1)[:, : shape[0], : shape[1]]
def _downsampled_channels(channels: np.ndarray, blacklevel: int) -> list[np.ndarray]:
"""Generate a downsampled, un-normalised image from which to calculate the LST.""" """Generate a downsampled, un-normalised image from which to calculate the LST."""
channel_shape = np.array(channels.shape[1:]) channel_shape = np.array(channels.shape[1:])
lst_shape = np.array([12, 16]) lst_shape = np.array([12, 16])
@ -398,9 +386,7 @@ def _grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarr
return np.stack([B, G, G, R], axis=0) return np.stack([B, G, G, R], axis=0)
def _raw_channels_from_camera( def _raw_channels_from_camera(camera: Picamera2, sensor_info: SensorInfo) -> np.ndarray:
camera: Picamera2, sensor_info: SensorInfo
) -> LensShadingTables:
"""Acquire a raw image and return a 4xNxM array of the colour channels.""" """Acquire a raw image and return a 4xNxM array of the colour channels."""
if camera.started: if camera.started:
camera.stop_recording() camera.stop_recording()

View file

@ -20,9 +20,12 @@ CALIBRATED_COLOUR_TEMP = 5000
DEFAULT_COLOUR_TEMP = 1234 DEFAULT_COLOUR_TEMP = 1234
class LensShading(BaseModel): class LensShadingModel(BaseModel):
"""A Pydantic model holding the lens shading tables. """A Pydantic model holding the lens shading tables.
Note this shouldn't be confused with the typehint for LensShadingTables in
recalibrate utils which is for the arrays.
PiCamera needs three numpy arrays for lens shading correction. Each array is PiCamera needs three numpy arrays for lens shading correction. Each array is
(12, 16) in size. The arrays are luminance, red-difference chroma (Cr), and (12, 16) in size. The arrays are luminance, red-difference chroma (Cr), and
blue-difference chroma (Cb). blue-difference chroma (Cb).
@ -165,7 +168,7 @@ def flatten_lst(tuning: dict, keep_luminance: bool = False) -> dict:
) )
def get_lst(tuning: dict) -> LensShading: def get_lst(tuning: dict) -> LensShadingModel:
"""Return the lens shading as a LenSading Base Model.""" """Return the lens shading as a LenSading Base Model."""
# Note "alsc" is the Picamera2 term for "Automatic Lens Shading Correction" # Note "alsc" is the Picamera2 term for "Automatic Lens Shading Correction"
alsc = find_tuning_algo(tuning, "rpi.alsc") alsc = find_tuning_algo(tuning, "rpi.alsc")
@ -175,7 +178,7 @@ def get_lst(tuning: dict) -> LensShading:
w, h = 16, 12 w, h = 16, 12
return [lin[w * i : w * (i + 1)] for i in range(h)] return [lin[w * i : w * (i + 1)] for i in range(h)]
return LensShading( return LensShadingModel(
luminance=reshape_lst(alsc["luminance_lut"]), luminance=reshape_lst(alsc["luminance_lut"]),
Cr=reshape_lst(alsc["calibrations_Cr"][0]["table"]), Cr=reshape_lst(alsc["calibrations_Cr"][0]["table"]),
Cb=reshape_lst(alsc["calibrations_Cb"][0]["table"]), Cb=reshape_lst(alsc["calibrations_Cb"][0]["table"]),

View file

@ -13,12 +13,13 @@ import logging
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 from typing import Literal, Optional, Self
import numpy as np import numpy as np
from PIL import Image, ImageFilter from PIL import Image, ImageFilter
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray
from openflexure_microscope_server.ui import ( from openflexure_microscope_server.ui import (
ActionButton, ActionButton,
@ -28,11 +29,11 @@ from openflexure_microscope_server.ui import (
) )
from ..stage.dummy import DummyStage from ..stage.dummy import DummyStage
from . import ArrayModel, BaseCamera from . import BaseCamera
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
# The ratio between "motor" steps and pixels # The ratio between "motor" steps and pixels in (x, y, z)
# higher related to a faster movement # higher related to a faster movement
RATIO = (2, 2, 0.2) RATIO = (2, 2, 0.2)
@ -195,17 +196,18 @@ class SimulatedCamera(BaseCamera):
self.canvas[top:bottom, left:right] -= sprite self.canvas[top:bottom, left:right] -= sprite
def generate_image(self, pos: tuple[int, int, int]) -> Image: def generate_image(self, pos: tuple[int, int, int]) -> Image.Image:
"""Generate an image with blobs based on supplied coordinates. """Generate an image with blobs based on supplied coordinates.
:param pos: a 3-item tuple containing the x,y,z coordinates of the 'stage' :param pos: a 3-item tuple containing the x,y,z coordinates of the 'stage'
""" """
canvas_width, canvas_height, _ = self.canvas_shape canvas_width, canvas_height, _ = self.canvas_shape
image_width, image_height, _ = self.shape image_width, image_height, _ = self.shape
pos = tuple(x * s for x, s in zip(pos, RATIO, strict=True)) # Scale position by RATIO to get position in base image.
im_pos = tuple(x * ratio for x, ratio in zip(pos, RATIO, strict=True))
top_left = ( top_left = (
int(pos[0]) - image_width // 2 + self.sample_limits[0] // 2, int(im_pos[0]) - image_width // 2 + self.sample_limits[0] // 2,
int(pos[1]) - image_height // 2 + self.sample_limits[1] // 2, int(im_pos[1]) - image_height // 2 + self.sample_limits[1] // 2,
) )
# Create index list with modulo rather than slicing to handle wrapping at the # Create index list with modulo rather than slicing to handle wrapping at the
# canvas edge. # canvas edge.
@ -216,7 +218,7 @@ class SimulatedCamera(BaseCamera):
# Use npx to make each 1d index list 3D # Use npx to make each 1d index list 3D
focused_image = canvas[np.ix_(x_indices, y_indices, z_indices)] focused_image = canvas[np.ix_(x_indices, y_indices, z_indices)]
image = fast_pil_blur(focused_image, sigma=np.abs(pos[2]) / 5) image = fast_pil_blur(focused_image, sigma=np.abs(im_pos[2]) / 5)
if image.shape != self.shape: if image.shape != self.shape:
raise ValueError( raise ValueError(
@ -229,7 +231,7 @@ class SimulatedCamera(BaseCamera):
image[image > 255] = 255 image[image > 255] = 255
return Image.fromarray(image.astype("uint8")) return Image.fromarray(image.astype("uint8"))
def generate_frame(self) -> Image: def generate_frame(self) -> Image.Image:
"""Generate a frame with blobs based on the stage coordinates.""" """Generate a frame with blobs based on the stage coordinates."""
try: try:
pos = self._stage.instantaneous_position pos = self._stage.instantaneous_position
@ -238,7 +240,7 @@ class SimulatedCamera(BaseCamera):
pos = {"x": 0, "y": 0, "z": 0} pos = {"x": 0, "y": 0, "z": 0}
return self.generate_image((pos["y"], pos["x"], pos["z"])) return self.generate_image((pos["y"], pos["x"], pos["z"]))
def __enter__(self) -> None: def __enter__(self) -> Self:
"""Start the capture thread when the Thing context manager is opened.""" """Start the capture thread when the Thing context manager is opened."""
self.start_streaming() self.start_streaming()
return self return self
@ -250,7 +252,7 @@ class SimulatedCamera(BaseCamera):
_traceback: Optional[TracebackType], _traceback: Optional[TracebackType],
) -> None: ) -> None:
"""Close the capture thread when the Thing context manager is closed.""" """Close the capture thread when the Thing context manager is closed."""
if self.stream_active: if self._capture_thread is not None and self._capture_thread.is_alive():
self._capture_enabled = False self._capture_enabled = False
self._capture_thread.join() self._capture_thread.join()
@ -299,7 +301,7 @@ class SimulatedCamera(BaseCamera):
try: try:
frame = self.generate_frame() frame = self.generate_frame()
self.mjpeg_stream.add_frame(_frame2bytes(frame)) self.mjpeg_stream.add_frame(_frame2bytes(frame))
ds_frame = frame.resize((320, 240), resample=Image.NEAREST) ds_frame = frame.resize((320, 240), resample=Image.Resampling.NEAREST)
self.lores_mjpeg_stream.add_frame(_frame2bytes(ds_frame)) self.lores_mjpeg_stream.add_frame(_frame2bytes(ds_frame))
except Exception as e: except Exception as e:
@ -315,9 +317,9 @@ class SimulatedCamera(BaseCamera):
@lt.action @lt.action
def capture_array( def capture_array(
self, self,
stream_name: Literal["main", "full"] = "full", stream_name: Literal["main", "lores", "raw", "full"] = "full",
wait: Optional[float] = None, wait: Optional[float] = None,
) -> ArrayModel: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array.
This function will produce a nested list containing an uncompressed RGB image. This function will produce a nested list containing an uncompressed RGB image.
@ -334,9 +336,9 @@ class SimulatedCamera(BaseCamera):
def capture_image( def capture_image(
self, self,
stream_name: Literal["main", "lores", "raw"], stream_name: Literal["main", "lores", "full"],
wait: Optional[float] = None, wait: Optional[float] = None,
) -> Image: ) -> 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.
@ -384,7 +386,7 @@ class SimulatedCamera(BaseCamera):
"""The calibration actions for both calibration wizard and settings panel.""" """The calibration actions for both calibration wizard and settings panel."""
return [ return [
action_button_for( action_button_for(
self.full_auto_calibrate, submit_label="Full Auto-Calibrate" self, "full_auto_calibrate", submit_label="Full Auto-Calibrate"
), ),
] ]
@ -392,8 +394,8 @@ class SimulatedCamera(BaseCamera):
def secondary_calibration_actions(self) -> list[ActionButton]: def secondary_calibration_actions(self) -> list[ActionButton]:
"""The calibration actions that appear only in settings panel.""" """The calibration actions that appear only in settings panel."""
return [ return [
action_button_for(self.load_sample, submit_label="Load Sample"), action_button_for(self, "load_sample", submit_label="Load Sample"),
action_button_for(self.remove_sample, submit_label="Remove Sample"), action_button_for(self, "remove_sample", submit_label="Remove Sample"),
] ]
@lt.property @lt.property
@ -402,7 +404,7 @@ class SimulatedCamera(BaseCamera):
return [property_control_for(self, "noise_level", label="Noise Level")] return [property_control_for(self, "noise_level", label="Noise Level")]
def _frame2bytes(frame: Image) -> bytes: def _frame2bytes(frame: Image.Image) -> bytes:
"""Convert frame to bytes.""" """Convert frame to bytes."""
with io.BytesIO() as buf: with io.BytesIO() as buf:
# Save in low quality for speed. # Save in low quality for speed.

View file

@ -10,19 +10,11 @@ This module is only intended to be called from the OpenFlexure Microscope
server, and depends on that server and its underlying LabThings library. server, and depends on that server and its underlying LabThings library.
""" """
import json
import time import time
from typing import ( from typing import Any, List, Mapping, NamedTuple, Optional, Tuple, cast
Any,
Dict,
List,
Mapping,
NamedTuple,
Optional,
Tuple,
)
import numpy as np import numpy as np
from fastapi import HTTPException
import labthings_fastapi as lt import labthings_fastapi as lt
from camera_stage_mapping.camera_stage_calibration_1d import ( from camera_stage_mapping.camera_stage_calibration_1d import (
@ -36,9 +28,6 @@ from labthings_fastapi.types.numpy import DenumpifyingDict
from .camera import BaseCamera from .camera import BaseCamera
from .stage import BaseStage from .stage import BaseStage
CoordinateType = Tuple[float, float, float]
XYCoordinateType = Tuple[float, float]
class MoveHistory(NamedTuple): class MoveHistory(NamedTuple):
"""A named tuple containing the position over time for a single move. """A named tuple containing the position over time for a single move.
@ -50,7 +39,27 @@ class MoveHistory(NamedTuple):
""" """
times: List[float] times: List[float]
stage_positions: List[CoordinateType] stage_positions: List[tuple[int, int, int]]
def _array_to_stage_tuple(pos: np.ndarray) -> tuple[int, int, int]:
"""Convert a numpy array into a tuple of ints.
:param pos: Input position array must be 3 elements long.
:return: a tuple of 3 integers
:raises ValueError: If the array is not of length 3.
"""
pos_tuple = tuple(int(i) for i in pos)
if len(pos_tuple) == 3:
return cast(tuple[int, int, int], pos_tuple)
raise ValueError("Input array was not 3 elements long.")
def _serialise_numpy_in_dict(dict_with_numpy: dict) -> dict:
serialised = json.loads(DenumpifyingDict(dict_with_numpy).model_dump_json())
if not isinstance(serialised, dict):
raise TypeError(f"Expecting a dictionary to serialise not a {type(serialised)}")
return serialised
class RecordedMove: class RecordedMove:
@ -69,21 +78,24 @@ class RecordedMove:
be called whenever the instance is called. be called whenever the instance is called.
""" """
self._stage = stage self._stage = stage
self._current_position: Optional[CoordinateType] = None self._current_position: Optional[tuple[int, int, int]] = None
self._history: List[Tuple[float, Optional[CoordinateType]]] = [] self._history: List[Tuple[float, tuple[int, int, int]]] = []
def __call__(self, new_position: CoordinateType) -> None: def __call__(self, new_position: np.ndarray) -> None:
"""Move to a new position, and record it.""" """Move to a new position, and record it."""
self._history.append((time.time(), self._current_position)) new_stage_pos = _array_to_stage_tuple(new_position)
self._stage.move_to_xyz_position(xyz_pos=new_position) starting_pos = self._current_position
self._current_position = new_position if starting_pos is not None:
self._history.append((time.time(), self._current_position)) self._history.append((time.time(), starting_pos))
self._stage.move_to_xyz_position(xyz_pos=new_stage_pos)
self._current_position = new_stage_pos
self._history.append((time.time(), new_stage_pos))
@property @property
def history(self) -> MoveHistory: def history(self) -> MoveHistory:
"""The history, as a numpy array of times and another of positions.""" """The history, as a numpy array of times and another of positions."""
times: List[float] = [t for t, p in self._history if p is not None] times: List[float] = [t for t, p in self._history]
positions: List[CoordinateType] = [p for t, p in self._history if p is not None] positions: List[tuple[int, int, int]] = [p for t, p in self._history]
return MoveHistory(times, positions) return MoveHistory(times, positions)
def clear_history(self) -> None: def clear_history(self) -> None:
@ -91,25 +103,14 @@ class RecordedMove:
self._history = [] self._history = []
class CSMUncalibratedError(HTTPException): class CSMUncalibratedError(lt.exceptions.InvocationError):
"""An HTTP Exception raised if camera stage mapping data is needed but unavailable. """An Exception raised if camera stage mapping data is needed but unavailable.
Camera Stage Mapping data is needed to convert from distances specified in fractions Camera Stage Mapping data is needed to convert from distances specified in fractions
of the field of view to distances in motor steps. This is used when clicking on the of the field of view to distances in motor steps. This is used when clicking on the
live preview to move, or when performing a scan. live preview to move, or when performing a scan.
""" """
def __init__(self) -> None:
"""Customise the default error code and message of HTTPException."""
HTTPException.__init__(
self,
503,
(
"The camera_stage_mapping calibration is not yet available. "
"This probably means you need to run the calibration routine."
),
)
class CameraStageMapper(lt.Thing): class CameraStageMapper(lt.Thing):
"""A Thing to manage mapping between image and stage coordinates. """A Thing to manage mapping between image and stage coordinates.
@ -121,8 +122,7 @@ class CameraStageMapper(lt.Thing):
_cam: BaseCamera = lt.thing_slot() _cam: BaseCamera = lt.thing_slot()
_stage: BaseStage = lt.thing_slot() _stage: BaseStage = lt.thing_slot()
@lt.action def calibrate_1d(self, direction: Tuple[int, int, int]) -> dict:
def calibrate_1d(self, direction: Tuple[float, float, float]) -> DenumpifyingDict:
"""Move a microscope's stage in 1D, and figure out the relationship with the camera.""" """Move a microscope's stage in 1D, and figure out the relationship with the camera."""
# Record positions and times for stage calibration # Record positions and times for stage calibration
recorded_move = RecordedMove(self._stage) recorded_move = RecordedMove(self._stage)
@ -152,7 +152,7 @@ class CameraStageMapper(lt.Thing):
return result return result
@lt.action @lt.action
def calibrate_xy(self) -> DenumpifyingDict: def calibrate_xy(self) -> dict:
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera. """Move the microscope's stage in X and Y, to calibrate its relationship to the camera.
This performs two 1d calibrations in x and y, then combines their results. This performs two 1d calibrations in x and y, then combines their results.
@ -182,7 +182,7 @@ class CameraStageMapper(lt.Thing):
) )
self.logger.info(f"CSM matrix is {csm_as_string}.") self.logger.info(f"CSM matrix is {csm_as_string}.")
data: Dict[str, dict] = { data = {
"camera_stage_mapping_calibration": cal_xy, "camera_stage_mapping_calibration": cal_xy,
"linear_calibration_x": cal_x, "linear_calibration_x": cal_x,
"linear_calibration_y": cal_y, "linear_calibration_y": cal_y,
@ -191,7 +191,8 @@ class CameraStageMapper(lt.Thing):
"downsampling": downsampling_factor, "downsampling": downsampling_factor,
} }
self.last_calibration = DenumpifyingDict(data).model_dump() data = _serialise_numpy_in_dict(data)
self.last_calibration = data
return data return data
@ -238,12 +239,14 @@ class CameraStageMapper(lt.Thing):
"""Whether the camera stage mapper needs calibrating.""" """Whether the camera stage mapper needs calibrating."""
return self.image_to_stage_displacement_matrix is None return self.image_to_stage_displacement_matrix is None
def assert_calibrated(self) -> None: def assert_calibration(self) -> List[List[float]]:
"""Raise an exception if the image_to_stage_displacement matrix is not set.""" """Return image_to_stage_displacement matrix or raise error if it's not set."""
if self.image_to_stage_displacement_matrix is None: if self.image_to_stage_displacement_matrix is None:
# Disable check of no message in raised exception as the message is explicitly raise CSMUncalibratedError(
# added by CSMUncalibratedError "The camera_stage_mapping calibration is not yet available. "
raise CSMUncalibratedError() # noqa: RSE102 "This probably means you need to run the calibration routine."
)
return self.image_to_stage_displacement_matrix
@lt.action @lt.action
def move_in_image_coordinates(self, x: float, y: float) -> None: def move_in_image_coordinates(self, x: float, y: float) -> None:
@ -258,24 +261,26 @@ class CameraStageMapper(lt.Thing):
and ``y`` to the shorter one. Checking what shape your chosen toolkit reports for and ``y`` to the shorter one. Checking what shape your chosen toolkit reports for
an image usually helps resolve any ambiguity. an image usually helps resolve any ambiguity.
""" """
self.assert_calibrated() self._stage.move_relative(
self._stage.move_relative(**self.convert_image_to_stage_coordinates(x=x, y=y)) **self.convert_image_to_stage_coordinates(x=x, y=y),
block_cancellation=False,
)
@lt.action @lt.action
def convert_image_to_stage_coordinates( def convert_image_to_stage_coordinates(
self, x: float, y: float, **_kwargs: float self, x: float, y: float, **_kwargs: float
) -> Mapping[str, int]: ) -> Mapping[str, int]:
"""Convert image coordinates to stage coordinates. Only x and y are returned.""" """Convert image coordinates to stage coordinates. Only x and y are returned."""
self.assert_calibrated() csm_matrix = self.assert_calibration()
return csm_img_to_stage(self.image_to_stage_displacement_matrix, x=x, y=y) return csm_img_to_stage(csm_matrix, x=x, y=y)
@lt.action @lt.action
def convert_stage_to_image_coordinates( def convert_stage_to_image_coordinates(
self, x: int, y: int, **_kwargs: int self, x: int, y: int, **_kwargs: int
) -> Mapping[str, float]: ) -> Mapping[str, float]:
"""Convert stage coordinates to image coordinates. Only x and y are returned.""" """Convert stage coordinates to image coordinates. Only x and y are returned."""
self.assert_calibrated() csm_matrix = self.assert_calibration()
return csm_stage_to_img(self.image_to_stage_displacement_matrix, x=x, y=y) return csm_stage_to_img(csm_matrix, x=x, y=y)
@lt.property @lt.property
def thing_state(self) -> Mapping[str, Any]: def thing_state(self) -> Mapping[str, Any]:

View file

@ -19,7 +19,6 @@ from typing import (
Mapping, Mapping,
Optional, Optional,
ParamSpec, ParamSpec,
Self,
TypeVar, TypeVar,
) )
@ -35,7 +34,7 @@ from openflexure_microscope_server import scan_directories, scan_planners, stitc
# Things # Things
from .autofocus import AutofocusThing, StackParams from .autofocus import AutofocusThing, StackParams
from .camera import BaseCamera from .camera import BaseCamera
from .camera_stage_mapping import CameraStageMapper from .camera_stage_mapping import CameraStageMapper, CSMUncalibratedError
from .stage import BaseStage from .stage import BaseStage
T = TypeVar("T") T = TypeVar("T")
@ -69,8 +68,8 @@ class ScanNotRunningError(RuntimeError):
def _scan_running( def _scan_running(
method: Callable[Concatenate[Self, P], T], method: Callable[Concatenate["SmartScanThing", P], T],
) -> Callable[Concatenate[Self, P], T]: ) -> Callable[Concatenate["SmartScanThing", P], T]:
"""Decorate a method so that it will error if a scan is not running. """Decorate a method so that it will error if a scan is not running.
This decorator is used by all methods in SmartScanThing that are using This decorator is used by all methods in SmartScanThing that are using
@ -79,7 +78,9 @@ def _scan_running(
the same time and released with the lock the same time and released with the lock
""" """
def scan_running_wrapper(self: Self, *args: P.args, **kwargs: P.kwargs) -> T: def scan_running_wrapper(
self: "SmartScanThing", *args: P.args, **kwargs: P.kwargs
) -> T:
"""Only start the requested method if the scan is running.""" """Only start the requested method if the scan is running."""
if self._scan_lock.locked(): if self._scan_lock.locked():
return method(self, *args, **kwargs) return method(self, *args, **kwargs)
@ -116,13 +117,63 @@ class SmartScanThing(lt.Thing):
self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder) self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder)
self._scan_lock = threading.Lock() self._scan_lock = threading.Lock()
# Variables set by the scan # Variables set by the scan
self._latest_scan_name: Optional[str] = None _stack_params: Optional[StackParams] = None
self._stack_params: Optional[StackParams] = None @property
self._ongoing_scan: Optional[scan_directories.ScanDirectory] = None def stack_params(self) -> StackParams:
self._scan_data: Optional[scan_directories.ScanData] = None """The parameters for z-stacking during the onging scan.
self._preview_stitcher: Optional[stitching.PreviewStitcher] = None
Only read this property is a scan is ongoing or it will raise an error.
"""
if self._stack_params is None:
raise RuntimeError("Cannot get stack parameters as they are not set.")
return self._stack_params
_ongoing_scan: Optional[scan_directories.ScanDirectory] = None
@property
def ongoing_scan(self) -> scan_directories.ScanDirectory:
"""The ScanDirectory object of the ongoing scan.
Only read this property is a scan is ongoing or it will raise an error.
"""
if self._ongoing_scan is None:
raise ScanNotRunningError("Cannot get ongoing scan if scan is not running.")
return self._ongoing_scan
_scan_data: Optional[scan_directories.ScanData] = None
@property
def scan_data(self) -> scan_directories.ScanData:
"""The ScanData object jolding information about the of the ongoing scan.
Only read this property is a scan is ongoing or it will raise an error.
"""
if self._scan_data is None:
raise ScanNotRunningError("Cannot get scan data if scan is not running.")
return self._scan_data
_preview_stitcher: Optional[stitching.PreviewStitcher] = None
@property
def preview_stitcher(self) -> stitching.PreviewStitcher:
"""The PreviewStitcher object for stitching live previews.
Only read this property is a scan is ongoing or it will raise an error.
"""
if self._preview_stitcher is None:
raise ScanNotRunningError(
"No preview stitcher agailable as scan is not running."
)
return self._preview_stitcher
_latest_scan_name: Optional[str] = None
@lt.property
def latest_scan_name(self) -> Optional[str]:
"""The name of the last scan to be started."""
return self._latest_scan_name
@lt.action @lt.action
def sample_scan(self, scan_name: str = "") -> None: def sample_scan(self, scan_name: str = "") -> None:
@ -143,7 +194,7 @@ class SmartScanThing(lt.Thing):
try: try:
self._check_background_and_csm_set() self._check_background_and_csm_set()
self._ongoing_scan = self._scan_dir_manager.new_scan_dir(scan_name) self._ongoing_scan = self._scan_dir_manager.new_scan_dir(scan_name)
self._latest_scan_name = self._ongoing_scan.name self._latest_scan_name = self.ongoing_scan.name
self._autofocus.looping_autofocus(dz=self.autofocus_dz, start="centre") self._autofocus.looping_autofocus(dz=self.autofocus_dz, start="centre")
self._run_scan() self._run_scan()
except Exception as e: except Exception as e:
@ -180,11 +231,7 @@ class SmartScanThing(lt.Thing):
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
""" """
if self._csm.image_resolution is None: self._csm.assert_calibration()
raise RuntimeError(
"Camera-stage mapping is not calibrated. This is required before "
"scans can be carried out."
)
if self.skip_background: if self.skip_background:
if not self._cam.background_detector_status.ready: if not self._cam.background_detector_status.ready:
@ -200,11 +247,6 @@ class SmartScanThing(lt.Thing):
"of motion." "of motion."
) )
@lt.property
def latest_scan_name(self) -> Optional[str]:
"""The name of the last scan to be started."""
return self._latest_scan_name
@_scan_running @_scan_running
def _move_to_next_point( def _move_to_next_point(
self, next_point: tuple[int, int], z_estimate: Optional[int] = None self, next_point: tuple[int, int], z_estimate: Optional[int] = None
@ -230,7 +272,7 @@ class SmartScanThing(lt.Thing):
return (next_point[0], next_point[1], z_estimate) return (next_point[0], next_point[1], z_estimate)
@_scan_running @_scan_running
def _calc_displacement_from_test_image(self, overlap: int) -> tuple[int, int]: def _calc_displacement_from_test_image(self, overlap: float) -> tuple[int, int]:
"""Take a test image and use camera stage mapping to calculate x and y displacement. """Take a test image and use camera stage mapping to calculate x and y displacement.
:param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means :param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means
@ -238,9 +280,15 @@ class SmartScanThing(lt.Thing):
:returns: (dx, dy) - the x and y displacements in steps :returns: (dx, dy) - the x and y displacements in steps
""" """
if (
self._csm.image_resolution is None
or self._csm.image_to_stage_displacement_matrix is None
):
raise CSMUncalibratedError("Camera stage mapping is not calibrated")
test_image = self._cam.grab_as_array() test_image = self._cam.grab_as_array()
test_image_res = list(test_image.shape) test_image_res = list(test_image.shape)
csm_image_res = [int(i) for i in self._csm.image_resolution] csm_image_res = [int(i) for i in self._csm.image_resolution]
# If current stream width is different to csm calibration width, # If current stream width is different to csm calibration width,
@ -295,7 +343,7 @@ class SmartScanThing(lt.Thing):
# Fix scan parameters in case UI is updated during scan. # Fix scan parameters in case UI is updated during scan.
return scan_directories.ScanData( return scan_directories.ScanData(
scan_name=self._ongoing_scan.name, scan_name=self.ongoing_scan.name,
starting_position=starting_position, starting_position=starting_position,
overlap=overlap, overlap=overlap,
max_dist=self.max_range, max_dist=self.max_range,
@ -317,16 +365,16 @@ class SmartScanThing(lt.Thing):
Takes scan_result, a string that is either "success", "cancelled by user", Takes scan_result, a string that is either "success", "cancelled by user",
or the error that ended the scan. or the error that ended the scan.
""" """
self._scan_data.set_final_data(result=scan_result) self.scan_data.set_final_data(result=scan_result)
self._ongoing_scan.save_scan_data(self._scan_data) self.ongoing_scan.save_scan_data(self.scan_data)
@_scan_running @_scan_running
def _manage_stitching_threads(self) -> None: def _manage_stitching_threads(self) -> None:
"""Manage the stitching threads, starting them if needed and not already running.""" """Manage the stitching threads, starting them if needed and not already running."""
# Assume 4 images means at least one offset in x and y, making the stitching # Assume 4 images means at least one offset in x and y, making the stitching
# well constrained. # well constrained.
if self._scan_data.image_count > 3 and not self._preview_stitcher.running: if self.scan_data.image_count > 3 and not self.preview_stitcher.running:
self._preview_stitcher.start() self.preview_stitcher.start()
@_scan_running @_scan_running
def _run_scan(self) -> None: def _run_scan(self) -> None:
@ -339,16 +387,21 @@ class SmartScanThing(lt.Thing):
try: try:
self._cam.start_streaming(main_resolution=(3280, 2464)) self._cam.start_streaming(main_resolution=(3280, 2464))
self._scan_data = self._collect_scan_data() self._scan_data = self._collect_scan_data()
self._ongoing_scan.save_scan_data(self._scan_data) self.ongoing_scan.save_scan_data(self._scan_data)
images_dir = self.ongoing_scan.images_dir
if images_dir is None:
raise RuntimeError(
"Couldn't run scan, images directory was not created."
)
self._stack_params = self._autofocus.create_stack_params( self._stack_params = self._autofocus.create_stack_params(
images_dir=self._ongoing_scan.images_dir, images_dir=images_dir,
autofocus_dz=self.autofocus_dz, autofocus_dz=self.autofocus_dz,
save_resolution=self._scan_data.save_resolution, save_resolution=self.scan_data.save_resolution,
) )
self._preview_stitcher = stitching.PreviewStitcher( self._preview_stitcher = stitching.PreviewStitcher(
self._ongoing_scan.images_dir, images_dir,
overlap=self._scan_data.overlap, overlap=self.scan_data.overlap,
correlation_resize=self._scan_data.correlation_resize, correlation_resize=self.scan_data.correlation_resize,
) )
# This is the main loop of the scan! # This is the main loop of the scan!
@ -392,9 +445,9 @@ class SmartScanThing(lt.Thing):
# have multiple starting positions, each of which will be visited before the # have multiple starting positions, each of which will be visited before the
# scan can end. # scan can end.
planner_settings = { planner_settings = {
"dx": self._scan_data.dx, "dx": self.scan_data.dx,
"dy": self._scan_data.dy, "dy": self.scan_data.dy,
"max_dist": self._scan_data.max_dist, "max_dist": self.scan_data.max_dist,
} }
route_planner = scan_planners.SmartSpiral( route_planner = scan_planners.SmartSpiral(
initial_position=(self._stage.position["x"], self._stage.position["y"]), initial_position=(self._stage.position["x"], self._stage.position["y"]),
@ -419,7 +472,7 @@ class SmartScanThing(lt.Thing):
capture_image = True capture_image = True
# If skipping background, take an image to check if current field of view is background # If skipping background, take an image to check if current field of view is background
if self._scan_data.skip_background: if self.scan_data.skip_background:
capture_image, bg_message = self._cam.image_is_sample() capture_image, bg_message = self._cam.image_is_sample()
if not capture_image: if not capture_image:
@ -431,23 +484,23 @@ class SmartScanThing(lt.Thing):
continue continue
focused, focused_height = self._autofocus.run_smart_stack( focused, focused_height = self._autofocus.run_smart_stack(
stack_parameters=self._stack_params, stack_parameters=self.stack_params,
save_on_failure=not self._scan_data.skip_background, save_on_failure=not self.scan_data.skip_background,
) )
current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focused_height) current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focused_height)
# An image was captured if we are focussed or we are not skipping background. # An image was captured if we are focussed or we are not skipping background.
imaged = focused or not self._scan_data.skip_background imaged = focused or not self.scan_data.skip_background
route_planner.mark_location_visited( route_planner.mark_location_visited(
current_pos_xyz, imaged=imaged, focused=focused current_pos_xyz, imaged=imaged, focused=focused
) )
# increment capture counter as thread has completed # increment capture counter as thread has completed
self._scan_data.image_count += 1 self.scan_data.image_count += 1
# Add it to the incremental zip # Add it to the incremental zip
self._ongoing_scan.zip_files() self.ongoing_scan.zip_files()
@_scan_running @_scan_running
def _return_to_starting_position(self) -> None: def _return_to_starting_position(self) -> None:
@ -455,7 +508,7 @@ class SmartScanThing(lt.Thing):
self.logger.info("Returning to starting position.") self.logger.info("Returning to starting position.")
if self._scan_data is not None: if self._scan_data is not None:
self._stage.move_absolute( self._stage.move_absolute(
**self._scan_data.starting_position, block_cancellation=True **self.scan_data.starting_position, block_cancellation=True
) )
@property @property
@ -463,28 +516,30 @@ class SmartScanThing(lt.Thing):
"""Return a metadata dict for ongoing scan to populate.""" """Return a metadata dict for ongoing scan to populate."""
if self._ongoing_scan is None: if self._ongoing_scan is None:
return {} return {}
return {"scan_name": self._ongoing_scan.name} return {"scan_name": self.ongoing_scan.name}
@_scan_running @_scan_running
def _perform_final_stitch(self) -> None: def _perform_final_stitch(self) -> None:
"""Update the scan zip and perform final stitch of the data.""" """Update the scan zip and perform final stitch of the data."""
if self._scan_data.image_count <= 3: if self.scan_data.image_count <= 3:
self.logger.info("Not performing a stitch as 3 or fewer images taken") self.logger.info("Not performing a stitch as 3 or fewer images taken")
return return
self._ongoing_scan.zip_files() self.ongoing_scan.zip_files()
self.logger.info("Waiting for background processes to finish...") self.logger.info("Waiting for background processes to finish...")
# Actually check the sticher exists rather than using self.preview_sticher as
# this method can be called during exception handling.
if self._preview_stitcher is not None: if self._preview_stitcher is not None:
self._preview_stitcher.wait() self._preview_stitcher.wait()
if self._scan_data.stitch_automatically: if self.scan_data.stitch_automatically:
self.logger.info("Stitching final image (may take some time)...") self.logger.info("Stitching final image (may take some time)...")
self.stitch_scan( self.stitch_scan(
scan_name=self._ongoing_scan.name, scan_name=self.ongoing_scan.name,
correlation_resize=self._scan_data.correlation_resize, correlation_resize=self.scan_data.correlation_resize,
overlap=self._scan_data.overlap, overlap=self.scan_data.overlap,
) )
@lt.endpoint( @lt.endpoint(
@ -540,7 +595,7 @@ class SmartScanThing(lt.Thing):
_scan_dir_manager.all_scans_info() directly as it will handle stopping the json _scan_dir_manager.all_scans_info() directly as it will handle stopping the json
in any ongoing scans being read. in any ongoing scans being read.
""" """
ongoing_name = None if self._ongoing_scan is None else self._ongoing_scan.name ongoing_name = None if self._ongoing_scan is None else self.ongoing_scan.name
return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name) return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name)
@lt.property @lt.property
@ -555,7 +610,7 @@ class SmartScanThing(lt.Thing):
""" """
return ScanListInfo( return ScanListInfo(
scans=self._get_all_scan_info(), scans=self._get_all_scan_info(),
ongoing=None if self._ongoing_scan is None else self._ongoing_scan.name, ongoing=None if self._ongoing_scan is None else self.ongoing_scan.name,
) )
@lt.endpoint( @lt.endpoint(
@ -632,7 +687,7 @@ class SmartScanThing(lt.Thing):
This is a wrapper around scan manager's delete_scan that logs to the This is a wrapper around scan manager's delete_scan that logs to the
things logger if there is a problem. things logger if there is a problem.
""" """
if self._ongoing_scan is not None and scan_name == self._ongoing_scan.name: if self._ongoing_scan is not None and scan_name == self.ongoing_scan.name:
self.logger.error("Attempted to delete ongoing scan.") self.logger.error("Attempted to delete ongoing scan.")
return False return False
try: try:
@ -648,7 +703,7 @@ class SmartScanThing(lt.Thing):
@property @property
def latest_preview_stitch_path(self) -> Optional[str]: def latest_preview_stitch_path(self) -> Optional[str]:
"""The path of the latest preview stitched image, or None if not available.""" """The path of the latest preview stitched image, or None if not available."""
if not self.latest_scan_name: if self.latest_scan_name is None:
return None return None
return self._scan_dir_manager.get_file_path_from_img_dir( return self._scan_dir_manager.get_file_path_from_img_dir(

View file

@ -12,7 +12,7 @@ As the object will be used as a context manager create the hardware connection i
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from typing import Any, Literal, Never from typing import Any, Literal, overload
import labthings_fastapi as lt import labthings_fastapi as lt
@ -82,11 +82,19 @@ class BaseStage(lt.Thing):
moving: bool = lt.property(default=False, readonly=True) moving: bool = lt.property(default=False, readonly=True)
"""Whether the stage is in motion.""" """Whether the stage is in motion."""
axis_inverted: Mapping[str, bool] = lt.setting( axis_inverted: dict[str, bool] = lt.setting(
default={"x": False, "y": False, "z": False}, readonly=True default={"x": False, "y": False, "z": False}, readonly=True
) )
"""Used to convert coordinates between the program frame and the hardware frame.""" """Used to convert coordinates between the program frame and the hardware frame."""
@overload
def _apply_axis_direction(self, position: list[int] | tuple[int]) -> list[int]: ...
@overload
def _apply_axis_direction(
self, position: Mapping[str, int]
) -> Mapping[str, int]: ...
def _apply_axis_direction( def _apply_axis_direction(
self, position: list[int] | tuple[int] | Mapping[str, int] self, position: list[int] | tuple[int] | Mapping[str, int]
) -> list[int] | Mapping[str, int]: ) -> list[int] | Mapping[str, int]:
@ -142,7 +150,7 @@ class BaseStage(lt.Thing):
def _hardware_move_relative( def _hardware_move_relative(
self, block_cancellation: bool = False, **kwargs: int self, block_cancellation: bool = False, **kwargs: int
) -> Never: ) -> None:
"""Make a relative move in the coordinate system used by the physical hardware. """Make a relative move in the coordinate system used by the physical hardware.
Make sure to use and update ``self._hardware_position`` not ``self.position``. Make sure to use and update ``self._hardware_position`` not ``self.position``.
@ -163,7 +171,7 @@ class BaseStage(lt.Thing):
self, self,
block_cancellation: bool = False, block_cancellation: bool = False,
**kwargs: int, **kwargs: int,
) -> Never: ) -> None:
"""Make a absolute move in the coordinate system used by the physical hardware. """Make a absolute move in the coordinate system used by the physical hardware.
Make sure to use and update ``self._hardware_position`` not ``self.position``. Make sure to use and update ``self._hardware_position`` not ``self.position``.

View file

@ -3,9 +3,8 @@
from __future__ import annotations from __future__ import annotations
import time import time
from collections.abc import Mapping
from types import TracebackType from types import TracebackType
from typing import Any, Optional from typing import Any, Optional, Self
import labthings_fastapi as lt import labthings_fastapi as lt
@ -36,9 +35,10 @@ class DummyStage(BaseStage):
self.step_time = step_time self.step_time = step_time
self.instantaneous_position = self._hardware_position self.instantaneous_position = self._hardware_position
def __enter__(self) -> None: def __enter__(self) -> Self:
"""Register the stage position when the Thing context manager is opened.""" """Register the stage position when the Thing context manager is opened."""
self.instantaneous_position = self._hardware_position self.instantaneous_position = self._hardware_position
return self
def __exit__( def __exit__(
self, self,
@ -48,7 +48,7 @@ class DummyStage(BaseStage):
) -> None: ) -> None:
"""Nothing to do when the Thing context manager is closed.""" """Nothing to do when the Thing context manager is closed."""
axis_inverted: Mapping[str, bool] = lt.setting( axis_inverted: dict[str, bool] = lt.setting(
default={"x": True, "y": False, "z": False}, readonly=True default={"x": True, "y": False, "z": False}, readonly=True
) )
"""Used to convert coordinates between the program frame and the hardware frame.""" """Used to convert coordinates between the program frame and the hardware frame."""

View file

@ -4,11 +4,10 @@ from __future__ import annotations
import threading import threading
import time import time
from collections.abc import Mapping
from contextlib import contextmanager from contextlib import contextmanager
from copy import copy from copy import copy
from types import TracebackType from types import TracebackType
from typing import Any, Iterator, Literal, Optional from typing import Any, Iterator, Literal, Optional, Self
import semver import semver
@ -33,7 +32,7 @@ class SangaboardThing(BaseStage):
def __init__( def __init__(
self, self,
thing_server_interface: lt.ThingServerInterface, thing_server_interface: lt.ThingServerInterface,
port: str = None, port: Optional[str] = None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
"""Initialise SangaboardThing. """Initialise SangaboardThing.
@ -53,13 +52,14 @@ class SangaboardThing(BaseStage):
self._sangaboard_lock = threading.RLock() self._sangaboard_lock = threading.RLock()
super().__init__(thing_server_interface, **kwargs) super().__init__(thing_server_interface, **kwargs)
def __enter__(self) -> None: def __enter__(self) -> Self:
"""Connect to the sangaboard when the Thing context manager is opened.""" """Connect to the sangaboard when the Thing context manager is opened."""
self._sangaboard = sangaboard.Sangaboard(**self.sangaboard_kwargs) self._sangaboard = sangaboard.Sangaboard(**self.sangaboard_kwargs)
with self.sangaboard() as sb: with self.sangaboard() as sb:
sb.query("blocking_moves false") sb.query("blocking_moves false")
self.check_firmware() self.check_firmware()
self.update_position() self.update_position()
return self
def __exit__( def __exit__(
self, self,
@ -80,7 +80,7 @@ class SangaboardThing(BaseStage):
with self._sangaboard_lock: with self._sangaboard_lock:
yield self._sangaboard yield self._sangaboard
axis_inverted: Mapping[str, bool] = lt.setting( axis_inverted: dict[str, bool] = lt.setting(
default={"x": True, "y": False, "z": True}, readonly=True default={"x": True, "y": False, "z": True}, readonly=True
) )
"""Used to convert coordinates between the program frame and the hardware frame.""" """Used to convert coordinates between the program frame and the hardware frame."""

View file

@ -14,8 +14,9 @@ this is 10% of the expected motion is the measured axis.
""" """
import time import time
from copy import copy
from threading import Lock from threading import Lock
from typing import Any, Literal, Optional, overload from typing import Any, Literal, Mapping, Optional, overload
import numpy as np import numpy as np
@ -48,20 +49,20 @@ class RomDataTracker:
def __init__(self) -> None: def __init__(self) -> None:
"""Define useful data tracked throughout test.""" """Define useful data tracked throughout test."""
self.stage_coords: list[dict[str, int]] = [] self.stage_coords: list[Mapping[str, int]] = []
self.offsets: list[dict[str, float]] = [] self.offsets: list[Mapping[str, float]] = []
def record_movement( def record_movement(
self, current_pos: dict[str, int], offset: dict[str, float] self, current_pos: Mapping[str, int], offset: Mapping[str, float]
) -> None: ) -> None:
"""Record the current position and the measured offset of the last move.""" """Record the current position and the measured offset of the last move."""
self.stage_coords.append(current_pos) self.stage_coords.append(current_pos)
self.offsets.append(offset) self.offsets.append(offset)
@property @property
def final_position(self) -> dict[str, int]: def final_position(self) -> Mapping[str, int]:
"""The last stage coordinate recorded.""" """The last stage coordinate recorded."""
return self.stage_coords[-1].copy() return copy(self.stage_coords[-1])
def fit_axis(self, axis: Literal["x", "y"]) -> np.poly1d: def fit_axis(self, axis: Literal["x", "y"]) -> np.poly1d:
"""Quadratic fit stage z against x or y and return poly1d of fit. """Quadratic fit stage z against x or y and return poly1d of fit.
@ -78,8 +79,8 @@ class RomDataTracker:
def predict_z_displacement( def predict_z_displacement(
self, self,
axis: Literal["x", "y"], axis: Literal["x", "y"],
stage_movement: dict[str, int], stage_movement: Mapping[str, int],
stage_position: dict[str, int], stage_position: Mapping[str, int],
) -> int: ) -> int:
"""Predict the z-displacement needed for a given movement. """Predict the z-displacement needed for a given movement.
@ -93,7 +94,7 @@ class RomDataTracker:
return int(z_dest - stage_position["z"]) return int(z_dest - stage_position["z"])
def find_turning_point(self, axis: Literal["x", "y"]) -> dict[str, int]: def find_turning_point(self, axis: Literal["x", "y"]) -> Mapping[str, int]:
"""Find the turing point from the recorded coordinates.""" """Find the turing point from the recorded coordinates."""
fit_func = self.fit_axis(axis) fit_func = self.fit_axis(axis)
turning_loc = fit_func.deriv().roots[0] turning_loc = fit_func.deriv().roots[0]
@ -121,7 +122,9 @@ class RangeofMotionThing(lt.Thing):
_csm: CameraStageMapper = lt.thing_slot() _csm: CameraStageMapper = lt.thing_slot()
_stage: BaseStage = lt.thing_slot() _stage: BaseStage = lt.thing_slot()
calibrated_range: Optional[list[int, int]] = lt.setting(default=None, readonly=True) calibrated_range: Optional[tuple[int, int]] = lt.setting(
default=None, readonly=True
)
def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None: def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None:
"""Initialise and create the lock.""" """Initialise and create the lock."""
@ -131,7 +134,7 @@ class RangeofMotionThing(lt.Thing):
self._rom_data = RomDataTracker() self._rom_data = RomDataTracker()
@lt.action @lt.action
def perform_rom_test(self) -> dict[str, Any]: def perform_rom_test(self) -> Mapping[str, Any]:
"""Measures the range of motion of the stage across the x and y axes. """Measures the range of motion of the stage across the x and y axes.
:return: Results dictionary separated into keys of each axis and direction. :return: Results dictionary separated into keys of each axis and direction.
@ -173,7 +176,7 @@ class RangeofMotionThing(lt.Thing):
self.logger.info( self.logger.info(
f"Range of motion is {step_range[0]} x {step_range[1]} steps" f"Range of motion is {step_range[0]} x {step_range[1]} steps"
) )
self.calibrated_range = step_range self.calibrated_range = (step_range[0], step_range[1])
finally: finally:
self._lock.release() self._lock.release()
return { return {
@ -324,7 +327,7 @@ class RangeofMotionThing(lt.Thing):
# if the distance is less than 1 big step away then move to it and exit # if the distance is less than 1 big step away then move to it and exit
if abs(img_perc) < BIG_STEP: if abs(img_perc) < BIG_STEP:
self.logger.info(f"Estimated centre of {axis}-axis is {estimate[axis]}") self.logger.info(f"Estimated centre of {axis}-axis is {estimate[axis]}")
self._stage.move_absolute(**estimate) self._stage.move_absolute(**estimate, block_cancellation=False)
# Note the second return, the direction, is meaningless here. # Note the second return, the direction, is meaningless here.
return True, 1 return True, 1
self.logger.info( self.logger.info(
@ -353,7 +356,7 @@ class RangeofMotionThing(lt.Thing):
return (fov_perc / 100) * self._stream_resolution[img_index] return (fov_perc / 100) * self._stream_resolution[img_index]
def _img_dir_from_stage_coords( def _img_dir_from_stage_coords(
self, target: dict[str, int], axis: Literal["x", "y"] self, target: Mapping[str, int], axis: Literal["x", "y"]
) -> Literal[1, -1]: ) -> Literal[1, -1]:
"""For a target location in stage coords, return the direction in image coordinates. """For a target location in stage coords, return the direction in image coordinates.
@ -369,7 +372,7 @@ class RangeofMotionThing(lt.Thing):
return -1 if target_im_coords[axis] < current_loc_im_coords[axis] else 1 return -1 if target_im_coords[axis] < current_loc_im_coords[axis] else 1
def _distance_in_img_percentage( def _distance_in_img_percentage(
self, target: dict[str, int], axis: Literal["x", "y"] self, target: Mapping[str, int], axis: Literal["x", "y"]
) -> float: ) -> float:
"""For a target location in stage coords return the distance in percentage of FOV. """For a target location in stage coords return the distance in percentage of FOV.
@ -394,7 +397,7 @@ class RangeofMotionThing(lt.Thing):
fov_perc: int, fov_perc: int,
axis: Literal["x", "y"], axis: Literal["x", "y"],
direction: Literal[1, -1], direction: Literal[1, -1],
) -> dict[str, float]: ) -> Mapping[str, float]:
"""Return the dictionary for a move in image coordinates. """Return the dictionary for a move in image coordinates.
This dictionary can be passed directly to csm.move_in_image_coordinates This dictionary can be passed directly to csm.move_in_image_coordinates
@ -546,11 +549,11 @@ class RangeofMotionThing(lt.Thing):
def _move_and_measure( def _move_and_measure(
self, self,
movement: dict[str, float], movement: Mapping[str, float],
perform_autofocus: bool = True, perform_autofocus: bool = True,
max_autofocus_repeats: int = 0, max_autofocus_repeats: int = 0,
abs_min_offset: float = 0.0, abs_min_offset: float = 0.0,
) -> dict[str, float]: ) -> Mapping[str, float]:
"""Move the stage and measure the offset between the two positions. """Move the stage and measure the offset between the two positions.
:param movement: A dictionary containing the distance to move in image coords. :param movement: A dictionary containing the distance to move in image coords.
@ -596,7 +599,7 @@ class RangeofMotionThing(lt.Thing):
return offset return offset
def _offset_from(self, before_img: np.ndarray) -> dict[str, float]: def _offset_from(self, before_img: np.ndarray) -> Mapping[str, float]:
"""Take an image and calculate the offset from an input image. """Take an image and calculate the offset from an input image.
:param before_img: The image the offset should be calculated with respect to. :param before_img: The image the offset should be calculated with respect to.
@ -623,11 +626,11 @@ class RangeofMotionThing(lt.Thing):
# let MyPy know with overloads typed to Literal[False] and Literal[True]. # let MyPy know with overloads typed to Literal[False] and Literal[True].
@overload @overload
def _axis_from_movement_dict( def _axis_from_movement_dict(
movement: dict[str, float], return_other: Literal[False] movement: Mapping[str, float], return_other: Literal[False]
) -> str: ... ) -> str: ...
@overload @overload
def _axis_from_movement_dict( def _axis_from_movement_dict(
movement: dict[str, float], return_other: Literal[True] movement: Mapping[str, float], return_other: Literal[True]
) -> tuple[str, str]: ... ) -> tuple[str, str]: ...
@ -635,12 +638,12 @@ def _axis_from_movement_dict(
# because MyPy doesn't see that return other's default is `False` and use # because MyPy doesn't see that return other's default is `False` and use
# the `Literal[False]` overload. # the `Literal[False]` overload.
@overload @overload
def _axis_from_movement_dict(movement: dict[str, float]) -> str: ... def _axis_from_movement_dict(movement: Mapping[str, float]) -> str: ...
# Finally The function. # Finally The function.
def _axis_from_movement_dict( def _axis_from_movement_dict(
movement: dict[str, float], return_other: bool = False movement: Mapping[str, float], return_other: bool = False
) -> str | tuple[str, str]: ) -> str | tuple[str, str]:
"""Return the axis that a given movement dictionary moves in. """Return the axis that a given movement dictionary moves in.
@ -661,7 +664,7 @@ def _axis_from_movement_dict(
def _parasitic_motion_detected( def _parasitic_motion_detected(
movement: dict[str, float], offset: dict[str, float] movement: Mapping[str, float], offset: Mapping[str, float]
) -> bool: ) -> bool:
"""Compare desired movement to measured offset, report if parasitic motion was detected. """Compare desired movement to measured offset, report if parasitic motion was detected.

View file

@ -51,8 +51,8 @@ class OpenFlexureSystem(lt.Thing):
return UUID(self._microscope_id) return UUID(self._microscope_id)
@microscope_id.setter @microscope_id.setter
def microscope_id(self, uuid: UUID) -> None: def _set_microscope_id(self, uuid: UUID) -> None:
self._microscope_id = uuid self._microscope_id = str(uuid)
@lt.property @lt.property
def hostname(self) -> str: def hostname(self) -> str:
@ -98,6 +98,7 @@ class OpenFlexureSystem(lt.Thing):
SHUTDOWN_CMD, SHUTDOWN_CMD,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
text=True,
) )
out, err = p.communicate() out, err = p.communicate()
@ -116,6 +117,7 @@ class OpenFlexureSystem(lt.Thing):
REBOOT_CMD, REBOOT_CMD,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
text=True,
) )
out, err = p.communicate() out, err = p.communicate()
return CommandOutput(output=out, error=err) return CommandOutput(output=out, error=err)

View file

@ -1,6 +1,6 @@
"""Functionality for communicating the required user interface for a thing.""" """Functionality for communicating the required user interface for a thing."""
from typing import Any, Callable from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
@ -47,14 +47,15 @@ class ActionButton(BaseModel):
"""The message to show on successful completion.""" """The message to show on successful completion."""
def action_button_for(action: Callable[..., Any], **kwargs: Any) -> ActionButton: def action_button_for(thing: lt.Thing, action_name: str, **kwargs: Any) -> ActionButton:
"""Create a ActionButton data for the specified Thing Action. """Create a ActionButton data for the specified Thing Action.
:param action: The thing action to create a button for. :param thing: The instance of the thing that has the action.
:param action_name: The name of the action to create a button for.
:param kwargs: Any attribute of `ActionButton` except for ``thing`` or ``action``. :param kwargs: Any attribute of `ActionButton` except for ``thing`` or ``action``.
:return: An ActionButton (Pydantic Model) object with all the information the
webapp needs to create the action button.
""" """
thing = action.args[0]
action_name = action.func.__name__
return ActionButton(thing=thing.name, action=action_name, **kwargs) return ActionButton(thing=thing.name, action=action_name, **kwargs)

View file

@ -8,15 +8,12 @@ import sys
import tomllib import tomllib
from functools import wraps from functools import wraps
from importlib.metadata import version from importlib.metadata import version
from threading import Thread
from typing import ( from typing import (
Any, Any,
Callable, Callable,
Concatenate, Concatenate,
Literal, Literal,
Optional,
ParamSpec, ParamSpec,
Self,
TypeAlias, TypeAlias,
TypeVar, TypeVar,
overload, overload,
@ -26,6 +23,7 @@ from pydantic import BaseModel
T = TypeVar("T") T = TypeVar("T")
P = ParamSpec("P") P = ParamSpec("P")
LockableClass = TypeVar("LockableClass")
JSONScalar: TypeAlias = str | int | float | bool | None JSONScalar: TypeAlias = str | int | float | bool | None
JSONType: TypeAlias = dict[str, "JSONType"] | list["JSONType"] | JSONScalar JSONType: TypeAlias = dict[str, "JSONType"] | list["JSONType"] | JSONScalar
@ -50,15 +48,15 @@ def _is_lock_like(obj: Any) -> bool:
def requires_lock( def requires_lock(
method: Callable[Concatenate[Self, P], T], method: Callable[Concatenate[LockableClass, P], T],
) -> Callable[Concatenate[Self, P], T]: ) -> Callable[Concatenate[LockableClass, P], T]:
"""Decorate a class method so that it requires the class lock to run. """Decorate a class method so that it requires the class lock to run.
The class should have a reentrant lock with the name ``self._lock``. The class should have a reentrant lock with the name ``self._lock``.
""" """
@wraps(method) @wraps(method)
def wrapper(self: Self, *args: P.args, **kwargs: P.kwargs) -> T: def wrapper(self: LockableClass, *args: P.args, **kwargs: P.kwargs) -> T:
# Confirm the object the method is attached to has a self._lock property # Confirm the object the method is attached to has a self._lock property
if not hasattr(self, "_lock"): if not hasattr(self, "_lock"):
raise AttributeError(f"{self.__class__.__name__} has no '_lock' attribute") raise AttributeError(f"{self.__class__.__name__} has no '_lock' attribute")
@ -71,91 +69,6 @@ def requires_lock(
return wrapper return wrapper
class ErrorCapturingThread(Thread):
"""Subclass of Thread that captures exceptions from the target function.
It wraps the target function in a try-except block.
Execution will stop with an unhandled exception, but the exception will not be raised
until the join method is called. When the join method is called, the exception is
called in the calling thread.
This allows for error handling to be performed by the calling thread at the time that
join is run.
"""
def __init__(
self,
group: Optional[Any] = None,
target: Optional[Callable[..., Any]] = None,
name: Optional[str] = None,
args: tuple[Any, ...] = (),
kwargs: Optional[dict[str, Any]] = None,
*,
daemon: Optional[bool] = None,
) -> None:
"""Initialise with the same arguments as Thread."""
# As all inputs are keywords we need to set the default values for args and kwargs:
if args is None:
args = ()
if kwargs is None:
kwargs = {}
# Make an empty list for any error.
# We are only ever going to have one or zero errors, this is an empty
# list as we need to pass the reference not the value to collect the
# error. If we could simply make a pointer we would have done that.
self._error_buffer = []
# Add the target function end error buffer to the thread to the start of
# the argument list so they are passed to _wrap_and_catch_errors()
args = (target, self._error_buffer, *args)
# Start the thread with _wrap_and_catch_errors() as the target
super().__init__(
group=group,
target=_wrap_and_catch_errors,
name=name,
args=args,
kwargs=kwargs,
daemon=daemon,
)
def join(self, timeout: Optional[float] = None) -> None:
"""Join when the thread is complete.
If the thread ended due to an unhandled exception, the exception will be raised
when this method is called.
"""
super().join(timeout)
# If there is an error in the error buffer clear the buffer and raise it
if self._error_buffer:
err = self._error_buffer[0]
self._error_buffer = []
raise err
def _wrap_and_catch_errors(
target: Callable[..., Any], error_buffer: list, *args: Any, **kwargs: Any
) -> None:
"""Run target function in a try-except block.
This function is designed only to be used by ErrorCapturingThread.
It will run a target function in a try-except block catching any exception.
If an exception is caught it is added to ``error_buffer``.
:param target: The target function to call
:param error_buffer: An empty list that is used for returning the exception from
the thread. It is a list to ensure it is passed by reference.
:param ``*args``: The arguments for the target function
:param ``**kwargs``: The Keyword arguments for the target function
"""
try:
target(*args, **kwargs)
except BaseException as e:
error_buffer.append(e)
# Compiled regular expressions for unsafe characters # Compiled regular expressions for unsafe characters
# Matches anything that isn't a-z, A-Z, 0-9, _, ., -, :, /, \ # Matches anything that isn't a-z, A-Z, 0-9, _, ., -, :, /, \
_WINDOWS_UNSAFE_PATTERN = re.compile(r"[^a-zA-Z0-9_.\-:/\\]") _WINDOWS_UNSAFE_PATTERN = re.compile(r"[^a-zA-Z0-9_.\-:/\\]")

View file

@ -58,34 +58,38 @@ def test_bg_detect_base_class():
BackgroundDetectAlgorithm() BackgroundDetectAlgorithm()
def test_partial_base_class(background_image): def test_partial_base_classes():
"""Create a partial class to initialise the base class and test other methods. """Create a partial classes and check they raise the correct errors."""
This test is to check that if the necessary methods are not set, that an
appropriate error is raised.
"""
class BadAlgo1(BackgroundDetectAlgorithm): class BadAlgo1(BackgroundDetectAlgorithm):
"""Only has a settings model so it can initialise.""" """Only has a settings model so it cannot initialise."""
settings_data_model: BaseModel = ColourChannelDetectSettings settings_data_model = ColourChannelDetectSettings
bad_algo1 = BadAlgo1()
status = bad_algo1.status
assert not status.ready
# Check the settings dictionary can be validated as ``ColourChannelDetectSettings``
ColourChannelDetectSettings(**status.settings)
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
# Should error on any dictionary input. This simulates loading settings from BadAlgo1()
# disk
bad_algo1.background_data = {"key": 1} class BadAlgo2(BackgroundDetectAlgorithm):
"""Only has a background model so it cannot initialise."""
background_data_model = ChannelDistributions
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
bad_algo1.set_background(background_image) BadAlgo2()
class BadAlgo3(BackgroundDetectAlgorithm):
"""Has both models, intalises by cannot run set_background or image_is_sample."""
settings_data_model = ColourChannelDetectSettings
background_data_model = ChannelDistributions
bad_algo3 = BadAlgo3()
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
bad_algo1.image_is_sample(background_image) bad_algo3.set_background(background_image)
with pytest.raises(NotImplementedError):
bad_algo3.image_is_sample(background_image)
def test_colour_channel_luv(background_image, sample_image): def test_colour_channel_luv(background_image, sample_image):

View file

@ -33,7 +33,7 @@ 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) buffer_id = mem_buf.add_image(misc_image, random_metadata())
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
@ -46,7 +46,7 @@ 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) buffer_id = mem_buf.add_image(misc_image, random_metadata())
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
@ -62,7 +62,7 @@ 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) mem_buf.add_image(misc_image, random_metadata())
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
@ -76,8 +76,8 @@ 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, buffer_max=2) buffer_id1 = mem_buf.add_image(misc_image1, random_metadata(), buffer_max=2)
buffer_id2 = mem_buf.add_image(misc_image2, buffer_max=2) buffer_id2 = mem_buf.add_image(misc_image2, random_metadata(), buffer_max=2)
returned_image1, _ = mem_buf.get_image(buffer_id1) returned_image1, _ = mem_buf.get_image(buffer_id1)
returned_image2, _ = mem_buf.get_image(buffer_id2) returned_image2, _ = mem_buf.get_image(buffer_id2)
# It they the same images # It they the same images
@ -95,8 +95,8 @@ 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) buffer_id1 = mem_buf.add_image(misc_image1, random_metadata())
buffer_id2 = mem_buf.add_image(misc_image2) buffer_id2 = mem_buf.add_image(misc_image2, random_metadata())
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)
@ -110,10 +110,10 @@ 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, buffer_max=3) buffer_id1 = mem_buf.add_image(misc_image1, random_metadata(), buffer_max=3)
buffer_id2 = mem_buf.add_image(misc_image2, buffer_max=3) buffer_id2 = mem_buf.add_image(misc_image2, random_metadata(), 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) buffer_id3 = mem_buf.add_image(misc_image3, random_metadata())
# 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)
@ -129,8 +129,8 @@ 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, buffer_max=2) mem_buf.add_image(misc_image1, random_metadata(), buffer_max=2)
mem_buf.add_image(misc_image2, buffer_max=2) mem_buf.add_image(misc_image2, random_metadata(), 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
@ -148,7 +148,7 @@ 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, buffer_max=5) buffer_id = mem_buf.add_image(image, random_metadata(), buffer_max=5)
images.append(image) images.append(image)
buffer_ids.append(buffer_id) buffer_ids.append(buffer_id)
@ -169,7 +169,7 @@ 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, buffer_max=10) buffer_id = mem_buf.add_image(image, random_metadata(), buffer_max=10)
images.append(image) images.append(image)
buffer_ids.append(buffer_id) buffer_ids.append(buffer_id)

View file

@ -6,13 +6,14 @@ from socket import gethostname
# Import as ofm server to attempt to minimise confusion with server as a var in other # Import as ofm server to attempt to minimise confusion with server as a var in other
# functions and also FastAPI `Server`. # functions and also FastAPI `Server`.
from openflexure_microscope_server.server import legacy_api from openflexure_microscope_server.server import legacy_api
from openflexure_microscope_server.things.camera import BaseCamera
def test_v2_endpoints(mocker): def test_v2_endpoints(mocker):
"""Check that the expected v2 endpoints are added.""" """Check that the expected v2 endpoints are added."""
mock_server = mocker.Mock() mock_server = mocker.Mock()
# Mock the camera thing to mocke the lores_mjpeg stream get_frame() # Mock the camera thing to mocke the lores_mjpeg stream get_frame()
mock_server.things = {"camera": mocker.Mock()} mock_server.things = {"camera": mocker.Mock(spec=BaseCamera)}
mock_server.things["camera"].lores_mjpeg_stream.grab_frame = mocker.AsyncMock( mock_server.things["camera"].lores_mjpeg_stream.grab_frame = mocker.AsyncMock(
return_value="Mock Frame" return_value="Mock Frame"
) )

View file

@ -111,7 +111,7 @@ def test_tuning_lst_tools(imx219_tuning, imx477_tuning):
assert not tf_utils.lst_calibrated(tuning) assert not tf_utils.lst_calibrated(tuning)
lst_model = tf_utils.get_lst(tuning) lst_model = tf_utils.get_lst(tuning)
assert isinstance(lst_model, tf_utils.LensShading) assert isinstance(lst_model, tf_utils.LensShadingModel)
# Check the tables are lists of lists # Check the tables are lists of lists
assert isinstance(lst_model.luminance, list) assert isinstance(lst_model.luminance, list)
assert isinstance(lst_model.luminance[0], list) assert isinstance(lst_model.luminance[0], list)

View file

@ -8,8 +8,9 @@ from fastapi import FastAPI
# Import as ofm server to attempt to minimise confusion with server as a var in other # Import as ofm server to attempt to minimise confusion with server as a var in other
# functions and also FastAPI `Server`. # functions and also FastAPI `Server`.
from openflexure_microscope_server import server as ofm_server from openflexure_microscope_server import server as ofm_server
from openflexure_microscope_server.things.camera import BaseCamera
from .test_server_config import FULL_CONFIG from .test_server_config import SIM_CONFIG
def test_no_config(): def test_no_config():
@ -27,7 +28,7 @@ def test_successful_start(mocker, caplog):
mock_server = mocker.Mock() mock_server = mocker.Mock()
# Create a mock for the camera so we can check the MJPEG streams are closed on # Create a mock for the camera so we can check the MJPEG streams are closed on
# shutdown. # shutdown.
mock_camera = mocker.Mock() mock_camera = mocker.Mock(spec=BaseCamera)
mock_server.things = {"camera": mock_camera} mock_server.things = {"camera": mock_camera}
# Mock the LabThings function that returns the server so we have a mock server # Mock the LabThings function that returns the server so we have a mock server
mocker.patch( mocker.patch(
@ -40,7 +41,7 @@ def test_successful_start(mocker, caplog):
mock_uvicorn_run = mocker.patch("openflexure_microscope_server.server.uvicorn.run") mock_uvicorn_run = mocker.patch("openflexure_microscope_server.server.uvicorn.run")
# Run the mock CLI # Run the mock CLI
ofm_server.serve_from_cli(["-c", FULL_CONFIG]) ofm_server.serve_from_cli(["-c", SIM_CONFIG])
# Check that the server was customised and the run # Check that the server was customised and the run
assert mock_customise.call_count == 1 assert mock_customise.call_count == 1
@ -90,10 +91,10 @@ def test_failed_customise(mocker):
# Running the mock CLI will error # Running the mock CLI will error
with pytest.raises(RuntimeError, match="Can't touch this"): with pytest.raises(RuntimeError, match="Can't touch this"):
ofm_server.serve_from_cli(["-c", FULL_CONFIG]) ofm_server.serve_from_cli(["-c", SIM_CONFIG])
# But with the fallback flag uvicorn run will be run # But with the fallback flag uvicorn run will be run
ofm_server.serve_from_cli(["-c", FULL_CONFIG, "--fallback"]) ofm_server.serve_from_cli(["-c", SIM_CONFIG, "--fallback"])
assert mock_uvicorn_run.call_count == 1 assert mock_uvicorn_run.call_count == 1
# Get the fallback app passed to uvicorn tun # Get the fallback app passed to uvicorn tun
@ -101,4 +102,4 @@ def test_failed_customise(mocker):
# Check it really is a fastapi # Check it really is a fastapi
assert isinstance(fallback_app, FastAPI) assert isinstance(fallback_app, FastAPI)
# An that it has the error to display # An that it has the error to display
assert str(fallback_app.labthings_error) == "Can't touch this" assert str(fallback_app._context.error) == "Can't touch this"

View file

@ -3,7 +3,6 @@
import itertools import itertools
import pytest import pytest
from httpx import HTTPStatusError
from hypothesis import HealthCheck, given, settings from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st from hypothesis import strategies as st
@ -155,10 +154,8 @@ def test_direction_errors_local_and_http():
assert stage_client.axis_inverted == {"x": True, "y": False, "z": False} assert stage_client.axis_inverted == {"x": True, "y": False, "z": False}
# Can't set an arbitrary value via a client as read only: # Can't set an arbitrary value via a client as read only:
with pytest.raises(HTTPStatusError) as excinfo: with pytest.raises(lt.exceptions.ClientPropertyError):
stage_client.axis_inverted = {"x": 2, "y": 1, "z": 1} stage_client.axis_inverted = {"x": 2, "y": 1, "z": 1}
# Read only should set a 405 error code ...
assert excinfo.value.response.status_code == 405
# ... and should not modify the initial value # ... and should not modify the initial value
assert stage_client.axis_inverted == {"x": True, "y": False, "z": False} assert stage_client.axis_inverted == {"x": True, "y": False, "z": False}
@ -166,10 +163,9 @@ def test_direction_errors_local_and_http():
# Should error if axis doesn't exist, this is a KeyError in the server # Should error if axis doesn't exist, this is a KeyError in the server
with pytest.raises(KeyError): with pytest.raises(KeyError):
dummy_stage.invert_axis_direction(axis="theta") dummy_stage.invert_axis_direction(axis="theta")
# But a 422 over HTTP # But a FailedToInvokeActionError over HTTP
with pytest.raises(HTTPStatusError) as excinfo: with pytest.raises(lt.exceptions.FailedToInvokeActionError):
stage_client.invert_axis_direction(axis="theta") stage_client.invert_axis_direction(axis="theta")
assert excinfo.value.response.status_code == 422
def _test_move_relative(dummy_stage, axis_inverted, path): def _test_move_relative(dummy_stage, axis_inverted, path):