Start fixing some issues found by MyPy

This commit is contained in:
Julian Stirling 2025-12-17 20:04:16 +00:00
parent ff976e7187
commit 99f3d2d311
4 changed files with 99 additions and 57 deletions

View file

@ -20,7 +20,6 @@ from typing import Any, Literal, Mapping, Optional, Tuple
import numpy as np import numpy as np
import piexif import piexif
from PIL import Image from PIL import Image
from pydantic import RootModel
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
@ -46,12 +45,6 @@ class PNGBlob(lt.blob.Blob):
media_type: str = "image/png" media_type: str = "image/png"
class ArrayModel(RootModel):
"""A model for an array."""
root: NDArray
class CaptureError(RuntimeError): class CaptureError(RuntimeError):
"""An error trying to capture from a CameraThing.""" """An error trying to capture from a CameraThing."""
@ -255,7 +248,7 @@ class BaseCamera(lt.Thing):
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "main", stream_name: Literal["main", "lores", "raw", "full"] = "main",
wait: Optional[float] = 5, wait: Optional[float] = 5,
) -> ArrayModel: ) -> NDArray:
"""Acquire one image from the camera and return as an array.""" """Acquire one image from the camera and return as an array."""
raise NotImplementedError( raise NotImplementedError(
"CameraThings must define their own capture_array method" "CameraThings must define their own capture_array method"
@ -265,7 +258,7 @@ class BaseCamera(lt.Thing):
"""The downsampling factor when calling capture_downsampled_array.""" """The downsampling factor when calling capture_downsampled_array."""
@lt.action @lt.action
def capture_downsampled_array(self) -> ArrayModel: def capture_downsampled_array(self) -> NDArray:
"""Acquire one image from the camera, downsample, and return as an array. """Acquire one image from the camera, downsample, and return as an array.
* The array is downsamples by the thing property `downsampled_array_factor`. * The array is downsamples by the thing property `downsampled_array_factor`.
@ -334,7 +327,7 @@ class BaseCamera(lt.Thing):
def grab_as_array( def grab_as_array(
self, self,
stream_name: Literal["main", "lores"] = "main", stream_name: Literal["main", "lores"] = "main",
) -> ArrayModel: ) -> NDArray:
"""Acquire one image from the preview stream and return as an array. """Acquire one image from the preview stream and return as an array.
It works like ``grab_jpeg`` but reliably handles broken streams. Prefer using It works like ``grab_jpeg`` but reliably handles broken streams. Prefer using

View file

@ -36,6 +36,7 @@ from pydantic import BaseModel, BeforeValidator
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.exceptions import ServerNotRunningError from labthings_fastapi.exceptions import ServerNotRunningError
from labthings_fastapi.types.numpy import NDArray
from openflexure_microscope_server.background_detect import ChannelBlankError from openflexure_microscope_server.background_detect import ChannelBlankError
from openflexure_microscope_server.ui import ( from openflexure_microscope_server.ui import (
@ -45,7 +46,7 @@ from openflexure_microscope_server.ui import (
property_control_for, property_control_for,
) )
from . import ArrayModel, BaseCamera from . import BaseCamera
from . import picamera_recalibrate_utils as recalibrate_utils from . import picamera_recalibrate_utils as recalibrate_utils
from . import picamera_tuning_file_utils as tf_utils from . import picamera_tuning_file_utils as tf_utils
@ -573,7 +574,7 @@ class StreamingPiCamera2(BaseCamera):
self, self,
stream_name: Literal["main", "lores", "raw", "full"] = "main", stream_name: Literal["main", "lores", "raw", "full"] = "main",
wait: Optional[float] = 0.9, wait: Optional[float] = 0.9,
) -> ArrayModel: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array.
This function will produce a nested list containing an uncompressed RGB image. This function will produce a nested list containing an uncompressed RGB image.

View file

@ -19,6 +19,7 @@ import numpy as np
from PIL import Image, ImageFilter from PIL import Image, ImageFilter
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray
from openflexure_microscope_server.ui import ( from openflexure_microscope_server.ui import (
ActionButton, ActionButton,
@ -28,7 +29,7 @@ from openflexure_microscope_server.ui import (
) )
from ..stage.dummy import DummyStage from ..stage.dummy import DummyStage
from . import ArrayModel, BaseCamera from . import BaseCamera
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -317,7 +318,7 @@ class SimulatedCamera(BaseCamera):
self, self,
stream_name: Literal["main", "full"] = "full", stream_name: Literal["main", "full"] = "full",
wait: Optional[float] = None, wait: Optional[float] = None,
) -> ArrayModel: ) -> NDArray:
"""Acquire one image from the camera and return as an array. """Acquire one image from the camera and return as an array.
This function will produce a nested list containing an uncompressed RGB image. This function will produce a nested list containing an uncompressed RGB image.

View file

@ -116,13 +116,63 @@ class SmartScanThing(lt.Thing):
self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder) self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder)
self._scan_lock = threading.Lock() self._scan_lock = threading.Lock()
# Variables set by the scan # Variables set by the scan
self._latest_scan_name: Optional[str] = None _stack_params: Optional[StackParams] = None
self._stack_params: Optional[StackParams] = None @property
self._ongoing_scan: Optional[scan_directories.ScanDirectory] = None def stack_params(self) -> StackParams:
self._scan_data: Optional[scan_directories.ScanData] = None """The parameters for z-stacking during the onging scan.
self._preview_stitcher: Optional[stitching.PreviewStitcher] = None
Only read this property is a scan is ongoing or it will raise an error.
"""
if self._stack_params is None:
raise RuntimeError("Cannot get stack parameters as they are not set.")
return self._stack_params
_ongoing_scan: Optional[scan_directories.ScanDirectory] = None
@property
def ongoing_scan(self) -> scan_directories.ScanDirectory:
"""The ScanDirectory object of the ongoing scan.
Only read this property is a scan is ongoing or it will raise an error.
"""
if self._ongoing_scan is None:
raise ScanNotRunningError("Cannot get ongoing scan if scan is not running.")
return self._ongoing_scan
_scan_data: Optional[scan_directories.ScanData] = None
@property
def scan_data(self) -> scan_directories.ScanData:
"""The ScanData object jolding information about the of the ongoing scan.
Only read this property is a scan is ongoing or it will raise an error.
"""
if self._scan_data is None:
raise ScanNotRunningError("Cannot get scan data if scan is not running.")
return self._scan_data
_preview_stitcher: Optional[stitching.PreviewStitcher] = None
@property
def preview_stitcher(self) -> stitching.PreviewStitcher:
"""The PreviewStitcher object for stitching live previews.
Only read this property is a scan is ongoing or it will raise an error.
"""
if self._preview_stitcher is None:
raise ScanNotRunningError(
"No preview stitcher agailable as scan is not running."
)
return self._preview_stitcher
_latest_scan_name: Optional[str] = None
@lt.property
def latest_scan_name(self) -> Optional[str]:
"""The name of the last scan to be started."""
return self._latest_scan_name
@lt.action @lt.action
def sample_scan(self, scan_name: str = "") -> None: def sample_scan(self, scan_name: str = "") -> None:
@ -143,7 +193,7 @@ class SmartScanThing(lt.Thing):
try: try:
self._check_background_and_csm_set() self._check_background_and_csm_set()
self._ongoing_scan = self._scan_dir_manager.new_scan_dir(scan_name) self._ongoing_scan = self._scan_dir_manager.new_scan_dir(scan_name)
self._latest_scan_name = self._ongoing_scan.name self._latest_scan_name = self.ongoing_scan.name
self._autofocus.looping_autofocus(dz=self.autofocus_dz, start="centre") self._autofocus.looping_autofocus(dz=self.autofocus_dz, start="centre")
self._run_scan() self._run_scan()
except Exception as e: except Exception as e:
@ -200,11 +250,6 @@ class SmartScanThing(lt.Thing):
"of motion." "of motion."
) )
@lt.property
def latest_scan_name(self) -> Optional[str]:
"""The name of the last scan to be started."""
return self._latest_scan_name
@_scan_running @_scan_running
def _move_to_next_point( def _move_to_next_point(
self, next_point: tuple[int, int], z_estimate: Optional[int] = None self, next_point: tuple[int, int], z_estimate: Optional[int] = None
@ -295,7 +340,7 @@ class SmartScanThing(lt.Thing):
# Fix scan parameters in case UI is updated during scan. # Fix scan parameters in case UI is updated during scan.
return scan_directories.ScanData( return scan_directories.ScanData(
scan_name=self._ongoing_scan.name, scan_name=self.ongoing_scan.name,
starting_position=starting_position, starting_position=starting_position,
overlap=overlap, overlap=overlap,
max_dist=self.max_range, max_dist=self.max_range,
@ -317,16 +362,16 @@ class SmartScanThing(lt.Thing):
Takes scan_result, a string that is either "success", "cancelled by user", Takes scan_result, a string that is either "success", "cancelled by user",
or the error that ended the scan. or the error that ended the scan.
""" """
self._scan_data.set_final_data(result=scan_result) self.scan_data.set_final_data(result=scan_result)
self._ongoing_scan.save_scan_data(self._scan_data) self.ongoing_scan.save_scan_data(self._scan_data)
@_scan_running @_scan_running
def _manage_stitching_threads(self) -> None: def _manage_stitching_threads(self) -> None:
"""Manage the stitching threads, starting them if needed and not already running.""" """Manage the stitching threads, starting them if needed and not already running."""
# Assume 4 images means at least one offset in x and y, making the stitching # Assume 4 images means at least one offset in x and y, making the stitching
# well constrained. # well constrained.
if self._scan_data.image_count > 3 and not self._preview_stitcher.running: if self.scan_data.image_count > 3 and not self.preview_stitcher.running:
self._preview_stitcher.start() self.preview_stitcher.start()
@_scan_running @_scan_running
def _run_scan(self) -> None: def _run_scan(self) -> None:
@ -339,16 +384,16 @@ class SmartScanThing(lt.Thing):
try: try:
self._cam.start_streaming(main_resolution=(3280, 2464)) self._cam.start_streaming(main_resolution=(3280, 2464))
self._scan_data = self._collect_scan_data() self._scan_data = self._collect_scan_data()
self._ongoing_scan.save_scan_data(self._scan_data) self.ongoing_scan.save_scan_data(self._scan_data)
self._stack_params = self._autofocus.create_stack_params( self._stack_params = self._autofocus.create_stack_params(
images_dir=self._ongoing_scan.images_dir, images_dir=self.ongoing_scan.images_dir,
autofocus_dz=self.autofocus_dz, autofocus_dz=self.autofocus_dz,
save_resolution=self._scan_data.save_resolution, save_resolution=self.scan_data.save_resolution,
) )
self._preview_stitcher = stitching.PreviewStitcher( self._preview_stitcher = stitching.PreviewStitcher(
self._ongoing_scan.images_dir, self.ongoing_scan.images_dir,
overlap=self._scan_data.overlap, overlap=self.scan_data.overlap,
correlation_resize=self._scan_data.correlation_resize, correlation_resize=self.scan_data.correlation_resize,
) )
# This is the main loop of the scan! # This is the main loop of the scan!
@ -392,9 +437,9 @@ class SmartScanThing(lt.Thing):
# have multiple starting positions, each of which will be visited before the # have multiple starting positions, each of which will be visited before the
# scan can end. # scan can end.
planner_settings = { planner_settings = {
"dx": self._scan_data.dx, "dx": self.scan_data.dx,
"dy": self._scan_data.dy, "dy": self.scan_data.dy,
"max_dist": self._scan_data.max_dist, "max_dist": self.scan_data.max_dist,
} }
route_planner = scan_planners.SmartSpiral( route_planner = scan_planners.SmartSpiral(
initial_position=(self._stage.position["x"], self._stage.position["y"]), initial_position=(self._stage.position["x"], self._stage.position["y"]),
@ -419,7 +464,7 @@ class SmartScanThing(lt.Thing):
capture_image = True capture_image = True
# If skipping background, take an image to check if current field of view is background # If skipping background, take an image to check if current field of view is background
if self._scan_data.skip_background: if self.scan_data.skip_background:
capture_image, bg_message = self._cam.image_is_sample() capture_image, bg_message = self._cam.image_is_sample()
if not capture_image: if not capture_image:
@ -432,22 +477,22 @@ class SmartScanThing(lt.Thing):
focused, focused_height = self._autofocus.run_smart_stack( focused, focused_height = self._autofocus.run_smart_stack(
stack_parameters=self._stack_params, stack_parameters=self._stack_params,
save_on_failure=not self._scan_data.skip_background, save_on_failure=not self.scan_data.skip_background,
) )
current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focused_height) current_pos_xyz = (new_pos_xyz[0], new_pos_xyz[1], focused_height)
# An image was captured if we are focussed or we are not skipping background. # An image was captured if we are focussed or we are not skipping background.
imaged = focused or not self._scan_data.skip_background imaged = focused or not self.scan_data.skip_background
route_planner.mark_location_visited( route_planner.mark_location_visited(
current_pos_xyz, imaged=imaged, focused=focused current_pos_xyz, imaged=imaged, focused=focused
) )
# increment capture counter as thread has completed # increment capture counter as thread has completed
self._scan_data.image_count += 1 self.scan_data.image_count += 1
# Add it to the incremental zip # Add it to the incremental zip
self._ongoing_scan.zip_files() self.ongoing_scan.zip_files()
@_scan_running @_scan_running
def _return_to_starting_position(self) -> None: def _return_to_starting_position(self) -> None:
@ -455,7 +500,7 @@ class SmartScanThing(lt.Thing):
self.logger.info("Returning to starting position.") self.logger.info("Returning to starting position.")
if self._scan_data is not None: if self._scan_data is not None:
self._stage.move_absolute( self._stage.move_absolute(
**self._scan_data.starting_position, block_cancellation=True **self.scan_data.starting_position, block_cancellation=True
) )
@property @property
@ -463,28 +508,30 @@ class SmartScanThing(lt.Thing):
"""Return a metadata dict for ongoing scan to populate.""" """Return a metadata dict for ongoing scan to populate."""
if self._ongoing_scan is None: if self._ongoing_scan is None:
return {} return {}
return {"scan_name": self._ongoing_scan.name} return {"scan_name": self.ongoing_scan.name}
@_scan_running @_scan_running
def _perform_final_stitch(self) -> None: def _perform_final_stitch(self) -> None:
"""Update the scan zip and perform final stitch of the data.""" """Update the scan zip and perform final stitch of the data."""
if self._scan_data.image_count <= 3: if self.scan_data.image_count <= 3:
self.logger.info("Not performing a stitch as 3 or fewer images taken") self.logger.info("Not performing a stitch as 3 or fewer images taken")
return return
self._ongoing_scan.zip_files() self.ongoing_scan.zip_files()
self.logger.info("Waiting for background processes to finish...") self.logger.info("Waiting for background processes to finish...")
# Actually check the sticher exists rather than using self.preview_sticher as
# this method can be called during exception handling.
if self._preview_stitcher is not None: if self._preview_stitcher is not None:
self._preview_stitcher.wait() self._preview_stitcher.wait()
if self._scan_data.stitch_automatically: if self.scan_data.stitch_automatically:
self.logger.info("Stitching final image (may take some time)...") self.logger.info("Stitching final image (may take some time)...")
self.stitch_scan( self.stitch_scan(
scan_name=self._ongoing_scan.name, scan_name=self.ongoing_scan.name,
correlation_resize=self._scan_data.correlation_resize, correlation_resize=self.scan_data.correlation_resize,
overlap=self._scan_data.overlap, overlap=self.scan_data.overlap,
) )
@lt.endpoint( @lt.endpoint(
@ -540,7 +587,7 @@ class SmartScanThing(lt.Thing):
_scan_dir_manager.all_scans_info() directly as it will handle stopping the json _scan_dir_manager.all_scans_info() directly as it will handle stopping the json
in any ongoing scans being read. in any ongoing scans being read.
""" """
ongoing_name = None if self._ongoing_scan is None else self._ongoing_scan.name ongoing_name = None if self._ongoing_scan is None else self.ongoing_scan.name
return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name) return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name)
@lt.property @lt.property
@ -555,7 +602,7 @@ class SmartScanThing(lt.Thing):
""" """
return ScanListInfo( return ScanListInfo(
scans=self._get_all_scan_info(), scans=self._get_all_scan_info(),
ongoing=None if self._ongoing_scan is None else self._ongoing_scan.name, ongoing=None if self._ongoing_scan is None else self.ongoing_scan.name,
) )
@lt.endpoint( @lt.endpoint(
@ -632,7 +679,7 @@ class SmartScanThing(lt.Thing):
This is a wrapper around scan manager's delete_scan that logs to the This is a wrapper around scan manager's delete_scan that logs to the
things logger if there is a problem. things logger if there is a problem.
""" """
if self._ongoing_scan is not None and scan_name == self._ongoing_scan.name: if self._ongoing_scan is not None and scan_name == self.ongoing_scan.name:
self.logger.error("Attempted to delete ongoing scan.") self.logger.error("Attempted to delete ongoing scan.")
return False return False
try: try:
@ -648,7 +695,7 @@ class SmartScanThing(lt.Thing):
@property @property
def latest_preview_stitch_path(self) -> Optional[str]: def latest_preview_stitch_path(self) -> Optional[str]:
"""The path of the latest preview stitched image, or None if not available.""" """The path of the latest preview stitched image, or None if not available."""
if not self.latest_scan_name: if self.latest_scan_name is None:
return None return None
return self._scan_dir_manager.get_file_path_from_img_dir( return self._scan_dir_manager.get_file_path_from_img_dir(