Restructured text fixes so that pydoctor would return without an error

This commit is contained in:
Julian Stirling 2025-07-09 23:55:46 +01:00
parent 58b056988a
commit a84a916719
31 changed files with 269 additions and 256 deletions

4
.gitignore vendored
View file

@ -58,8 +58,8 @@ pytest_report.xml
# Django stuff: # Django stuff:
*.log *.log
# Sphinx documentation # Documentation
docs/_build/ apidocs
# PyBuilder # PyBuilder
target/ target/

View file

@ -82,7 +82,7 @@ def test_client_connection() -> None:
def subscribe_to_mjpeg_stream() -> subprocess.Popen: def subscribe_to_mjpeg_stream() -> subprocess.Popen:
"""Start a background process subscribed to the mjpeg stream. """Start a background process subscribed to the mjpeg stream.
:return: The Popen object for the ongoing process. :returns: The Popen object for the ongoing process.
:raises: RuntimeError if the stream is not still connected after 2s :raises: RuntimeError if the stream is not still connected after 2s
""" """
@ -130,7 +130,7 @@ def start_server() -> subprocess.Popen:
The server is started in a subprocess and all outputs are buffered. The server is started in a subprocess and all outputs are buffered.
:return: Popen object for the ongoing process :returns: Popen object for the ongoing process
""" """
process = subprocess.Popen( process = subprocess.Popen(
SERVER_CMD, SERVER_CMD,
@ -156,7 +156,6 @@ def error_if_server_not_started(
:raises RuntimeError: If the server is not running as expected. :raises RuntimeError: If the server is not running as expected.
""" """
confirmed_uvicorn_is_running = False confirmed_uvicorn_is_running = False
t_start = time() t_start = time()

View file

@ -118,10 +118,31 @@ select = [
"C90", # McCabe complexity! "C90", # McCabe complexity!
"NPY", # Numpy linting "NPY", # Numpy linting
"N", # PEP8 naming "N", # PEP8 naming
# "D", # Docstring checks these may need to be added gradually "D", # Docstring checks these may need to be added gradually
"ERA001", # Commented out code! "ERA001", # Commented out code!
] ]
ignore = [ ignore = [
"E501" # Ignore long lines for now "E501", # Ignore long lines for now
"D203", # incompatible with D204
"D213", # incompatible with D212
# Below are checkers to be turned on gradually
"D212",
"D205",
"D415",
"D400",
"D200",
"D404",
"D401",
# These should be turned on before this MR is complete
"D100",
"D102",
"D101",
"D103",
"D104",
"D105",
"D107",
] ]
[tool.ruff.lint.pydocstyle]
property-decorators = ["labthings_fastapi.thing_property", "labthings_fastapi.thing_setting"]

View file

@ -25,7 +25,7 @@ def parse_arguments() -> Namespace:
def script_directory(path): def script_directory(path):
"""resolves path to directory of the current script""" """Resolves 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

@ -23,7 +23,6 @@ class Zenodo:
def create_new_deposit(self): def create_new_deposit(self):
"""Creates a new (unpublished) Zenodo deposit and return its deposition ID.""" """Creates a new (unpublished) Zenodo deposit and return its deposition ID."""
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
r = requests.post( r = requests.post(
self.zenodo_url, self.zenodo_url,
@ -37,7 +36,6 @@ class Zenodo:
def set_metadata(self, deposition_id, metadata): def set_metadata(self, deposition_id, metadata):
"""Sets the given metadata for the specified deposit.""" """Sets the given metadata for the specified deposit."""
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
r = requests.put( r = requests.put(
self.zenodo_url + "/{}".format(deposition_id), self.zenodo_url + "/{}".format(deposition_id),
@ -50,7 +48,6 @@ class Zenodo:
def upload_file(self, deposition_id, file_path): def upload_file(self, deposition_id, file_path):
"""Uploads a new file for the given deposit.""" """Uploads a new file for the given deposit."""
file_name = os.path.basename(file_path) file_name = os.path.basename(file_path)
data = {"filename": file_name} data = {"filename": file_name}
files = {"file": open(file_path, "rb")} files = {"file": open(file_path, "rb")}
@ -65,7 +62,6 @@ class Zenodo:
def publish_deposit(self, deposition_id): def publish_deposit(self, deposition_id):
"""Publishes the given deposit. BEWARE: It is now visible to all!!!""" """Publishes the given deposit. BEWARE: It is now visible to all!!!"""
r = requests.post( r = requests.post(
self.zenodo_url + "/{}/actions/publish".format(deposition_id), self.zenodo_url + "/{}/actions/publish".format(deposition_id),
params={"access_token": self._api_token}, params={"access_token": self._api_token},
@ -75,7 +71,6 @@ class Zenodo:
def create_new_version(self, deposition_id): def create_new_version(self, deposition_id):
"""Creates a new version of an already published deposit.""" """Creates a new version of an already published deposit."""
r = requests.post( r = requests.post(
self.zenodo_url + "/{}/actions/newversion".format(deposition_id), self.zenodo_url + "/{}/actions/newversion".format(deposition_id),
params={"access_token": self._api_token}, params={"access_token": self._api_token},
@ -86,7 +81,6 @@ class Zenodo:
def remove_all_files(self, deposition_id): def remove_all_files(self, deposition_id):
"""Removes all uploaded files of a unpublished deposit.""" """Removes all uploaded files of a unpublished deposit."""
r = requests.get( r = requests.get(
self.zenodo_url + "/{}/files".format(deposition_id), self.zenodo_url + "/{}/files".format(deposition_id),
params={"access_token": self._api_token}, params={"access_token": self._api_token},

View file

@ -43,10 +43,10 @@ def retrieve_log() -> PlainTextResponse:
Returns logs since we started running the server, up to a maximum of Returns logs since we started running the server, up to a maximum of
250 messages. This log is the one shown in the UI and on the logging page. 250 messages. This log is the one shown in the UI and on the logging page.
It does not include any of the `uvicorn.access` logs as these are emmitted It does not include any of the ``uvicorn.access`` logs as these are emmitted
every time any API route is called. every time any API route is called.
All logs, including `uvicorn.access` are logged to the OFM_LOG_FILE (see above) All logs, including ``uvicorn.access`` are logged to the OFM_LOG_FILE (see above)
ths is the best place to get logs about crashes. ths is the best place to get logs about crashes.
""" """
return PlainTextResponse(OFM_HANDLER.log_history) return PlainTextResponse(OFM_HANDLER.log_history)

View file

@ -115,7 +115,6 @@ class ScanDirectoryManager:
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())
@ -153,7 +152,7 @@ class ScanDirectoryManager:
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
scans are ordered alphanumerically). scans are ordered alphanumerically).
@ -161,7 +160,6 @@ class ScanDirectoryManager:
Returns the a ScanDirectory object Returns the a ScanDirectory object
""" """
# if no scan name is set set to "scan". This done here as empty strings # if no scan name is set set to "scan". This done here as empty strings
# get passed in otherwise. # get passed in otherwise.
if not scan_name: if not scan_name:
@ -180,7 +178,7 @@ class ScanDirectoryManager:
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
in a zip. in a zip.
""" """
@ -193,7 +191,7 @@ class ScanDirectoryManager:
: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)
:raises: NotEnoughFreeSpaceError if the remaining storage is below min_space :raises NotEnoughFreeSpaceError: if the remaining storage is below min_space
""" """
disk_usage = shutil.disk_usage(self._base_scan_dir) disk_usage = shutil.disk_usage(self._base_scan_dir)
if disk_usage.free < min_space: if disk_usage.free < min_space:
@ -258,7 +256,7 @@ class ScanDirectory:
"""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()``
:returns: The list of files that match the naming convention for scan images :returns: The list of files that match the naming convention for scan images
""" """
@ -298,7 +296,6 @@ class ScanDirectory:
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)
@ -338,11 +335,10 @@ class ScanDirectory:
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
in a zip. in a zip.
""" """
zip_fname = os.path.join(self.dir_path, "images.zip") zip_fname = os.path.join(self.dir_path, "images.zip")
if os.path.isfile(zip_fname): if os.path.isfile(zip_fname):

View file

@ -60,24 +60,24 @@ class ScanPlanner:
Each subclass should implement at least the methods with NotImplementedError Each subclass should implement at least the methods with NotImplementedError
set: set:
* _parse() - to parse the planner_settings dictionary, saving values to class
* ``_parse()`` - to parse the planner_settings dictionary, saving values to class
variables variables
* _intial_location_list() - Sets the list of locations for the scan to follow * ``_intial_location_list()`` - Sets the list of locations for the scan to follow
For a simple scan pattern this should be sufficent. For more complex ones that For a simple scan pattern this should be sufficent. For more complex ones that
dynanically adjust the path it is suggested to override `mark_location_visited()` dynamically adjust the path it is suggested to override ``mark_location_visited()``
calling `super().mark_location_visited()` at the start of the method so that all calling ``super().mark_location_visited()`` at the start of the method so that all
locations are adjusted. locations are adjusted.
When subclassing be sure to use enforce_xy_tuple and enforce_xyz_tuple on any user When subclassing be sure to use ``enforce_xy_tuple`` and ``enforce_xyz_tuple`` on
data before running any user data before running.
""" """
def __init__(self, intial_position: XYPos, planner_settings: Optional[dict] = None): def __init__(self, intial_position: XYPos, planner_settings: Optional[dict] = None):
""" """
Set up lists for the path planning, and scan history. Set up lists for the path planning, and scan history.
""" """
self._initial_position = enforce_xy_tuple(intial_position) self._initial_position = enforce_xy_tuple(intial_position)
self._parse(planner_settings) self._parse(planner_settings)
@ -225,6 +225,7 @@ class ScanPlanner:
xyz_pos: the x_y_z position xyz_pos: the x_y_z position
imaged: true if an image was taken, false if not (due to background detect) imaged: true if an image was taken, false if not (due to background detect)
focused: true if autofocus completed successfully focused: true if autofocus completed successfully
""" """
# ensure is tuple! # ensure is tuple!
xyz_pos = enforce_xyz_tuple(xyz_pos) xyz_pos = enforce_xyz_tuple(xyz_pos)
@ -273,7 +274,6 @@ class SmartSpiral(ScanPlanner):
"dy" - the movement size in y "dy" - the movement size in y
"max_dist" - The maximum distance to a location can be from the centre. "max_dist" - The maximum distance to a location can be from the centre.
""" """
expected_keys = ["max_dist", "dx", "dy"] expected_keys = ["max_dist", "dx", "dy"]
invalid_msg = "SmartSpiral requires a planner_settings dictionary with keys: " invalid_msg = "SmartSpiral requires a planner_settings dictionary with keys: "
if not planner_settings: if not planner_settings:
@ -303,6 +303,7 @@ class SmartSpiral(ScanPlanner):
xyz_pos: the x_y_z position xyz_pos: the x_y_z position
imaged: true if an image was taken, false if not (due to background detect) imaged: true if an image was taken, false if not (due to background detect)
focused: true if autofocus completed successfully focused: true if autofocus completed successfully
""" """
# First call the base class to update the positions # First call the base class to update the positions
super().mark_location_visited(xyz_pos, imaged, focused) super().mark_location_visited(xyz_pos, imaged, focused)
@ -420,6 +421,7 @@ class SmartSpiral(ScanPlanner):
Args: Args:
starting_pos: the position to measure from starting_pos: the position to measure from
ending_pos: the position to measure to ending_pos: the position to measure to
""" """
move_size = np.array([self._dx, self._dy]) move_size = np.array([self._dx, self._dy])
@ -437,7 +439,7 @@ def distance_between(
""" """
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``
""" """
next_pos = np.array(next_pos, dtype="float64") next_pos = np.array(next_pos, dtype="float64")
current_pos = np.array(current_pos, dtype="float64") current_pos = np.array(current_pos, dtype="float64")

View file

@ -18,7 +18,7 @@ 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
background tasks have completed. background tasks have completed.
Without this the system exits cleanly only if no client is receiving a Without this the system exits cleanly only if no client is receiving a
@ -26,9 +26,8 @@ def set_shutdown_function(shutdown_function: Callable[[], None]):
send streaming responses. send streaming responses.
:param shutdown_function: A callable with no arguments or outputs. This :param shutdown_function: A callable with no arguments or outputs. This
should stop any async generators that may be sending to streaming responses. should stop any async generators that may be sending to streaming responses.
""" """
original_handler = Server.handle_exit original_handler = Server.handle_exit
@wraps(Server.handle_exit) @wraps(Server.handle_exit)
@ -58,7 +57,6 @@ def _get_scans_dir(config: dict) -> Optional[str]:
Return is None if there is no /smart_scan/ thing loaded. Return is None if there is no /smart_scan/ thing loaded.
""" """
if "/smart_scan/" in config["things"]: if "/smart_scan/" in config["things"]:
try: try:
return config["things"]["/smart_scan/"]["kwargs"]["scans_folder"] return config["things"]["/smart_scan/"]["kwargs"]["scans_folder"]

View file

@ -10,8 +10,8 @@ THIS_DIR = os.path.dirname(os.path.abspath(__file__))
def add_static_file(app: FastAPI, fname: str, folder: str) -> None: def add_static_file(app: FastAPI, fname: str, folder: str) -> None:
"""Add a single file to the root of the FastAPI app """Add a single file to the root of the FastAPI app
The file with name `fname` will be mounted at `/fname` - the The file with name ``fname`` will be mounted at ``/fname`` - the
`folder` does not affect where it is mounted in the app. ``folder`` does not affect where it is mounted in the 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
fname: the name of the file to add fname: the name of the file to add

View file

@ -43,7 +43,6 @@ class RecentringThing(lt.Thing):
much between these sites, making the procedure more sensitive much between these sites, making the procedure more sensitive
to noise or a failed autofocus. to noise or a failed autofocus.
""" """
max_steps = 20 max_steps = 20
dx = lateral_distance dx = lateral_distance

View file

@ -33,10 +33,10 @@ class StackParams:
:param stack_dz: The number of motor steps between images :param stack_dz: The number of motor steps between images
:param images_to_save: The number of images to save to disk :param images_to_save: The number of images to save to disk
:param min_images_to_test: The minimum number of images in the stack before, the :param min_images_to_test: The minimum number of images in the stack before, the
stack is evaluated for focus. As more images are captured evaluation of the focus stack is evaluated for focus. As more images are captured evaluation of the
is always evaluated with the same number of images. i.e. if min_images_to_test=9, focus is always evaluated with the same number of images. i.e. if
then 9 images are captured, if the stack is not well focused, a 10th image is ``min_images_to_test=9``, then 9 images are captured, if the stack is not well
captured and images 2 to 10 are evaluated for focus focused, a 10th image is captured and images 2 to 10 are evaluated for focus
:param autofocus_dz: The number of steps in a full autofocus (when required) :param autofocus_dz: The number of steps in a full autofocus (when required)
:param images_dir: The directory to save images to disk :param images_dir: The directory to save images to disk
:param save_resolution: The resolution to save the captures to disk with :param save_resolution: The resolution to save the captures to disk with
@ -101,7 +101,8 @@ class StackParams:
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
saved.""" saved.
"""
return self.stack_dz * (self.min_images_to_test - 1) return self.stack_dz * (self.min_images_to_test - 1)
@property @property
@ -109,7 +110,6 @@ class StackParams:
""" """
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
@ -157,7 +157,7 @@ def _get_capture_by_id(captures: list[CaptureInfo], buffer_id: int) -> CaptureIn
:returns: the CaptureInfo object of the capture with matching id :returns: the CaptureInfo object of the capture with matching id
:raises: ValueError if buffer_id does not match the buffer_id of any captures :raises ValueError: if buffer_id does not match the buffer_id of any captures
""" """
return captures[_get_capture_index_by_id(captures, buffer_id)] return captures[_get_capture_index_by_id(captures, buffer_id)]
@ -171,7 +171,7 @@ def _get_capture_index_by_id(captures: list[CaptureInfo], buffer_id: int) -> int
:returns: the list index of the capture with matching id :returns: the list index of the capture with matching id
:raises: ValueError if buffer_id does not match the buffer_id of any captures :raises ValueError: if buffer_id does not match the buffer_id of any captures
""" """
ids = [capture.buffer_id for capture in captures] ids = [capture.buffer_id for capture in captures]
if buffer_id not in ids: if buffer_id not in ids:
@ -290,7 +290,8 @@ class AutofocusThing(lt.Thing):
Actions here involve moving a stage in z, and using the camera to either Actions here involve moving a stage in z, and using the camera to either
capture images (generally, z-stacking) and measuring the sharpness of the capture images (generally, z-stacking) and measuring the sharpness of the
field of view to assess focus (autofocus and testing the success of a z-stack)""" field of view to assess focus (autofocus and testing the success of a z-stack)
"""
@lt.thing_action @lt.thing_action
def fast_autofocus( def fast_autofocus(
@ -336,9 +337,9 @@ class AutofocusThing(lt.Thing):
for the moves. This can be used to calibrate autofocus. for the moves. This can be used to calibrate autofocus.
Each move is relative to the last one, i.e. we will finish at Each move is relative to the last one, i.e. we will finish at
`sum(dz)` relative to the starting position. ``sum(dz)`` relative to the starting position.
If `wait` is specified, we will wait for that many seconds If ``wait`` is specified, we will wait for that many seconds
between moves. between moves.
""" """
with sharpness_monitor.run(): with sharpness_monitor.run():
@ -358,9 +359,9 @@ class AutofocusThing(lt.Thing):
): ):
"""Repeatedly autofocus the stage until it looks focused. """Repeatedly autofocus the stage until it looks focused.
This action will run the `fast_autofocus` action until it settles on a point This action will run the ``fast_autofocus`` action until it settles on a point
in the middle 3/5 of its range. Such logic can be helpful if the microscope in the middle 3/5 of its range. Such logic can be helpful if the microscope
is close to focus, but not quite within `dz/2`. It will attempt to autofocus is close to focus, but not quite within ``dz/2``. It will attempt to autofocus
up to 10 times. up to 10 times.
""" """
repeat = True repeat = True
@ -444,17 +445,18 @@ class AutofocusThing(lt.Thing):
:param cam: Camera Dependency supplied by LabThings dependency injection :param cam: Camera Dependency supplied by LabThings dependency injection
:param stage: Stage Dependency supplied by LabThings dependency injection :param stage: Stage Dependency supplied by LabThings dependency injection
:param sharpness_monitor: Sharpness Monitor Dependency (for focus detection) :param sharpness_monitor: Sharpness Monitor Dependency (for focus detection)
supplied by LabThings dependency injection supplied by LabThings dependency injection
:param images_dir: the folder to save all images :param images_dir: the folder to save all images
:param autofocus_dz: the range to autofocus over if a stack fails :param autofocus_dz: the range to autofocus over if a stack fails
:param save_resolution: The resolution the images should be saved at, the :param save_resolution: The resolution the images should be saved at, the
images will be resampled if this doesn't match the camera's capture resolution images will be resampled if this doesn't match the camera's capture
resolution
:returns: A tuple containing: :returns: A tuple containing:
- A boolean, True if stack was successfully
- The z position of the sharpest image
"""
* A boolean, True if stack was successfully
* The z position of the sharpest image
"""
# Set the variables to prevent changes from the GUI or other windows # Set the variables to prevent changes from the GUI or other windows
stack_parameters = StackParams( stack_parameters = StackParams(
stack_dz=self.stack_dz, stack_dz=self.stack_dz,
@ -511,11 +513,12 @@ class AutofocusThing(lt.Thing):
"""Return to the initial height of the current stack, and run """Return to the initial height of the current stack, and run
a looping autofocus. a looping autofocus.
Arguments: :param initial_z_pos: The initial z positions of previous captures
initial_z_pos: The initial z positions of previous captures :param autofocus_dz: the range in steps to autofocus
autofocus_dz: the range in steps to autofocus
variables stage and sharpness_monitor are Thing dependencies passed through from ``stage`` and ``sharpness_monitor`` are Thing dependencies passed through
the calling action from the calling action
""" """
stage.move_absolute(z=initial_z_pos) stage.move_absolute(z=initial_z_pos)
self.looping_autofocus( self.looping_autofocus(
@ -534,14 +537,13 @@ class AutofocusThing(lt.Thing):
"""Save the required captures to disk. Will save the sharpest image, """Save the required captures to disk. Will save the sharpest image,
and any images either side of focus. and any images either side of focus.
Arguments: :param sharpest_id: the buffer id index of the sharpest image
sharpest_id: the buffer id index of the sharpest image :param captures: a list of captures, including file name, image data and
captures: a list of captures, including file name, image data and metadata metadata
stack_parameters: a StackParams object holding stack parameters :param stack_parameters: a StackParams object holding stack parameters
variables logger and capture are Thing dependencies passed through from the :param cam: is a Thing dependency passed through from the calling action
calling action
"""
"""
sharpest_index = _get_capture_index_by_id(captures, sharpest_id) sharpest_index = _get_capture_index_by_id(captures, sharpest_id)
slice_to_save = stack_parameters.slice_to_save(sharpest_index) slice_to_save = stack_parameters.slice_to_save(sharpest_index)
@ -569,9 +571,10 @@ class AutofocusThing(lt.Thing):
:param stage: Stage Dependency to be passed through from the calling action :param stage: Stage Dependency to be passed through from the calling action
:returns: A tuple of :returns: A tuple of
- the stack result (True for successful stack, False for failed stack),
- a list of CaptureInfo objects, * the stack result (True for successful stack, False for failed stack),
- the buffer_id of the shapest image (or None if the stack failed) * a list of CaptureInfo objects,
* the buffer_id of the shapest image (or None if the stack failed)
""" """
# Move down by the height of the z stack, plus an overshoot # Move down by the height of the z stack, plus an overshoot
# Better to start too low and take too many images than too high and need to refocus # Better to start too low and take too many images than too high and need to refocus
@ -628,11 +631,11 @@ class AutofocusThing(lt.Thing):
:param cam: Camera Dependency to be passed through from the calling action :param cam: Camera Dependency to be passed through from the calling action
:param stage: Stage Dependency to be passed through from the calling action :param stage: Stage Dependency to be passed through from the calling action
:buffer_max: The maximum number of images to tell the camera to keep in memory :param buffer_max: The maximum number of images to tell the camera to keep in memory
for saving once the stack is complete for saving once the stack is complete
:return: A CaptureInfo object containing the capture information including its :returns: A CaptureInfo object containing the capture information including its
camera buffer_id needed for saving. camera buffer_id needed for saving.
""" """
stage_location = stage.position stage_location = stage.position
buffer_id = cam.capture_to_memory(buffer_max=buffer_max) buffer_id = cam.capture_to_memory(buffer_max=buffer_max)
@ -649,14 +652,19 @@ class AutofocusThing(lt.Thing):
stack is centrally enough in the stack stack is centrally enough in the stack
:param captures: a list of the capture objects to for testing if the :param captures: a list of the capture objects to for testing if the
sharpeness has converged in the centre sharpness has converged in the centre
:return: A tuple with two values: :returns: A tuple with two values:
- result - which is one of three literal values:
'success' if the sharpest image is towards the centre * result - which is one of three literal values:
'continue' if the sharpest image is in the final two images of the list
'restart' if the sharpest image is in the first two images of the list * ``success`` if the sharpest image is towards the centre
- capture_id - the buffer id of the sharpest image * ``continue`` if the sharpest image is in the final two images of the
list
* ``restart`` if the sharpest image is in the first two images of the
list
* capture_id - the buffer id of the sharpest image
""" """
sharpest_index = np.argmax([capture.sharpness for capture in captures]) sharpest_index = np.argmax([capture.sharpness for capture in captures])
# The buffer id of the sharpest image # The buffer id of the sharpest image

View file

@ -70,12 +70,12 @@ class CameraMemoryBuffer:
every time an image is added. every time an image is added.
:param image: The image to add. A PIL image is recommended, but cameras :param image: The image to add. A PIL image is recommended, but cameras
can choose to use other formats can choose to use other formats
:param metadata: Optional, a dictionary of the image metadata. :param metadata: Optional, a dictionary of the image metadata.
:param buffer_max: The maximum number of images that should be in the buffer :param buffer_max: The maximum number of images that should be in the buffer
once this images is added. Default is 1. once this images is added. Default is 1.
:return buffer_id: The id in the buffer for this image :returns: The id in the buffer for this image
""" """
self._latest_id += 1 self._latest_id += 1
self._create_space(buffer_max) self._create_space(buffer_max)
@ -94,9 +94,8 @@ class CameraMemoryBuffer:
:param buffer_id: The buffer id of the image to retrieve :param buffer_id: The buffer id of the image to retrieve
:param remove: True (default) to remove this image from the buffer, False :param remove: True (default) to remove this image from the buffer, False
to leave the image in the buffer. to leave the image in the buffer.
""" """
# No id given # No id given
if buffer_id is None: if buffer_id is None:
# Get the latest image and metadata tuple from storage # Get the latest image and metadata tuple from storage
@ -128,7 +127,7 @@ class CameraMemoryBuffer:
Create space to add an image. Create space to add an image.
:param buffer_max: The maximum number of images that should be in the buffer :param buffer_max: The maximum number of images that should be in the buffer
once another images is added. once another images is added.
""" """
# If only one image to be stored just clear the storage and return # If only one image to be stored just clear the storage and return
if buffer_max <= 1: if buffer_max <= 1:
@ -164,7 +163,8 @@ class BaseCamera(lt.Thing):
self, main_resolution: tuple[int, int], buffer_count: int self, main_resolution: tuple[int, int], buffer_count: int
) -> None: ) -> None:
"""Start (or stop and restart) the camera with the given resolution """Start (or stop and restart) the camera with the given resolution
for the main stream, and buffer_count number of images in the buffer""" for the main stream, and buffer_count number of images in the buffer
"""
raise NotImplementedError( raise NotImplementedError(
"CameraThings must define their own start_streaming method" "CameraThings must define their own start_streaming method"
) )
@ -175,9 +175,9 @@ class BaseCamera(lt.Thing):
This is called when uvicorn gets the a shutdown signal. As this is called from This is called when uvicorn gets the a shutdown signal. As this is called from
the event loop it cannot interact with the our ThingProperties or run the event loop it cannot interact with the our ThingProperties or run
`self.mjpeg_stream.stop()` as the portal cannot be called from this loop. ``self.mjpeg_stream.stop()`` as the portal cannot be called from this loop.
Instead we just set the `_streaming` value to False. This stops the async frame Instead we just set the ``_streaming`` value to False. This stops the async frame
generator when the next frame notifies. generator when the next frame notifies.
""" """
if self.stream_active: if self.stream_active:
@ -186,7 +186,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"
) )
@ -221,7 +221,7 @@ class BaseCamera(lt.Thing):
) -> 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
stream (either "main" for the preview stream, or "lores" for the low stream (either "main" for the preview stream, or "lores" for the low
resolution preview). No metadata is returned. resolution preview). No metadata is returned.
@ -267,11 +267,11 @@ class BaseCamera(lt.Thing):
: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
when calling the action when calling the action
:param metadata_getter: This should be injected automatically by Labthings :param metadata_getter: This should be injected automatically by Labthings
FastAPI when calling the action FastAPI when calling the action
:param save_resolution: can be set to resize the image before saving. By :param save_resolution: can be set to resize the image before saving. By
default this is None meaning that the image is saved at original resolution. default this is None meaning that the image is saved at original resolution.
""" """
image, metadata = self._robust_image_capture( image, metadata = self._robust_image_capture(
metadata_getter, metadata_getter,
@ -294,19 +294,19 @@ class BaseCamera(lt.Thing):
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.
:param logger: This should be injected automatically by Labthings FastAPI :param logger: This should be injected automatically by Labthings FastAPI
when calling the action when calling the action
:param metadata_getter: This should be injected automatically by Labthings :param metadata_getter: This should be injected automatically by Labthings
FastAPI when calling the action FastAPI when calling the action
:param buffer_max: The maximum number of images that should be in the buffer :param buffer_max: The maximum number of images that should be in the buffer
once this images is added. Default is 1. once this images is added. Default is 1.
:return: the buffer id of the image captured :returns: the buffer id of the image captured
""" """
image, metadata = self._robust_image_capture(metadata_getter, logger) image, metadata = self._robust_image_capture(metadata_getter, logger)
return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max) return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max)
@ -324,11 +324,12 @@ class BaseCamera(lt.Thing):
: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
when calling the action when calling the action
:param save_resolution: can be set to resize the image before saving. By :param save_resolution: can be set to resize the image before saving. By
default this is None meaning that the image is saved at original resolution. default this is None meaning that the image is saved at original
resolution.
:param buffer_id: The buffer id of the image to save, this was returned by :param buffer_id: The buffer id of the image to save, this was returned by
`capture_to_memory` ``capture_to_memory``
""" """
image, metadata = self._memory_buffer.get_image(buffer_id) image, metadata = self._memory_buffer.get_image(buffer_id)
@ -379,7 +380,8 @@ class BaseCamera(lt.Thing):
"""Saving the captured image and metadata to disk """Saving the captured image and metadata to disk
logger warning (via InvocationLogger) is raised if metadata is failed to be added logger warning (via InvocationLogger) is raised if metadata is failed to be added
IOError is raised if the file cannot be saved IOError is raised if the file cannot be saved
nothing is returned on success""" nothing is returned on success
"""
if save_resolution is not None and image.size != save_resolution: if save_resolution is not None and image.size != save_resolution:
image = image.resize(save_resolution, Image.BOX) image = image.resize(save_resolution, Image.BOX)
try: try:

View file

@ -1,7 +1,7 @@
"""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``.
See repository root for licensing information. See repository root for licensing information.
""" """
@ -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

View file

@ -264,7 +264,6 @@ class StreamingPiCamera2(BaseCamera):
@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):
@ -287,9 +286,9 @@ class StreamingPiCamera2(BaseCamera):
tuning = lt.ThingSetting(Optional[dict], None, readonly=True) tuning = lt.ThingSetting(Optional[dict], None, readonly=True)
def _initialise_picamera(self): def _initialise_picamera(self):
"""Acquire the picamera device and store it as `self._picamera`. """Acquire the picamera device and store it as ``self._picamera``.
This duplicates logic in `Picamera2.__init__` to provide a tuning file that This duplicates logic in ``Picamera2.__init__`` to provide a tuning file that
will be read when the camera system initialises. will be read when the camera system initialises.
""" """
if self._picamera_lock is not None: if self._picamera_lock is not None:
@ -332,16 +331,17 @@ class StreamingPiCamera2(BaseCamera):
@contextmanager @contextmanager
def _streaming_picamera(self, pause_stream=False) -> Iterator[Picamera2]: def _streaming_picamera(self, pause_stream=False) -> Iterator[Picamera2]:
"""Lock access to picamera and return the underlying `Picamera2` instance. """Lock access to picamera and return the underlying ``Picamera2`` instance.
Optionally the stream can be paused to allow updating the camera settings. Optionally the stream can be paused to allow updating the camera settings.
:param pause_stream: If False the `Picamera2` instance is simply yielded. :param pause_stream: If False the ``Picamera2`` instance is simply yielded.
If True: If True:
* Stop the MJPEG Stream
* Yield the `Picamera2` instance for function calling the context manager to * Stop the MJPEG Stream
make changes. * Yield the ``Picamera2`` instance for function calling the context manager to
* On closing of the context manager the stream will restart. make changes.
* On closing of the context manager the stream will restart.
""" """
already_streaming = self.stream_active already_streaming = self.stream_active
with self._picamera_lock: with self._picamera_lock:
@ -372,8 +372,9 @@ class StreamingPiCamera2(BaseCamera):
manual. manual.
Create two streams: Create two streams:
- `lores_mjpeg_stream` for autofocus at low-res resolution
- `mjpeg_stream` for preview. This is the `main_resolution` if this is less * ``lores_mjpeg_stream`` for autofocus at low-res resolution
* ``mjpeg_stream`` for preview. This is the ``main_resolution`` if this is less
than (1280, 960), or the low-res resolution if above. This allows for than (1280, 960), or the low-res resolution if above. This allows for
high resolution capture without streaming high resolution video. high resolution capture without streaming high resolution video.
@ -489,7 +490,6 @@ class StreamingPiCamera2(BaseCamera):
A TimeoutError is raised if this time is exceeded during capture. A TimeoutError is raised if this time is exceeded during capture.
Default = 0.9s, lower than the 1s timeout default in picamera yaml settings Default = 0.9s, lower than the 1s timeout default in picamera yaml settings
""" """
# This was slower than capture_image for our use case, but directly returning # This was slower than capture_image for our use case, but directly returning
# an image as an array is still a useful feature # an image as an array is still a useful feature
if stream_name == "full": if stream_name == "full":
@ -523,13 +523,13 @@ class StreamingPiCamera2(BaseCamera):
) -> 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
from the main preview stream, or the low-res preview stream, from the main preview stream, or the low-res preview stream,
respectively. This means the camera won't be reconfigured, and respectively. This means the camera won't be reconfigured, and
the stream will not pause (though it may miss one frame). the stream will not pause (though it may miss one frame).
If `full` resolution is requested, we will briefly pause the If ``full`` resolution is requested, we will briefly pause the
MJPEG stream and reconfigure the camera to capture a full MJPEG stream and reconfigure the camera to capture a full
resolution image. resolution image.
@ -588,11 +588,12 @@ class StreamingPiCamera2(BaseCamera):
the image reaches the specified white level. the image reaches the specified white level.
:param target_white_level: The target 10bit white level. 10-bit data has a :param target_white_level: The target 10bit white level. 10-bit data has a
theoretical maximum of 1023, but with black level correction the true maxiumum theoretical maximum of 1023, but with black level correction the true
is about 950. Default is 700 as this is approximately 70% saturated. maximum is about 950. Default is 700 as this is approximately 70%
saturated.
:param percentile: The percentile to use instead of maximum. Default 99.9. When :param percentile: The percentile to use instead of maximum. Default 99.9. When
calculating the brightest pixel, a percentile is used rather than the calculating the brightest pixel, a percentile is used rather than the
maximum in order to be robust to a small number of noisy/bright pixels. maximum in order to be robust to a small number of noisy/bright pixels.
""" """
with self._streaming_picamera(pause_stream=True) as cam: with self._streaming_picamera(pause_stream=True) as cam:
recalibrate_utils.adjust_shutter_and_gain_from_raw( recalibrate_utils.adjust_shutter_and_gain_from_raw(
@ -615,7 +616,7 @@ class StreamingPiCamera2(BaseCamera):
image with the lens shading correction applied, which should mean image with the lens shading correction applied, which should mean
that the image is uniform, rather than weighted towards the centre. that the image is uniform, rather than weighted towards the centre.
If `method` is `"centre"`, we will correct the mean of the central 10% If ``method`` is ``"centre"``, we will correct the mean of the central 10%
of the image. of the image.
""" """
with self._streaming_picamera(pause_stream=True) as cam: with self._streaming_picamera(pause_stream=True) as cam:
@ -657,7 +658,7 @@ class StreamingPiCamera2(BaseCamera):
def colour_correction_matrix( def colour_correction_matrix(
self, self,
) -> tuple[float, float, float, float, float, float, float, float, float]: ) -> tuple[float, float, float, float, float, float, float, float, float]:
"""The `colour_correction_matrix` from the tuning file. """The ``colour_correction_matrix`` from the tuning file.
This is broken out into its own property for convenience and compatibility with This is broken out into its own property for convenience and compatibility with
the micromanager API the micromanager API
@ -720,11 +721,11 @@ class StreamingPiCamera2(BaseCamera):
This function will call the other calibration actions in sequence: This function will call the other calibration actions in sequence:
* `flat_lens_shading` to disable flat-field * ``flat_lens_shading`` to disable flat-field
* `auto_expose_from_minimum` * ``auto_expose_from_minimum``
* `set_static_green_equalisation` to set geq offset to max * ``set_static_green_equalisation`` to set geq offset to max
* `calibrate_lens_shading` * ``calibrate_lens_shading``
* `calibrate_white_balance` * ``calibrate_white_balance``
""" """
self.flat_lens_shading() self.flat_lens_shading()
self.auto_expose_from_minimum() self.auto_expose_from_minimum()
@ -804,15 +805,15 @@ class StreamingPiCamera2(BaseCamera):
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
said pixels is the `colour_gains`. said pixels is the ``colour_gains``.
The lens shading correction is normalised such that the *minimum* The lens shading correction is normalised such that the *minimum*
gain in the `Cr` and `Cb` channels is 1. The white balance gain in the ``Cr`` and ``Cb`` channels is 1. The white balance
assumption above requires that the gain for the brightest pixels assumption above requires that the gain for the brightest pixels
is 1. The solution might be that, when calibrating, we note which is 1. The solution might be that, when calibrating, we note which
pixels are brightest (usually the centre) and explicitly use pixels are brightest (usually the centre) and explicitly use
the LST values for there. However, for now I will assume that we the LST values for there. However, for now I will assume that we
need to normalise by the **maximum** of the `Cr` and `Cb` need to normalise by the **maximum** of the ``Cr`` and ``Cb``
channels, which is correct the majority of the time. channels, which is correct the majority of the time.
""" """
if not self.lens_shading_is_static: if not self.lens_shading_is_static:

View file

@ -3,7 +3,7 @@ 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
the `picamera2` Python library. It's mostly used by the OpenFlexure the ``picamera2`` Python library. It's mostly used by the OpenFlexure
Microscope, though it deliberately has no hard dependencies on Microscope, though it deliberately has no hard dependencies on
said software, so that it's useful on its own. said software, so that it's useful on its own.
@ -20,14 +20,15 @@ to "memory" or nonlinearities in the camera's image processing
pipeline, is to use raw images. This is quite slow, but very pipeline, is to use raw images. This is quite slow, but very
reliable. The three steps above can be accomplished by: reliable. The three steps above can be accomplished by:
``` .. code-block:: python
picamera = picamera2.Picamera2()
picamera = picamera2.Picamera2()
adjust_shutter_and_gain_from_raw(picamera)
adjust_white_balance_from_raw(picamera)
lst = lst_from_camera(picamera)
picamera.lens_shading_table = lst
adjust_shutter_and_gain_from_raw(picamera)
adjust_white_balance_from_raw(picamera)
lst = lst_from_camera(picamera)
picamera.lens_shading_table = lst
```
""" """
# Disable N806 & 803, which checks that all variables and args are lowercase. # Disable N806 & 803, which checks that all variables and args are lowercase.
@ -55,7 +56,7 @@ 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
file built in. If not, this will probably crash with an error. file built in. If not, this will probably crash with an error.
Error handling for unsupported cameras is not something we are likely Error handling for unsupported cameras is not something we are likely
@ -152,25 +153,19 @@ def adjust_shutter_and_gain_from_raw(
This routine is slow but effective. It uses raw images, so we This routine is slow but effective. It uses raw images, so we
are not affected by white balance or digital gain. are not affected by white balance or digital gain.
:param camera: A Picamera2 object.
:param target_white_level: The raw, 10-bit value we aim for. The brightest pixels
should be approximately this bright. Maximum possible is about 900, 700 is
reasonable.
:param max_iterations: We will terminate once we perform this many iterations,
whether or not we converge. More than 10 shouldn't happen.
:param tolerance: How close to the target value we consider "done". Expressed as a
fraction of the ``target_white_level`` so 0.05 means +/- 5%
:param percentile: Rather then use the maximum value for each channel, we calculate
a percentile. This makes us robust to single pixels that are bright/noisy.
99.9% still picks the top of the brightness range, but seems much more reliable
than just ``np.max()``.
Arguments:
target_white_level:
The raw, 10-bit value we aim for. The brightest pixels
should be approximately this bright. Maximum possible
is about 900, 700 is reasonable.
max_iterations:
We will terminate once we perform this many iterations,
whether or not we converge. More than 10 shouldn't happen.
tolerance:
How close to the target value we consider "done". Expressed
as a fraction of the ``target_white_level`` so 0.05 means
+/- 5%
percentile:
Rather then use the maximum value for each channel, we
calculate a percentile. This makes us robust to single
pixels that are bright/noisy. 99.9% still picks the top
of the brightness range, but seems much more reliable
than just ``np.max()``.
""" """
# TODO: read black level and bit depth from camera? # TODO: read black level and bit depth from camera?
if target_white_level * (tolerance + 1) >= 959: if target_white_level * (tolerance + 1) >= 959:
@ -351,7 +346,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``
""" """
zoom_factors = [ zoom_factors = [
1, 1,
@ -381,7 +376,7 @@ def downsampled_channels(channels: np.ndarray, blacklevel=64) -> list[np.ndarray
def lst_from_channels(channels: np.ndarray) -> LensShadingTables: def lst_from_channels(channels: np.ndarray) -> LensShadingTables:
"""Given the 4 Bayer colour channels from a white image, generate a LST. """Given the 4 Bayer colour channels from a white image, generate a LST.
Internally, is just calls `downsampled_channels` and `lst_from_grids`. Internally, is just calls ``downsampled_channels`` and ``lst_from_grids``.
""" """
grids = downsampled_channels(channels) grids = downsampled_channels(channels)
return lst_from_grids(grids) return lst_from_grids(grids)
@ -392,11 +387,10 @@ def lst_from_grids(grids: np.ndarray) -> LensShadingTables:
The grids are the 4 BAYER channels RGGB The grids are the 4 BAYER channels RGGB
The LST format has changed with `picamera2` and now uses a fixed resolution, The LST format has changed with ``picamera2`` and now uses a fixed resolution,
and is in luminance, Cr, Cb format. This function returns three ndarrays of and is in luminance, Cr, Cb format. This function returns three ndarrays of
luminance, Cr, Cb, each with shape (12, 16). luminance, Cr, Cb, each with shape (12, 16).
""" """
# Calculated red, green, and blue channels from Bayer data # Calculated red, green, and blue channels from Bayer data
r: np.ndarray = grids[3, ...] r: np.ndarray = grids[3, ...]
g: np.ndarray = np.mean(grids[1:3, ...], axis=0) g: np.ndarray = np.mean(grids[1:3, ...], axis=0)
@ -419,7 +413,7 @@ def grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarra
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
`channels_from_raw_image` function. This should probably change in the ``channels_from_raw_image`` function. This should probably change in the
future. future.
""" """
G = 1 / np.array(lum) G = 1 / np.array(lum)
@ -434,9 +428,9 @@ def set_static_lst(
cr: np.ndarray, cr: np.ndarray,
cb: np.ndarray, cb: np.ndarray,
) -> None: ) -> None:
"""Update the `rpi.alsc` section of a camera tuning dict to use a static correcton. """Update the ``rpi.alsc`` section of a camera tuning dict to use a static correcton.
`tuning` will be updated in-place to set its shading to static, and disable any ``tuning`` will be updated in-place to set its shading to static, and disable any
adaptive tweaking by the algorithm. adaptive tweaking by the algorithm.
""" """
for table in luminance, cr, cb: for table in luminance, cr, cb:
@ -459,9 +453,9 @@ def set_static_ccm(
float, float, float, float, float, float, float, float, float float, float, float, float, float, float, float, float, float
], ],
) -> None: ) -> None:
"""Update the `rpi.alsc` section of a camera tuning dict to use a static correcton. """Update the ``rpi.alsc`` section of a camera tuning dict to use a static correcton.
`tuning` will be updated in-place to set its shading to static, and disable any ``tuning`` will be updated in-place to set its shading to static, and disable any
adaptive tweaking by the algorithm. adaptive tweaking by the algorithm.
""" """
ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm")
@ -469,7 +463,7 @@ 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"]
@ -484,14 +478,13 @@ def set_static_geq(
tuning: dict, tuning: dict,
offset: int = 65535, offset: int = 65535,
) -> None: ) -> None:
"""Update the `rpi.geq` section of a camera tuning dict to always use green """Update the ``rpi.geq`` section of a camera tuning dict to always use green
equalisation that averages the green pixels in the red and blue rows. equalisation that averages the green pixels in the red and blue rows.
`tuning` will be updated in-place to set the geq offest to the given value. ``tuning`` will be updated in-place to set the geq offest to the given value.
The default 65535 is the maximum allowed value. This means The default 65535 is the maximum allowed value. This means
the brightness will always be below the threshold where averaging is used. the brightness will always be below the threshold where averaging is used.
""" """
geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") geq = Picamera2.find_tuning_algo(tuning, "rpi.geq")
geq["offset"] = offset # max out offset to disable the adaptive green equalisation geq["offset"] = offset # max out offset to disable the adaptive green equalisation
@ -511,7 +504,7 @@ def index_of_algorithm(algorithms: list[dict], algorithm: str) -> int:
def copy_alsc_section(from_tuning: dict, to_tuning: dict) -> None: def copy_alsc_section(from_tuning: dict, to_tuning: dict) -> None:
"""Copy the `rpi.alsc` algorithm from one tuning to another. """Copy the ``rpi.alsc`` algorithm from one tuning to another.
This is done in-place, i.e. modifying to_tuning. This is done in-place, i.e. modifying to_tuning.
""" """

View file

@ -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

View file

@ -57,7 +57,7 @@ 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
not an integer multiple of the resampling factor, we discard not an integer multiple of the resampling factor, we discard
the left-over pixels. This avoids odd edge effects and keeps the left-over pixels. This avoids odd edge effects and keeps
performance quick. performance quick.
@ -265,14 +265,16 @@ class CameraStageMapper(lt.Thing):
that conversion. that conversion.
It is often helpful to give a concrete example: to make a move in image coordinates It is often helpful to give a concrete example: to make a move in image coordinates
(`dy`, `dx`), where `dx` is horizontal, i.e. the longer dimension of the image, you (``dy``, ``dx``), where ``dx`` is horizontal, i.e. the longer dimension of the image, you
should move the stage by: should move the stage by:
```
stage_disp = np.dot( .. code-block:: python
np.array(image_to_stage_displacement_matrix),
np.array([dy,dx]), stage_disp = np.dot(
) np.array(image_to_stage_displacement_matrix),
``` np.array([dy,dx]),
)
""" """
if self.last_calibration is None: if self.last_calibration is None:
return None return None
@ -316,8 +318,8 @@ class CameraStageMapper(lt.Thing):
swap the order of these coordinates. This includes opencv and PIL. So, don't be swap the order of these coordinates. This includes opencv and PIL. So, don't be
surprised if you find it necessary to swap x and y around. surprised if you find it necessary to swap x and y around.
As a general rule, `x` usually corresponds to the longer dimension of the image, As a general rule, ``x`` usually corresponds to the longer dimension of the image,
and `y` to the shorter one. Checking what shape your chosen toolkit reports for and ``y`` to the shorter one. Checking what shape your chosen toolkit reports for
an image usually helps resolve any ambiguity. an image usually helps resolve any ambiguity.
""" """
self.assert_calibrated() self.assert_calibrated()

View file

@ -66,9 +66,9 @@ class SettingsManager(lt.Thing):
recursively, i.e. if a key exists, it will be added to rather than recursively, i.e. if a key exists, it will be added to rather than
replaced. replaced.
If a key is supplied, we will treat `data` as being relative to that If a key is supplied, we will treat ``data`` as being relative to that
key, i.e. calling this action with `data={"a":1 }, key="foo/bar"` is key, i.e. calling this action with ``data={"a":1 }, key="foo/bar"`` is
equivalent to calling it with `data={"foo": {"bar": {"a": 1}}}`. equivalent to calling it with ``data={"foo": {"bar": {"a": 1}}}``.
""" """
metadata = self.external_metadata metadata = self.external_metadata
subdict = metadata subdict = metadata
@ -84,8 +84,8 @@ class SettingsManager(lt.Thing):
"""Delete a key from the stored metadata. """Delete a key from the stored metadata.
The key may contain forward slashes, which are understood to separate The key may contain forward slashes, which are understood to separate
levels of the dictionary - i.e. `'a/c'` will remove the `c` key from a levels of the dictionary - i.e. ``'a/c'`` will remove the ``c`` key from a
dictionary that looks like: `{'a': {'c': 1}, 'b': {'d': 2}}` dictionary that looks like: ``{'a': {'c': 1}, 'b': {'d': 2}}``
""" """
metadata = self.external_metadata metadata = self.external_metadata
try: try:

View file

@ -114,7 +114,6 @@ class SmartScanThing(lt.Thing):
stopping once it is surrounded by "background" (as detected by the stopping once it is surrounded by "background" (as detected by the
background_detect Thing) or reaches the "max_range" measured in steps. background_detect Thing) or reaches the "max_range" measured in steps.
""" """
got_lock = self._scan_lock.acquire(timeout=0.1) got_lock = self._scan_lock.acquire(timeout=0.1)
if not got_lock: if not got_lock:
raise RuntimeError("Trying to run scan while scan is already running!") raise RuntimeError("Trying to run scan while scan is already running!")
@ -214,7 +213,6 @@ class SmartScanThing(lt.Thing):
Returns the (x,y,z) with the chosen z_estimate Returns the (x,y,z) with the chosen z_estimate
""" """
if z_estimate is None: if z_estimate is None:
z_estimate = self._stage.position["z"] z_estimate = self._stage.position["z"]
@ -233,9 +231,9 @@ class SmartScanThing(lt.Thing):
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%.
Return (dx, dy) - the x and y displacments in steps :returns: (dx, dy) - the x and y displacments in steps
""" """
test_jpg = self._cam.grab_jpeg() test_jpg = self._cam.grab_jpeg()
test_image = np.array(Image.open(test_jpg.open())) test_image = np.array(Image.open(test_jpg.open()))
@ -375,7 +373,6 @@ class SmartScanThing(lt.Thing):
""" """
Manage the stitching threads, starting them if needed and not already running. Manage the stitching threads, starting them if needed and not already running.
""" """
# Assume 4 images means at least one offset in x and y, making the stitching # Assume 4 images means at least one offset in x and y, making the stitching
# well constrained. # well constrained.
if self._scan_images_taken > 3: if self._scan_images_taken > 3:
@ -390,7 +387,6 @@ class SmartScanThing(lt.Thing):
determine whether the scan should be stitched and the microscope determine whether the scan should be stitched and the microscope
should return to the starting x,y,z position should return to the starting x,y,z position
""" """
# Used to check if finally was reached via exception (except # Used to check if finally was reached via exception (except
# cancel by user) # cancel by user)
scan_successful = True scan_successful = True
@ -462,7 +458,6 @@ class SmartScanThing(lt.Thing):
The loop to run through during a scan, until no more scan x,y positions The loop to run through during a scan, until no more scan x,y positions
are remaining. are remaining.
""" """
# The initial plan for the scan should be a single x,y position. All future # The initial plan for the scan should be a single x,y position. All future
# moves will be planned around this point. In future, route planner could # moves will be planned around this point. In future, route planner could
# have multiple starting positions, each of which will be visited before the # have multiple starting positions, each of which will be visited before the
@ -537,7 +532,6 @@ 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
@ -608,7 +602,7 @@ class SmartScanThing(lt.Thing):
model=bool, model=bool,
description="""Whether to detect and skip empty fields of view description="""Whether to detect and skip empty fields of view
This uses the settings from the `background_detect` Thing.""", This uses the settings from the ``BackgroundDetectThing``.""",
) )
autofocus_dz = lt.ThingSetting( autofocus_dz = lt.ThingSetting(
@ -638,7 +632,7 @@ class SmartScanThing(lt.Thing):
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
the number of items in the `images` folder. Note that image count the number of items in the ``images`` folder. Note that image count
uses a regular expression, and changes to the naming scheme will uses a regular expression, and changes to the naming scheme will
break it. break it.
""" """
@ -660,8 +654,8 @@ class SmartScanThing(lt.Thing):
"""Return the stitched image corresponding to a given scan name, if it exists. """Return the stitched image corresponding to a given scan name, if it exists.
Will only return a file ending in suffix STITCH_SUFFIX Will only return a file ending in suffix STITCH_SUFFIX
Note: when downloading this, the default filename will be `scan_name`.jpeg""" Note: when downloading this, the default filename will be ``scan_name``.jpeg
"""
stitch_path = self._scan_dir_manager.get_final_stitch_path(scan_name) stitch_path = self._scan_dir_manager.get_final_stitch_path(scan_name)
if stitch_path is None: if stitch_path is None:
@ -709,7 +703,6 @@ class SmartScanThing(lt.Thing):
""" """
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:
@ -732,7 +725,6 @@ 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
@ -744,9 +736,10 @@ class SmartScanThing(lt.Thing):
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.
This is used for two reasons: This is used for two reasons:
1. If all caching was turned off this stitch would be sent over the network 1. If all caching was turned off this stitch would be sent over the network
repeatedly repeatedly
2. If caching was is on, then the stitch will not update when needed. 2. If caching was is on, then the stitch will not update when needed.
@ -834,12 +827,14 @@ class SmartScanThing(lt.Thing):
Raises: Raises:
ChildProcessError if exit code is not zero ChildProcessError if exit code is not zero
InvocationCancelledError if the action is cancelled. InvocationCancelledError if the action is cancelled.
""" """
logger.info(f"Running command in subprocess: `{' '.join(cmd)}`") logger.info(f"Running command in subprocess: `{' '.join(cmd)}`")
def log_buffer(buffer): def log_buffer(buffer):
"""A short internal function to read everything in the buffer to """A short internal function to read everything in the buffer to
the log""" the log
"""
while line := buffer.readline(): while line := buffer.readline():
logger.info(line) logger.info(line)
@ -968,6 +963,7 @@ class SmartScanThing(lt.Thing):
scan_name: str, scan_name: str,
): ):
"""Update the zip to include the files left until the end, then return the """Update the zip to include the files left until the end, then return the
zip file as a Blob""" zip file as a Blob
"""
zip_fname = self._scan_dir_manager.zip_scan(scan_name, final_version=True) zip_fname = self._scan_dir_manager.zip_scan(scan_name, final_version=True)
return ZipBlob.from_file(zip_fname) return ZipBlob.from_file(zip_fname)

View file

@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from typing import TypeAlias
from collections.abc import Sequence, Mapping from collections.abc import Sequence, Mapping
import labthings_fastapi as lt import labthings_fastapi as lt
@ -10,8 +9,8 @@ class BaseStage(lt.Thing):
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
`move_relative` and `move_absolute` actions, which update the ``move_relative`` and ``move_absolute`` actions, which update the
`position` property on completion, and provide `set_zero_position`. ``position`` property on completion, and provide ``set_zero_position``.
""" """
_axis_names = ("x", "y", "z") _axis_names = ("x", "y", "z")
@ -79,6 +78,4 @@ class BaseStage(lt.Thing):
) )
StageDependency: TypeAlias = lt.deps.direct_thing_client_dependency( StageDependency = lt.deps.direct_thing_client_dependency(BaseStage, "/stage/")
BaseStage, "/stage/"
)

View file

@ -13,13 +13,26 @@ from . import BaseStage
class SangaboardThing(BaseStage): class SangaboardThing(BaseStage):
def __init__(self, port: str = None, **kwargs): """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
as ``sangaboard``. As ``pysangaboard`` does not support some features added
to the Sangaboard firmware v1 (LED flashing, aborting moves, etc) this
functionality is accessed by directly querying the serial interface.
"""
def __init__(self, port: str = None, **kwargs):
"""Initialise SangaboardThing.
Initialise the "Thing", but do not initialise an underlying
``Sangaboard`` object from ``pysangaboard`` until the Thing context
manager is started.
:param port: The serial port for the Sangaboard. Optional, this is used
to stop the Sangaboard object querying available devices.
:param ``**kwargs``: Any other keyword arguments to be passed to the
Sangaboard class
Internally, this uses the `pysangaboard` package from PyPi. This imports
as `sangaboard`. As `pysangaboard` does not support some features added
to the Sangaboard firmware v1 (LED flashing, aborting moves, etc) this
functionality is accessed by directly querying the serial interface.
""" """
self.sangaboard_kwargs = kwargs self.sangaboard_kwargs = kwargs
self.sangaboard_kwargs["port"] = port self.sangaboard_kwargs["port"] = port
@ -41,9 +54,9 @@ class SangaboardThing(BaseStage):
@contextmanager @contextmanager
def sangaboard(self) -> Iterator[sangaboard.Sangaboard]: def sangaboard(self) -> Iterator[sangaboard.Sangaboard]:
"""Return the wrapped `sangaboard.Sangaboard` instance. """Return the wrapped ``sangaboard.Sangaboard`` instance.
This is protected by a `threading.RLock`, which may change in future. This is protected by a ``threading.RLock``, which may change in future.
""" """
with self._sangaboard_lock: with self._sangaboard_lock:
yield self._sangaboard yield self._sangaboard

View file

@ -36,9 +36,7 @@ class SystemControlThing(lt.Thing):
@lt.thing_property @lt.thing_property
def is_raspberrypi() -> bool: def is_raspberrypi() -> bool:
""" """Return True if running on a Raspberry Pi."""
Checks if we are running on a Raspberry Pi.
"""
return os.path.exists("/usr/bin/raspi-config") return os.path.exists("/usr/bin/raspi-config")
@lt.thing_action @lt.thing_action

View file

@ -60,20 +60,18 @@ class ErrorCapturingThread(Thread):
def _wrap_and_catch_errors(target, error_buffer, *args, **kwargs): def _wrap_and_catch_errors(target, error_buffer, *args, **kwargs):
""" """Run target function in a try-except block.
This function is designed only to be used by
ErrorCapturingThread
It will run a target function in a try-except block This function is designed only to be used by ErrorCapturingThread.
any errors caught are added to an empty list that
should be supplied as a keyword argument
Arguments: It will run a target function in a try-except block catching any exception.
* target - The target function to call If an exception is caught it is added to ``error_buffer``.
* error_buffer - An empty list that is used for returning
the exception from the thread :param target: The target function to call
*args: The arguments for the target function :param error_buffer: An empty list that is used for returning the exception from
**kwargs: The Keyword arguments for the target function the thread. It is a list to ensure it is passed by reference.
:param ``*args``: The arguments for the target function
:param ``**kwargs``: The Keyword arguments for the target function
""" """
try: try:
target(*args, **kwargs) target(*args, **kwargs)

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
"""This function mocks 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

@ -68,7 +68,6 @@ def _make_fake_dzi(scan_dir: ScanDirectory, n_layers: int = 8) -> None:
: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
""" """
# Add an a dzi image # Add an a dzi image
dzi_fname = scan_dir.name + ".dzi" dzi_fname = scan_dir.name + ".dzi"
dzi_path = os.path.join(scan_dir.images_dir, dzi_fname) dzi_path = os.path.join(scan_dir.images_dir, dzi_fname)
@ -309,7 +308,6 @@ 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"]:

View file

@ -257,7 +257,8 @@ def test_example_smart_spiral():
below and defined in scan_test_helpers.load_sample_points below and defined in scan_test_helpers.load_sample_points
Will fail if the locations or path between locations visited has changed Will fail if the locations or path between locations visited has changed
for any of the samples listed""" for any of the samples listed
"""
example_samples = [ example_samples = [
"regular", "regular",
"lobed", "lobed",

View file

@ -65,7 +65,8 @@ def test_initial_properties(smart_scan_thing):
def test_inaccessible_scan_methods(smart_scan_thing): def test_inaccessible_scan_methods(smart_scan_thing):
"""The @_scan_running decorator should make some methods """The @_scan_running decorator should make some methods
inaccessible unless a scan is running""" inaccessible unless a scan is running
"""
with pytest.raises(ScanNotRunningError): with pytest.raises(ScanNotRunningError):
smart_scan_thing._run_scan() smart_scan_thing._run_scan()
with pytest.raises(ScanNotRunningError): with pytest.raises(ScanNotRunningError):
@ -74,7 +75,6 @@ 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,6 @@ 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"
@ -166,7 +165,6 @@ def _run_only_outer_scan(adjust_inital_state: Optional[Callable] = None):
This seems hard to do with a fixture so it is being done with a private This seems hard to do with a fixture so it is being done with a private
function function
""" """
# cancel handle shouldn't be used. Set to arbitrary value for checking # cancel handle shouldn't be used. Set to arbitrary value for checking
cancel_mock = 1 # not called cancel_mock = 1 # not called
af_mock = MockAutoFocusThing() af_mock = MockAutoFocusThing()

View file

@ -23,7 +23,7 @@ RANDOM_GENERATOR = np.random.default_rng()
def odd_integers(min_value=0, max_value=1000): def odd_integers(min_value=0, max_value=1000):
"""A hypothesis strategy for odd integers""" """Return a hypothesis strategy for odd integers."""
min_base = (min_value) // 2 min_base = (min_value) // 2
max_base = (max_value - 1) // 2 max_base = (max_value - 1) // 2
# Ensure the range allows at least one odd number # Ensure the range allows at least one odd number
@ -33,7 +33,7 @@ def odd_integers(min_value=0, max_value=1000):
def even_integers(min_value=0, max_value=1000): def even_integers(min_value=0, max_value=1000):
"""A hypothesis strategy for even integers""" """Return a hypothesis strategy for even integers."""
min_base = (min_value + 1) // 2 min_base = (min_value + 1) // 2
max_base = (max_value) // 2 max_base = (max_value) // 2
# Ensure the range allows at least one even number # Ensure the range allows at least one even number
@ -54,7 +54,6 @@ def test_stack_params_validation(save_ims, extra_ims):
images_to_save. (even so that, min_images_to_test is odd and larger than images_to_save. (even so that, min_images_to_test is odd and larger than
images_to_save images_to_save
""" """
StackParams( StackParams(
stack_dz=50, stack_dz=50,
images_to_save=save_ims, images_to_save=save_ims,

View file

@ -12,7 +12,8 @@ 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."""
def assert_unique_of_length(data: SizedIterableHashable, length: int) -> None: def assert_unique_of_length(data: SizedIterableHashable, length: int) -> None:

View file

@ -91,7 +91,6 @@ def interp_closed_path(xy_points: list[tuple[int, int]], n_points: int) -> MatPa
Modified from: Modified from:
https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy
""" """
# Use zip to seperate x and y points into tuples # Use zip to seperate x and y points into tuples
x, y = zip(*xy_points) x, y = zip(*xy_points)