Add punctuation to docstrings

This commit is contained in:
Julian Stirling 2025-07-10 02:03:02 +01:00
parent 4dc41bb008
commit 80beeea07b
34 changed files with 232 additions and 235 deletions

View file

@ -18,7 +18,7 @@ logging.basicConfig(level=logging.DEBUG)
def _test_exposure_time_drift(desired_time): def _test_exposure_time_drift(desired_time):
"""Capture 10 full resolution images and check that the exposure time remains constant """Capture 10 full resolution images and check that the exposure time remains constant.
This confirms that automatic exposure time adjustment is fully turned off This confirms that automatic exposure time adjustment is fully turned off
""" """

View file

@ -12,7 +12,7 @@ logging.basicConfig(level=logging.DEBUG)
def test_sensor_mode(): def test_sensor_mode():
"""Test capturing raw arrays in two different sensor modes""" """Test capturing raw arrays in two different sensor modes."""
cam = StreamingPiCamera2() cam = StreamingPiCamera2()
server = ThingServer() server = ThingServer()
server.add_thing(cam, "/camera/") server.add_thing(cam, "/camera/")

View file

@ -1,4 +1,4 @@
"""Tests that check that a tuning file can be reloaded""" """Tests that check that a tuning file can be reloaded."""
import os import os

View file

