From 99f3d2d31113654997213c7a30015040b5fb9977 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Dec 2025 20:04:16 +0000 Subject: [PATCH 01/29] Start fixing some issues found by MyPy --- .../things/camera/__init__.py | 13 +- .../things/camera/picamera.py | 5 +- .../things/camera/simulation.py | 5 +- .../things/smart_scan.py | 133 ++++++++++++------ 4 files changed, 99 insertions(+), 57 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 18855ec2..f65747ce 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -20,7 +20,6 @@ from typing import Any, Literal, Mapping, Optional, Tuple import numpy as np import piexif from PIL import Image -from pydantic import RootModel import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray @@ -46,12 +45,6 @@ class PNGBlob(lt.blob.Blob): media_type: str = "image/png" -class ArrayModel(RootModel): - """A model for an array.""" - - root: NDArray - - class CaptureError(RuntimeError): """An error trying to capture from a CameraThing.""" @@ -255,7 +248,7 @@ class BaseCamera(lt.Thing): self, stream_name: Literal["main", "lores", "raw", "full"] = "main", wait: Optional[float] = 5, - ) -> ArrayModel: + ) -> NDArray: """Acquire one image from the camera and return as an array.""" raise NotImplementedError( "CameraThings must define their own capture_array method" @@ -265,7 +258,7 @@ class BaseCamera(lt.Thing): """The downsampling factor when calling capture_downsampled_array.""" @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. * The array is downsamples by the thing property `downsampled_array_factor`. @@ -334,7 +327,7 @@ class BaseCamera(lt.Thing): def grab_as_array( self, stream_name: Literal["main", "lores"] = "main", - ) -> ArrayModel: + ) -> NDArray: """Acquire one image from the preview stream and return as an array. It works like ``grab_jpeg`` but reliably handles broken streams. Prefer using diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index b621c888..cd7b50ad 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -36,6 +36,7 @@ from pydantic import BaseModel, BeforeValidator import labthings_fastapi as lt 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.ui import ( @@ -45,7 +46,7 @@ from openflexure_microscope_server.ui import ( property_control_for, ) -from . import ArrayModel, BaseCamera +from . import BaseCamera from . import picamera_recalibrate_utils as recalibrate_utils from . import picamera_tuning_file_utils as tf_utils @@ -573,7 +574,7 @@ class StreamingPiCamera2(BaseCamera): self, stream_name: Literal["main", "lores", "raw", "full"] = "main", wait: Optional[float] = 0.9, - ) -> ArrayModel: + ) -> NDArray: """Acquire one image from the camera and return as an array. This function will produce a nested list containing an uncompressed RGB image. diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index a4e2f316..b7ceeb26 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -19,6 +19,7 @@ import numpy as np from PIL import Image, ImageFilter import labthings_fastapi as lt +from labthings_fastapi.types.numpy import NDArray from openflexure_microscope_server.ui import ( ActionButton, @@ -28,7 +29,7 @@ from openflexure_microscope_server.ui import ( ) from ..stage.dummy import DummyStage -from . import ArrayModel, BaseCamera +from . import BaseCamera LOGGER = logging.getLogger(__name__) @@ -317,7 +318,7 @@ class SimulatedCamera(BaseCamera): self, stream_name: Literal["main", "full"] = "full", wait: Optional[float] = None, - ) -> ArrayModel: + ) -> NDArray: """Acquire one image from the camera and return as an array. This function will produce a nested list containing an uncompressed RGB image. diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 340726d3..45d9333d 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -116,13 +116,63 @@ class SmartScanThing(lt.Thing): self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder) self._scan_lock = threading.Lock() - # Variables set by the scan - self._latest_scan_name: Optional[str] = None + # Variables set by the scan + _stack_params: Optional[StackParams] = None - self._stack_params: Optional[StackParams] = None - self._ongoing_scan: Optional[scan_directories.ScanDirectory] = None - self._scan_data: Optional[scan_directories.ScanData] = None - self._preview_stitcher: Optional[stitching.PreviewStitcher] = None + @property + def stack_params(self) -> StackParams: + """The parameters for z-stacking during the onging scan. + + 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 def sample_scan(self, scan_name: str = "") -> None: @@ -143,7 +193,7 @@ class SmartScanThing(lt.Thing): try: self._check_background_and_csm_set() 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._run_scan() except Exception as e: @@ -200,11 +250,6 @@ class SmartScanThing(lt.Thing): "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 def _move_to_next_point( self, next_point: tuple[int, int], z_estimate: Optional[int] = None @@ -295,7 +340,7 @@ class SmartScanThing(lt.Thing): # Fix scan parameters in case UI is updated during scan. return scan_directories.ScanData( - scan_name=self._ongoing_scan.name, + scan_name=self.ongoing_scan.name, starting_position=starting_position, overlap=overlap, max_dist=self.max_range, @@ -317,16 +362,16 @@ class SmartScanThing(lt.Thing): Takes scan_result, a string that is either "success", "cancelled by user", or the error that ended the scan. """ - self._scan_data.set_final_data(result=scan_result) - self._ongoing_scan.save_scan_data(self._scan_data) + self.scan_data.set_final_data(result=scan_result) + self.ongoing_scan.save_scan_data(self._scan_data) @_scan_running def _manage_stitching_threads(self) -> None: """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 # well constrained. - if self._scan_data.image_count > 3 and not self._preview_stitcher.running: - self._preview_stitcher.start() + if self.scan_data.image_count > 3 and not self.preview_stitcher.running: + self.preview_stitcher.start() @_scan_running def _run_scan(self) -> None: @@ -339,16 +384,16 @@ class SmartScanThing(lt.Thing): try: self._cam.start_streaming(main_resolution=(3280, 2464)) 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) self._stack_params = self._autofocus.create_stack_params( - images_dir=self._ongoing_scan.images_dir, + images_dir=self.ongoing_scan.images_dir, 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._ongoing_scan.images_dir, - overlap=self._scan_data.overlap, - correlation_resize=self._scan_data.correlation_resize, + self.ongoing_scan.images_dir, + overlap=self.scan_data.overlap, + correlation_resize=self.scan_data.correlation_resize, ) # This is the main loop of the scan! @@ -392,9 +437,9 @@ class SmartScanThing(lt.Thing): # have multiple starting positions, each of which will be visited before the # scan can end. planner_settings = { - "dx": self._scan_data.dx, - "dy": self._scan_data.dy, - "max_dist": self._scan_data.max_dist, + "dx": self.scan_data.dx, + "dy": self.scan_data.dy, + "max_dist": self.scan_data.max_dist, } route_planner = scan_planners.SmartSpiral( initial_position=(self._stage.position["x"], self._stage.position["y"]), @@ -419,7 +464,7 @@ class SmartScanThing(lt.Thing): capture_image = True # 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() if not capture_image: @@ -432,22 +477,22 @@ class SmartScanThing(lt.Thing): focused, focused_height = self._autofocus.run_smart_stack( 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) # 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( current_pos_xyz, imaged=imaged, focused=focused ) # 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 - self._ongoing_scan.zip_files() + self.ongoing_scan.zip_files() @_scan_running def _return_to_starting_position(self) -> None: @@ -455,7 +500,7 @@ class SmartScanThing(lt.Thing): self.logger.info("Returning to starting position.") if self._scan_data is not None: self._stage.move_absolute( - **self._scan_data.starting_position, block_cancellation=True + **self.scan_data.starting_position, block_cancellation=True ) @property @@ -463,28 +508,30 @@ class SmartScanThing(lt.Thing): """Return a metadata dict for ongoing scan to populate.""" if self._ongoing_scan is None: return {} - return {"scan_name": self._ongoing_scan.name} + return {"scan_name": self.ongoing_scan.name} @_scan_running def _perform_final_stitch(self) -> None: """Update the scan zip and perform final stitch of the data.""" - 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") return - self._ongoing_scan.zip_files() + self.ongoing_scan.zip_files() 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: 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.stitch_scan( - scan_name=self._ongoing_scan.name, - correlation_resize=self._scan_data.correlation_resize, - overlap=self._scan_data.overlap, + scan_name=self.ongoing_scan.name, + correlation_resize=self.scan_data.correlation_resize, + overlap=self.scan_data.overlap, ) @lt.endpoint( @@ -540,7 +587,7 @@ class SmartScanThing(lt.Thing): _scan_dir_manager.all_scans_info() directly as it will handle stopping the json 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) @lt.property @@ -555,7 +602,7 @@ class SmartScanThing(lt.Thing): """ return ScanListInfo( 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( @@ -632,7 +679,7 @@ class SmartScanThing(lt.Thing): This is a wrapper around scan manager's delete_scan that logs to the 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.") return False try: @@ -648,7 +695,7 @@ class SmartScanThing(lt.Thing): @property def latest_preview_stitch_path(self) -> Optional[str]: """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 self._scan_dir_manager.get_file_path_from_img_dir( From 472aa03822a0892a5270fbdcd056c9c9f839e56d Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Dec 2025 21:50:16 +0000 Subject: [PATCH 02/29] Ignore dmypy.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3054abce..b78e7d7d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ # Mypy cache .mypy_cache* +.dmypy.json # C extensions *.so From 4060c06a2ba24168452720ef26fda3aab4071538 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Dec 2025 21:50:31 +0000 Subject: [PATCH 03/29] Assorted type fixes --- .../scan_directories.py | 2 +- .../things/autofocus.py | 2 +- .../things/camera/__init__.py | 8 ++-- .../things/camera/opencv.py | 2 +- .../things/camera/picamera.py | 2 +- .../things/camera/simulation.py | 8 ++-- .../things/camera_stage_mapping.py | 23 +++-------- .../things/smart_scan.py | 38 +++++++++++-------- 8 files changed, 41 insertions(+), 44 deletions(-) diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index 8c0c4a5f..e0bee445 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -346,7 +346,7 @@ class ScanDirectoryManager: shutil.rmtree(self.path_for(scan_name)) @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. ``final_version`` Set true to stitch all files not just the scan images diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index d885c6b8..f46f7ee9 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -555,7 +555,7 @@ class AutofocusThing(lt.Thing): stack_parameters: StackParams, save_on_failure: bool = False, check_turning_points: bool = True, - ) -> tuple[bool, Optional[int]]: + ) -> tuple[bool, int]: """Run a smart stack. A smart stack captures images offset in z, testing whether the sharpest image diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index f65747ce..561d612f 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -204,7 +204,7 @@ class BaseCamera(lt.Thing): @lt.action 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: """Start (or stop and restart) the camera. @@ -364,7 +364,7 @@ class BaseCamera(lt.Thing): self, stream_name: Literal["main", "lores", "raw"], wait: Optional[float] = None, - ) -> Image: + ) -> Image.Image: """Capture a PIL image from stream stream_name with timeout wait.""" raise NotImplementedError( "CameraThings must define their own capture_image method" @@ -431,7 +431,7 @@ class BaseCamera(lt.Thing): """Clear all images in memory.""" 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. This robust capturing method attempts to capture the image five times @@ -513,7 +513,7 @@ class BaseCamera(lt.Thing): def _save_capture( self, jpeg_path: str, - image: Image, + image: Image.Image, metadata: dict, save_resolution: Optional[Tuple[int, int]] = None, ) -> None: diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 3882730e..db8ee41f 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -113,7 +113,7 @@ class OpenCVCamera(BaseCamera): self, stream_name: Literal["main", "full"] = "main", wait: Optional[float] = None, - ) -> Image: + ) -> Image.Image: """Acquire one image from the camera and return as a PIL image. This function will produce a JPEG image. diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index cd7b50ad..de8389ad 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -534,7 +534,7 @@ class StreamingPiCamera2(BaseCamera): self, stream_name: Literal["main", "lores", "full"] = "main", wait: Optional[float] = 0.9, - ) -> Image: + ) -> Image.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 diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index b7ceeb26..c825a4ce 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -196,7 +196,7 @@ class SimulatedCamera(BaseCamera): 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. :param pos: a 3-item tuple containing the x,y,z coordinates of the 'stage' @@ -230,7 +230,7 @@ class SimulatedCamera(BaseCamera): image[image > 255] = 255 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.""" try: pos = self._stage.instantaneous_position @@ -337,7 +337,7 @@ class SimulatedCamera(BaseCamera): self, stream_name: Literal["main", "lores", "raw"], wait: Optional[float] = None, - ) -> Image: + ) -> Image.Image: """Capture to a PIL image. This is not exposed as a ThingAction. It is used for capture to memory. @@ -403,7 +403,7 @@ class SimulatedCamera(BaseCamera): 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.""" with io.BytesIO() as buf: # Save in low quality for speed. diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index 6ca3ee7f..fd33d7af 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -22,7 +22,6 @@ from typing import ( ) import numpy as np -from fastapi import HTTPException import labthings_fastapi as lt from camera_stage_mapping.camera_stage_calibration_1d import ( @@ -91,25 +90,14 @@ class RecordedMove: self._history = [] -class CSMUncalibratedError(HTTPException): - """An HTTP Exception raised if camera stage mapping data is needed but unavailable. +class CSMUncalibratedError(lt.exceptions.InvocationError): + """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 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. """ - 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): """A Thing to manage mapping between image and stage coordinates. @@ -241,9 +229,10 @@ class CameraStageMapper(lt.Thing): def assert_calibrated(self) -> None: """Raise an exception if the image_to_stage_displacement matrix is not set.""" if self.image_to_stage_displacement_matrix is None: - # Disable check of no message in raised exception as the message is explicitly - # added by CSMUncalibratedError - raise CSMUncalibratedError() # noqa: RSE102 + raise CSMUncalibratedError( + "The camera_stage_mapping calibration is not yet available. " + "This probably means you need to run the calibration routine." + ) @lt.action def move_in_image_coordinates(self, x: float, y: float) -> None: diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 45d9333d..06d2b6b6 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -19,7 +19,6 @@ from typing import ( Mapping, Optional, ParamSpec, - Self, TypeVar, ) @@ -35,7 +34,7 @@ from openflexure_microscope_server import scan_directories, scan_planners, stitc # Things from .autofocus import AutofocusThing, StackParams from .camera import BaseCamera -from .camera_stage_mapping import CameraStageMapper +from .camera_stage_mapping import CameraStageMapper, CSMUncalibratedError from .stage import BaseStage T = TypeVar("T") @@ -69,8 +68,8 @@ class ScanNotRunningError(RuntimeError): def _scan_running( - method: Callable[Concatenate[Self, P], T], -) -> Callable[Concatenate[Self, P], T]: + method: Callable[Concatenate["SmartScanThing", P], T], +) -> Callable[Concatenate["SmartScanThing", P], T]: """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 @@ -79,7 +78,9 @@ def _scan_running( 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.""" if self._scan_lock.locked(): return method(self, *args, **kwargs) @@ -230,11 +231,7 @@ class SmartScanThing(lt.Thing): Raise warning if not using background detect that scan will go on until max steps reached """ - if self._csm.image_resolution is None: - raise RuntimeError( - "Camera-stage mapping is not calibrated. This is required before " - "scans can be carried out." - ) + self._csm.assert_calibrated() if self.skip_background: if not self._cam.background_detector_status.ready: @@ -275,7 +272,7 @@ class SmartScanThing(lt.Thing): return (next_point[0], next_point[1], z_estimate) @_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. :param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means @@ -283,9 +280,15 @@ class SmartScanThing(lt.Thing): :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_res = list(test_image.shape) + csm_image_res = [int(i) for i in self._csm.image_resolution] # If current stream width is different to csm calibration width, @@ -363,7 +366,7 @@ class SmartScanThing(lt.Thing): or the error that ended the scan. """ 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 def _manage_stitching_threads(self) -> None: @@ -385,13 +388,18 @@ class SmartScanThing(lt.Thing): self._cam.start_streaming(main_resolution=(3280, 2464)) self._scan_data = self._collect_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( - images_dir=self.ongoing_scan.images_dir, + images_dir=images_dir, autofocus_dz=self.autofocus_dz, save_resolution=self.scan_data.save_resolution, ) self._preview_stitcher = stitching.PreviewStitcher( - self.ongoing_scan.images_dir, + images_dir, overlap=self.scan_data.overlap, correlation_resize=self.scan_data.correlation_resize, ) @@ -476,7 +484,7 @@ class SmartScanThing(lt.Thing): continue 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, ) From 85671e897eb5218afd0bfcd2adc98c48c3bf0de5 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Dec 2025 21:54:50 +0000 Subject: [PATCH 04/29] Remove vesitgial code from utilities and fix types --- .../utilities.py | 95 +------------------ 1 file changed, 4 insertions(+), 91 deletions(-) diff --git a/src/openflexure_microscope_server/utilities.py b/src/openflexure_microscope_server/utilities.py index 9c88e170..ca1f740e 100644 --- a/src/openflexure_microscope_server/utilities.py +++ b/src/openflexure_microscope_server/utilities.py @@ -8,15 +8,12 @@ import sys import tomllib from functools import wraps from importlib.metadata import version -from threading import Thread from typing import ( Any, Callable, Concatenate, Literal, - Optional, ParamSpec, - Self, TypeAlias, TypeVar, overload, @@ -26,6 +23,7 @@ from pydantic import BaseModel T = TypeVar("T") P = ParamSpec("P") +LockableClass = TypeVar("LockableClass") JSONScalar: TypeAlias = str | int | float | bool | None JSONType: TypeAlias = dict[str, "JSONType"] | list["JSONType"] | JSONScalar @@ -50,15 +48,15 @@ def _is_lock_like(obj: Any) -> bool: def requires_lock( - method: Callable[Concatenate[Self, P], T], -) -> Callable[Concatenate[Self, P], T]: + method: Callable[Concatenate[LockableClass, P], T], +) -> Callable[Concatenate[LockableClass, P], T]: """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``. """ @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 if not hasattr(self, "_lock"): raise AttributeError(f"{self.__class__.__name__} has no '_lock' attribute") @@ -71,91 +69,6 @@ def requires_lock( 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 # Matches anything that isn't a-z, A-Z, 0-9, _, ., -, :, /, \ _WINDOWS_UNSAFE_PATTERN = re.compile(r"[^a-zA-Z0-9_.\-:/\\]") From dcf329f1c55f60d42bd9446c4d02542366ad28bc Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Dec 2025 22:19:25 +0000 Subject: [PATCH 05/29] Fix typing in stage_measure --- .../things/stage_measure.py | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index adaba880..5bb40ebf 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -14,8 +14,9 @@ this is 10% of the expected motion is the measured axis. """ import time +from copy import copy from threading import Lock -from typing import Any, Literal, Optional, overload +from typing import Any, Literal, Mapping, Optional, overload import numpy as np @@ -48,20 +49,20 @@ class RomDataTracker: def __init__(self) -> None: """Define useful data tracked throughout test.""" - self.stage_coords: list[dict[str, int]] = [] - self.offsets: list[dict[str, float]] = [] + self.stage_coords: list[Mapping[str, int]] = [] + self.offsets: list[Mapping[str, float]] = [] def record_movement( - self, current_pos: dict[str, int], offset: dict[str, float] + self, current_pos: Mapping[str, int], offset: Mapping[str, float] ) -> None: """Record the current position and the measured offset of the last move.""" self.stage_coords.append(current_pos) self.offsets.append(offset) @property - def final_position(self) -> dict[str, int]: + def final_position(self) -> Mapping[str, int]: """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: """Quadratic fit stage z against x or y and return poly1d of fit. @@ -78,8 +79,8 @@ class RomDataTracker: def predict_z_displacement( self, axis: Literal["x", "y"], - stage_movement: dict[str, int], - stage_position: dict[str, int], + stage_movement: Mapping[str, int], + stage_position: Mapping[str, int], ) -> int: """Predict the z-displacement needed for a given movement. @@ -93,7 +94,7 @@ class RomDataTracker: 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.""" fit_func = self.fit_axis(axis) turning_loc = fit_func.deriv().roots[0] @@ -121,7 +122,9 @@ class RangeofMotionThing(lt.Thing): _csm: CameraStageMapper = 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: """Initialise and create the lock.""" @@ -131,7 +134,7 @@ class RangeofMotionThing(lt.Thing): self._rom_data = RomDataTracker() @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. :return: Results dictionary separated into keys of each axis and direction. @@ -173,7 +176,7 @@ class RangeofMotionThing(lt.Thing): self.logger.info( 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: self._lock.release() 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 abs(img_perc) < BIG_STEP: 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. return True, 1 self.logger.info( @@ -353,7 +356,7 @@ class RangeofMotionThing(lt.Thing): return (fov_perc / 100) * self._stream_resolution[img_index] 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]: """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 def _distance_in_img_percentage( - self, target: dict[str, int], axis: Literal["x", "y"] + self, target: Mapping[str, int], axis: Literal["x", "y"] ) -> float: """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, axis: Literal["x", "y"], direction: Literal[1, -1], - ) -> dict[str, float]: + ) -> Mapping[str, float]: """Return the dictionary for a 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( self, - movement: dict[str, float], + movement: Mapping[str, float], perform_autofocus: bool = True, max_autofocus_repeats: int = 0, abs_min_offset: float = 0.0, - ) -> dict[str, float]: + ) -> Mapping[str, float]: """Move the stage and measure the offset between the two positions. :param movement: A dictionary containing the distance to move in image coords. @@ -596,7 +599,7 @@ class RangeofMotionThing(lt.Thing): 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. :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]. @overload def _axis_from_movement_dict( - movement: dict[str, float], return_other: Literal[False] + movement: Mapping[str, float], return_other: Literal[False] ) -> str: ... @overload def _axis_from_movement_dict( - movement: dict[str, float], return_other: Literal[True] + movement: Mapping[str, float], return_other: Literal[True] ) -> 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 # the `Literal[False]` 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. 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]: """Return the axis that a given movement dictionary moves in. @@ -661,7 +664,7 @@ def _axis_from_movement_dict( def _parasitic_motion_detected( - movement: dict[str, float], offset: dict[str, float] + movement: Mapping[str, float], offset: Mapping[str, float] ) -> bool: """Compare desired movement to measured offset, report if parasitic motion was detected. From e0c63ab35448f0c56839804f3ec6be6bfc1c2ee1 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Dec 2025 22:27:58 +0000 Subject: [PATCH 06/29] Unify camera capute signatures --- src/openflexure_microscope_server/things/camera/__init__.py | 2 +- src/openflexure_microscope_server/things/camera/opencv.py | 4 ++-- src/openflexure_microscope_server/things/camera/simulation.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 561d612f..a50a553e 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -362,7 +362,7 @@ class BaseCamera(lt.Thing): def capture_image( self, - stream_name: Literal["main", "lores", "raw"], + stream_name: Literal["main", "lores", "full"], wait: Optional[float] = None, ) -> Image.Image: """Capture a PIL image from stream stream_name with timeout wait.""" diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index db8ee41f..adf67996 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -90,7 +90,7 @@ class OpenCVCamera(BaseCamera): @lt.action def capture_array( self, - stream_name: Literal["main", "full"] = "full", + stream_name: Literal["main", "lores", "raw", "full"] = "full", wait: Optional[float] = None, ) -> NDArray: """Acquire one image from the camera and return as an array. @@ -111,7 +111,7 @@ class OpenCVCamera(BaseCamera): def capture_image( self, - stream_name: Literal["main", "full"] = "main", + stream_name: Literal["main", "lores", "full"] = "main", wait: Optional[float] = None, ) -> Image.Image: """Acquire one image from the camera and return as a PIL image. diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index c825a4ce..ec220371 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -316,7 +316,7 @@ class SimulatedCamera(BaseCamera): @lt.action def capture_array( self, - stream_name: Literal["main", "full"] = "full", + stream_name: Literal["main", "lores", "raw", "full"] = "full", wait: Optional[float] = None, ) -> NDArray: """Acquire one image from the camera and return as an array. @@ -335,7 +335,7 @@ class SimulatedCamera(BaseCamera): def capture_image( self, - stream_name: Literal["main", "lores", "raw"], + stream_name: Literal["main", "lores", "full"], wait: Optional[float] = None, ) -> Image.Image: """Capture to a PIL image. This is not exposed as a ThingAction. From d52548cc13b501fe55e8ff34d6da3a5853076a00 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Dec 2025 22:43:52 +0000 Subject: [PATCH 07/29] Improve camera buffer typing --- .../things/camera/__init__.py | 8 +++--- tests/unit_tests/test_camera_buffer.py | 28 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index a50a553e..cc56c01d 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -59,7 +59,7 @@ class CameraMemoryBuffer: 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: """Create the buffer instance.""" @@ -73,7 +73,7 @@ class CameraMemoryBuffer: def add_image( self, image: Any, - metadata: Optional[Mapping[str, Any]] = None, + metadata: Mapping[str, Any], buffer_max: int = 1, ) -> int: """Add an image to the Memory buffer. @@ -97,7 +97,7 @@ class CameraMemoryBuffer: def get_image( 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. If no id is given the most recent image is returned. However, the @@ -514,7 +514,7 @@ class BaseCamera(lt.Thing): self, jpeg_path: str, image: Image.Image, - metadata: dict, + metadata: Mapping[str, Any], save_resolution: Optional[Tuple[int, int]] = None, ) -> None: """Save the captured image and metadata to disk. diff --git a/tests/unit_tests/test_camera_buffer.py b/tests/unit_tests/test_camera_buffer.py index 3fb0635f..1ea6620d 100644 --- a/tests/unit_tests/test_camera_buffer.py +++ b/tests/unit_tests/test_camera_buffer.py @@ -33,7 +33,7 @@ def test_add_and_get_image(): """Check images can be captured and retrieved.""" mem_buf = CameraMemoryBuffer() 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) # It is the same 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.""" mem_buf = CameraMemoryBuffer() 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) # It is the same 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.""" mem_buf = CameraMemoryBuffer() misc_image = random_image() - mem_buf.add_image(misc_image) + mem_buf.add_image(misc_image, random_metadata()) returned_image, _ = mem_buf.get_image() # It is the same image assert misc_image is returned_image @@ -76,8 +76,8 @@ def test_get_two_images(): mem_buf = CameraMemoryBuffer() misc_image1 = random_image() misc_image2 = random_image() - buffer_id1 = mem_buf.add_image(misc_image1, buffer_max=2) - buffer_id2 = mem_buf.add_image(misc_image2, buffer_max=2) + buffer_id1 = mem_buf.add_image(misc_image1, random_metadata(), 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_image2, _ = mem_buf.get_image(buffer_id2) # It they the same images @@ -95,8 +95,8 @@ def test_get_two_images_without_setting_buffer_size(): mem_buf = CameraMemoryBuffer() misc_image1 = random_image() misc_image2 = random_image() - buffer_id1 = mem_buf.add_image(misc_image1) - buffer_id2 = mem_buf.add_image(misc_image2) + buffer_id1 = mem_buf.add_image(misc_image1, random_metadata()) + buffer_id2 = mem_buf.add_image(misc_image2, random_metadata()) with pytest.raises(NoImageInMemoryError): mem_buf.get_image(buffer_id1) returned_image2, _ = mem_buf.get_image(buffer_id2) @@ -110,10 +110,10 @@ def test_buffer_size_changing(): misc_image1 = random_image() misc_image2 = random_image() misc_image3 = random_image() - buffer_id1 = mem_buf.add_image(misc_image1, buffer_max=3) - buffer_id2 = mem_buf.add_image(misc_image2, buffer_max=3) + buffer_id1 = mem_buf.add_image(misc_image1, random_metadata(), 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 - 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 with pytest.raises(NoImageInMemoryError): mem_buf.get_image(buffer_id1) @@ -129,8 +129,8 @@ def test_capture_two_images_get_without_id(): mem_buf = CameraMemoryBuffer() misc_image1 = random_image() misc_image2 = random_image() - mem_buf.add_image(misc_image1, buffer_max=2) - mem_buf.add_image(misc_image2, buffer_max=2) + mem_buf.add_image(misc_image1, random_metadata(), buffer_max=2) + mem_buf.add_image(misc_image2, random_metadata(), buffer_max=2) returned_image, _ = mem_buf.get_image() # When buffer_id is not specified, the most recent image (image2) is expected to # be retrieved @@ -148,7 +148,7 @@ def test_buffer_size_respected(): buffer_ids = [] for _i in range(10): 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) buffer_ids.append(buffer_id) @@ -169,7 +169,7 @@ def test_clear_buffer(): buffer_ids = [] for _i in range(10): 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) buffer_ids.append(buffer_id) From afbc00acef0347f2d013af84f8cbcb84bb2f265a Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Dec 2025 23:07:39 +0000 Subject: [PATCH 08/29] Improve __enter__ typing for things --- .../things/camera/__init__.py | 4 ++-- .../things/camera/opencv.py | 7 ++++--- .../things/camera/picamera.py | 12 ++++-------- .../things/camera/simulation.py | 4 ++-- .../things/stage/dummy.py | 5 +++-- .../things/stage/sangaboard.py | 7 ++++--- 6 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index cc56c01d..fa20710f 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -15,7 +15,7 @@ import tempfile import time from datetime import datetime 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 piexif @@ -180,7 +180,7 @@ class BaseCamera(lt.Thing): } self._detector_name = "Channel Deviations (LUV)" - def __enter__(self) -> None: + def __enter__(self) -> Self: """Open hardware connection when the Thing context manager is opened.""" raise NotImplementedError("CameraThings must define their own __enter__ method") diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index adf67996..fb45d65f 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -11,7 +11,7 @@ from __future__ import annotations import logging from threading import Thread from types import TracebackType -from typing import Literal, Optional +from typing import Literal, Optional, Self import cv2 from PIL import Image @@ -39,7 +39,7 @@ class OpenCVCamera(BaseCamera): self._capture_thread: Optional[Thread] = None self._capture_enabled = False - def __enter__(self) -> None: + def __enter__(self) -> Self: """Start the capture thread when the Thing context manager is opened.""" self.cap = cv2.VideoCapture(self.camera_index) self._capture_enabled = True @@ -59,7 +59,8 @@ class OpenCVCamera(BaseCamera): """ if self.stream_active: self._capture_enabled = False - self._capture_thread.join() + if self._capture_thread is not None: + self._capture_thread.join() self.cap.release() @lt.property diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index de8389ad..6d38772e 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -25,7 +25,7 @@ import time from contextlib import contextmanager from threading import RLock 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 from picamera2 import Picamera2 @@ -148,7 +148,7 @@ class StreamingPiCamera2(BaseCamera): f"are {SUPPORTED_CAMS_SENSOR_INFO.keys()}." ) self._sensor_info = SUPPORTED_CAMS_SENSOR_INFO[camera_board] - self._picamera_lock = None + self._picamera_lock = RLock() self._picamera = None # Load the tuning file for the specified sensor mode. @@ -340,10 +340,7 @@ class StreamingPiCamera2(BaseCamera): :raises PicameraModelError: If check_sensor_model is True and the real camera sensor model doesn't match the expected sensor model. """ - if self._picamera_lock is not None: - # Don't close the camera if it's in use - self._picamera_lock.acquire() - with tempfile.NamedTemporaryFile("w") as tuning_file: + with self._picamera_lock, tempfile.NamedTemporaryFile("w") as tuning_file: json.dump(self.tuning, tuning_file) tuning_file.flush() # but leave it open as closing it will delete it os.environ["LIBCAMERA_RPI_TUNING_FILE"] = tuning_file.name @@ -371,9 +368,8 @@ class StreamingPiCamera2(BaseCamera): f"Wrong Picamera model. Expecting {self._sensor_info.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. This opens the picamera connection, initialises the camera, sets the diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index ec220371..b019a6d8 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -13,7 +13,7 @@ import logging import time from threading import Thread from types import TracebackType -from typing import Literal, Optional +from typing import Literal, Optional, Self import numpy as np from PIL import Image, ImageFilter @@ -239,7 +239,7 @@ class SimulatedCamera(BaseCamera): pos = {"x": 0, "y": 0, "z": 0} 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.""" self.start_streaming() return self diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index 3b8025ff..a535fb3c 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -5,7 +5,7 @@ from __future__ import annotations import time from collections.abc import Mapping from types import TracebackType -from typing import Any, Optional +from typing import Any, Optional, Self import labthings_fastapi as lt @@ -36,9 +36,10 @@ class DummyStage(BaseStage): self.step_time = step_time 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.""" self.instantaneous_position = self._hardware_position + return self def __exit__( self, diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 290be4bf..d50ad539 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -8,7 +8,7 @@ from collections.abc import Mapping from contextlib import contextmanager from copy import copy from types import TracebackType -from typing import Any, Iterator, Literal, Optional +from typing import Any, Iterator, Literal, Optional, Self import semver @@ -33,7 +33,7 @@ class SangaboardThing(BaseStage): def __init__( self, thing_server_interface: lt.ThingServerInterface, - port: str = None, + port: Optional[str] = None, **kwargs: Any, ) -> None: """Initialise SangaboardThing. @@ -53,13 +53,14 @@ class SangaboardThing(BaseStage): self._sangaboard_lock = threading.RLock() 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.""" self._sangaboard = sangaboard.Sangaboard(**self.sangaboard_kwargs) with self.sangaboard() as sb: sb.query("blocking_moves false") self.check_firmware() self.update_position() + return self def __exit__( self, From 51512f36f7d8ea29e8767b2e34612a20c4d7fecb Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Dec 2025 23:36:06 +0000 Subject: [PATCH 09/29] Unify UI for action_button_for and property_control_for --- .../things/camera/picamera.py | 18 ++++++++++++------ .../things/camera/simulation.py | 6 +++--- src/openflexure_microscope_server/ui.py | 6 ++---- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 6d38772e..17bfec18 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -770,7 +770,8 @@ class StreamingPiCamera2(BaseCamera): """The calibration actions for both calibration wizard and settings panel.""" return [ action_button_for( - self.full_auto_calibrate, + self, + "full_auto_calibrate", submit_label="Full Auto-Calibrate", can_terminate=False, requires_confirmation=True, @@ -788,13 +789,15 @@ class StreamingPiCamera2(BaseCamera): """The calibration actions that appear only in settings panel.""" return [ action_button_for( - self.auto_expose_from_minimum, + self, + "auto_expose_from_minimum", submit_label="Auto Gain & Shutter Speed", can_terminate=False, button_primary=False, ), action_button_for( - self.calibrate_lens_shading, + self, + "calibrate_lens_shading", submit_label="Auto Flat Field Correction", can_terminate=False, button_primary=False, @@ -806,19 +809,22 @@ class StreamingPiCamera2(BaseCamera): ), ), action_button_for( - self.flat_lens_shading, + self, + "flat_lens_shading", submit_label="Disable Flat Field Correction", can_terminate=False, button_primary=False, ), action_button_for( - self.flat_lens_shading_chrominance, + self, + "flat_lens_shading_chrominance", submit_label="Disable Flat Field Chrominance", can_terminate=False, button_primary=False, ), action_button_for( - self.reset_lens_shading, + self, + "reset_lens_shading", submit_label="Reset Flat Field Correction", can_terminate=False, button_primary=False, diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index b019a6d8..e1630597 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -385,7 +385,7 @@ class SimulatedCamera(BaseCamera): """The calibration actions for both calibration wizard and settings panel.""" return [ action_button_for( - self.full_auto_calibrate, submit_label="Full Auto-Calibrate" + self, "full_auto_calibrate", submit_label="Full Auto-Calibrate" ), ] @@ -393,8 +393,8 @@ class SimulatedCamera(BaseCamera): def secondary_calibration_actions(self) -> list[ActionButton]: """The calibration actions that appear only in settings panel.""" return [ - action_button_for(self.load_sample, submit_label="Load Sample"), - action_button_for(self.remove_sample, submit_label="Remove Sample"), + action_button_for(self, "load_sample", submit_label="Load Sample"), + action_button_for(self, "remove_sample", submit_label="Remove Sample"), ] @lt.property diff --git a/src/openflexure_microscope_server/ui.py b/src/openflexure_microscope_server/ui.py index 3b7ace59..58641a4f 100644 --- a/src/openflexure_microscope_server/ui.py +++ b/src/openflexure_microscope_server/ui.py @@ -1,6 +1,6 @@ """Functionality for communicating the required user interface for a thing.""" -from typing import Any, Callable +from typing import Any from pydantic import BaseModel @@ -47,14 +47,12 @@ class ActionButton(BaseModel): """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. :param action: The thing action to create a button for. :param kwargs: Any attribute of `ActionButton` except for ``thing`` or ``action``. """ - thing = action.args[0] - action_name = action.func.__name__ return ActionButton(thing=thing.name, action=action_name, **kwargs) From 26d395f05dcfdcd24276042eb3e85fcd3732b8fc Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 17 Dec 2025 23:40:30 +0000 Subject: [PATCH 10/29] Type fixes for system Thng --- src/openflexure_microscope_server/things/system.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/system.py b/src/openflexure_microscope_server/things/system.py index 7c72b2ab..df89ca73 100644 --- a/src/openflexure_microscope_server/things/system.py +++ b/src/openflexure_microscope_server/things/system.py @@ -51,7 +51,7 @@ class OpenFlexureSystem(lt.Thing): return UUID(self._microscope_id) @microscope_id.setter - def microscope_id(self, uuid: UUID) -> None: + def _set_microscope_id(self, uuid: UUID) -> None: self._microscope_id = uuid @lt.property @@ -98,6 +98,7 @@ class OpenFlexureSystem(lt.Thing): SHUTDOWN_CMD, stderr=subprocess.PIPE, stdout=subprocess.PIPE, + text=True, ) out, err = p.communicate() @@ -116,6 +117,7 @@ class OpenFlexureSystem(lt.Thing): REBOOT_CMD, stderr=subprocess.PIPE, stdout=subprocess.PIPE, + text=True, ) out, err = p.communicate() return CommandOutput(output=out, error=err) From 5df5651fc22369214005a1febd293d07ab04cef3 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 10:00:43 +0000 Subject: [PATCH 11/29] Adjust name for a number of propery/setting setters --- .../things/camera/__init__.py | 4 ++-- .../things/camera/picamera.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index fa20710f..455459cf 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -584,7 +584,7 @@ class BaseCamera(lt.Thing): return self._detector_name @detector_name.setter - def detector_name(self, name: str) -> None: + def _set_detector_name(self, name: str) -> None: """Validate and set detector_name.""" if name not in self.background_detectors: self.logger.warning(f"{name} is not a valid background detector name.") @@ -633,7 +633,7 @@ class BaseCamera(lt.Thing): return data @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. Do not call over HTTP. This needs to be updated once LbaThings Settings can be diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 17bfec18..e9747cd3 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -215,7 +215,7 @@ class StreamingPiCamera2(BaseCamera): return self._analogue_gain @analogue_gain.setter - def analogue_gain(self, value: float) -> None: + def _set_analogue_gain(self, value: float) -> None: self._analogue_gain = value if self.streaming: with self._streaming_picamera() as cam: @@ -235,7 +235,7 @@ class StreamingPiCamera2(BaseCamera): return self._colour_gains @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 if self.streaming: with self._streaming_picamera() as cam: @@ -259,7 +259,7 @@ class StreamingPiCamera2(BaseCamera): return self._exposure_time @exposure_time.setter - def exposure_time(self, value: int) -> None: + def _set_exposure_time(self, value: int) -> None: self._exposure_time = value if self.streaming: with self._streaming_picamera() as cam: @@ -303,7 +303,7 @@ class StreamingPiCamera2(BaseCamera): return SensorModeSelector(**self._sensor_mode) @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.""" if new_mode is None: self._sensor_mode = None @@ -873,7 +873,7 @@ class StreamingPiCamera2(BaseCamera): return tf_utils.get_lst(self.tuning) @lens_shading_tables.setter - def lens_shading_tables(self, lst: tf_utils.LensShading) -> None: + def _set_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( From afeca2bbed3777cc7fa5718be989f29853abcff0 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 10:12:33 +0000 Subject: [PATCH 12/29] Fix picamera tuning setting type --- .../things/camera/picamera.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index e9747cd3..a8bbf20d 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -123,6 +123,9 @@ class StreamingPiCamera2(BaseCamera): generalisation. """ + tuning: dict = lt.setting(default_factory=dict, readonly=True) + """The Raspberry PiCamera Tuning File JSON.""" + def __init__( self, thing_server_interface: lt.ThingServerInterface, @@ -160,10 +163,12 @@ class StreamingPiCamera2(BaseCamera): # connected to the server if tuning is saved to disk. try: 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 - # a server. But we know this, so we ignore the error. - pass + # a server. But we know this, so we ignore the error as long as the + # 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 # trigger a ServerNotRunningError @@ -324,9 +329,6 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera() as cam: 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: """Acquire the picamera device and store it as ``self._picamera``. From 9976c5cb849e68c79f2f7b42ee043d96b2c128c2 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 10:27:55 +0000 Subject: [PATCH 13/29] Fix stage typehints --- .../things/stage/__init__.py | 16 ++++++++++++---- .../things/stage/dummy.py | 3 +-- .../things/stage/sangaboard.py | 3 +-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index b8de8e19..fd495186 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -12,7 +12,7 @@ As the object will be used as a context manager create the hardware connection i from __future__ import annotations from collections.abc import Mapping, Sequence -from typing import Any, Literal, Never +from typing import Any, Literal, overload import labthings_fastapi as lt @@ -82,11 +82,19 @@ class BaseStage(lt.Thing): moving: bool = lt.property(default=False, readonly=True) """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 ) """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( self, position: list[int] | tuple[int] | Mapping[str, int] ) -> list[int] | Mapping[str, int]: @@ -142,7 +150,7 @@ class BaseStage(lt.Thing): def _hardware_move_relative( self, block_cancellation: bool = False, **kwargs: int - ) -> Never: + ) -> None: """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``. @@ -163,7 +171,7 @@ class BaseStage(lt.Thing): self, block_cancellation: bool = False, **kwargs: int, - ) -> Never: + ) -> None: """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``. diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index a535fb3c..7130fcb7 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -3,7 +3,6 @@ from __future__ import annotations import time -from collections.abc import Mapping from types import TracebackType from typing import Any, Optional, Self @@ -49,7 +48,7 @@ class DummyStage(BaseStage): ) -> None: """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 ) """Used to convert coordinates between the program frame and the hardware frame.""" diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index d50ad539..db64ccc3 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -4,7 +4,6 @@ from __future__ import annotations import threading import time -from collections.abc import Mapping from contextlib import contextmanager from copy import copy from types import TracebackType @@ -81,7 +80,7 @@ class SangaboardThing(BaseStage): with self._sangaboard_lock: 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 ) """Used to convert coordinates between the program frame and the hardware frame.""" From 99eee2e912415a20ba2d501bbd52ea2fe390a27f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 11:02:07 +0000 Subject: [PATCH 14/29] Fix camera type issues --- .../things/camera/picamera.py | 20 +++++-------------- .../camera/picamera_recalibrate_utils.py | 18 ++--------------- .../camera/picamera_tuning_file_utils.py | 9 ++++++--- .../things/camera/simulation.py | 15 +++++++------- .../unit_tests/test_picamera_tuning_files.py | 2 +- 5 files changed, 22 insertions(+), 42 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index a8bbf20d..b0886614 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -288,7 +288,7 @@ class StreamingPiCamera2(BaseCamera): "Sharpness": 1, } - _sensor_modes = None + _sensor_modes: Optional[list[SensorMode]] = None @lt.property def sensor_modes(self) -> list[SensorMode]: @@ -363,6 +363,9 @@ class StreamingPiCamera2(BaseCamera): camera_num=self._camera_num, tuning=self.tuning, ) + if self._picamera is None: + # Type narrow (error if failure) + raise RuntimeError("Failed to start Picamera") if check_sensor_model: hw_sensor_model = self._picamera.camera_properties["Model"] if hw_sensor_model != self._sensor_info.sensor_model: @@ -861,7 +864,7 @@ class StreamingPiCamera2(BaseCamera): ] @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). Return the current lens shading correction, as three 2D lists each with @@ -874,19 +877,6 @@ class StreamingPiCamera2(BaseCamera): """ return tf_utils.get_lst(self.tuning) - @lens_shading_tables.setter - def _set_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 def flat_lens_shading(self) -> None: """Disable flat-field correction. diff --git a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py index b5b3c5ff..a11b6c91 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -51,7 +51,6 @@ import numpy as np import picamera2 from picamera2 import Picamera2 from pydantic import BaseModel -from scipy.ndimage import zoom 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)) -def _upsample_channels(grids: np.ndarray, shape: tuple[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]: +def _downsampled_channels(channels: np.ndarray, blacklevel: int) -> np.ndarray: """Generate a downsampled, un-normalised image from which to calculate the LST.""" channel_shape = np.array(channels.shape[1:]) 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) -def _raw_channels_from_camera( - camera: Picamera2, sensor_info: SensorInfo -) -> LensShadingTables: +def _raw_channels_from_camera(camera: Picamera2, sensor_info: SensorInfo) -> np.ndarray: """Acquire a raw image and return a 4xNxM array of the colour channels.""" if camera.started: camera.stop_recording() diff --git a/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py b/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py index 50965f77..c6b4f77d 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_tuning_file_utils.py @@ -20,9 +20,12 @@ CALIBRATED_COLOUR_TEMP = 5000 DEFAULT_COLOUR_TEMP = 1234 -class LensShading(BaseModel): +class LensShadingModel(BaseModel): """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 (12, 16) in size. The arrays are luminance, red-difference chroma (Cr), and 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.""" # Note "alsc" is the Picamera2 term for "Automatic Lens Shading Correction" alsc = find_tuning_algo(tuning, "rpi.alsc") @@ -175,7 +178,7 @@ def get_lst(tuning: dict) -> LensShading: w, h = 16, 12 return [lin[w * i : w * (i + 1)] for i in range(h)] - return LensShading( + return LensShadingModel( luminance=reshape_lst(alsc["luminance_lut"]), Cr=reshape_lst(alsc["calibrations_Cr"][0]["table"]), Cb=reshape_lst(alsc["calibrations_Cb"][0]["table"]), diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index e1630597..f723c669 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -33,7 +33,7 @@ from . import BaseCamera 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 RATIO = (2, 2, 0.2) @@ -203,10 +203,11 @@ class SimulatedCamera(BaseCamera): """ canvas_width, canvas_height, _ = self.canvas_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 = ( - int(pos[0]) - image_width // 2 + self.sample_limits[0] // 2, - int(pos[1]) - image_height // 2 + self.sample_limits[1] // 2, + int(im_pos[0]) - image_width // 2 + self.sample_limits[0] // 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 # canvas edge. @@ -217,7 +218,7 @@ class SimulatedCamera(BaseCamera): # Use npx to make each 1d index list 3D 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: raise ValueError( @@ -251,7 +252,7 @@ class SimulatedCamera(BaseCamera): _traceback: Optional[TracebackType], ) -> None: """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_thread.join() @@ -300,7 +301,7 @@ class SimulatedCamera(BaseCamera): try: frame = self.generate_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)) except Exception as e: diff --git a/tests/unit_tests/test_picamera_tuning_files.py b/tests/unit_tests/test_picamera_tuning_files.py index 4938d8a3..47807766 100644 --- a/tests/unit_tests/test_picamera_tuning_files.py +++ b/tests/unit_tests/test_picamera_tuning_files.py @@ -111,7 +111,7 @@ def test_tuning_lst_tools(imx219_tuning, imx477_tuning): assert not tf_utils.lst_calibrated(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 assert isinstance(lst_model.luminance, list) assert isinstance(lst_model.luminance[0], list) From 7abede0db0806c57838a9fca7532b0d3c249e6b7 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 11:03:45 +0000 Subject: [PATCH 15/29] Fix type errors in remaining Things --- src/openflexure_microscope_server/things/autofocus.py | 2 +- src/openflexure_microscope_server/things/system.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index f46f7ee9..b0b56584 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -264,7 +264,7 @@ class JPEGSharpnessMonitor: """Clean up after context manager is closed.""" 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. This performs exactly one move. Multiple calls of this method diff --git a/src/openflexure_microscope_server/things/system.py b/src/openflexure_microscope_server/things/system.py index df89ca73..ff9da423 100644 --- a/src/openflexure_microscope_server/things/system.py +++ b/src/openflexure_microscope_server/things/system.py @@ -52,7 +52,7 @@ class OpenFlexureSystem(lt.Thing): @microscope_id.setter def _set_microscope_id(self, uuid: UUID) -> None: - self._microscope_id = uuid + self._microscope_id = str(uuid) @lt.property def hostname(self) -> str: From 2701576a2c86d3a8743861ba7f6b43f094678400 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 14:01:37 +0000 Subject: [PATCH 16/29] Properly type the generic Background Detector class --- .../background_detect.py | 87 +++++++++++-------- tests/unit_tests/test_background_detectors.py | 42 +++++---- 2 files changed, 73 insertions(+), 56 deletions(-) diff --git a/src/openflexure_microscope_server/background_detect.py b/src/openflexure_microscope_server/background_detect.py index f42bba95..a7e749ba 100644 --- a/src/openflexure_microscope_server/background_detect.py +++ b/src/openflexure_microscope_server/background_detect.py @@ -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. """ -from typing import Any, Optional +from typing import Any, Generic, Optional, Type, TypeVar import cv2 import numpy as np from pydantic import BaseModel, ConfigDict, Field -from pydantic.errors import PydanticUserError from labthings_fastapi.thing_description import type_to_dataschema +SettingsType = TypeVar("SettingsType", bound=BaseModel) +BackgroundType = TypeVar("BackgroundType", bound=BaseModel) + class MissingBackgroundDataError(RuntimeError): """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.""" - background_data_model: BaseModel = BaseModel + background_data_model: Type[BackgroundType] """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""" def __init__(self) -> None: """Initialise the algorithm settings.""" - try: - self._settings: BaseModel = self.settings_data_model() - except PydanticUserError as e: + if not hasattr(self, "background_data_model") or not hasattr( + self, "settings_data_model" + ): raise NotImplementedError( - "BackgroundDetectAlgorithms must set their own settings data model." - ) from e + "All BackgroundDetectAlgorithm subclesses must set their own settings " + "and background data models." + ) + self._settings: SettingsType = self.settings_data_model() @property def status(self) -> BackgroundDetectorStatus: @@ -86,10 +90,10 @@ class BackgroundDetectAlgorithm: # Requires a getter and a setter to support being a BaseModel but being # saved to file as a dict - _background_data: Optional[BaseModel] = None + _background_data: Optional[BackgroundType] = None @property - def background_data(self) -> Optional[BaseModel]: + def background_data(self) -> Optional[BackgroundType]: """The statistics of the background image.""" bd = self._background_data if bd is None: @@ -97,36 +101,31 @@ class BackgroundDetectAlgorithm: return bd @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. 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 ``self.background_data_model``. """ - try: - if value is None: - self._background_data = None - elif isinstance(value, self.background_data_model): - self._background_data = value - elif isinstance(value, dict): - self._background_data = self.background_data_model(**value) - else: - raise TypeError( - 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 + if value is None: + self._background_data = None + elif isinstance(value, self.background_data_model): + self._background_data = value + elif isinstance(value, dict): + self._background_data = self.background_data_model(**value) + else: + raise TypeError( + f"Cannot set background_data with an object of type {type(value)}" + ) @property - def settings(self) -> BaseModel: + def settings(self) -> SettingsType: """The statistics of the background image.""" return self._settings @settings.setter - def settings(self, value: BaseModel | dict) -> None: + def settings(self, value: SettingsType | dict) -> None: if isinstance(value, self.settings_data_model): self._settings = value 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. This uses an LUV colour space checking only the mean and standard deviation of the @@ -194,8 +198,8 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm): intuitive way. """ - background_data_model: BaseModel = ChannelDistributions - settings_data_model: BaseModel = ColourChannelDetectSettings + background_data_model: Type[ChannelDistributions] = ChannelDistributions + settings_data_model: Type[ColourChannelDetectSettings] = ColourChannelDetectSettings # These are the same as those used for ChannelDeviationLUV. More detail is # provided there. @@ -209,6 +213,10 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm): The image should be in LUV format, the output will be binary with the 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. # Only U and V are used as brightness (L) often changes as # the height of the sample changes. @@ -240,7 +248,7 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm): image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV) 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]: """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. 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 # distributions model - background_data_model: BaseModel = ChannelDistributions - settings_data_model: BaseModel = ColourChannelDetectSettings + background_data_model: Type[ChannelDistributions] = ChannelDistributions + settings_data_model: Type[ColourChannelDetectSettings] = ColourChannelDetectSettings # 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 diff --git a/tests/unit_tests/test_background_detectors.py b/tests/unit_tests/test_background_detectors.py index 764daee0..1484bfdc 100644 --- a/tests/unit_tests/test_background_detectors.py +++ b/tests/unit_tests/test_background_detectors.py @@ -58,34 +58,38 @@ def test_bg_detect_base_class(): BackgroundDetectAlgorithm() -def test_partial_base_class(background_image): - """Create a partial class to initialise the base class and test other methods. - - This test is to check that if the necessary methods are not set, that an - appropriate error is raised. - """ +def test_partial_base_classes(): + """Create a partial classes and check they raise the correct errors.""" 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 - - bad_algo1 = BadAlgo1() - status = bad_algo1.status - assert not status.ready - # Check the settings dictionary can be validated as ``ColourChannelDetectSettings`` - ColourChannelDetectSettings(**status.settings) + settings_data_model = ColourChannelDetectSettings with pytest.raises(NotImplementedError): - # Should error on any dictionary input. This simulates loading settings from - # disk - bad_algo1.background_data = {"key": 1} + BadAlgo1() + + class BadAlgo2(BackgroundDetectAlgorithm): + """Only has a background model so it cannot initialise.""" + + background_data_model = ChannelDistributions 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): - 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): From 2e03ad28c4940edac6253365143e2a9885bbeddf Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 14:26:27 +0000 Subject: [PATCH 17/29] Type fix scan directories --- src/openflexure_microscope_server/scan_directories.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index e0bee445..6770a9cd 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -304,7 +304,14 @@ class ScanDirectoryManager: else: last_matching_scan = sorted(matching_scans)[-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 # based on our zero padding. From 88905905517dd649e10179348e51be130eb377c5 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 14:26:45 +0000 Subject: [PATCH 18/29] Type fix stitching --- .../stitching.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/stitching.py b/src/openflexure_microscope_server/stitching.py index 5a984bed..8eb7be1e 100644 --- a/src/openflexure_microscope_server/stitching.py +++ b/src/openflexure_microscope_server/stitching.py @@ -12,8 +12,7 @@ import shlex import signal import subprocess import threading -from io import TextIOWrapper -from typing import Any, Optional +from typing import IO, Any, Optional import labthings_fastapi as lt @@ -87,7 +86,7 @@ class BaseStitcher: self.min_overlap = round(overlap * 0.9, 2) self.correlation_resize = float(correlation_resize) self._mode = "all" - self._extra_args = [] + self._extra_args: list[str] = [] @property def command(self) -> list[str]: @@ -236,8 +235,8 @@ class FinalStitcher(BaseStitcher): def _process_inputs( self, - overlap: float, - correlation_resize: float, + overlap: Optional[float], + correlation_resize: Optional[float], scan_data_dict: Optional[dict[str, Any]], ) -> tuple[float, float]: """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 # forwarding the stdrerr to stdout - process = subprocess.Popen( + process: subprocess.Popen[str] = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, 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 os.set_blocking(process.stdout.fileno(), False) @@ -320,8 +321,11 @@ class FinalStitcher(BaseStitcher): 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.""" + 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 while process.poll() is None: self.log_buffer(process.stdout) @@ -340,7 +344,7 @@ class FinalStitcher(BaseStitcher): # Print everything in the buffer when program finishes 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.""" # Use rstrip to remove newlines from each line, as logging provides its own # new lines From 18e89aa1486c917dcba20b61436c60d1deed6b89 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 14:46:03 +0000 Subject: [PATCH 19/29] More typing fixes --- .../server/__init__.py | 46 +++++++++++++------ tests/unit_tests/test_server_cli.py | 3 +- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/openflexure_microscope_server/server/__init__.py b/src/openflexure_microscope_server/server/__init__.py index 73cb609c..dfffc067 100644 --- a/src/openflexure_microscope_server/server/__init__.py +++ b/src/openflexure_microscope_server/server/__init__.py @@ -13,7 +13,9 @@ from uvicorn.main import Server import labthings_fastapi as lt 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 ..logging import configure_logging, retrieve_log, retrieve_log_from_file @@ -44,7 +46,9 @@ def set_shutdown_function(shutdown_function: Callable[[], None]) -> None: shutdown_function() 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( @@ -82,21 +86,24 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None: log_config["loggers"]["uvicorn"]["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. - config = None + lt_config = None server = None try: - 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(config) - customise_server(server, log_folder, scans_folder) + lt_config, internal_config = _full_config_from_args(args) + + server = lt.ThingServer.from_config(lt_config) + customise_server( + server, internal_config["log_folder"], internal_config["scans_folder"] + ) def shutdown_call() -> None: try: - # Kill any mjpeg streams so that StreamingResponses close. - server.things["camera"].kill_mjpeg_streams() + camera_thing = server.things["camera"] + if not isinstance(camera_thing, BaseCamera): + raise RuntimeError("Camera thing is not a BaseCamera") + camera_thing.kill_mjpeg_streams() except BaseException as e: # Catch anything and log as it is essential that this # function cannot raise an unhandled exception or Uvicorn @@ -122,7 +129,7 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None: print(f"Error: {e}") # noqa: T201 print("Starting fallback server.") # noqa: T201 app = fallback.app - app.labthings_config = config + app.labthings_config = lt_config app.labthings_server = server app.labthings_error = e uvicorn.run( @@ -135,14 +142,25 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None: 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. + 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 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 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 diff --git a/tests/unit_tests/test_server_cli.py b/tests/unit_tests/test_server_cli.py index dde9879f..32a35b05 100644 --- a/tests/unit_tests/test_server_cli.py +++ b/tests/unit_tests/test_server_cli.py @@ -8,6 +8,7 @@ from fastapi import FastAPI # Import as ofm server to attempt to minimise confusion with server as a var in other # functions and also FastAPI `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 @@ -27,7 +28,7 @@ def test_successful_start(mocker, caplog): mock_server = mocker.Mock() # Create a mock for the camera so we can check the MJPEG streams are closed on # shutdown. - mock_camera = mocker.Mock() + mock_camera = mocker.Mock(spec=BaseCamera) mock_server.things = {"camera": mock_camera} # Mock the LabThings function that returns the server so we have a mock server mocker.patch( From 58aa3df587cf87ca56d8fcf504b41c37dd1a29bd Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 16:46:04 +0000 Subject: [PATCH 20/29] Fix a number of issues with camera_stage_mapping --- .../things/camera_stage_mapping.py | 86 +++++++++++-------- .../things/smart_scan.py | 2 +- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index fd33d7af..7860f938 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -10,16 +10,9 @@ This module is only intended to be called from the OpenFlexure Microscope server, and depends on that server and its underlying LabThings library. """ +import json import time -from typing import ( - Any, - Dict, - List, - Mapping, - NamedTuple, - Optional, - Tuple, -) +from typing import Any, List, Mapping, NamedTuple, Optional, Tuple, cast import numpy as np @@ -35,9 +28,6 @@ from labthings_fastapi.types.numpy import DenumpifyingDict from .camera import BaseCamera from .stage import BaseStage -CoordinateType = Tuple[float, float, float] -XYCoordinateType = Tuple[float, float] - class MoveHistory(NamedTuple): """A named tuple containing the position over time for a single move. @@ -49,7 +39,27 @@ class MoveHistory(NamedTuple): """ 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: @@ -68,21 +78,24 @@ class RecordedMove: be called whenever the instance is called. """ self._stage = stage - self._current_position: Optional[CoordinateType] = None - self._history: List[Tuple[float, Optional[CoordinateType]]] = [] + self._current_position: Optional[tuple[int, int, int]] = None + 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.""" - self._history.append((time.time(), self._current_position)) - self._stage.move_to_xyz_position(xyz_pos=new_position) - self._current_position = new_position - self._history.append((time.time(), self._current_position)) + new_stage_pos = _array_to_stage_tuple(new_position) + starting_pos = self._current_position + if starting_pos is not None: + 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 def history(self) -> MoveHistory: """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] - positions: List[CoordinateType] = [p for t, p in self._history if p is not None] + times: List[float] = [t for t, p in self._history] + positions: List[tuple[int, int, int]] = [p for t, p in self._history] return MoveHistory(times, positions) def clear_history(self) -> None: @@ -109,8 +122,7 @@ class CameraStageMapper(lt.Thing): _cam: BaseCamera = lt.thing_slot() _stage: BaseStage = lt.thing_slot() - @lt.action - def calibrate_1d(self, direction: Tuple[float, float, float]) -> DenumpifyingDict: + def calibrate_1d(self, direction: Tuple[int, int, int]) -> dict: """Move a microscope's stage in 1D, and figure out the relationship with the camera.""" # Record positions and times for stage calibration recorded_move = RecordedMove(self._stage) @@ -140,7 +152,7 @@ class CameraStageMapper(lt.Thing): return result @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. This performs two 1d calibrations in x and y, then combines their results. @@ -170,7 +182,7 @@ class CameraStageMapper(lt.Thing): ) self.logger.info(f"CSM matrix is {csm_as_string}.") - data: Dict[str, dict] = { + data = { "camera_stage_mapping_calibration": cal_xy, "linear_calibration_x": cal_x, "linear_calibration_y": cal_y, @@ -179,7 +191,8 @@ class CameraStageMapper(lt.Thing): "downsampling": downsampling_factor, } - self.last_calibration = DenumpifyingDict(data).model_dump() + data = _serialise_numpy_in_dict(data) + self.last_calibration = data return data @@ -226,13 +239,14 @@ class CameraStageMapper(lt.Thing): """Whether the camera stage mapper needs calibrating.""" return self.image_to_stage_displacement_matrix is None - def assert_calibrated(self) -> None: - """Raise an exception if the image_to_stage_displacement matrix is not set.""" + def assert_calibration(self) -> List[List[float]]: + """Return image_to_stage_displacement matrix or raise error if it's not set.""" if self.image_to_stage_displacement_matrix is None: raise CSMUncalibratedError( "The camera_stage_mapping calibration is not yet available. " "This probably means you need to run the calibration routine." ) + return self.image_to_stage_displacement_matrix @lt.action def move_in_image_coordinates(self, x: float, y: float) -> None: @@ -247,24 +261,26 @@ class CameraStageMapper(lt.Thing): and ``y`` to the shorter one. Checking what shape your chosen toolkit reports for an image usually helps resolve any ambiguity. """ - self.assert_calibrated() - self._stage.move_relative(**self.convert_image_to_stage_coordinates(x=x, y=y)) + self._stage.move_relative( + **self.convert_image_to_stage_coordinates(x=x, y=y), + block_cancellation=False, + ) @lt.action def convert_image_to_stage_coordinates( self, x: float, y: float, **_kwargs: float ) -> Mapping[str, int]: """Convert image coordinates to stage coordinates. Only x and y are returned.""" - self.assert_calibrated() - return csm_img_to_stage(self.image_to_stage_displacement_matrix, x=x, y=y) + csm_matrix = self.assert_calibration() + return csm_img_to_stage(csm_matrix, x=x, y=y) @lt.action def convert_stage_to_image_coordinates( self, x: int, y: int, **_kwargs: int ) -> Mapping[str, float]: """Convert stage coordinates to image coordinates. Only x and y are returned.""" - self.assert_calibrated() - return csm_stage_to_img(self.image_to_stage_displacement_matrix, x=x, y=y) + csm_matrix = self.assert_calibration() + return csm_stage_to_img(csm_matrix, x=x, y=y) @lt.property def thing_state(self) -> Mapping[str, Any]: diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 06d2b6b6..47298a82 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -231,7 +231,7 @@ class SmartScanThing(lt.Thing): Raise warning if not using background detect that scan will go on until max steps reached """ - self._csm.assert_calibrated() + self._csm.assert_calibration() if self.skip_background: if not self._cam.background_detector_status.ready: From 141ccc06c19aee502afb0c151fabc25c3c3e1e0d Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 16:46:30 +0000 Subject: [PATCH 21/29] Some minor type fixes --- src/openflexure_microscope_server/logging.py | 5 +++-- src/openflexure_microscope_server/server/legacy_api.py | 7 ++++++- .../things/camera/__init__.py | 6 +++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/logging.py b/src/openflexure_microscope_server/logging.py index 5007c8f8..a32061b7 100644 --- a/src/openflexure_microscope_server/logging.py +++ b/src/openflexure_microscope_server/logging.py @@ -14,12 +14,13 @@ output to STDOUT/STDERR are captured by ``systemd``. These can be viewed by usin import logging import os from logging.handlers import RotatingFileHandler +from typing import Optional from fastapi import HTTPException from fastapi.responses import PlainTextResponse LOGGER = logging.getLogger(__name__) -OFM_LOG_FILE = None +OFM_LOG_FILE: Optional[str] = None def configure_logging(log_folder: str) -> None: @@ -128,7 +129,7 @@ class OFMHandler(logging.Handler): how many can be returned over HTTP. """ super().__init__(level=level) - self._log = [] + self._log: list[str] = [] self._max_logs = max_logs def append_record(self, record: logging.LogRecord) -> None: diff --git a/src/openflexure_microscope_server/server/legacy_api.py b/src/openflexure_microscope_server/server/legacy_api.py index 7d0e7265..69996f52 100644 --- a/src/openflexure_microscope_server/server/legacy_api.py +++ b/src/openflexure_microscope_server/server/legacy_api.py @@ -6,6 +6,8 @@ from fastapi import Response import labthings_fastapi as lt +from openflexure_microscope_server.things.camera import BaseCamera + FAKE_ROUTES = [ "/api/v2/", "/api/v2/streams/snapshot", @@ -38,7 +40,10 @@ def add_v2_endpoints(thing_server: lt.ThingServer) -> None: @app.head("/api/v2/streams/snapshot") async def thumbnail() -> JPEGResponse: """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) @app.get("/api/v2/instrument/settings/name") diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 455459cf..7592de1d 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -272,7 +272,7 @@ class BaseCamera(lt.Thing): @lt.action def capture_jpeg( self, - stream_name: str = "main", + stream_name: Literal["main", "lores", "full"] = "main", wait: Optional[float] = None, ) -> JPEGBlob: """Acquire one image from the camera as a JPEG. @@ -526,7 +526,7 @@ class BaseCamera(lt.Thing): nothing is returned on success """ 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: # Per PIL documentation, # (https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg) @@ -537,7 +537,7 @@ class BaseCamera(lt.Thing): # disabled, file size increases and quality is barely or not affected image.save(jpeg_path, quality=95, subsampling=0) try: - self._add_metadata_to_capture(jpeg_path, metadata) + self._add_metadata_to_capture(jpeg_path, dict(metadata)) except Exception: # We need to capture any exception as there are many reasons metadata # might not be added. We warn rather than log the error. From c1ad445517615eca4fb54c8d6f6c2f75fafae61d Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 16:52:47 +0000 Subject: [PATCH 22/29] Fix some final type issues in Autofocus --- .../things/autofocus.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index b0b56584..b1165888 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -148,7 +148,7 @@ class CaptureInfo: """The information from a capture in a z_stack.""" buffer_id: int - position: dict[str, int] + position: Mapping[str, int] sharpness: int @property @@ -426,7 +426,7 @@ class AutofocusThing(lt.Thing): while attempt < 10: attempt += 1 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) # Always start centrally for future runs @@ -612,7 +612,7 @@ class AutofocusThing(lt.Thing): def reset_stack( self, - initial_z_pos: list[int], + initial_z_pos: int, autofocus_dz: int, ) -> None: """Return to the initial z position and run a looping autofocus. @@ -629,7 +629,7 @@ class AutofocusThing(lt.Thing): def save_stack( self, sharpest_id: int, - captures: list[list], + captures: list[CaptureInfo], stack_parameters: StackParams, ) -> int: """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 # Better to start too low and take too many images than too high and need to refocus self._stage.move_relative( - z=-( + z=-int( stack_parameters.steps_undershoot + stack_parameters.backlash_correction + stack_parameters.stack_z_range / 2 @@ -689,7 +689,7 @@ class AutofocusThing(lt.Thing): ) 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 # stack so check is fair ims_to_check = slice(-stack_parameters.min_images_to_test, None) From 5d25067a211747f39470ae4cae210c8c427ba351 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 19 Dec 2025 17:04:18 +0000 Subject: [PATCH 23/29] Update CI to fail if MyPy fails but to still give code quality report --- .gitignore | 2 ++ .gitlab-ci.yml | 9 ++++++--- pyproject.toml | 1 - 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index b78e7d7d..d51d9650 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ __pycache__/ # Mypy cache .mypy_cache* .dmypy.json +mypy/ +mypy-out.txt # C extensions *.so diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b1ab0055..f9dda30d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -95,12 +95,15 @@ mypy: extends: .python script: - 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 || true + - mypy src --cobertura-xml-report mypy --no-error-summary > mypy-out.txt + 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 - allow_failure: true + - cat mypy-out.txt artifacts: + when: always reports: codequality: codequality.json coverage_report: diff --git a/pyproject.toml b/pyproject.toml index 5573e309..d3d48bee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ dev = [ "ruff~=0.13.0", "mypy[reports]", - "mypy-gitlab-code-quality", # "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings "pytest", "pytest-cov", From e85a1d30802c699dc23817d303ca591f90c47102 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sat, 20 Dec 2025 16:23:01 +0000 Subject: [PATCH 24/29] Final test fixes --- tests/unit_tests/test_legacy_api.py | 3 ++- tests/unit_tests/test_server_cli.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/test_legacy_api.py b/tests/unit_tests/test_legacy_api.py index b040d064..78ca9915 100644 --- a/tests/unit_tests/test_legacy_api.py +++ b/tests/unit_tests/test_legacy_api.py @@ -6,13 +6,14 @@ from socket import gethostname # Import as ofm server to attempt to minimise confusion with server as a var in other # functions and also FastAPI `Server`. from openflexure_microscope_server.server import legacy_api +from openflexure_microscope_server.things.camera import BaseCamera def test_v2_endpoints(mocker): """Check that the expected v2 endpoints are added.""" mock_server = mocker.Mock() # 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( return_value="Mock Frame" ) diff --git a/tests/unit_tests/test_server_cli.py b/tests/unit_tests/test_server_cli.py index 32a35b05..079cdf15 100644 --- a/tests/unit_tests/test_server_cli.py +++ b/tests/unit_tests/test_server_cli.py @@ -10,7 +10,7 @@ from fastapi import FastAPI 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(): @@ -41,7 +41,7 @@ def test_successful_start(mocker, caplog): mock_uvicorn_run = mocker.patch("openflexure_microscope_server.server.uvicorn.run") # 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 assert mock_customise.call_count == 1 @@ -91,10 +91,10 @@ def test_failed_customise(mocker): # Running the mock CLI will error 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 - 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 # Get the fallback app passed to uvicorn tun From b46f8af8e70be616020d2c6ac6eb864a15c0ee03 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 9 Jan 2026 16:43:14 +0000 Subject: [PATCH 25/29] Bump labthings-fastapi to 0.0.14 and fix error types in unit tests --- pyproject.toml | 2 +- tests/unit_tests/test_stage.py | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d3d48bee..d70dbb2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "labthings-fastapi==0.0.13", + "labthings-fastapi==0.0.14", "sangaboard", "camera-stage-mapping ~= 0.1.10", "opencv-python ~= 4.11.0", diff --git a/tests/unit_tests/test_stage.py b/tests/unit_tests/test_stage.py index 65b6ab63..aeedc0dc 100644 --- a/tests/unit_tests/test_stage.py +++ b/tests/unit_tests/test_stage.py @@ -3,7 +3,6 @@ import itertools import pytest -from httpx import HTTPStatusError from hypothesis import HealthCheck, given, settings 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} # 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} - # Read only should set a 405 error code ... - assert excinfo.value.response.status_code == 405 # ... and should not modify the initial value 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 with pytest.raises(KeyError): dummy_stage.invert_axis_direction(axis="theta") - # But a 422 over HTTP - with pytest.raises(HTTPStatusError) as excinfo: + # But a FailedToInvokeActionError over HTTP + with pytest.raises(lt.exceptions.FailedToInvokeActionError): stage_client.invert_axis_direction(axis="theta") - assert excinfo.value.response.status_code == 422 def _test_move_relative(dummy_stage, axis_inverted, path): From 3db83534e8e7a68eda4dbf560820dda09c714063 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 9 Jan 2026 17:17:52 +0000 Subject: [PATCH 26/29] Update for how Labthings-Fastapi 0.0.14 impements the fallback server --- .../server/__init__.py | 21 +++++++++++++++---- tests/unit_tests/test_server_cli.py | 2 +- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/server/__init__.py b/src/openflexure_microscope_server/server/__init__.py index dfffc067..6d2a70ac 100644 --- a/src/openflexure_microscope_server/server/__init__.py +++ b/src/openflexure_microscope_server/server/__init__.py @@ -18,7 +18,12 @@ 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 ..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 .serve_static_files import add_static_files @@ -128,10 +133,18 @@ def serve_from_cli(argv: Optional[list[str]] = None) -> None: # presented in the fallback logs. print(f"Error: {e}") # 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.labthings_config = lt_config - app.labthings_server = server - app.labthings_error = e + app.set_context( + fallback.FallbackContext( + server=server, config=lt_config, error=e, log_history=log_history + ) + ) uvicorn.run( app, host=args.host, diff --git a/tests/unit_tests/test_server_cli.py b/tests/unit_tests/test_server_cli.py index 079cdf15..7901fc69 100644 --- a/tests/unit_tests/test_server_cli.py +++ b/tests/unit_tests/test_server_cli.py @@ -102,4 +102,4 @@ def test_failed_customise(mocker): # Check it really is a fastapi assert isinstance(fallback_app, FastAPI) # 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" From 560ca6e9fe4aa76c21b2a9f5a0a47325803f2a65 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sat, 10 Jan 2026 16:55:57 +0000 Subject: [PATCH 27/29] Tweak a docstring as the variables out of date --- src/openflexure_microscope_server/ui.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/ui.py b/src/openflexure_microscope_server/ui.py index 58641a4f..d6c68290 100644 --- a/src/openflexure_microscope_server/ui.py +++ b/src/openflexure_microscope_server/ui.py @@ -50,8 +50,11 @@ class ActionButton(BaseModel): def action_button_for(thing: lt.Thing, action_name: str, **kwargs: Any) -> ActionButton: """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``. + :return: An ActionButton (Pydantic Model) object with all the information the + webapp needs to create the action button. """ return ActionButton(thing=thing.name, action=action_name, **kwargs) From 06cab8a6a4d5b9c098b02b7f55139e3661ab81a6 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 12 Jan 2026 14:56:10 +0000 Subject: [PATCH 28/29] Use type[] not Type[] in typehints, and add clarification comment. --- .../background_detect.py | 14 +++++++------- .../things/camera/__init__.py | 3 +++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/openflexure_microscope_server/background_detect.py b/src/openflexure_microscope_server/background_detect.py index a7e749ba..37c08aa4 100644 --- a/src/openflexure_microscope_server/background_detect.py +++ b/src/openflexure_microscope_server/background_detect.py @@ -5,7 +5,7 @@ for analysis. Information from these images is used to detect whether an image f current camera field of view contains sample. """ -from typing import Any, Generic, Optional, Type, TypeVar +from typing import Any, Generic, Optional, TypeVar import cv2 import numpy as np @@ -61,9 +61,9 @@ class BackgroundDetectorStatus(BaseModel): class BackgroundDetectAlgorithm(Generic[SettingsType, BackgroundType]): """The base class for defining background detect algorithms.""" - background_data_model: Type[BackgroundType] + background_data_model: type[BackgroundType] """The data model of the background data. This must be set by child classes""" - settings_data_model: Type[SettingsType] + settings_data_model: type[SettingsType] """The data model of algorithm settings. This must be set by child classes""" def __init__(self) -> None: @@ -198,8 +198,8 @@ class ColourChannelDetectLUV( intuitive way. """ - background_data_model: Type[ChannelDistributions] = ChannelDistributions - settings_data_model: Type[ColourChannelDetectSettings] = ColourChannelDetectSettings + background_data_model: type[ChannelDistributions] = ChannelDistributions + settings_data_model: type[ColourChannelDetectSettings] = ColourChannelDetectSettings # These are the same as those used for ChannelDeviationLUV. More detail is # provided there. @@ -297,8 +297,8 @@ class ChannelDeviationLUV( # Note we don't use the means in this algorithm but we use the same channel # distributions model - background_data_model: Type[ChannelDistributions] = ChannelDistributions - settings_data_model: Type[ColourChannelDetectSettings] = ColourChannelDetectSettings + background_data_model: type[ChannelDistributions] = ChannelDistributions + settings_data_model: type[ColourChannelDetectSettings] = ColourChannelDetectSettings # 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 diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 7592de1d..43869872 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -210,6 +210,9 @@ class BaseCamera(lt.Thing): :param main_resolution: the resolution to use for the main stream. :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( "CameraThings must define their own start_streaming method" From 426692312370f5b91a442447a0598a26eb56a19f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 12 Jan 2026 15:33:13 +0000 Subject: [PATCH 29/29] Update picamera coverage zip --- picamera_coverage.zip | Bin 54038 -> 54038 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/picamera_coverage.zip b/picamera_coverage.zip index a227c5ac7832e6d3e68c8d97d47c4a59d2be513b..205de81504155e1428a78ff38e84125c691facc8 100644 GIT binary patch delta 766 zcmbQXjCtBJW}yIYW)=|!5D>4?iTQqXcg02_%?93s{5$!h`R?;A;Vb1!+UzLc#aA!L z!NSO?!n%s*_O{&HEC)8^&S2JMXJO=w;W!jMiSdJvM>~T`fbwq!h6_`l?_~MM!JzPg zv*=klG*3=BMPK0M~+XJEV3vRRlx zt)cyZ`O^Dd3jZ#}3#v6VPh(hc<1k|tYdtF?XJZK)6T`3j@7dNRrzKVz{gIq;(1=0) zTtUk=)*WiME-(K3SL#pgult$n4@%t-V7ONu%h#00%dnsI9wS4*f&c$(Ev##9wZ$_t zF#JhoX1H>JA>jtYfjYJp`z!{D0}YIK9iJG@DEUb*2 z^^F$HOux#xH|ZEVPgG)fz{|kE&~bnMw*c$;k=ZA)CU$fMg|5B1_cHMh6kJm8Vn2!Obowl znEpv`)|?>dzyXRsmZ+)ECp%q8(=ai%FtfBUH!?IxGcz_wGBh!@NJ%kDOi4DkG`27@ zH!wCgF-=XeNSl1&f{VI=g@u`!p^2G+p{1EgiiweFqG?i^L1L1Txv80jiA9=ml37ZM zS<2)K7i=b%UzF5HG&D>yOSUvLH%&A$HZ?XeGDu7`H%duPG)PV|H88O>OH58oOii@} zYxr_evOd`?$t2C(JSElA#3I$i$ig@+#l$el&@3q_)gam2#Kh3p%*@!p$Wlp1K}jz; zzbv&VF+EkUATv2JH?=5H$x1;fEyd8p#K6?ZG$ke3$il$VAl1ksF~uk?G0g&GOp-yG hfu)&|xq(t`fHxzP2s28OW~`Xpd&vS3KxZ#`0sz6~_j&*T delta 766 zcmbQXjCtBJW}yIYW)=|!5cs}rQnY9H$*CKKG#hvi^Y7-5<$J)lgs+q@X|tn%7hk;~ z2MZ&o3hOGK+uL$)vn1RwJHx2U&cetU!*M8j65~A1mjVqeSt;@i47)zrOg|vc$iOl$ zL5_h#j$!tL2b_Ekv-_B=*jN}j%h;Jfw!Nsmxh?ned3zyt1_p^6504AWG4L$mOjl>{ zVc>7f_u4zD6k%T74V!Fi4m&Y?#mZuwROS%^-ond3=9k|4;Tz~G=#HA7%(t3986%AZs0v|^)gdKF5``M zRuuzA1BTg*3FTz+{oS_d`tQVbA&mQz=3&ErU?lNA`A>13TgY zez?C`bAq4)2PpnN>YZ~tYb!_dOQ)HF5O$kM_%**G=L$lS!(Aki{0B{|j7DAB;e z(!|ozG}*+!Wb%azF6w4RhRGHd7A7V}76z#XNol4@rY2@)$>t_$M#e^oiHQcLi54cQ zrjsvRu$f$bQBotxG|4#GG%d{{(a_K$Da|z1GR@4=(mdHPDbd`}Bq`C@G%?vIHQ4~H z;mbwIdQ$^SGt)G4b5jd*VvM+5m4xCJ|