@ -30,7 +30,7 @@ SERVER_CMD: list[str] = [
def main() -> None: def main() -> None:
"""Set up the server, run basic checks, shutdown, check for graceful exit """Set up the server, run basic checks, shutdown, check for graceful exit.
The basic checks include checks that: The basic checks include checks that:
- The server boots - The server boots
@ -69,7 +69,7 @@ def main() -> None:
def test_client_connection() -> None: def test_client_connection() -> None:
"""Check a ThingClient can interact with the simulation microscope camera""" """Check a ThingClient can interact with the simulation microscope camera."""
print("Connecting Python client to microscope, and capturing image") print("Connecting Python client to microscope, and capturing image")
cam_client = lt.ThingClient.from_url("http://localhost:5000/camera/") cam_client = lt.ThingClient.from_url("http://localhost:5000/camera/")
img = Image.open(cam_client.grab_jpeg().open()) img = Image.open(cam_client.grab_jpeg().open())
@ -178,7 +178,7 @@ def error_if_server_not_started(
def check_for_graceful_shutdown(server_process: subprocess.Popen) -> None: def check_for_graceful_shutdown(server_process: subprocess.Popen) -> None:
"""Check the server shutdown gracefully """Check the server shutdown gracefully.
Check the subprocess is not running Check the subprocess is not running
Check the logs have no errors Check the logs have no errors
@ -201,7 +201,7 @@ def check_for_graceful_shutdown(server_process: subprocess.Popen) -> None:
def read_process_buffers(process: subprocess.Popen) -> list[str]: def read_process_buffers(process: subprocess.Popen) -> list[str]:
"""Return STDOUT from a process""" """Return STDOUT from a process."""
stdout = [] stdout = []
while line := process.stdout.readline(): while line := process.stdout.readline():

View file

@ -127,11 +127,8 @@ ignore = [
"E501", # Ignore long lines for now "E501", # Ignore long lines for now
"D203", # incompatible with D204 "D203", # incompatible with D204
"D213", # incompatible with D212 "D213", # incompatible with D212
"D400", # A stricter version of #415 "D400", # A stricter version of #415 that doesn't allow !
# Below are checkers to be turned on gradually # The checkers below should be turned on as they complain about missing docstrings.
"D415",
"D200",
# These should be turned on before this MR is complete
"D100", "D100",
"D102", "D102",
"D101", "D101",

View file

@ -25,7 +25,7 @@ def parse_arguments() -> Namespace:
def script_directory(path): def script_directory(path):
"""Resolve path to directory of the current script""" """Resolve path to directory of the current script."""
return os.path.join(os.path.dirname(os.path.realpath(__file__)), path) return os.path.join(os.path.dirname(os.path.realpath(__file__)), path)

View file

@ -87,7 +87,7 @@ class OFMHandler(logging.Handler):
self._log.pop(0) self._log.pop(0)
def emit(self, record): def emit(self, record):
"""Emit will save the logged record to the log""" """Emit will save the logged record to the log."""
# Basic filter for now that simply stops uvicorn.access logs # Basic filter for now that simply stops uvicorn.access logs
# These are the logs each time an API endpoint is accessed # These are the logs each time an API endpoint is accessed
# This is only the log for the UI. # This is only the log for the UI.
@ -105,7 +105,7 @@ class OFMHandler(logging.Handler):
@property @property
def log_history(self): def log_history(self):
"""Return the log history up to the maximum number of logs""" """Return the log history up to the maximum number of logs."""
return "\n".join(self._log) return "\n".join(self._log)

View file

@ -20,7 +20,7 @@ class NotEnoughFreeSpaceError(IOError):
class ScanInfo(BaseModel): class ScanInfo(BaseModel):
"""Summary information about a scan folder""" """Summary information about a scan folder."""
name: str name: str
created: float created: float
@ -31,7 +31,7 @@ class ScanInfo(BaseModel):
class ScanDirectoryManager: class ScanDirectoryManager:
"""A class for managing interactions with scan directories""" """A class for managing interactions with scan directories."""
_base_scan_dir: str _base_scan_dir: str
@ -42,22 +42,22 @@ class ScanDirectoryManager:
@property @property
def base_dir(self) -> str: def base_dir(self) -> str:
"""The base directory scans are saved to""" """The base directory scans are saved to."""
return self._base_scan_dir return self._base_scan_dir
def exists(self, scan_name: str) -> bool: def exists(self, scan_name: str) -> bool:
"""Return True if scan of this name exists on disk""" """Return True if scan of this name exists on disk."""
return os.path.isdir(self.path_for(scan_name)) return os.path.isdir(self.path_for(scan_name))
def path_for(self, scan_name: str) -> str: def path_for(self, scan_name: str) -> str:
"""Return the path for a given scan name """Return the path for a given scan name.
Returns the path even if it doesn't exist Returns the path even if it doesn't exist
""" """
return os.path.join(self._base_scan_dir, scan_name) return os.path.join(self._base_scan_dir, scan_name)
def img_dir_for(self, scan_name: str) -> str: def img_dir_for(self, scan_name: str) -> str:
"""Return the path for the image dir for a given scan name """Return the path for the image dir for a given scan name.
Returns the path even if it doesn't exist Returns the path even if it doesn't exist
""" """
@ -66,7 +66,7 @@ class ScanDirectoryManager:
def get_file_path_from( def get_file_path_from(
self, scan_name: str, filename: str, check_exists: bool = False self, scan_name: str, filename: str, check_exists: bool = False
) -> Optional[str]: ) -> Optional[str]:
"""Return the full file path for the file within a scan directory """Return the full file path for the file within a scan directory.
If check_exists is True then None will be returned if the file does If check_exists is True then None will be returned if the file does
not exist. not exist.
@ -80,7 +80,7 @@ class ScanDirectoryManager:
def get_file_path_from_img_dir( def get_file_path_from_img_dir(
self, scan_name: str, filename: str, check_exists: bool = False self, scan_name: str, filename: str, check_exists: bool = False
) -> Optional[str]: ) -> Optional[str]:
"""Return the full file path for the file within a scan directory """Return the full file path for the file within a scan directory.
If check_exists is True, None is returned if the file does not exist. If False If check_exists is True, None is returned if the file does not exist. If False
then the path is returned anyway then the path is returned anyway
@ -103,18 +103,18 @@ class ScanDirectoryManager:
@property @property
def all_scans(self) -> list[str]: def all_scans(self) -> list[str]:
"""Return a list of the scan names in the base directory""" """Return a list of the scan names in the base directory."""
return [f.name for f in os.scandir(self._base_scan_dir) if f.is_dir()] return [f.name for f in os.scandir(self._base_scan_dir) if f.is_dir()]
def all_scans_info(self) -> list[ScanInfo]: def all_scans_info(self) -> list[ScanInfo]:
"""Return a lists of ScanInfo objects for each scan""" """Return a lists of ScanInfo objects for each scan."""
all_info: list[ScanInfo] = [] all_info: list[ScanInfo] = []
for scan_name in self.all_scans: for scan_name in self.all_scans:
all_info.append(ScanDirectory(scan_name, self.base_dir).scan_info()) all_info.append(ScanDirectory(scan_name, self.base_dir).scan_info())
return all_info return all_info
def _unique_scan_name(self, scan_name: str) -> str: def _unique_scan_name(self, scan_name: str) -> str:
"""Get the next unique scan name starting with the given name """Get the next unique scan name starting with the given name.
For more explanation on the scan naming see `new_scan_dir` For more explanation on the scan naming see `new_scan_dir`
""" """
@ -143,7 +143,7 @@ class ScanDirectoryManager:
return f"{scan_name}_{scan_num:0{SCAN_ZERO_PAD_DIGITS}d}" return f"{scan_name}_{scan_num:0{SCAN_ZERO_PAD_DIGITS}d}"
def new_scan_dir(self, scan_name: str) -> "ScanDirectory": def new_scan_dir(self, scan_name: str) -> "ScanDirectory":
"""Get a unique name for this scan and create a directory for it """Get a unique name for this scan and create a directory for it.
The scan will be named ``{scan_name}_0001`` where the number is The scan will be named ``{scan_name}_0001`` where the number is
zero-padded to be SCAN_ZERO_PAD_DIGITS digits long (to allow correct sorting if the zero-padded to be SCAN_ZERO_PAD_DIGITS digits long (to allow correct sorting if the
@ -165,11 +165,11 @@ class ScanDirectoryManager:
return ScanDirectory(full_scan_name, self.base_dir) return ScanDirectory(full_scan_name, self.base_dir)
def delete_scan(self, scan_name: str) -> None: def delete_scan(self, scan_name: str) -> None:
"""Delete a scan""" """Delete a scan."""
shutil.rmtree(self.path_for(scan_name)) shutil.rmtree(self.path_for(scan_name))
def zip_scan(self, scan_name: str, final_version: bool = False) -> "ScanDirectory": def zip_scan(self, scan_name: str, final_version: bool = False) -> "ScanDirectory":
"""Zips any images from the scan not yet zipped, return full path to zip """Zips any images from the scan not yet zipped, return full path to zip.
``final_version`` Set true to stitch all files not just the scan images ``final_version`` Set true to stitch all files not just the scan images
this should only be done at the end as it is not possible to update a file this should only be done at the end as it is not possible to update a file
@ -178,7 +178,7 @@ class ScanDirectoryManager:
return ScanDirectory(scan_name, self.base_dir).zip_files(final_version) return ScanDirectory(scan_name, self.base_dir).zip_files(final_version)
def check_free_disk_space(self, min_space: int = 500000000) -> None: def check_free_disk_space(self, min_space: int = 500000000) -> None:
"""Raise an exception if there is not enough free disk space to continue scanning """Raise an exception if there is not enough free disk space to continue scanning.
:param min_space: the minimum space required in bytes. :param min_space: the minimum space required in bytes.
Default = 500,000,000 (500MB) Default = 500,000,000 (500MB)
@ -194,7 +194,7 @@ class ScanDirectoryManager:
class ScanDirectory: class ScanDirectory:
"""A class for handling interactions with scan directories """A class for handling interactions with scan directories.
Initalisation parameters: Initalisation parameters:
name: the name of the scan (the scan directory basename) name: the name of the scan (the scan directory basename)
@ -214,12 +214,12 @@ class ScanDirectory:
@property @property
def name(self) -> str: def name(self) -> str:
"""The name of the scan""" """The name of the scan."""
return self._name return self._name
@property @property
def dir_path(self) -> str: def dir_path(self) -> str:
"""The full path to the scan directory""" """The full path to the scan directory."""
return os.path.join(self._base_scan_dir, self._name) return os.path.join(self._base_scan_dir, self._name)
@property @property
@ -235,17 +235,17 @@ class ScanDirectory:
@property @property
def created_time(self) -> float: def created_time(self) -> float:
"""The time the directory was created on disk""" """The time the directory was created on disk."""
return os.path.getctime(self.dir_path) return os.path.getctime(self.dir_path)
def get_scan_files(self): def get_scan_files(self):
"""Return a list of the files in the images dir""" """Return a list of the files in the images dir."""
if self.images_dir is None: if self.images_dir is None:
return [] return []
return os.listdir(self.images_dir) return os.listdir(self.images_dir)
def _extract_scan_images(self, file_list: list[str]): def _extract_scan_images(self, file_list: list[str]):
"""Extract files which match the naming convention for scan images """Extract files which match the naming convention for scan images.
:param file_list: The list of files to search. Normally this would be :param file_list: The list of files to search. Normally this would be
``self.get_scan_files()`` ``self.get_scan_files()``
@ -255,7 +255,7 @@ class ScanDirectory:
return [i for i in file_list if IMAGE_REGEX.search(i)] return [i for i in file_list if IMAGE_REGEX.search(i)]
def _extract_final_stitches(self, file_list: list[str]): def _extract_final_stitches(self, file_list: list[str]):
"""Extract files which match the naming convention for final stitches """Extract files which match the naming convention for final stitches.
:param file_list: The list of files to search. :param file_list: The list of files to search.
@ -264,7 +264,7 @@ class ScanDirectory:
return [i for i in file_list if STITCH_REGEX.search(i)] return [i for i in file_list if STITCH_REGEX.search(i)]
def _extract_dzi_files(self, file_list: list[str]): def _extract_dzi_files(self, file_list: list[str]):
"""Extract files which match the naming convention for dzi_files """Extract files which match the naming convention for dzi_files.
:param file_list: The list of files to search. :param file_list: The list of files to search.
@ -273,7 +273,7 @@ class ScanDirectory:
return [i for i in file_list if i.endswith("dzi")] return [i for i in file_list if i.endswith("dzi")]
def get_final_stitch_name(self) -> Optional[str]: def get_final_stitch_name(self) -> Optional[str]:
"""Return the filename for the final stitch (in the images dir) """Return the filename for the final stitch (in the images dir).
If no final stitch is found, return None If no final stitch is found, return None
""" """
@ -283,11 +283,11 @@ class ScanDirectory:
return stitches[0] return stitches[0]
def get_modified_time(self) -> float: def get_modified_time(self) -> float:
"""Return the modified time of the directory""" """Return the modified time of the directory."""
return max(os.stat(root).st_mtime for root, _, _ in os.walk(self.dir_path)) return max(os.stat(root).st_mtime for root, _, _ in os.walk(self.dir_path))
def scan_info(self) -> ScanInfo: def scan_info(self) -> ScanInfo:
"""Return the information for the scan directory as a ScanInfo object""" """Return the information for the scan directory as a ScanInfo object."""
scan_files = self.get_scan_files() scan_files = self.get_scan_files()
scan_images = self._extract_scan_images(scan_files) scan_images = self._extract_scan_images(scan_files)
stitches = self._extract_final_stitches(scan_files) stitches = self._extract_final_stitches(scan_files)
@ -325,7 +325,7 @@ class ScanDirectory:
return files return files
def zip_files(self, final_version: bool = False) -> str: def zip_files(self, final_version: bool = False) -> str:
"""Zips any images from the scan not yet zipped, return full path to zip """Zips any images from the scan not yet zipped, return full path to zip.
``final_version`` Set true to stitch all files not just the scan images ``final_version`` Set true to stitch all files not just the scan images
this should only be done at the end as it is not possible to update a file this should only be done at the end as it is not possible to update a file
@ -357,6 +357,6 @@ class ScanDirectory:
def get_files_in_zip(zip_path): def get_files_in_zip(zip_path):
"""List the relative paths of all files and folders in the zip folder specified""" """List the relative paths of all files and folders in the zip folder specified."""
scan_zip = zipfile.ZipFile(zip_path) scan_zip = zipfile.ZipFile(zip_path)
return [os.path.normpath(i) for i in scan_zip.namelist()] return [os.path.normpath(i) for i in scan_zip.namelist()]

View file

@ -115,7 +115,7 @@ class ScanPlanner:
@property @property
def imaged_locations(self) -> XYZPosList: def imaged_locations(self) -> XYZPosList:
"""Property to access a copy of the imaged_locations""" """Property to access a copy of the imaged_locations."""
return copy(self._imaged_locations) return copy(self._imaged_locations)
@property @property
@ -145,12 +145,12 @@ class ScanPlanner:
raise NotImplementedError("Did you call the ScanPlanner base class?") raise NotImplementedError("Did you call the ScanPlanner base class?")
def position_visited(self, position: XYPos) -> bool: def position_visited(self, position: XYPos) -> bool:
"""Return True if input xy position has been visited before""" """Return True if input xy position has been visited before."""
# Ensure tuple for correct matching! # Ensure tuple for correct matching!
return tuple(position) in self._path_history return tuple(position) in self._path_history
def position_planned(self, position: XYPos) -> bool: def position_planned(self, position: XYPos) -> bool:
"""Return True if input xy position is planned""" """Return True if input xy position is planned."""
# Ensure tuple for correct matching! # Ensure tuple for correct matching!
return tuple(position) in self._remaining_locations return tuple(position) in self._remaining_locations
@ -205,7 +205,7 @@ class ScanPlanner:
def mark_location_visited( def mark_location_visited(
self, xyz_pos: XYZPos, imaged: bool, focused: bool self, xyz_pos: XYZPos, imaged: bool, focused: bool
) -> None: ) -> None:
"""Mark the location as visited """Mark the location as visited.
Args: Args:
xyz_pos: the x_y_z position xyz_pos: the x_y_z position
@ -282,7 +282,7 @@ class SmartSpiral(ScanPlanner):
def mark_location_visited( def mark_location_visited(
self, xyz_pos: XYZPos, imaged: bool = True, focused: bool = True self, xyz_pos: XYZPos, imaged: bool = True, focused: bool = True
) -> None: ) -> None:
"""Mark the location as visited. Adjust extra positions accordingly """Mark the location as visited. Adjust extra positions accordingly.
Args: Args:
xyz_pos: the x_y_z position xyz_pos: the x_y_z position
@ -327,7 +327,7 @@ class SmartSpiral(ScanPlanner):
self._remaining_locations.append(new_pos) self._remaining_locations.append(new_pos)
def _re_sort_remaining_locations(self, current_pos: XYPos) -> None: def _re_sort_remaining_locations(self, current_pos: XYPos) -> None:
"""Sort the remaining positions besed on the current location""" """Sort the remaining positions besed on the current location."""
# Defined rather than use a lambda for readability # Defined rather than use a lambda for readability
def sort_key(pos): def sort_key(pos):
@ -421,7 +421,7 @@ class SmartSpiral(ScanPlanner):
def distance_between( def distance_between(
current_pos: XYPos | np.ndarray, next_pos: XYPos | np.ndarray current_pos: XYPos | np.ndarray, next_pos: XYPos | np.ndarray
) -> float: ) -> float:
"""Calculate the distance between the two xy positions """Calculate the distance between the two xy positions.
This was previously called ``distance_to_site`` This was previously called ``distance_to_site``
""" """

View file

@ -14,7 +14,7 @@ from ..logging import configure_logging, retrieve_log, retrieve_log_from_file
def set_shutdown_function(shutdown_function: Callable[[], None]): def set_shutdown_function(shutdown_function: Callable[[], None]):
"""Ensure a function is called before the shutdown """Ensure a function is called before the shutdown.
This monkey patches the Uvicorn Server's handle_exit. This is needed because This monkey patches the Uvicorn Server's handle_exit. This is needed because
the uvicorn ``lifecycle`` events and FastAPI ``shutdown`` events only fire once the uvicorn ``lifecycle`` events and FastAPI ``shutdown`` events only fire once
@ -65,7 +65,7 @@ def _get_scans_dir(config: dict) -> Optional[str]:
def serve_from_cli(argv: Optional[list[str]] = None): def serve_from_cli(argv: Optional[list[str]] = None):
"""Start the server from the command line""" """Start the server from the command line."""
args = lt.cli.parse_args(argv) args = lt.cli.parse_args(argv)
log_config = copy(uvicorn.config.LOGGING_CONFIG) log_config = copy(uvicorn.config.LOGGING_CONFIG)

View file

@ -25,7 +25,7 @@ def add_static_file(app: FastAPI, fname: str, folder: str) -> None:
def add_static_files(app: FastAPI, scans_folder: Optional[str]) -> None: def add_static_files(app: FastAPI, scans_folder: Optional[str]) -> None:
"""Add the static files responsible for the webapp app to the FastAPI app """Add the static files responsible for the webapp app to the FastAPI app.
app: The FastAPI app to add to, in this case the OpenFlexure server app: The FastAPI app to add to, in this case the OpenFlexure server
""" """

View file

@ -22,7 +22,7 @@ class RecentringThing(lt.Thing):
max_steps=15, max_steps=15,
lateral_distance=5000, lateral_distance=5000,
): ):
"""Recentre the stage, based on the focal plane """Recentre the stage, based on the focal plane.
Autofocuses at multiple points around the sample to Autofocuses at multiple points around the sample to
find the overall maximum (or minimum) height, which find the overall maximum (or minimum) height, which

View file

@ -1,4 +1,4 @@
"""OpenFlexure Microscope autofocus module """OpenFlexure Microscope autofocus module.
This module defines a Thing that is responsible for using the stage and This module defines a Thing that is responsible for using the stage and
camera together to perform an autofocus routine. camera together to perform an autofocus routine.
@ -26,7 +26,7 @@ from .stage import StageDependency as Stage
class StackParams: class StackParams:
"""A class for holding for scan parameters """A class for holding for scan parameters.
All arguments are keyword only All arguments are keyword only
@ -97,7 +97,7 @@ class StackParams:
@property @property
def stack_z_range(self) -> int: def stack_z_range(self) -> int:
"""The range of the z stack, in steps """The range of the z stack, in steps.
Note that this is the range of the minimum number of images captured, Note that this is the range of the minimum number of images captured,
which is also the range of the images stored in memory that can be which is also the range of the images stored in memory that can be
@ -107,7 +107,7 @@ class StackParams:
@property @property
def steps_undershoot(self) -> int: def steps_undershoot(self) -> int:
"""The distance to deliberately undershoot the estimated optimal starting point""" """The distance to deliberately undershoot the estimated optimal starting point."""
# Starting too low by "steps_undershoot" makes smart stacking faster. # Starting too low by "steps_undershoot" makes smart stacking faster.
# Starting a stack too high requires it to move to the start, # Starting a stack too high requires it to move to the start,
# autofocus and then re-stack. Starting slightly too low only # autofocus and then re-stack. Starting slightly too low only
@ -116,14 +116,14 @@ class StackParams:
@property @property
def max_images_to_test(self) -> int: def max_images_to_test(self) -> int:
"""The maximum number of images that will be captured and tested in a stack """The maximum number of images that will be captured and tested in a stack.
This is 15 images more then the minimum number that are captured. This is 15 images more then the minimum number that are captured.
""" """
return self.min_images_to_test + 15 return self.min_images_to_test + 15
def slice_to_save(self, sharpest_index): def slice_to_save(self, sharpest_index):
"""Return the slice of images to save given the index of the sharpest image""" """Return the slice of images to save given the index of the sharpest image."""
images_each_side = (self.images_to_save - 1) // 2 images_each_side = (self.images_to_save - 1) // 2
return slice( return slice(
max(sharpest_index - images_each_side, 0), max(sharpest_index - images_each_side, 0),
@ -133,7 +133,7 @@ class StackParams:
@dataclass @dataclass
class CaptureInfo: class CaptureInfo:
"""The information from a capture in a z_stack""" """The information from a capture in a z_stack."""
buffer_id: int buffer_id: int
position: dict[str, int] position: dict[str, int]
@ -141,7 +141,7 @@ class CaptureInfo:
@property @property
def filename(self) -> str: def filename(self) -> str:
"""The filename for this image generated from the position""" """The filename for this image generated from the position."""
return f"{self.position['x']}_{self.position['y']}_{self.position['z']}.jpeg" return f"{self.position['x']}_{self.position['y']}_{self.position['z']}.jpeg"
@ -195,7 +195,7 @@ class JPEGSharpnessMonitor:
running = False running = False
async def monitor_sharpness(self): async def monitor_sharpness(self):
"""Start monitoring the frame sizes""" """Start monitoring the frame sizes."""
self.running = True self.running = True
async for frame in self.camera.lores_mjpeg_stream.frame_async_generator(): async for frame in self.camera.lores_mjpeg_stream.frame_async_generator():
self.jpeg_times.append(time.time()) self.jpeg_times.append(time.time())
@ -205,7 +205,7 @@ class JPEGSharpnessMonitor:
@contextmanager @contextmanager
def run(self): def run(self):
"""Context manager, during which we will monitor sharpness from the camera""" """Context manager, during which we will monitor sharpness from the camera."""
self.portal.start_task_soon(self.monitor_sharpness) self.portal.start_task_soon(self.monitor_sharpness)
try: try:
yield yield
@ -233,7 +233,7 @@ class JPEGSharpnessMonitor:
def move_data( def move_data(
self, istart: int, istop: Optional[int] = None self, istart: int, istop: Optional[int] = None
) -> tuple[np.ndarray, np.ndarray, np.ndarray]: ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Extract sharpness as a function of (interpolated) z""" """Extract sharpness as a function of (interpolated) z."""
if istop is None: if istop is None:
istop = istart + 2 istop = istart + 2
jpeg_times: np.ndarray = np.array(self.jpeg_times) jpeg_times: np.ndarray = np.array(self.jpeg_times)
@ -261,7 +261,7 @@ class JPEGSharpnessMonitor:
return jpeg_times, jpeg_zs, jpeg_sizes[start:stop] return jpeg_times, jpeg_zs, jpeg_sizes[start:stop]
def sharpest_z_on_move(self, index: int) -> int: def sharpest_z_on_move(self, index: int) -> int:
"""Return the z position of the sharpest image on a given move""" """Return the z position of the sharpest image on a given move."""
_, jz, js = self.move_data(index) _, jz, js = self.move_data(index)
if len(js) == 0: if len(js) == 0:
raise ValueError( raise ValueError(
@ -270,7 +270,7 @@ class JPEGSharpnessMonitor:
return jz[np.argmax(js)] return jz[np.argmax(js)]
def data_dict(self) -> SharpnessDataArrays: def data_dict(self) -> SharpnessDataArrays:
"""Return the gathered data as a single convenient dictionary""" """Return the gathered data as a single convenient dictionary."""
data = {} data = {}
for k in ["jpeg_times", "jpeg_sizes", "stage_times", "stage_positions"]: for k in ["jpeg_times", "jpeg_sizes", "stage_times", "stage_positions"]:
data[k] = getattr(self, k) data[k] = getattr(self, k)
@ -295,7 +295,7 @@ class AutofocusThing(lt.Thing):
dz: int = 2000, dz: int = 2000,
start: str = "centre", start: str = "centre",
) -> SharpnessDataArrays: ) -> SharpnessDataArrays:
"""Sweep the stage up and down, then move to the sharpest point """Sweep the stage up and down, then move to the sharpest point.
This method will will move down by dz/2, sweep up by dz, and then evaluate This method will will move down by dz/2, sweep up by dz, and then evaluate
the position where the image was sharpest. We'll then move back down, and the position where the image was sharpest. We'll then move back down, and
@ -325,7 +325,7 @@ class AutofocusThing(lt.Thing):
dz: Sequence[int], dz: Sequence[int],
wait: float = 0, wait: float = 0,
) -> SharpnessDataArrays: ) -> SharpnessDataArrays:
"""Make a move (or a series of moves) and monitor sharpness """Make a move (or a series of moves) and monitor sharpness.
This method will will make a series of relative moves in z, and This method will will make a series of relative moves in z, and
return the sharpness (JPEG size) vs time, along with timestamps return the sharpness (JPEG size) vs time, along with timestamps
@ -561,7 +561,7 @@ class AutofocusThing(lt.Thing):
cam: WrappedCamera, cam: WrappedCamera,
stage: Stage, stage: Stage,
) -> tuple[bool, list[CaptureInfo], Optional[int]]: ) -> tuple[bool, list[CaptureInfo], Optional[int]]:
"""Capture a series of images checking that sharpest image central """Capture a series of images checking that sharpest image central.
The images are seperated in z offset by stack_parameters.stack_dz, as they The images are seperated in z offset by stack_parameters.stack_dz, as they
are captured the last stack_parameters.min_images_to_test images are checked are captured the last stack_parameters.min_images_to_test images are checked

View file

@ -22,7 +22,7 @@ class BackgroundDetectThing(lt.Thing):
@lt.thing_setting @lt.thing_setting
def background_distributions(self) -> Optional[ChannelDistributions]: def background_distributions(self) -> Optional[ChannelDistributions]:
"""The statistics of the background image""" """The statistics of the background image."""
bd = self._background_distributions bd = self._background_distributions
if bd is None: if bd is None:
return None return None
@ -56,7 +56,7 @@ class BackgroundDetectThing(lt.Thing):
) )
def background_mask(self, image: np.ndarray) -> np.ndarray: def background_mask(self, image: np.ndarray) -> np.ndarray:
"""Calculate a binary image, showing whether each pixel is background """Calculate a binary image, showing whether each pixel is background.
The image should be in LUV format, the ouput will be binary with the The image should be in LUV format, the ouput will be binary with the
same shape in the first two dimensions. same shape in the first two dimensions.
@ -78,7 +78,7 @@ class BackgroundDetectThing(lt.Thing):
@lt.thing_action @lt.thing_action
def background_fraction(self, cam: CamDep) -> float: def background_fraction(self, cam: CamDep) -> float:
"""Determine what fraction of the current image is background """Determine what fraction of the current image is background.
This action will acquire a new image from the preview stream, then This action will acquire a new image from the preview stream, then
evaluate whether it is foreground or background, by comparing it evaluate whether it is foreground or background, by comparing it
@ -96,7 +96,7 @@ class BackgroundDetectThing(lt.Thing):
@lt.thing_action @lt.thing_action
def image_is_sample(self, cam: CamDep) -> bool: def image_is_sample(self, cam: CamDep) -> bool:
"""Label the current image as either background or sample""" """Label the current image as either background or sample."""
b_fraction = self.background_fraction(cam) b_fraction = self.background_fraction(cam)
fraction_threshold = self.fraction fraction_threshold = self.fraction
@ -104,7 +104,7 @@ class BackgroundDetectThing(lt.Thing):
@lt.thing_action @lt.thing_action
def set_background(self, cam: CamDep): def set_background(self, cam: CamDep):
"""Grab an image, and use its statistics to set the background """Grab an image, and use its statistics to set the background.
This should be run when the microscope is looking at an empty region, This should be run when the microscope is looking at an empty region,
and will calculate the mean and standard deviation of the pixel values and will calculate the mean and standard deviation of the pixel values

View file

@ -1,4 +1,4 @@
"""OpenFlexure Microscope Camera """OpenFlexure Microscope Camera.
This module defines the interface for cameras. Any compatible lt.Thing This module defines the interface for cameras. Any compatible lt.Thing
should enabe the server to work. should enabe the server to work.
@ -23,23 +23,23 @@ class JPEGBlob(lt.blob.Blob):
class PNGBlob(lt.blob.Blob): class PNGBlob(lt.blob.Blob):
"""A class representing a PNG image as a LabThings FastAPI Blob""" """A class representing a PNG image as a LabThings FastAPI Blob."""
media_type: str = "image/png" media_type: str = "image/png"
class ArrayModel(RootModel): class ArrayModel(RootModel):
"""A model for an array""" """A model for an array."""
root: NDArray root: NDArray
class CaptureError(RuntimeError): class CaptureError(RuntimeError):
"""An error trying to capture from a CameraThing""" """An error trying to capture from a CameraThing."""
class NoImageInMemoryError(RuntimeError): class NoImageInMemoryError(RuntimeError):
"""An error called if no image in in memory when an method is called to use that image""" """An error called if no image in in memory when an method is called to use that image."""
class CameraMemoryBuffer: class CameraMemoryBuffer:
@ -61,7 +61,7 @@ class CameraMemoryBuffer:
def add_image( def add_image(
self, image: Any, metadata: Optional[dict] = None, buffer_max: int = 1 self, image: Any, metadata: Optional[dict] = None, buffer_max: int = 1
) -> int: ) -> int:
"""Add an image to the Memory buffer """Add an image to the Memory buffer.
This will add an image to the memory buffer. By default the buffer will This will add an image to the memory buffer. By default the buffer will
be cleared. To allow saving multiple images the buffer_max must be set be cleared. To allow saving multiple images the buffer_max must be set
@ -114,7 +114,7 @@ class CameraMemoryBuffer:
) from e ) from e
def clear(self): def clear(self):
"""Clear all images from memory""" """Clear all images from memory."""
self._storage.clear() self._storage.clear()
def _create_space(self, buffer_max: int) -> None: def _create_space(self, buffer_max: int) -> None:
@ -140,7 +140,7 @@ class CameraMemoryBuffer:
class BaseCamera(lt.Thing): class BaseCamera(lt.Thing):
"""The base class for all cameras. All cameras must directly inherit from this class""" """The base class for all cameras. All cameras must directly inherit from this class."""
mjpeg_stream = lt.outputs.MJPEGStreamDescriptor() mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor() lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
@ -181,7 +181,7 @@ class BaseCamera(lt.Thing):
@lt.thing_property @lt.thing_property
def stream_active(self) -> bool: def stream_active(self) -> bool:
"""Whether the MJPEG stream is active""" """Whether the MJPEG stream is active."""
raise NotImplementedError( raise NotImplementedError(
"CameraThings must define their own stream_active method" "CameraThings must define their own stream_active method"
) )
@ -203,7 +203,7 @@ class BaseCamera(lt.Thing):
resolution: Literal["lores", "main", "full"] = "main", resolution: Literal["lores", "main", "full"] = "main",
wait: Optional[float] = 5, wait: Optional[float] = 5,
) -> JPEGBlob: ) -> JPEGBlob:
"""Acquire one image from the camera and return as a JPEG blob""" """Acquire one image from the camera and return as a JPEG blob."""
raise NotImplementedError( raise NotImplementedError(
"CameraThings must define their own capture_jpeg method" "CameraThings must define their own capture_jpeg method"
) )
@ -214,7 +214,7 @@ class BaseCamera(lt.Thing):
portal: lt.deps.BlockingPortal, portal: lt.deps.BlockingPortal,
stream_name: Literal["main", "lores"] = "main", stream_name: Literal["main", "lores"] = "main",
) -> JPEGBlob: ) -> JPEGBlob:
"""Acquire one image from the preview stream and return as an array """Acquire one image from the preview stream and return as an array.
This differs from ``capture_jpeg`` in that it does not pause the MJPEG This differs from ``capture_jpeg`` in that it does not pause the MJPEG
preview stream. Instead, we simply return the next frame from that preview stream. Instead, we simply return the next frame from that
@ -233,7 +233,7 @@ class BaseCamera(lt.Thing):
portal: lt.deps.BlockingPortal, portal: lt.deps.BlockingPortal,
stream_name: Literal["main", "lores"] = "main", stream_name: Literal["main", "lores"] = "main",
) -> int: ) -> int:
"""Acquire one image from the preview stream and return its size""" """Acquire one image from the preview stream and return its size."""
stream = ( stream = (
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
) )
@ -245,7 +245,7 @@ class BaseCamera(lt.Thing):
stream_name: Literal["main", "lores", "raw"], stream_name: Literal["main", "lores", "raw"],
wait: Optional[float], wait: Optional[float],
) -> None: ) -> None:
"""Capture a PIL image from stream stream_name with timeout wait""" """Capture a PIL image from stream stream_name with timeout wait."""
raise NotImplementedError( raise NotImplementedError(
"CameraThings must define their own capture_image method" "CameraThings must define their own capture_image method"
) )
@ -258,7 +258,7 @@ class BaseCamera(lt.Thing):
metadata_getter: lt.deps.GetThingStates, metadata_getter: lt.deps.GetThingStates,
save_resolution: Optional[Tuple[int, int]] = None, save_resolution: Optional[Tuple[int, int]] = None,
) -> None: ) -> None:
"""Capture an image and save it to disk """Capture an image and save it to disk.
:param jpeg_path: The path to save the file to :param jpeg_path: The path to save the file to
:param logger: This should be injected automatically by Labthings FastAPI :param logger: This should be injected automatically by Labthings FastAPI
@ -288,7 +288,7 @@ class BaseCamera(lt.Thing):
metadata_getter: lt.deps.GetThingStates, metadata_getter: lt.deps.GetThingStates,
buffer_max: int = 1, buffer_max: int = 1,
) -> None: ) -> None:
"""Capture an image to memory. This can be saved later with ``save_from_memory`` """Capture an image to memory. This can be saved later with ``save_from_memory``.
Note that only one image is held in memory so this will overwrite any image Note that only one image is held in memory so this will overwrite any image
in memory. in memory.
@ -336,7 +336,7 @@ class BaseCamera(lt.Thing):
@lt.thing_action @lt.thing_action
def clear_buffers(self) -> None: def clear_buffers(self) -> None:
"""Clear all images in memory""" """Clear all images in memory."""
self._memory_buffer.clear() self._memory_buffer.clear()
def _robust_image_capture( def _robust_image_capture(

View file

@ -1,4 +1,4 @@
"""OpenFlexure Microscope OpenCV Camera """OpenFlexure Microscope OpenCV Camera.
This module defines a camera Thing that uses OpenCV's This module defines a camera Thing that uses OpenCV's
``VideoCapture``. ``VideoCapture``.
@ -23,7 +23,7 @@ from . import BaseCamera, JPEGBlob
class OpenCVCamera(BaseCamera): class OpenCVCamera(BaseCamera):
"""A Thing representing an OpenCV camera""" """A Thing representing an OpenCV camera."""
def __init__(self, camera_index: int = 0): def __init__(self, camera_index: int = 0):
self.camera_index = camera_index self.camera_index = camera_index
@ -45,7 +45,7 @@ class OpenCVCamera(BaseCamera):
@lt.thing_property @lt.thing_property
def stream_active(self) -> bool: def stream_active(self) -> bool:
"""Whether the MJPEG stream is active""" """Whether the MJPEG stream is active."""
if self._capture_enabled and self._capture_thread: if self._capture_enabled and self._capture_thread:
return self._capture_thread.is_alive() return self._capture_thread.is_alive()
return False return False
@ -71,7 +71,7 @@ class OpenCVCamera(BaseCamera):
self, self,
resolution: Literal["main", "full"] = "full", resolution: Literal["main", "full"] = "full",
) -> NDArray: ) -> 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.
It's likely to be highly inefficient - raw and/or uncompressed captures using It's likely to be highly inefficient - raw and/or uncompressed captures using
@ -91,7 +91,7 @@ class OpenCVCamera(BaseCamera):
metadata_getter: lt.deps.GetThingStates, metadata_getter: lt.deps.GetThingStates,
resolution: Literal["main", "full"] = "main", resolution: Literal["main", "full"] = "main",
) -> JPEGBlob: ) -> JPEGBlob:
"""Acquire one image from the camera and return as a JPEG blob """Acquire one image from the camera and return as a JPEG blob.
This function will produce a JPEG image. This function will produce a JPEG image.
""" """

View file

@ -40,10 +40,10 @@ from . import BaseCamera, JPEGBlob, ArrayModel
class PicameraStreamOutput(Output): class PicameraStreamOutput(Output):
"""An Output class that sends frames to a stream""" """An Output class that sends frames to a stream."""
def __init__(self, stream: lt.outputs.MJPEGStream, portal: lt.deps.BlockingPortal): def __init__(self, stream: lt.outputs.MJPEGStream, portal: lt.deps.BlockingPortal):
"""Create an output that puts frames in an MJPEGStream """Create an output that puts frames in an MJPEGStream.
We need to pass the stream object, and also the blocking portal, because We need to pass the stream object, and also the blocking portal, because
new frame notifications happen in the anyio event loop and frames are new frame notifications happen in the anyio event loop and frames are
@ -57,7 +57,7 @@ class PicameraStreamOutput(Output):
def outputframe( def outputframe(
self, frame, _keyframe=True, _timestamp=None, _packet=None, _audio=False self, frame, _keyframe=True, _timestamp=None, _packet=None, _audio=False
): ):
"""Add a frame to the stream's ringbuffer""" """Add a frame to the stream's ringbuffer."""
self.stream.add_frame(frame, self.portal) self.stream.add_frame(frame, self.portal)
@ -104,7 +104,7 @@ class LensShading(BaseModel):
class StreamingPiCamera2(BaseCamera): class StreamingPiCamera2(BaseCamera):
"""A Thing that represents an OpenCV camera""" """A Thing that represents an OpenCV camera."""
def __init__(self, camera_num: int = 0): def __init__(self, camera_num: int = 0):
self._setting_save_in_progress = False self._setting_save_in_progress = False
@ -244,7 +244,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_property @lt.thing_property
def sensor_modes(self) -> list[SensorMode]: def sensor_modes(self) -> list[SensorMode]:
"""All the available modes the current sensor supports""" """All the available modes the current sensor supports."""
if not self._sensor_modes: if not self._sensor_modes:
with self._streaming_picamera() as cam: with self._streaming_picamera() as cam:
self._sensor_modes = cam.sensor_modes self._sensor_modes = cam.sensor_modes
@ -254,14 +254,14 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_property @lt.thing_property
def sensor_mode(self) -> Optional[SensorModeSelector]: def sensor_mode(self) -> Optional[SensorModeSelector]:
"""The intended sensor mode of the camera""" """The intended sensor mode of the camera."""
if self._sensor_mode is None: if self._sensor_mode is None:
return None return None
return SensorModeSelector(**self._sensor_mode) return SensorModeSelector(**self._sensor_mode)
@sensor_mode.setter @sensor_mode.setter
def sensor_mode(self, new_mode: Optional[SensorModeSelector | dict]): def sensor_mode(self, new_mode: Optional[SensorModeSelector | dict]):
"""Change the sensor mode used""" """Change the sensor mode used."""
if new_mode is None: if new_mode is None:
self._sensor_mode = None self._sensor_mode = None
elif isinstance(new_mode, SensorModeSelector): elif isinstance(new_mode, SensorModeSelector):
@ -277,7 +277,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_property @lt.thing_property
def sensor_resolution(self) -> Optional[tuple[int, int]]: def sensor_resolution(self) -> Optional[tuple[int, int]]:
"""The native resolution of the camera's sensor""" """The native resolution of the camera's sensor."""
with self._streaming_picamera() as cam: with self._streaming_picamera() as cam:
return cam.sensor_resolution return cam.sensor_resolution
@ -324,7 +324,7 @@ class StreamingPiCamera2(BaseCamera):
@property @property
def streaming(self) -> bool: def streaming(self) -> bool:
"""True if the camera is streaming""" """True if the camera is streaming."""
return self._picamera is not None and self._picamera.started return self._picamera is not None and self._picamera.started
@contextmanager @contextmanager
@ -432,7 +432,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_action @lt.thing_action
def stop_streaming(self, stop_web_stream: bool = True) -> None: def stop_streaming(self, stop_web_stream: bool = True) -> None:
"""Stop the MJPEG stream""" """Stop the MJPEG stream."""
with self._streaming_picamera() as picam: with self._streaming_picamera() as picam:
try: try:
picam.stop_recording() # This should also stop the extra lores encoder picam.stop_recording() # This should also stop the extra lores encoder
@ -474,7 +474,7 @@ class StreamingPiCamera2(BaseCamera):
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: ) -> ArrayModel:
"""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.
It's likely to be highly inefficient - raw and/or uncompressed captures using It's likely to be highly inefficient - raw and/or uncompressed captures using
@ -496,7 +496,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_property @lt.thing_property
def camera_configuration(self) -> Mapping: def camera_configuration(self) -> Mapping:
"""The "configuration" dictionary of the picamera2 object """The "configuration" dictionary of the picamera2 object.
The "configuration" sets the resolution and format of the camera's streams. The "configuration" sets the resolution and format of the camera's streams.
Together with the "tuning" it determines how the sensor is configured and Together with the "tuning" it determines how the sensor is configured and
@ -516,7 +516,7 @@ class StreamingPiCamera2(BaseCamera):
resolution: Literal["lores", "main", "full"] = "main", resolution: Literal["lores", "main", "full"] = "main",
wait: Optional[float] = 0.9, wait: Optional[float] = 0.9,
) -> JPEGBlob: ) -> JPEGBlob:
"""Acquire one image from the camera as a JPEG """Acquire one image from the camera as a JPEG.
The JPEG will be acquired using ``Picamera2.capture_file``. If the The JPEG will be acquired using ``Picamera2.capture_file``. If the
``resolution`` parameter is ``main`` or ``lores``, it will be captured ``resolution`` parameter is ``main`` or ``lores``, it will be captured
@ -567,7 +567,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_property @lt.thing_property
def capture_metadata(self) -> dict: def capture_metadata(self) -> dict:
"""Return the metadata from the camera""" """Return the metadata from the camera."""
with self._streaming_picamera() as cam: with self._streaming_picamera() as cam:
return cam.capture_metadata() return cam.capture_metadata()
@ -577,7 +577,7 @@ class StreamingPiCamera2(BaseCamera):
target_white_level: int = 700, target_white_level: int = 700,
percentile: float = 99.9, percentile: float = 99.9,
): ):
"""Adjust exposure until a the target white level is reached """Adjust exposure until a the target white level is reached.
Starting from the minimum exposure, gradually increase exposure until Starting from the minimum exposure, gradually increase exposure until
the image reaches the specified white level. the image reaches the specified white level.
@ -603,7 +603,7 @@ class StreamingPiCamera2(BaseCamera):
method: Literal["percentile", "centre"] = "centre", method: Literal["percentile", "centre"] = "centre",
luminance_power: float = 1.0, luminance_power: float = 1.0,
): ):
"""Correct the white balance of the image """Correct the white balance of the image.
This calibration requires a neutral image, such that the 99th centile This calibration requires a neutral image, such that the 99th centile
of each colour channel should correspond to white. We calculate the of each colour channel should correspond to white. We calculate the
@ -711,7 +711,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_action @lt.thing_action
def full_auto_calibrate(self) -> None: def full_auto_calibrate(self) -> None:
"""Perform a full auto-calibration """Perform a full auto-calibration.
This function will call the other calibration actions in sequence: This function will call the other calibration actions in sequence:
@ -729,7 +729,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_action @lt.thing_action
def flat_lens_shading(self) -> None: def flat_lens_shading(self) -> None:
"""Disable flat-field correction """Disable flat-field correction.
This method will set a completely flat lens shading table. It is not the This method will set a completely flat lens shading table. It is not the
same as the default behaviour, which is to use an adaptive lens shading same as the default behaviour, which is to use an adaptive lens shading
@ -748,7 +748,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_property @lt.thing_property
def lens_shading_tables(self) -> Optional[LensShading]: def lens_shading_tables(self) -> Optional[LensShading]:
"""The current lens shading (i.e. flat-field correction) """The current lens shading (i.e. flat-field correction).
Return the current lens shading correction, as three 2D lists each with Return the current lens shading correction, as three 2D lists each with
dimensions 16x12, if a static lens shading table is in use. dimensions 16x12, if a static lens shading table is in use.
@ -770,7 +770,7 @@ class StreamingPiCamera2(BaseCamera):
return None return None
def reshape_lst(lin: list[float]) -> list[list[float]]: def reshape_lst(lin: list[float]) -> list[list[float]]:
"""Reshape the 192 element list into a 2D 16x12 list""" """Reshape the 192 element list into a 2D 16x12 list."""
w, h = 16, 12 w, h = 16, 12
return [lin[w * i : w * (i + 1)] for i in range(h)] return [lin[w * i : w * (i + 1)] for i in range(h)]
@ -782,7 +782,7 @@ class StreamingPiCamera2(BaseCamera):
@lens_shading_tables.setter @lens_shading_tables.setter
def lens_shading_tables(self, lst: LensShading) -> None: def lens_shading_tables(self, lst: LensShading) -> None:
"""Set the lens shading tables""" """Set the lens shading tables."""
with self._streaming_picamera(pause_stream=True): with self._streaming_picamera(pause_stream=True):
recalibrate_utils.set_static_lst( recalibrate_utils.set_static_lst(
self.tuning, self.tuning,
@ -795,7 +795,7 @@ class StreamingPiCamera2(BaseCamera):
def correct_colour_gains_for_lens_shading( def correct_colour_gains_for_lens_shading(
self, colour_gains: tuple[float, float] self, colour_gains: tuple[float, float]
) -> tuple[float, float]: ) -> tuple[float, float]:
"""Correct white balance gains for the effect of lens shading """Correct white balance gains for the effect of lens shading.
The white balance algorithm we use assumes the brightest pixels The white balance algorithm we use assumes the brightest pixels
should be white, and that the only thing affecting the colour of should be white, and that the only thing affecting the colour of
@ -825,7 +825,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_action @lt.thing_action
def flat_lens_shading_chrominance(self) -> None: def flat_lens_shading_chrominance(self) -> None:
"""Disable flat-field correction """Disable flat-field correction.
This method will set the chrominance of the lens shading table to be This method will set the chrominance of the lens shading table to be
flat, i.e. we'll correct vignetting of intensity, but not any change in flat, i.e. we'll correct vignetting of intensity, but not any change in
@ -840,7 +840,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_action @lt.thing_action
def reset_lens_shading(self) -> None: def reset_lens_shading(self) -> None:
"""Revert to default lens shading settings """Revert to default lens shading settings.
This method will restore the default "adaptive" lens shading method used This method will restore the default "adaptive" lens shading method used
by the Raspberry Pi camera. by the Raspberry Pi camera.
@ -851,7 +851,7 @@ class StreamingPiCamera2(BaseCamera):
@lt.thing_property @lt.thing_property
def lens_shading_is_static(self) -> bool: def lens_shading_is_static(self) -> bool:
"""Whether the lens shading is static """Whether the lens shading is static.
This property is true if the lens shading correction has been set to use This property is true if the lens shading correction has been set to use
a static table (i.e. the number of automatic correction iterations is zero). a static table (i.e. the number of automatic correction iterations is zero).

View file

@ -1,4 +1,4 @@
"""Functions to set up a Raspberry Pi Camera v2 for scientific use """Functions to set up a Raspberry Pi Camera v2 for scientific use.
This module provides slower, simpler functions to set the This module provides slower, simpler functions to set the
gain, exposure, and white balance of a Raspberry Pi camera, using gain, exposure, and white balance of a Raspberry Pi camera, using
@ -52,7 +52,7 @@ LensShadingTables = tuple[np.ndarray, np.ndarray, np.ndarray]
def load_default_tuning(cam: Picamera2) -> dict: def load_default_tuning(cam: Picamera2) -> dict:
"""Load the default tuning file for the camera """Load the default tuning file for the camera.
This will open and close the camera to determine its model. If you are This will open and close the camera to determine its model. If you are
using a model that's supported by ``picamera2`` it should have a tuning using a model that's supported by ``picamera2`` it should have a tuning
@ -76,7 +76,7 @@ def load_default_tuning(cam: Picamera2) -> dict:
def set_minimum_exposure(camera: Picamera2) -> None: def set_minimum_exposure(camera: Picamera2) -> None:
"""Enable manual exposure, with low gain and shutter speed """Enable manual exposure, with low gain and shutter speed.
We set exposure mode to manual, analog and digital gain We set exposure mode to manual, analog and digital gain
to 1, and shutter speed to the minimum (8us for Pi Camera v2) to 1, and shutter speed to the minimum (8us for Pi Camera v2)
@ -93,7 +93,7 @@ def set_minimum_exposure(camera: Picamera2) -> None:
class ExposureTest(BaseModel): class ExposureTest(BaseModel):
"""Record the results of testing the camera's current exposure settings""" """Record the results of testing the camera's current exposure settings."""
level: int level: int
exposure_time: int exposure_time: int
@ -101,7 +101,7 @@ class ExposureTest(BaseModel):
def test_exposure_settings(camera: Picamera2, percentile: float) -> ExposureTest: def test_exposure_settings(camera: Picamera2, percentile: float) -> ExposureTest:
"""Evaluate current exposure settings using a raw image """Evaluate current exposure settings using a raw image.
CAMERA SHOULD BE STARTED! CAMERA SHOULD BE STARTED!
@ -136,7 +136,7 @@ def test_exposure_settings(camera: Picamera2, percentile: float) -> ExposureTest
def check_convergence(test: ExposureTest, target: int, tolerance: float) -> bool: def check_convergence(test: ExposureTest, target: int, tolerance: float) -> bool:
"""Check whether the brightness is within the specified target range""" """Check whether the brightness is within the specified target range."""
return abs(test.level - target) < target * tolerance return abs(test.level - target) < target * tolerance
@ -321,7 +321,7 @@ def channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray:
def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray: def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray:
"""Compresses channel down to a 16x12 grid - from libcamera """Compresses channel down to a 16x12 grid - from libcamera.
This is taken from This is taken from
https://git.linuxtv.org/libcamera.git/tree/utils/raspberrypi/ctt/ctt_alsc.py https://git.linuxtv.org/libcamera.git/tree/utils/raspberrypi/ctt/ctt_alsc.py
@ -343,7 +343,7 @@ def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray:
def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray: def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray:
"""Zoom an image in the last two dimensions """Zoom an image in the last two dimensions.
This is effectively the inverse operation of ``get_16x12_grid`` This is effectively the inverse operation of ``get_16x12_grid``
""" """
@ -354,7 +354,7 @@ def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray:
def downsampled_channels(channels: np.ndarray, blacklevel=64) -> list[np.ndarray]: def downsampled_channels(channels: np.ndarray, blacklevel=64) -> list[np.ndarray]:
"""Generate a downsampled, un-normalised image from which to calculate the LST """Generate a downsampled, un-normalised image from which to calculate the LST.
TODO: blacklevel probably ought to be determined from the camera... TODO: blacklevel probably ought to be determined from the camera...
""" """
@ -382,7 +382,7 @@ def lst_from_channels(channels: np.ndarray) -> LensShadingTables:
def lst_from_grids(grids: np.ndarray) -> LensShadingTables: def lst_from_grids(grids: np.ndarray) -> LensShadingTables:
"""Given 4 downsampled grids, generate the luminance and chrominance tables """Given 4 downsampled grids, generate the luminance and chrominance tables.
The grids are the 4 BAYER channels RGGB The grids are the 4 BAYER channels RGGB
@ -408,7 +408,7 @@ def lst_from_grids(grids: np.ndarray) -> LensShadingTables:
def grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarray: def grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarray:
"""Convert form luminance/chrominance dict to four RGGB channels """Convert form luminance/chrominance dict to four RGGB channels.
Note that these will be normalised - the maximum green value is always 1. Note that these will be normalised - the maximum green value is always 1.
Also, note that the channels are BGGR, to be consistent with the Also, note that the channels are BGGR, to be consistent with the
@ -462,13 +462,13 @@ def set_static_ccm(
def get_static_ccm(tuning: dict) -> None: def get_static_ccm(tuning: dict) -> None:
"""Get the ``rpi.ccm`` section of a camera tuning dict""" """Get the ``rpi.ccm`` section of a camera tuning dict."""
ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm")
return ccm["ccms"] return ccm["ccms"]
def lst_is_static(tuning: dict) -> bool: def lst_is_static(tuning: dict) -> bool:
"""Whether the lens shading table is set to static""" """Whether the lens shading table is set to static."""
alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc") alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc")
return alsc["n_iter"] == 0 return alsc["n_iter"] == 0
@ -493,13 +493,13 @@ def set_static_geq(
def _geq_is_static(tuning: dict) -> bool: def _geq_is_static(tuning: dict) -> bool:
"""Whether the green equalisation is set to static""" """Whether the green equalisation is set to static."""
geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") geq = Picamera2.find_tuning_algo(tuning, "rpi.geq")
return geq["offset"] == 65535 return geq["offset"] == 65535
def index_of_algorithm(algorithms: list[dict], algorithm: str) -> int: def index_of_algorithm(algorithms: list[dict], algorithm: str) -> int:
"""Find the index of an algorithm's section in the tuning file""" """Find the index of an algorithm's section in the tuning file."""
for i, a in enumerate(algorithms): for i, a in enumerate(algorithms):
if algorithm in a: if algorithm in a:
return i return i
@ -557,5 +557,5 @@ def recreate_camera_manager() -> None:
def as_flat_rounded_list(array: np.ndarray, round_to: int = 3) -> list[float]: def as_flat_rounded_list(array: np.ndarray, round_to: int = 3) -> list[float]:
"""Flatten array, round, and then convert to list""" """Flatten array, round, and then convert to list."""
return np.reshape(array, -1).round(round_to).tolist() return np.reshape(array, -1).round(round_to).tolist()

View file

@ -1,4 +1,4 @@
"""OpenFlexure Microscope OpenCV Camera """OpenFlexure Microscope OpenCV Camera.
This module defines a Thing that is responsible for using the stage and This module defines a Thing that is responsible for using the stage and
camera together to perform an autofocus routine. camera together to perform an autofocus routine.
@ -30,7 +30,7 @@ RATIO = 0.2
class SimulatedCamera(BaseCamera): class SimulatedCamera(BaseCamera):
"""A Thing representing an OpenCV camera""" """A Thing representing an OpenCV camera."""
_stage: Optional[BaseStage] = None _stage: Optional[BaseStage] = None
_server: Optional[lt.ThingServer] = None _server: Optional[lt.ThingServer] = None
@ -53,7 +53,7 @@ class SimulatedCamera(BaseCamera):
self.generate_canvas() self.generate_canvas()
def generate_sprites(self): def generate_sprites(self):
"""Generate sprites to populate the image""" """Generate sprites to populate the image."""
self.sprites = [] self.sprites = []
black = np.zeros(self.glyph_shape, dtype=np.uint8) black = np.zeros(self.glyph_shape, dtype=np.uint8)
x = np.arange(black.shape[0]) x = np.arange(black.shape[0])
@ -65,7 +65,7 @@ class SimulatedCamera(BaseCamera):
self.sprites.append(sprite) self.sprites.append(sprite)
def generate_blobs(self, n_blobs: int = 1000): def generate_blobs(self, n_blobs: int = 1000):
"""Generate coordinates of blobs """Generate coordinates of blobs.
Blobs are characterised by X, Y, sprite Blobs are characterised by X, Y, sprite
We also generate a KD tree to rapidly find blobs in an image We also generate a KD tree to rapidly find blobs in an image
@ -78,7 +78,7 @@ class SimulatedCamera(BaseCamera):
self.blobs[:, 2] = rng.choice(len(self.sprites), n_blobs) self.blobs[:, 2] = rng.choice(len(self.sprites), n_blobs)
def generate_canvas(self): def generate_canvas(self):
"""Generate a blank canvas""" """Generate a blank canvas."""
self.canvas = np.zeros(self.canvas_shape, dtype=np.uint8) self.canvas = np.zeros(self.canvas_shape, dtype=np.uint8)
self.canvas[...] = 255 self.canvas[...] = 255
w, h, _ = self.glyph_shape w, h, _ = self.glyph_shape
@ -89,7 +89,7 @@ class SimulatedCamera(BaseCamera):
] -= self.sprites[int(sprite)] ] -= self.sprites[int(sprite)]
def generate_image(self, pos: tuple[int, int, int]): def generate_image(self, pos: tuple[int, int, int]):
"""Generate an image with blobs based on supplied coordinates""" """Generate an image with blobs based on supplied coordinates."""
canvas_width, canvas_height, _ = self.canvas_shape canvas_width, canvas_height, _ = self.canvas_shape
image_width, image_height, _ = self.shape image_width, image_height, _ = self.shape
pos = tuple(x * RATIO for x in pos) pos = tuple(x * RATIO for x in pos)
@ -124,7 +124,7 @@ class SimulatedCamera(BaseCamera):
return self._stage.instantaneous_position return self._stage.instantaneous_position
def generate_frame(self): def generate_frame(self):
"""Generate a frame with blobs based on the stage coordinates""" """Generate a frame with blobs based on the stage coordinates."""
try: try:
pos = self.get_stage_position() pos = self.get_stage_position()
except Exception as e: except Exception as e:
@ -145,7 +145,7 @@ class SimulatedCamera(BaseCamera):
@lt.thing_property @lt.thing_property
def stream_active(self) -> bool: def stream_active(self) -> bool:
"""Whether the MJPEG stream is active""" """Whether the MJPEG stream is active."""
if self._capture_enabled and self._capture_thread: if self._capture_enabled and self._capture_thread:
return self._capture_thread.is_alive() return self._capture_thread.is_alive()
return False return False
@ -170,7 +170,7 @@ class SimulatedCamera(BaseCamera):
self, self,
resolution: Literal["main", "full"] = "full", resolution: Literal["main", "full"] = "full",
) -> ArrayModel: ) -> ArrayModel:
"""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.
It's likely to be highly inefficient - raw and/or uncompressed captures using It's likely to be highly inefficient - raw and/or uncompressed captures using
@ -185,7 +185,7 @@ class SimulatedCamera(BaseCamera):
metadata_getter: lt.deps.GetThingStates, metadata_getter: lt.deps.GetThingStates,
resolution: Literal["main", "full"] = "main", resolution: Literal["main", "full"] = "main",
) -> JPEGBlob: ) -> JPEGBlob:
"""Acquire one image from the camera and return as a JPEG blob """Acquire one image from the camera and return as a JPEG blob.
This function will produce a JPEG image. This function will produce a JPEG image.
""" """

View file

@ -1,4 +1,4 @@
"""OpenFlexure Microscope API extension for stage calibration """OpenFlexure Microscope API extension for stage calibration.
This file contains the HTTP API for camera/stage calibration. It This file contains the HTTP API for camera/stage calibration. It
includes calibration functions that measure the relationship between includes calibration functions that measure the relationship between
@ -53,7 +53,7 @@ class HardwareInterfaceModel(BaseModel):
def downsample(factor: int, image: np.ndarray) -> np.ndarray: def downsample(factor: int, image: np.ndarray) -> np.ndarray:
"""Downsample an image by taking the mean of each nxn region """Downsample an image by taking the mean of each nxn region.
This should be very efficient: we calculate the mean of each This should be very efficient: we calculate the mean of each
``factor * factor`` square, no interpolation. If the image is ``factor * factor`` square, no interpolation. If the image is
@ -79,7 +79,7 @@ DEFAULT_SETTLING_TIME = 0.2
def make_hardware_interface( def make_hardware_interface(
stage: Stage, camera: Camera, downsample_factor: int = 2 stage: Stage, camera: Camera, downsample_factor: int = 2
) -> HardwareInterfaceModel: ) -> HardwareInterfaceModel:
"""Construct the functions we need to interface with the hardware""" """Construct the functions we need to interface with the hardware."""
axes = stage.axis_names axes = stage.axis_names
def pos2dict(pos: Sequence[float]) -> Mapping[str, float]: def pos2dict(pos: Sequence[float]) -> Mapping[str, float]:
@ -144,7 +144,7 @@ class LoggingMoveWrapper:
self.clear_history() self.clear_history()
def __call__(self, new_position: CoordinateType, *args, **kwargs): def __call__(self, new_position: CoordinateType, *args, **kwargs):
"""Move to a new position, and record it""" """Move to a new position, and record it."""
self._history.append((time.time(), self._current_position)) self._history.append((time.time(), self._current_position))
self._move_function(new_position, *args, **kwargs) self._move_function(new_position, *args, **kwargs)
self._current_position = new_position self._current_position = new_position
@ -152,13 +152,13 @@ class LoggingMoveWrapper:
@property @property
def history(self) -> MoveHistory: def history(self) -> MoveHistory:
"""The history, as a numpy array of times and another of positions""" """The history, as a numpy array of times and another of positions."""
times: List[float] = [t for t, p in self._history if p is not None] times: List[float] = [t for t, p in self._history if p is not None]
positions: List[CoordinateType] = [p 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]
return MoveHistory(times, positions) return MoveHistory(times, positions)
def clear_history(self): def clear_history(self):
"""Reset our history to be an empty list""" """Reset our history to be an empty list."""
self._history: List[Tuple[float, Optional[CoordinateType]]] = [] self._history: List[Tuple[float, Optional[CoordinateType]]] = []
@ -175,7 +175,7 @@ class CSMUncalibratedError(HTTPException):
class CameraStageMapper(lt.Thing): class CameraStageMapper(lt.Thing):
"""A Thing to manage mapping between image and stage coordinates""" """A Thing to manage mapping between image and stage coordinates."""
@lt.thing_action @lt.thing_action
def calibrate_1d( def calibrate_1d(
@ -185,7 +185,7 @@ class CameraStageMapper(lt.Thing):
logger: lt.deps.InvocationLogger, logger: lt.deps.InvocationLogger,
direction: Tuple[float, float, float], direction: Tuple[float, float, float],
) -> DenumpifyingDict: ) -> DenumpifyingDict:
"""Move a microscope's stage in 1D, and figure out the relationship with the camera""" """Move a microscope's stage in 1D, and figure out the relationship with the camera."""
move = LoggingMoveWrapper( move = LoggingMoveWrapper(
hw.move hw.move
) # log positions and times for stage calibration ) # log positions and times for stage calibration
@ -214,7 +214,7 @@ class CameraStageMapper(lt.Thing):
def calibrate_xy( def calibrate_xy(
self, hw: HardwareInterfaceDep, stage: Stage, logger: lt.deps.InvocationLogger self, hw: HardwareInterfaceDep, stage: Stage, logger: lt.deps.InvocationLogger
) -> DenumpifyingDict: ) -> DenumpifyingDict:
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera """Move the microscope's stage in X and Y, to calibrate its relationship to the camera.
This performs two 1d calibrations in x and y, then combines their results. This performs two 1d calibrations in x and y, then combines their results.
""" """
@ -291,13 +291,13 @@ class CameraStageMapper(lt.Thing):
@lt.thing_property @lt.thing_property
def image_resolution(self) -> Optional[Tuple[float, float]]: def image_resolution(self) -> Optional[Tuple[float, float]]:
"""The image size used to calibrate the image_to_stage_displacement_matrix""" """The image size used to calibrate the image_to_stage_displacement_matrix."""
if self.last_calibration is None: if self.last_calibration is None:
return None return None
return self.last_calibration["image_resolution"] return self.last_calibration["image_resolution"]
def assert_calibrated(self): def assert_calibrated(self):
"""Raise an exception if the image_to_stage_displacement matrix is not set""" """Raise an exception if the image_to_stage_displacement matrix is not set."""
if self.image_to_stage_displacement_matrix is None: if self.image_to_stage_displacement_matrix is None:
# Disable check of no message in raised exception as the message is explicitly # Disable check of no message in raised exception as the message is explicitly
# added by CSMUncalibratedError # added by CSMUncalibratedError
@ -310,7 +310,7 @@ class CameraStageMapper(lt.Thing):
x: float, x: float,
y: float, y: float,
): ):
"""Move by a given number of pixels on the camera """Move by a given number of pixels on the camera.
NB x and y here refer to what is usually understood to be the horizontal and NB x and y here refer to what is usually understood to be the horizontal and
vertical axes of the image. In many toolkits, "matrix indices" are used, which vertical axes of the image. In many toolkits, "matrix indices" are used, which
@ -329,7 +329,7 @@ class CameraStageMapper(lt.Thing):
@lt.thing_property @lt.thing_property
def thing_state(self) -> dict[str, Any]: def thing_state(self) -> dict[str, Any]:
"""Summary metadata describing the current state of the Thing""" """Summary metadata describing the current state of the Thing."""
return { return {
k: getattr(self, k) k: getattr(self, k)
for k in ["image_to_stage_displacement_matrix", "image_resolution"] for k in ["image_to_stage_displacement_matrix", "image_resolution"]

View file

@ -1,4 +1,4 @@
"""OpenFlexure Settings Management """OpenFlexure Settings Management.
This module provides some settings management across the other Things, and This module provides some settings management across the other Things, and
for code that currently lives in clients but needs to persist settings on for code that currently lives in clients but needs to persist settings on
@ -21,7 +21,7 @@ ThingServerDep = Annotated[lt.ThingServer, Depends(thing_server_from_request)]
def recursive_update(old_dict: MutableMapping, update: Mapping): def recursive_update(old_dict: MutableMapping, update: Mapping):
"""Update a dictionary recursively""" """Update a dictionary recursively."""
for k, v in update.items(): for k, v in update.items():
if isinstance(v, Mapping): if isinstance(v, Mapping):
if k in old_dict and isinstance(old_dict[k], MutableMapping): if k in old_dict and isinstance(old_dict[k], MutableMapping):
@ -33,7 +33,7 @@ def recursive_update(old_dict: MutableMapping, update: Mapping):
def nested_dict_get(data: dict, key: Sequence[str], create=False) -> Any: def nested_dict_get(data: dict, key: Sequence[str], create=False) -> Any:
"""Index a dict-of-dicts with a sequence of strings""" """Index a dict-of-dicts with a sequence of strings."""
subdict = data subdict = data
for k in key: for k in key:
if k not in subdict and create: if k not in subdict and create:
@ -100,7 +100,7 @@ class SettingsManager(lt.Thing):
@lt.thing_setting @lt.thing_setting
def microscope_id(self) -> UUID: def microscope_id(self) -> UUID:
"""A unique identifier for this microscope""" """A unique identifier for this microscope."""
if self._microscope_id is None: if self._microscope_id is None:
self._microscope_id = str(uuid4()) self._microscope_id = str(uuid4())
return UUID(self._microscope_id) return UUID(self._microscope_id)
@ -117,7 +117,7 @@ class SettingsManager(lt.Thing):
@lt.thing_action @lt.thing_action
def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping: def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping:
"""Metadata summarising the current state of all Things in the server""" """Metadata summarising the current state of all Things in the server."""
return metadata_getter() return metadata_getter()
@property @property

View file

@ -42,7 +42,7 @@ STITCHING_RESOLUTION = (820, 616)
class ScanNotRunningError(RuntimeError): class ScanNotRunningError(RuntimeError):
"""Exception called when scan not running that requires a scan to be running""" """Exception called when scan not running that requires a scan to be running."""
def _scan_running(method): def _scan_running(method):
@ -170,7 +170,7 @@ class SmartScanThing(lt.Thing):
@_scan_running @_scan_running
def _check_background_and_csm_set(self): def _check_background_and_csm_set(self):
"""Before starting a scan, check that background and camera-stage-mapping are set """Before starting a scan, check that background and camera-stage-mapping are set.
Raise error if: Raise error if:
- background is to be skipped but is not set - background is to be skipped but is not set
@ -229,7 +229,7 @@ class SmartScanThing(lt.Thing):
@_scan_running @_scan_running
def _calc_displacement_from_test_image(self, overlap: int) -> tuple[int, int]: def _calc_displacement_from_test_image(self, overlap: int) -> tuple[int, int]:
"""Take a test image and use camera stage mapping to calculate x and y displacement """Take a test image and use camera stage mapping to calculate x and y displacement.
:param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means :param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means
that each image should overlap its nearest neighbour by 50%. that each image should overlap its nearest neighbour by 50%.
@ -520,7 +520,7 @@ class SmartScanThing(lt.Thing):
@_scan_running @_scan_running
def _return_to_starting_position(self): def _return_to_starting_position(self):
"""Return to the initial scan position, if set""" """Return to the initial scan position, if set."""
self._scan_logger.info("Returning to starting position.") self._scan_logger.info("Returning to starting position.")
if self._starting_position is not None: if self._starting_position is not None:
self._stage.move_absolute( self._stage.move_absolute(
@ -529,7 +529,7 @@ class SmartScanThing(lt.Thing):
@_scan_running @_scan_running
def _perform_final_stitch(self): def _perform_final_stitch(self):
"""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_images_taken <= 3: if self._scan_images_taken <= 3:
self._scan_logger.info("Not performing a stitch as 3 or fewer images taken") self._scan_logger.info("Not performing a stitch as 3 or fewer images taken")
return return
@ -626,7 +626,7 @@ class SmartScanThing(lt.Thing):
@lt.thing_property @lt.thing_property
def scans(self) -> list[scan_directories.ScanInfo]: def scans(self) -> list[scan_directories.ScanInfo]:
"""All the available scans """All the available scans.
Each scan has a name (which can be used to access it), along with Each scan has a name (which can be used to access it), along with
its modified and created times (according to the filesystem) and its modified and created times (according to the filesystem) and
@ -687,7 +687,7 @@ class SmartScanThing(lt.Thing):
"scans", "scans",
) )
def delete_all_scans(self, logger: lt.deps.InvocationLogger) -> None: def delete_all_scans(self, logger: lt.deps.InvocationLogger) -> None:
"""Delete all the scans on the microscope """Delete all the scans on the microscope.
**This will irreversibly remove all scanned data from the **This will irreversibly remove all scanned data from the
microscope!** microscope!**
@ -698,7 +698,7 @@ class SmartScanThing(lt.Thing):
@lt.thing_action @lt.thing_action
def purge_empty_scans(self, logger: lt.deps.InvocationLogger) -> None: def purge_empty_scans(self, logger: lt.deps.InvocationLogger) -> None:
"""Delete all scan folders containing no images at the top level""" """Delete all scan folders containing no images at the top level."""
# JSON is ignored as it's created before any images are captured # JSON is ignored as it's created before any images are captured
for scan_info in self._scan_dir_manager.all_scans_info(): for scan_info in self._scan_dir_manager.all_scans_info():
if scan_info.number_of_images == 0: if scan_info.number_of_images == 0:
@ -722,7 +722,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 not self.latest_scan_name:
return None return None
@ -732,7 +732,7 @@ class SmartScanThing(lt.Thing):
@lt.thing_property @lt.thing_property
def latest_preview_stitch_time(self) -> Optional[float]: def latest_preview_stitch_time(self) -> Optional[float]:
"""The modification time of the latest preview image, to allow live updating """The modification time of the latest preview image, to allow live updating.
This will return None (``null`` to JS) if there is no preview image to return. This will return None (``null`` to JS) if there is no preview image to return.
@ -795,7 +795,7 @@ class SmartScanThing(lt.Thing):
@_scan_running @_scan_running
def _preview_stitch_running(self) -> bool: def _preview_stitch_running(self) -> bool:
"""Whether there is a preview stitch running in a subprocess""" """Whether there is a preview stitch running in a subprocess."""
with self._preview_stitch_popen_lock: with self._preview_stitch_popen_lock:
if self._preview_stitch_popen is None: if self._preview_stitch_popen is None:
return False return False
@ -805,7 +805,7 @@ class SmartScanThing(lt.Thing):
@_scan_running @_scan_running
def _preview_stitch_wait(self): def _preview_stitch_wait(self):
"""Wait for an ongoing preview stitch to return""" """Wait for an ongoing preview stitch to return."""
if self._preview_stitch_running(): if self._preview_stitch_running():
with self._preview_stitch_popen_lock: with self._preview_stitch_popen_lock:
self._preview_stitch_popen.wait() self._preview_stitch_popen.wait()
@ -816,7 +816,7 @@ class SmartScanThing(lt.Thing):
cancel: lt.deps.CancelHook, cancel: lt.deps.CancelHook,
cmd: list[str], cmd: list[str],
) -> CompletedProcess: ) -> CompletedProcess:
"""Run a subprocess and log any output """Run a subprocess and log any output.
Raises: Raises:
ChildProcessError if exit code is not zero ChildProcessError if exit code is not zero
@ -870,7 +870,7 @@ class SmartScanThing(lt.Thing):
stitch_resize: Optional[float] = None, stitch_resize: Optional[float] = None,
overlap: float = 0.0, overlap: float = 0.0,
) -> None: ) -> None:
"""Generate a stitched image based on stage position metadata """Generate a stitched image based on stage position metadata.
Note that as this is a lt.thing_action it needs the logger passed as Note that as this is a lt.thing_action it needs the logger passed as
a variable if called from another thing action a variable if called from another thing action

View file

@ -5,7 +5,7 @@ import labthings_fastapi as lt
class BaseStage(lt.Thing): class BaseStage(lt.Thing):
"""A base stage class for OpenFlexure translation stages """A base stage class for OpenFlexure translation stages.
This can't be used directly but should reduce boilerplate code when This can't be used directly but should reduce boilerplate code when
implementing new stages. A minimal working stage must implement implementing new stages. A minimal working stage must implement
@ -38,7 +38,7 @@ class BaseStage(lt.Thing):
@property @property
def thing_state(self): def thing_state(self):
"""Summary metadata describing the current state of the stage""" """Summary metadata describing the current state of the stage."""
return {"position": self.position} return {"position": self.position}
@lt.thing_action @lt.thing_action
@ -67,7 +67,7 @@ class BaseStage(lt.Thing):
@lt.thing_action @lt.thing_action
def set_zero_position(self): def set_zero_position(self):
"""Make the current position zero in all axes """Make the current position zero in all axes.
This action does not move the stage, but resets the position to zero. This action does not move the stage, but resets the position to zero.
It is intended for use after manually or automatically recentring the It is intended for use after manually or automatically recentring the

View file

@ -9,7 +9,7 @@ from . import BaseStage
class DummyStage(BaseStage): class DummyStage(BaseStage):
"""A dummy stage for testing purposes """A dummy stage for testing purposes.
This stage should work similarly to a Sangaboard stage, but without any This stage should work similarly to a Sangaboard stage, but without any
hardware attached. hardware attached.
@ -83,7 +83,7 @@ class DummyStage(BaseStage):
@lt.thing_action @lt.thing_action
def set_zero_position(self): def set_zero_position(self):
"""Make the current position zero in all axes """Make the current position zero in all axes.
This action does not move the stage, but resets the position to zero. This action does not move the stage, but resets the position to zero.
It is intended for use after manually or automatically recentring the It is intended for use after manually or automatically recentring the

View file

@ -13,7 +13,7 @@ from . import BaseStage
class SangaboardThing(BaseStage): class SangaboardThing(BaseStage):
"""A Thing to manage a Sangaboard motor controller """A Thing to manage a Sangaboard motor controller.
Internally, this uses the ``pysangaboard`` package from PyPi. This imports Internally, this uses the ``pysangaboard`` package from PyPi. This imports
as ``sangaboard``. As ``pysangaboard`` does not support some features added as ``sangaboard``. As ``pysangaboard`` does not support some features added
@ -115,7 +115,7 @@ class SangaboardThing(BaseStage):
@lt.thing_action @lt.thing_action
def set_zero_position(self) -> None: def set_zero_position(self) -> None:
"""Make the current position zero in all axes """Make the current position zero in all axes.
This action does not move the stage, but resets the position to zero. This action does not move the stage, but resets the position to zero.
It is intended for use after manually or automatically recentring the It is intended for use after manually or automatically recentring the
@ -132,7 +132,7 @@ class SangaboardThing(BaseStage):
dt: float = 0.5, dt: float = 0.5,
led_channel: Literal["cc"] = "cc", led_channel: Literal["cc"] = "cc",
) -> None: ) -> None:
"""Flash the LED to identify the board """Flash the LED to identify the board.
This is intended to be useful in situations where there are multiple This is intended to be useful in situations where there are multiple
Sangaboards in use, and it is necessary to identify which one is Sangaboards in use, and it is necessary to identify which one is

View file

@ -1,4 +1,4 @@
"""OpenFlexure Microscope system control Thing """OpenFlexure Microscope system control Thing.
This module defines a Thing that can shut down or restart the host computer. This module defines a Thing that can shut down or restart the host computer.
""" """
@ -15,11 +15,11 @@ class CommandOutput(BaseModel):
class SystemControlThing(lt.Thing): class SystemControlThing(lt.Thing):
"""Attempt to shutdown the device""" """Attempt to shutdown the device."""
@lt.thing_action @lt.thing_action
def shutdown(self) -> CommandOutput: def shutdown(self) -> CommandOutput:
"""Attempt to shutdown the device""" """Attempt to shutdown the device."""
p = subprocess.Popen( p = subprocess.Popen(
["sudo", "shutdown", "-h", "now"], ["sudo", "shutdown", "-h", "now"],
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
@ -36,7 +36,7 @@ class SystemControlThing(lt.Thing):
@lt.thing_action @lt.thing_action
def reboot(self) -> CommandOutput: def reboot(self) -> CommandOutput:
"""Attempt to reboot the device""" """Attempt to reboot the device."""
p = subprocess.Popen( p = subprocess.Popen(
["sudo", "shutdown", "-r", "now"], ["sudo", "shutdown", "-r", "now"],
stderr=subprocess.PIPE, stderr=subprocess.PIPE,

View file

@ -17,7 +17,7 @@ class ErrorCapturingThread(Thread):
""" """
def __init__(self, group=None, target=None, args=None, kwargs=None, daemon=None): def __init__(self, group=None, target=None, args=None, kwargs=None, daemon=None):
"""Initialise with the same arguments as Thread""" """Initialise with the same arguments as Thread."""
# As all inputs are keywords we need to set the default values for args and kwargs: # As all inputs are keywords we need to set the default values for args and kwargs:
if args is None: if args is None:
args = () args = ()

View file

@ -13,5 +13,5 @@ class MockAutoFocusThing:
mock_call_count = {"looping_autofocus": 0} mock_call_count = {"looping_autofocus": 0}
def looping_autofocus(self, dz: int = 2000, start: str = "centre") -> None: # noqa: ARG002 def looping_autofocus(self, dz: int = 2000, start: str = "centre") -> None: # noqa: ARG002
"""Mock autofocus with no return""" """Mock autofocus with no return."""
self.mock_call_count["looping_autofocus"] += 1 self.mock_call_count["looping_autofocus"] += 1

View file

@ -16,7 +16,7 @@ RANDOM_GENERATOR = np.random.default_rng()
def random_image(): def random_image():
"""Create a random image""" """Create a random image."""
imarray = RANDOM_GENERATOR.integers( imarray = RANDOM_GENERATOR.integers(
low=0, high=255, size=(100, 100, 3), dtype="uint8" low=0, high=255, size=(100, 100, 3), dtype="uint8"
) )
@ -24,14 +24,14 @@ def random_image():
def random_metadata(): def random_metadata():
"""Create a misc dictionary to pretend to be metadata""" """Create a misc dictionary to pretend to be metadata."""
# Not very metadata like, but we are just checking that the same dict it # Not very metadata like, but we are just checking that the same dict it
# is returned # is returned
return {"a": randint(1, 100), "b": randint(1, 100)} return {"a": randint(1, 100), "b": randint(1, 100)}
def test_add_and_get_image(): def test_add_and_get_image():
"""Check images can be captured and retrieved""" """Check images can be captured and retrieved."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image = random_image() misc_image = random_image()
buffer_id = mem_buf.add_image(misc_image) buffer_id = mem_buf.add_image(misc_image)
@ -44,7 +44,7 @@ def test_add_and_get_image():
def test_add_and_get_image_twice(): def test_add_and_get_image_twice():
"""Check images can be retrieved twice if remove flag set false""" """Check images can be retrieved twice if remove flag set false."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image = random_image() misc_image = random_image()
buffer_id = mem_buf.add_image(misc_image) buffer_id = mem_buf.add_image(misc_image)
@ -60,7 +60,7 @@ def test_add_and_get_image_twice():
def test_get_without_id(): def test_get_without_id():
"""Check images can be captured and retrieved without ID""" """Check images can be captured and retrieved without ID."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image = random_image() misc_image = random_image()
mem_buf.add_image(misc_image) mem_buf.add_image(misc_image)
@ -92,7 +92,7 @@ def test_get_two_images():
def test_get_two_images_without_setting_buffer_size(): def test_get_two_images_without_setting_buffer_size():
"""Check two images can't be retrieved if the buffer size isn't set""" """Check two images can't be retrieved if the buffer size isn't set."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image1 = random_image() misc_image1 = random_image()
misc_image2 = random_image() misc_image2 = random_image()
@ -106,7 +106,7 @@ def test_get_two_images_without_setting_buffer_size():
def test_buffer_size_changing(): def test_buffer_size_changing():
"""Check buffer size resets back to 1 when if not set""" """Check buffer size resets back to 1 when if not set."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image1 = random_image() misc_image1 = random_image()
misc_image2 = random_image() misc_image2 = random_image()
@ -126,7 +126,7 @@ def test_buffer_size_changing():
def test_capture_two_images_get_without_id(): def test_capture_two_images_get_without_id():
"""Check that all images are deleted when getting without id""" """Check that all images are deleted when getting without id."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
misc_image1 = random_image() misc_image1 = random_image()
misc_image2 = random_image() misc_image2 = random_image()
@ -142,7 +142,7 @@ def test_capture_two_images_get_without_id():
def test_buffer_size_respected(): def test_buffer_size_respected():
"""Capture 10 images with a buffer size of 5. Check only last 5 exist""" """Capture 10 images with a buffer size of 5. Check only last 5 exist."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
images = [] images = []
@ -163,7 +163,7 @@ def test_buffer_size_respected():
def test_clear_buffer(): def test_clear_buffer():
"""Capture 10 images clear the buffer and check they are gone""" """Capture 10 images clear the buffer and check they are gone."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
images = [] images = []
@ -184,7 +184,7 @@ def test_clear_buffer():
def test_get_metadata_too(): def test_get_metadata_too():
"""Capture 10 images with metadata and check metadata is returned as expected""" """Capture 10 images with metadata and check metadata is returned as expected."""
mem_buf = CameraMemoryBuffer() mem_buf = CameraMemoryBuffer()
images = [] images = []

View file

@ -28,13 +28,13 @@ BASE_SCAN_DIR = os.path.join(tempfile.gettempdir(), "scans")
def _clear_scan_dir() -> None: def _clear_scan_dir() -> None:
"""Delete the scan dir""" """Delete the scan dir."""
if os.path.exists(BASE_SCAN_DIR): if os.path.exists(BASE_SCAN_DIR):
shutil.rmtree(BASE_SCAN_DIR) shutil.rmtree(BASE_SCAN_DIR)
def _add_fake_image(scan_dir: ScanDirectory) -> None: def _add_fake_image(scan_dir: ScanDirectory) -> None:
"""Make a fake image on disk in the scan directory""" """Make a fake image on disk in the scan directory."""
unique = False unique = False
while not unique: while not unique:
x_pos = random.randint(-10000, 10000) x_pos = random.randint(-10000, 10000)
@ -63,7 +63,7 @@ def _add_fake_file(
def _make_fake_dzi(scan_dir: ScanDirectory, n_layers: int = 8) -> None: def _make_fake_dzi(scan_dir: ScanDirectory, n_layers: int = 8) -> None:
"""Create a fake DZI in a scan """Create a fake DZI in a scan.
:param n_layers: The number of layers of tiles. I.e. tile directories numbered :param n_layers: The number of layers of tiles. I.e. tile directories numbered
0...(n_layers-1) will be created. Default 8 0...(n_layers-1) will be created. Default 8
@ -96,7 +96,7 @@ def _make_fake_dzi(scan_dir: ScanDirectory, n_layers: int = 8) -> None:
def test_basic_directory_operations(): def test_basic_directory_operations():
"""Test some basic operations """Test some basic operations.
Test some basic operations, including: Test some basic operations, including:
- ScanDirectoryManager creates a scan directory - ScanDirectoryManager creates a scan directory
@ -156,7 +156,7 @@ def test_basic_directory_operations():
def test_scan_sequence_and_listing(): def test_scan_sequence_and_listing():
"""Check created scans are added in order and listed correctly""" """Check created scans are added in order and listed correctly."""
_clear_scan_dir() _clear_scan_dir()
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR) scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
# Make 4 scans # Make 4 scans
@ -183,7 +183,7 @@ def test_scan_sequence_and_listing():
def test_scan_name_non_sequential(): def test_scan_name_non_sequential():
"""Check new scan has the correct name if the directories are not sequential""" """Check new scan has the correct name if the directories are not sequential."""
_clear_scan_dir() _clear_scan_dir()
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0001")) os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0001"))
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0002")) os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0002"))
@ -217,7 +217,7 @@ def test_all_scan_names_taken():
def test_no_scan_names_given(): def test_no_scan_names_given():
"""Check correct default scan name is used if empty string is given""" """Check correct default scan name is used if empty string is given."""
_clear_scan_dir() _clear_scan_dir()
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR) scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
scan_dir = scan_dir_manager.new_scan_dir("") scan_dir = scan_dir_manager.new_scan_dir("")
@ -225,7 +225,7 @@ def test_no_scan_names_given():
def test_scan_info(): def test_scan_info():
"""Test the scan info is correct even using fake scan data""" """Test the scan info is correct even using fake scan data."""
_clear_scan_dir() _clear_scan_dir()
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR) scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
scan_dir = scan_dir_manager.new_scan_dir("fake_scan") scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
@ -252,7 +252,7 @@ def test_scan_info():
def test_get_final_stitch(): def test_get_final_stitch():
"""Check that the final stitch can be retrieved""" """Check that the final stitch can be retrieved."""
_clear_scan_dir() _clear_scan_dir()
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR) scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
scan_dir = scan_dir_manager.new_scan_dir("fake_scan") scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
@ -280,7 +280,7 @@ def test_get_final_stitch():
def test_empty_scan_info(): def test_empty_scan_info():
"""Test the scan info is correct even if the scan is empty""" """Test the scan info is correct even if the scan is empty."""
_clear_scan_dir() _clear_scan_dir()
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR) scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
scan_dir = scan_dir_manager.new_scan_dir("fake_scan") scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
@ -298,7 +298,7 @@ def test_empty_scan_info():
def test_zipping_scan_data(): def test_zipping_scan_data():
"""Test zipping the scan images with fake image data""" """Test zipping the scan images with fake image data."""
# Run twice, once calling the ScanDirectory directly, # Run twice, once calling the ScanDirectory directly,
# Once calling the ScanDirectoryManager # Once calling the ScanDirectoryManager
for caller in ["scan_dir", "manager"]: for caller in ["scan_dir", "manager"]:
@ -338,7 +338,7 @@ def test_zipping_scan_data():
def test_all_files(): def test_all_files():
"""Test all_files returns the path, and respects skipped directories""" """Test all_files returns the path, and respects skipped directories."""
_clear_scan_dir() _clear_scan_dir()
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR) scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
scan_dir = scan_dir_manager.new_scan_dir("fake_scan") scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
@ -393,7 +393,7 @@ def test_none_returned_for_missing_images_dir():
def test_extracting_files(): def test_extracting_files():
"""Test the private _find_files method of ScanDirectories """Test the private _find_files method of ScanDirectories.
Add files to directory and check expected returns. Add files to directory and check expected returns.
""" """
@ -445,7 +445,7 @@ DiskUsage = namedtuple("DiskUsage", ["total", "used", "free"])
@pytest.mark.parametrize("free_space", [500_000_001, 700_000_000, 5_000_000_000]) @pytest.mark.parametrize("free_space", [500_000_001, 700_000_000, 5_000_000_000])
def test_disk_not_full(mocker, free_space): def test_disk_not_full(mocker, free_space):
"""Check no error thrown if disk has over 500MB of space""" """Check no error thrown if disk has over 500MB of space."""
total_space = 16_000_000_000 total_space = 16_000_000_000
# Mock the disk_usage # Mock the disk_usage
mocker.patch( mocker.patch(
@ -461,7 +461,7 @@ def test_disk_not_full(mocker, free_space):
@pytest.mark.parametrize("free_space", [100_000_000, 499_999_999]) @pytest.mark.parametrize("free_space", [100_000_000, 499_999_999])
def test_disk_full(mocker, free_space): def test_disk_full(mocker, free_space):
"""Check error thrown if disk has under 500MB of space""" """Check error thrown if disk has under 500MB of space."""
total_space = 16_000_000_000 total_space = 16_000_000_000
# Mock the disk_usage # Mock the disk_usage
mocker.patch( mocker.patch(

View file

@ -41,14 +41,14 @@ SCAN_DIR = os.path.join(tempfile.gettempdir(), "scans")
def _clear_scan_dir() -> None: def _clear_scan_dir() -> None:
"""Delete the scan dir""" """Delete the scan dir."""
if os.path.exists(SCAN_DIR): if os.path.exists(SCAN_DIR):
shutil.rmtree(SCAN_DIR) shutil.rmtree(SCAN_DIR)
@pytest.fixture @pytest.fixture
def smart_scan_thing(): def smart_scan_thing():
"""Return a smart scan thing as a fixture""" """Return a smart scan thing as a fixture."""
return SmartScanThing(SCAN_DIR) return SmartScanThing(SCAN_DIR)
@ -75,7 +75,7 @@ def test_inaccessible_scan_methods(smart_scan_thing):
def test_private_delete_scan(smart_scan_thing, caplog): def test_private_delete_scan(smart_scan_thing, caplog):
"""Test the private _delete_scan method deletes directories or warns if it can't""" """Test the private _delete_scan method deletes directories or warns if it can't."""
_clear_scan_dir() _clear_scan_dir()
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
fake_scan_name = "fake_scan_0001" fake_scan_name = "fake_scan_0001"
@ -101,7 +101,7 @@ def test_private_delete_scan(smart_scan_thing, caplog):
def test_public_delete_scan(smart_scan_thing, caplog): def test_public_delete_scan(smart_scan_thing, caplog):
"""Test the delete_scan API call deletes directories or warns if it can't""" """Test the delete_scan API call deletes directories or warns if it can't."""
_clear_scan_dir() _clear_scan_dir()
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
fake_scan_name = "fake_scan_0001" fake_scan_name = "fake_scan_0001"
@ -152,7 +152,7 @@ def test_delete_all_scans(smart_scan_thing, caplog):
def _run_only_outer_scan(adjust_inital_state: Optional[Callable] = None): def _run_only_outer_scan(adjust_inital_state: Optional[Callable] = None):
"""Create a subclass of SmartScanThing to mock _run_scan and run sample_scan """Create a subclass of SmartScanThing to mock _run_scan and run sample_scan.
This should do all the set up for a scan, move into the mocked This should do all the set up for a scan, move into the mocked
_run_scan method where this can be tested. Once this is done _run_scan method where this can be tested. Once this is done

View file

@ -90,7 +90,7 @@ def test_stack_params_not_enough_test_images(save_ims, extra_ims):
extra_ims=even_integers(min_value=0, max_value=10), extra_ims=even_integers(min_value=0, max_value=10),
) )
def test_stack_params_negative_images_to_save(save_ims, extra_ims): def test_stack_params_negative_images_to_save(save_ims, extra_ims):
"""save_ims is negative so images_to_save is negative, failing validation """save_ims is negative so images_to_save is negative, failing validation.
For arguments see test_stack_params_validation For arguments see test_stack_params_validation
""" """
@ -110,7 +110,7 @@ def test_stack_params_negative_images_to_save(save_ims, extra_ims):
extra_ims=odd_integers(min_value=0, max_value=10), extra_ims=odd_integers(min_value=0, max_value=10),
) )
def test_even_min_images_to_test(save_ims, extra_ims): def test_even_min_images_to_test(save_ims, extra_ims):
"""extra_ims is odd so min_images_to_test is even, failing validation """extra_ims is odd so min_images_to_test is even, failing validation.
For arguments see test_stack_params_validation For arguments see test_stack_params_validation
""" """
@ -130,7 +130,7 @@ def test_even_min_images_to_test(save_ims, extra_ims):
extra_ims=odd_integers(min_value=0, max_value=10), extra_ims=odd_integers(min_value=0, max_value=10),
) )
def test_even_images_to_save(save_ims, extra_ims): def test_even_images_to_save(save_ims, extra_ims):
"""save_ims is even so images_to_save is even, failing validation """save_ims is even so images_to_save is even, failing validation.
For arguments see test_stack_params_validation For arguments see test_stack_params_validation
""" """
@ -146,7 +146,7 @@ def test_even_images_to_save(save_ims, extra_ims):
def test_computed_stack_params(): def test_computed_stack_params():
"""Test StackParams computed properties are as expected """Test StackParams computed properties are as expected.
Not using hypothesis or we will just copy in the same formulas. Not using hypothesis or we will just copy in the same formulas.
""" """
@ -187,7 +187,7 @@ def test_computed_stack_params():
def random_capture(set_id: Optional[int] = None): def random_capture(set_id: Optional[int] = None):
"""Create a capture with random values """Create a capture with random values.
:param set_id: Optional, use to set a fixed id rather than a random one :param set_id: Optional, use to set a fixed id rather than a random one
""" """
@ -204,14 +204,14 @@ def random_capture(set_id: Optional[int] = None):
def test_capture_filename_matches_regex(): def test_capture_filename_matches_regex():
"""For 100 random captures check the image always matches the regex""" """For 100 random captures check the image always matches the regex."""
for _ in range(100): for _ in range(100):
assert IMAGE_REGEX.search(random_capture().filename) assert IMAGE_REGEX.search(random_capture().filename)
@given(st.integers(min_value=0, max_value=5000)) @given(st.integers(min_value=0, max_value=5000))
def test_retrieval_of_captures(start): def test_retrieval_of_captures(start):
"""For 20 random captures, check each can be retrieved correctly by id""" """For 20 random captures, check each can be retrieved correctly by id."""
captures = [random_capture(start + i) for i in range(20)] captures = [random_capture(start + i) for i in range(20)]
for i, capture in enumerate(captures): for i, capture in enumerate(captures):

View file

@ -9,7 +9,7 @@ from collections.abc import Hashable
class SizedIterableHashable(Iterable[Hashable], Protocol): class SizedIterableHashable(Iterable[Hashable], Protocol):
"""A protocol for sized iterable of hashable objects""" """A protocol for sized iterable of hashable objects."""
def __len__(self) -> int: def __len__(self) -> int:
"""Add a len function to protocol so Python knows the object is sized.""" """Add a len function to protocol so Python knows the object is sized."""

View file

@ -44,14 +44,14 @@ class FakeSample:
@property @property
def patch(self) -> PathPatch: def patch(self) -> PathPatch:
"""The sample as a matplotlib patch for plotting""" """The sample as a matplotlib patch for plotting."""
patch = PathPatch(self._sample_perimeter) patch = PathPatch(self._sample_perimeter)
patch.set(color=(1.0, 0.8, 1.0, 1.0)) patch.set(color=(1.0, 0.8, 1.0, 1.0))
return patch return patch
def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Figure: def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Figure:
"""For a given sample and scanner object return a matplotlib figure of the scan""" """For a given sample and scanner object return a matplotlib figure of the scan."""
fig, ax = plt.subplots(figsize=(8, 8)) fig, ax = plt.subplots(figsize=(8, 8))
ax.add_artist(sample.patch) ax.add_artist(sample.patch)
xh, yh = zip(*planner._path_history) xh, yh = zip(*planner._path_history)
@ -184,7 +184,7 @@ def get_expected_result_for_example_smart_spiral(
def load_sample_points(sample_name: str): def load_sample_points(sample_name: str):
"""Return the points to generate the FakeSample corresponding to the given input name """Return the points to generate the FakeSample corresponding to the given input name.
Options are "lobed", "regular", and "core". Options are "lobed", "regular", and "core".
""" """