From a84a916719a58fbf6f2d8472b831b6145bfb3467 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 9 Jul 2025 23:55:46 +0100 Subject: [PATCH] Restructured text fixes so that pydoctor would return without an error --- .gitignore | 4 +- integration-tests/testfile.py | 5 +- pyproject.toml | 25 ++++- scripts/zenodo/upload_to_zenodo.py | 2 +- scripts/zenodo/zenodo.py | 6 -- src/openflexure_microscope_server/logging.py | 4 +- .../scan_directories.py | 14 +-- .../scan_planners.py | 20 ++-- .../server/__init__.py | 6 +- .../server/serve_static_files.py | 4 +- .../things/auto_recentre_stage.py | 1 - .../things/autofocus.py | 96 ++++++++++--------- .../things/camera/__init__.py | 48 +++++----- .../things/camera/opencv.py | 4 +- .../things/camera/picamera.py | 61 ++++++------ .../camera/picamera_recalibrate_utils.py | 75 +++++++-------- .../things/camera/simulation.py | 2 +- .../things/camera_stage_mapping.py | 22 +++-- .../things/settings_manager.py | 10 +- .../things/smart_scan.py | 30 +++--- .../things/stage/__init__.py | 9 +- .../things/stage/sangaboard.py | 29 ++++-- .../things/system_control.py | 4 +- .../utilities.py | 22 ++--- tests/mock_things/mock_autofocus.py | 2 +- tests/test_scan_directories.py | 2 - tests/test_scan_planners.py | 3 +- tests/test_smart_scan.py | 6 +- tests/test_stack.py | 5 +- tests/utilities/__init__.py | 3 +- tests/utilities/scan_test_helpers.py | 1 - 31 files changed, 269 insertions(+), 256 deletions(-) diff --git a/.gitignore b/.gitignore index 19581d57..b94a21d8 100644 --- a/.gitignore +++ b/.gitignore @@ -58,8 +58,8 @@ pytest_report.xml # Django stuff: *.log -# Sphinx documentation -docs/_build/ +# Documentation +apidocs # PyBuilder target/ diff --git a/integration-tests/testfile.py b/integration-tests/testfile.py index 959e24cc..fe6cce77 100755 --- a/integration-tests/testfile.py +++ b/integration-tests/testfile.py @@ -82,7 +82,7 @@ def test_client_connection() -> None: def subscribe_to_mjpeg_stream() -> subprocess.Popen: """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 """ @@ -130,7 +130,7 @@ def start_server() -> subprocess.Popen: 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( SERVER_CMD, @@ -156,7 +156,6 @@ def error_if_server_not_started( :raises RuntimeError: If the server is not running as expected. """ - confirmed_uvicorn_is_running = False t_start = time() diff --git a/pyproject.toml b/pyproject.toml index ba5bd0da..f0428cca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,10 +118,31 @@ select = [ "C90", # McCabe complexity! "NPY", # Numpy linting "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! ] 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"] \ No newline at end of file diff --git a/scripts/zenodo/upload_to_zenodo.py b/scripts/zenodo/upload_to_zenodo.py index 64eefdeb..2531f4cf 100644 --- a/scripts/zenodo/upload_to_zenodo.py +++ b/scripts/zenodo/upload_to_zenodo.py @@ -25,7 +25,7 @@ def parse_arguments() -> Namespace: 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) diff --git a/scripts/zenodo/zenodo.py b/scripts/zenodo/zenodo.py index 943a50df..2d64cf1a 100644 --- a/scripts/zenodo/zenodo.py +++ b/scripts/zenodo/zenodo.py @@ -23,7 +23,6 @@ class Zenodo: def create_new_deposit(self): """Creates a new (unpublished) Zenodo deposit and return its deposition ID.""" - headers = {"Content-Type": "application/json"} r = requests.post( self.zenodo_url, @@ -37,7 +36,6 @@ class Zenodo: def set_metadata(self, deposition_id, metadata): """Sets the given metadata for the specified deposit.""" - headers = {"Content-Type": "application/json"} r = requests.put( self.zenodo_url + "/{}".format(deposition_id), @@ -50,7 +48,6 @@ class Zenodo: def upload_file(self, deposition_id, file_path): """Uploads a new file for the given deposit.""" - file_name = os.path.basename(file_path) data = {"filename": file_name} files = {"file": open(file_path, "rb")} @@ -65,7 +62,6 @@ class Zenodo: def publish_deposit(self, deposition_id): """Publishes the given deposit. BEWARE: It is now visible to all!!!""" - r = requests.post( self.zenodo_url + "/{}/actions/publish".format(deposition_id), params={"access_token": self._api_token}, @@ -75,7 +71,6 @@ class Zenodo: def create_new_version(self, deposition_id): """Creates a new version of an already published deposit.""" - r = requests.post( self.zenodo_url + "/{}/actions/newversion".format(deposition_id), params={"access_token": self._api_token}, @@ -86,7 +81,6 @@ class Zenodo: def remove_all_files(self, deposition_id): """Removes all uploaded files of a unpublished deposit.""" - r = requests.get( self.zenodo_url + "/{}/files".format(deposition_id), params={"access_token": self._api_token}, diff --git a/src/openflexure_microscope_server/logging.py b/src/openflexure_microscope_server/logging.py index 63832652..cb50251f 100644 --- a/src/openflexure_microscope_server/logging.py +++ b/src/openflexure_microscope_server/logging.py @@ -43,10 +43,10 @@ def retrieve_log() -> PlainTextResponse: 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. - 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. - 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. """ return PlainTextResponse(OFM_HANDLER.log_history) diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index 361abf40..8a9282eb 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -115,7 +115,6 @@ class ScanDirectoryManager: def all_scans_info(self) -> list[ScanInfo]: """Return a lists of ScanInfo objects for each scan""" - all_info: list[ScanInfo] = [] for scan_name in self.all_scans: 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": """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 scans are ordered alphanumerically). @@ -161,7 +160,6 @@ class ScanDirectoryManager: Returns the a ScanDirectory object """ - # if no scan name is set set to "scan". This done here as empty strings # get passed in otherwise. if not scan_name: @@ -180,7 +178,7 @@ class ScanDirectoryManager: 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 - `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 in a zip. """ @@ -193,7 +191,7 @@ class ScanDirectoryManager: :param min_space: the minimum space required in bytes. 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) if disk_usage.free < min_space: @@ -258,7 +256,7 @@ class ScanDirectory: """Extract files which match the naming convention for scan images :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 """ @@ -298,7 +296,6 @@ class ScanDirectory: def scan_info(self) -> ScanInfo: """Return the information for the scan directory as a ScanInfo object""" - scan_files = self.get_scan_files() scan_images = self._extract_scan_images(scan_files) stitches = self._extract_final_stitches(scan_files) @@ -338,11 +335,10 @@ class ScanDirectory: def zip_files(self, final_version: bool = False) -> str: """Zips any images from the scan not yet zipped, return full path to zip - `final_version` Set true to stitch all files not just the scan images + ``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 in a zip. """ - zip_fname = os.path.join(self.dir_path, "images.zip") if os.path.isfile(zip_fname): diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 32dea090..a6f5951f 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -60,24 +60,24 @@ class ScanPlanner: Each subclass should implement at least the methods with NotImplementedError set: - * _parse() - to parse the planner_settings dictionary, saving values to class + + * ``_parse()`` - to parse the planner_settings dictionary, saving values to class 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 - dynanically 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 + 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 locations are adjusted. - When subclassing be sure to use enforce_xy_tuple and enforce_xyz_tuple on any user - data before running + When subclassing be sure to use ``enforce_xy_tuple`` and ``enforce_xyz_tuple`` on + any user data before running. """ def __init__(self, intial_position: XYPos, planner_settings: Optional[dict] = None): """ Set up lists for the path planning, and scan history. """ - self._initial_position = enforce_xy_tuple(intial_position) self._parse(planner_settings) @@ -225,6 +225,7 @@ class ScanPlanner: xyz_pos: the x_y_z position imaged: true if an image was taken, false if not (due to background detect) focused: true if autofocus completed successfully + """ # ensure is tuple! xyz_pos = enforce_xyz_tuple(xyz_pos) @@ -273,7 +274,6 @@ class SmartSpiral(ScanPlanner): "dy" - the movement size in y "max_dist" - The maximum distance to a location can be from the centre. """ - expected_keys = ["max_dist", "dx", "dy"] invalid_msg = "SmartSpiral requires a planner_settings dictionary with keys: " if not planner_settings: @@ -303,6 +303,7 @@ class SmartSpiral(ScanPlanner): xyz_pos: the x_y_z position imaged: true if an image was taken, false if not (due to background detect) focused: true if autofocus completed successfully + """ # First call the base class to update the positions super().mark_location_visited(xyz_pos, imaged, focused) @@ -420,6 +421,7 @@ class SmartSpiral(ScanPlanner): Args: starting_pos: the position to measure from ending_pos: the position to measure to + """ move_size = np.array([self._dx, self._dy]) @@ -437,7 +439,7 @@ def distance_between( """ 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") current_pos = np.array(current_pos, dtype="float64") diff --git a/src/openflexure_microscope_server/server/__init__.py b/src/openflexure_microscope_server/server/__init__.py index 7f54b05d..4b7ec87d 100644 --- a/src/openflexure_microscope_server/server/__init__.py +++ b/src/openflexure_microscope_server/server/__init__.py @@ -18,7 +18,7 @@ def set_shutdown_function(shutdown_function: Callable[[], None]): Ensure a function is called before the shutdown 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. 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. :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 @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. """ - if "/smart_scan/" in config["things"]: try: return config["things"]["/smart_scan/"]["kwargs"]["scans_folder"] diff --git a/src/openflexure_microscope_server/server/serve_static_files.py b/src/openflexure_microscope_server/server/serve_static_files.py index 123c6d3e..c4e67bf4 100644 --- a/src/openflexure_microscope_server/server/serve_static_files.py +++ b/src/openflexure_microscope_server/server/serve_static_files.py @@ -10,8 +10,8 @@ THIS_DIR = os.path.dirname(os.path.abspath(__file__)) def add_static_file(app: FastAPI, fname: str, folder: str) -> None: """Add a single file to the root of the FastAPI app - The file with name `fname` will be mounted at `/fname` - the - `folder` does not affect where it is mounted in the app. + The file with name ``fname`` will be mounted at ``/fname`` - the + ``folder`` does not affect where it is mounted in the app. app: The FastAPI app to add to, in this case the OpenFlexure server fname: the name of the file to add diff --git a/src/openflexure_microscope_server/things/auto_recentre_stage.py b/src/openflexure_microscope_server/things/auto_recentre_stage.py index 28ca6936..56bcd78b 100644 --- a/src/openflexure_microscope_server/things/auto_recentre_stage.py +++ b/src/openflexure_microscope_server/things/auto_recentre_stage.py @@ -43,7 +43,6 @@ class RecentringThing(lt.Thing): much between these sites, making the procedure more sensitive to noise or a failed autofocus. """ - max_steps = 20 dx = lateral_distance diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 23766e72..e7b9c339 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -33,10 +33,10 @@ class StackParams: :param stack_dz: The number of motor steps between images :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 - stack is evaluated for focus. As more images are captured evaluation of the focus - is always evaluated with the same number of images. i.e. if min_images_to_test=9, - then 9 images are captured, if the stack is not well focused, a 10th image is - captured and images 2 to 10 are evaluated for focus + stack is evaluated for focus. As more images are captured evaluation of the + focus is always evaluated with the same number of images. i.e. if + ``min_images_to_test=9``, then 9 images are captured, if the stack is not well + 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 images_dir: The directory to save images to disk :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, 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) @property @@ -109,7 +110,6 @@ class StackParams: """ The distance to deliberately undershoot the estimated optimal starting point """ - # Starting too low by "steps_undershoot" makes smart stacking faster. # Starting a stack too high requires it to move to the start, # 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 - :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)] @@ -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 - :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] 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 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 def fast_autofocus( @@ -336,9 +337,9 @@ class AutofocusThing(lt.Thing): for the moves. This can be used to calibrate autofocus. 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. """ with sharpness_monitor.run(): @@ -358,9 +359,9 @@ class AutofocusThing(lt.Thing): ): """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 - 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. """ repeat = True @@ -444,17 +445,18 @@ class AutofocusThing(lt.Thing): :param cam: Camera Dependency supplied by LabThings dependency injection :param stage: Stage Dependency supplied by LabThings dependency injection :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 autofocus_dz: the range to autofocus over if a stack fails :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: - - 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 stack_parameters = StackParams( stack_dz=self.stack_dz, @@ -511,11 +513,12 @@ class AutofocusThing(lt.Thing): """Return to the initial height of the current stack, and run a looping autofocus. - Arguments: - initial_z_pos: The initial z positions of previous captures - autofocus_dz: the range in steps to autofocus - variables stage and sharpness_monitor are Thing dependencies passed through from - the calling action + :param initial_z_pos: The initial z positions of previous captures + :param autofocus_dz: the range in steps to autofocus + + ``stage`` and ``sharpness_monitor`` are Thing dependencies passed through + from the calling action + """ stage.move_absolute(z=initial_z_pos) self.looping_autofocus( @@ -534,14 +537,13 @@ class AutofocusThing(lt.Thing): """Save the required captures to disk. Will save the sharpest image, and any images either side of focus. - Arguments: - sharpest_id: the buffer id index of the sharpest image - captures: a list of captures, including file name, image data and metadata - stack_parameters: a StackParams object holding stack parameters - variables logger and capture are Thing dependencies passed through from the - calling action - """ + :param sharpest_id: the buffer id index of the sharpest image + :param captures: a list of captures, including file name, image data and + metadata + :param stack_parameters: a StackParams object holding stack parameters + :param cam: is a Thing dependency passed through from the calling action + """ sharpest_index = _get_capture_index_by_id(captures, sharpest_id) 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 :returns: A tuple of - - the stack result (True for successful stack, False for failed stack), - - a list of CaptureInfo objects, - - the buffer_id of the shapest image (or None if the stack failed) + + * the stack result (True for successful stack, False for failed stack), + * 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 # 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 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 - for saving once the stack is complete + :param buffer_max: The maximum number of images to tell the camera to keep in memory + for saving once the stack is complete - :return: A CaptureInfo object containing the capture information including its - camera buffer_id needed for saving. + :returns: A CaptureInfo object containing the capture information including its + camera buffer_id needed for saving. """ stage_location = stage.position 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 :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: - - result - which is one of three literal values: - 'success' if the sharpest image is towards the centre - '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 + :returns: A tuple with two values: + + * result - which is one of three literal values: + + * ``success`` if the sharpest image is towards the centre + * ``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]) # The buffer id of the sharpest image diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 3ac1a3b1..5e72d066 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -70,12 +70,12 @@ class CameraMemoryBuffer: every time an image is added. :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 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._create_space(buffer_max) @@ -94,9 +94,8 @@ class CameraMemoryBuffer: :param buffer_id: The buffer id of the image to retrieve :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 if buffer_id is None: # Get the latest image and metadata tuple from storage @@ -128,7 +127,7 @@ class CameraMemoryBuffer: Create space to add an image. :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 buffer_max <= 1: @@ -164,7 +163,8 @@ class BaseCamera(lt.Thing): self, main_resolution: tuple[int, int], buffer_count: int ) -> None: """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( "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 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. """ if self.stream_active: @@ -186,7 +186,7 @@ class BaseCamera(lt.Thing): @lt.thing_property def stream_active(self) -> bool: - "Whether the MJPEG stream is active" + """Whether the MJPEG stream is active""" raise NotImplementedError( "CameraThings must define their own stream_active method" ) @@ -221,7 +221,7 @@ class BaseCamera(lt.Thing): ) -> JPEGBlob: """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 stream (either "main" for the preview stream, or "lores" for the low 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 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 - FastAPI when calling the action + FastAPI when calling the action :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( metadata_getter, @@ -294,19 +294,19 @@ class BaseCamera(lt.Thing): buffer_max: int = 1, ) -> 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 in memory. :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 - FastAPI when calling the action + FastAPI when calling the action :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) 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 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 - 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 - `capture_to_memory` + ``capture_to_memory`` """ 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 logger warning (via InvocationLogger) is raised if metadata is failed to be added 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: image = image.resize(save_resolution, Image.BOX) try: diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 66e0b050..cd9f4092 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -1,7 +1,7 @@ """OpenFlexure Microscope OpenCV Camera This module defines a camera Thing that uses OpenCV's -`VideoCapture`. +``VideoCapture``. See repository root for licensing information. """ @@ -45,7 +45,7 @@ class OpenCVCamera(BaseCamera): @lt.thing_property 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: return self._capture_thread.is_alive() return False diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 6ec2e862..d73b79ee 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -264,7 +264,6 @@ class StreamingPiCamera2(BaseCamera): @sensor_mode.setter def sensor_mode(self, new_mode: Optional[SensorModeSelector | dict]): """Change the sensor mode used""" - if new_mode is None: self._sensor_mode = None elif isinstance(new_mode, SensorModeSelector): @@ -287,9 +286,9 @@ class StreamingPiCamera2(BaseCamera): tuning = lt.ThingSetting(Optional[dict], None, readonly=True) 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. """ if self._picamera_lock is not None: @@ -332,16 +331,17 @@ class StreamingPiCamera2(BaseCamera): @contextmanager 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. - :param pause_stream: If False the `Picamera2` instance is simply yielded. - If True: - * Stop the MJPEG Stream - * Yield the `Picamera2` instance for function calling the context manager to - make changes. - * On closing of the context manager the stream will restart. + :param pause_stream: If False the ``Picamera2`` instance is simply yielded. + If True: + + * Stop the MJPEG Stream + * Yield the ``Picamera2`` instance for function calling the context manager to + make changes. + * On closing of the context manager the stream will restart. """ already_streaming = self.stream_active with self._picamera_lock: @@ -372,8 +372,9 @@ class StreamingPiCamera2(BaseCamera): manual. 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 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. 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 # an image as an array is still a useful feature if stream_name == "full": @@ -523,13 +523,13 @@ class StreamingPiCamera2(BaseCamera): ) -> JPEGBlob: """Acquire one image from the camera as a JPEG - The JPEG will be acquired using `Picamera2.capture_file`. If the - `resolution` parameter is `main` or `lores`, it will be captured + The JPEG will be acquired using ``Picamera2.capture_file``. If the + ``resolution`` parameter is ``main`` or ``lores``, it will be captured from the main preview stream, or the low-res preview stream, respectively. This means the camera won't be reconfigured, and 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 resolution image. @@ -588,11 +588,12 @@ class StreamingPiCamera2(BaseCamera): the image reaches the specified white level. :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 - is about 950. Default is 700 as this is approximately 70% saturated. + theoretical maximum of 1023, but with black level correction the true + 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 - 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. + 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. """ with self._streaming_picamera(pause_stream=True) as cam: 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 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. """ with self._streaming_picamera(pause_stream=True) as cam: @@ -657,7 +658,7 @@ class StreamingPiCamera2(BaseCamera): def colour_correction_matrix( self, ) -> 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 the micromanager API @@ -720,11 +721,11 @@ class StreamingPiCamera2(BaseCamera): This function will call the other calibration actions in sequence: - * `flat_lens_shading` to disable flat-field - * `auto_expose_from_minimum` - * `set_static_green_equalisation` to set geq offset to max - * `calibrate_lens_shading` - * `calibrate_white_balance` + * ``flat_lens_shading`` to disable flat-field + * ``auto_expose_from_minimum`` + * ``set_static_green_equalisation`` to set geq offset to max + * ``calibrate_lens_shading`` + * ``calibrate_white_balance`` """ self.flat_lens_shading() self.auto_expose_from_minimum() @@ -804,15 +805,15 @@ class StreamingPiCamera2(BaseCamera): The white balance algorithm we use assumes the brightest pixels 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* - 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 is 1. The solution might be that, when calibrating, we note which pixels are brightest (usually the centre) and explicitly use 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. """ if not self.lens_shading_is_static: diff --git a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py index cf51efe8..12b4c90f 100644 --- a/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py +++ b/src/openflexure_microscope_server/things/camera/picamera_recalibrate_utils.py @@ -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 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 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 reliable. The three steps above can be accomplished by: -``` -picamera = picamera2.Picamera2() +.. code-block:: python + + 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. @@ -55,7 +56,7 @@ def load_default_tuning(cam: Picamera2) -> dict: """Load the default tuning file for the camera 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. 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 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? 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: """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 = [ 1, @@ -381,7 +376,7 @@ def downsampled_channels(channels: np.ndarray, blacklevel=64) -> list[np.ndarray def lst_from_channels(channels: np.ndarray) -> LensShadingTables: """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) 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 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 luminance, Cr, Cb, each with shape (12, 16). """ - # Calculated red, green, and blue channels from Bayer data r: np.ndarray = grids[3, ...] 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. 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. """ G = 1 / np.array(lum) @@ -434,9 +428,9 @@ def set_static_lst( cr: np.ndarray, cb: np.ndarray, ) -> 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. """ for table in luminance, cr, cb: @@ -459,9 +453,9 @@ def set_static_ccm( float, float, float, float, float, float, float, float, float ], ) -> 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. """ ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm") @@ -469,7 +463,7 @@ def set_static_ccm( 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") return ccm["ccms"] @@ -484,14 +478,13 @@ def set_static_geq( tuning: dict, offset: int = 65535, ) -> 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. - `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 brightness will always be below the threshold where averaging is used. """ - geq = Picamera2.find_tuning_algo(tuning, "rpi.geq") 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: - """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. """ diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index c39e912a..e27f365a 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -145,7 +145,7 @@ class SimulatedCamera(BaseCamera): @lt.thing_property 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: return self._capture_thread.is_alive() return False diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index ae969b6c..abafa487 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -57,7 +57,7 @@ def downsample(factor: int, image: np.ndarray) -> np.ndarray: """Downsample an image by taking the mean of each nxn region 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 the left-over pixels. This avoids odd edge effects and keeps performance quick. @@ -265,14 +265,16 @@ class CameraStageMapper(lt.Thing): that conversion. 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: - ``` - stage_disp = np.dot( - np.array(image_to_stage_displacement_matrix), - np.array([dy,dx]), - ) - ``` + + .. code-block:: python + + stage_disp = np.dot( + np.array(image_to_stage_displacement_matrix), + np.array([dy,dx]), + ) + """ if self.last_calibration is 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 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, - and `y` to the shorter one. Checking what shape your chosen toolkit reports for + 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 an image usually helps resolve any ambiguity. """ self.assert_calibrated() diff --git a/src/openflexure_microscope_server/things/settings_manager.py b/src/openflexure_microscope_server/things/settings_manager.py index 6dacd7dd..8ac540b0 100644 --- a/src/openflexure_microscope_server/things/settings_manager.py +++ b/src/openflexure_microscope_server/things/settings_manager.py @@ -66,9 +66,9 @@ class SettingsManager(lt.Thing): recursively, i.e. if a key exists, it will be added to rather than replaced. - 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 - equivalent to calling it with `data={"foo": {"bar": {"a": 1}}}`. + 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 + equivalent to calling it with ``data={"foo": {"bar": {"a": 1}}}``. """ metadata = self.external_metadata subdict = metadata @@ -84,8 +84,8 @@ class SettingsManager(lt.Thing): """Delete a key from the stored metadata. 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 - dictionary that looks like: `{'a': {'c': 1}, 'b': {'d': 2}}` + 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}}`` """ metadata = self.external_metadata try: diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index c9c19627..bd485e09 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -114,7 +114,6 @@ class SmartScanThing(lt.Thing): stopping once it is surrounded by "background" (as detected by the background_detect Thing) or reaches the "max_range" measured in steps. """ - got_lock = self._scan_lock.acquire(timeout=0.1) if not got_lock: 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 """ - if z_estimate is None: 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 :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_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. """ - # Assume 4 images means at least one offset in x and y, making the stitching # well constrained. if self._scan_images_taken > 3: @@ -390,7 +387,6 @@ class SmartScanThing(lt.Thing): determine whether the scan should be stitched and the microscope should return to the starting x,y,z position """ - # Used to check if finally was reached via exception (except # cancel by user) 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 are remaining. """ - # 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 # have multiple starting positions, each of which will be visited before the @@ -537,7 +532,6 @@ class SmartScanThing(lt.Thing): @_scan_running def _perform_final_stitch(self): """Update the scan zip and perform final stitch of the data""" - if self._scan_images_taken <= 3: self._scan_logger.info("Not performing a stitch as 3 or fewer images taken") return @@ -608,7 +602,7 @@ class SmartScanThing(lt.Thing): model=bool, 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( @@ -638,7 +632,7 @@ class SmartScanThing(lt.Thing): Each scan has a name (which can be used to access it), along with 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 break it. """ @@ -660,8 +654,8 @@ class SmartScanThing(lt.Thing): """Return the stitched image corresponding to a given scan name, if it exists. 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) if stitch_path is None: @@ -709,7 +703,6 @@ class SmartScanThing(lt.Thing): """ Delete all scan folders containing no images at the top level """ - # JSON is ignored as it's created before any images are captured for scan_info in self._scan_dir_manager.all_scans_info(): if scan_info.number_of_images == 0: @@ -732,7 +725,6 @@ class SmartScanThing(lt.Thing): @property def latest_preview_stitch_path(self) -> Optional[str]: """The path of the latest preview stitched image, or None if not available""" - if not self.latest_scan_name: return None @@ -744,9 +736,10 @@ class SmartScanThing(lt.Thing): def latest_preview_stitch_time(self) -> Optional[float]: """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: + 1. If all caching was turned off this stitch would be sent over the network repeatedly 2. If caching was is on, then the stitch will not update when needed. @@ -834,12 +827,14 @@ class SmartScanThing(lt.Thing): Raises: ChildProcessError if exit code is not zero InvocationCancelledError if the action is cancelled. + """ logger.info(f"Running command in subprocess: `{' '.join(cmd)}`") def log_buffer(buffer): """A short internal function to read everything in the buffer to - the log""" + the log + """ while line := buffer.readline(): logger.info(line) @@ -968,6 +963,7 @@ class SmartScanThing(lt.Thing): scan_name: str, ): """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) return ZipBlob.from_file(zip_fname) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 3a40d44d..1a84aada 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -1,5 +1,4 @@ from __future__ import annotations -from typing import TypeAlias from collections.abc import Sequence, Mapping 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 implementing new stages. A minimal working stage must implement - `move_relative` and `move_absolute` actions, which update the - `position` property on completion, and provide `set_zero_position`. + ``move_relative`` and ``move_absolute`` actions, which update the + ``position`` property on completion, and provide ``set_zero_position``. """ _axis_names = ("x", "y", "z") @@ -79,6 +78,4 @@ class BaseStage(lt.Thing): ) -StageDependency: TypeAlias = lt.deps.direct_thing_client_dependency( - BaseStage, "/stage/" -) +StageDependency = lt.deps.direct_thing_client_dependency(BaseStage, "/stage/") diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 35b3b79e..9b5542f0 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -13,13 +13,26 @@ from . import 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["port"] = port @@ -41,9 +54,9 @@ class SangaboardThing(BaseStage): @contextmanager 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: yield self._sangaboard diff --git a/src/openflexure_microscope_server/things/system_control.py b/src/openflexure_microscope_server/things/system_control.py index cbca06ab..c294b2f7 100644 --- a/src/openflexure_microscope_server/things/system_control.py +++ b/src/openflexure_microscope_server/things/system_control.py @@ -36,9 +36,7 @@ class SystemControlThing(lt.Thing): @lt.thing_property def is_raspberrypi() -> bool: - """ - Checks if we are running on a Raspberry Pi. - """ + """Return True if running on a Raspberry Pi.""" return os.path.exists("/usr/bin/raspi-config") @lt.thing_action diff --git a/src/openflexure_microscope_server/utilities.py b/src/openflexure_microscope_server/utilities.py index e514fc97..610af794 100644 --- a/src/openflexure_microscope_server/utilities.py +++ b/src/openflexure_microscope_server/utilities.py @@ -60,20 +60,18 @@ class ErrorCapturingThread(Thread): def _wrap_and_catch_errors(target, error_buffer, *args, **kwargs): - """ - This function is designed only to be used by - ErrorCapturingThread + """Run target function in a try-except block. - It will run a target function in a try-except block - any errors caught are added to an empty list that - should be supplied as a keyword argument + This function is designed only to be used by ErrorCapturingThread. - Arguments: - * target - The target function to call - * error_buffer - An empty list that is used for returning - the exception from the thread - *args: The arguments for the target function - **kwargs: The Keyword arguments for the target function + It will run a target function in a try-except block catching any exception. + If an exception is caught it is added to ``error_buffer``. + + :param target: The target function to call + :param error_buffer: An empty list that is used for returning the exception from + the thread. It is a list to ensure it is passed by reference. + :param ``*args``: The arguments for the target function + :param ``**kwargs``: The Keyword arguments for the target function """ try: target(*args, **kwargs) diff --git a/tests/mock_things/mock_autofocus.py b/tests/mock_things/mock_autofocus.py index d32b5a50..ca00be60 100644 --- a/tests/mock_things/mock_autofocus.py +++ b/tests/mock_things/mock_autofocus.py @@ -13,5 +13,5 @@ class MockAutoFocusThing: mock_call_count = {"looping_autofocus": 0} 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 diff --git a/tests/test_scan_directories.py b/tests/test_scan_directories.py index 9471703c..32a85edb 100644 --- a/tests/test_scan_directories.py +++ b/tests/test_scan_directories.py @@ -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 0...(n_layers-1) will be created. Default 8 """ - # Add an a dzi image dzi_fname = scan_dir.name + ".dzi" 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(): """Test zipping the scan images with fake image data""" - # Run twice, once calling the ScanDirectory directly, # Once calling the ScanDirectoryManager for caller in ["scan_dir", "manager"]: diff --git a/tests/test_scan_planners.py b/tests/test_scan_planners.py index 9fe192ba..13af8a7a 100644 --- a/tests/test_scan_planners.py +++ b/tests/test_scan_planners.py @@ -257,7 +257,8 @@ def test_example_smart_spiral(): below and defined in scan_test_helpers.load_sample_points 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 = [ "regular", "lobed", diff --git a/tests/test_smart_scan.py b/tests/test_smart_scan.py index 2cd6d267..5b928ecf 100644 --- a/tests/test_smart_scan.py +++ b/tests/test_smart_scan.py @@ -65,7 +65,8 @@ def test_initial_properties(smart_scan_thing): def test_inaccessible_scan_methods(smart_scan_thing): """The @_scan_running decorator should make some methods - inaccessible unless a scan is running""" + inaccessible unless a scan is running + """ with pytest.raises(ScanNotRunningError): smart_scan_thing._run_scan() 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): """Test the private _delete_scan method deletes directories or warns if it can't""" - _clear_scan_dir() with caplog.at_level(logging.INFO): 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): """Test the delete_scan API call deletes directories or warns if it can't""" - _clear_scan_dir() with caplog.at_level(logging.INFO): 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 function """ - # cancel handle shouldn't be used. Set to arbitrary value for checking cancel_mock = 1 # not called af_mock = MockAutoFocusThing() diff --git a/tests/test_stack.py b/tests/test_stack.py index e9295601..d40fb977 100644 --- a/tests/test_stack.py +++ b/tests/test_stack.py @@ -23,7 +23,7 @@ RANDOM_GENERATOR = np.random.default_rng() 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 max_base = (max_value - 1) // 2 # 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): - """A hypothesis strategy for even integers""" + """Return a hypothesis strategy for even integers.""" min_base = (min_value + 1) // 2 max_base = (max_value) // 2 # 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 """ - StackParams( stack_dz=50, images_to_save=save_ims, diff --git a/tests/utilities/__init__.py b/tests/utilities/__init__.py index 6045086e..65e4f905 100644 --- a/tests/utilities/__init__.py +++ b/tests/utilities/__init__.py @@ -12,7 +12,8 @@ from collections.abc import Hashable class SizedIterableHashable(Iterable[Hashable], Protocol): """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: diff --git a/tests/utilities/scan_test_helpers.py b/tests/utilities/scan_test_helpers.py index 62bb7f73..d1a6a0fe 100644 --- a/tests/utilities/scan_test_helpers.py +++ b/tests/utilities/scan_test_helpers.py @@ -91,7 +91,6 @@ def interp_closed_path(xy_points: list[tuple[int, int]], n_points: int) -> MatPa Modified from: https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy """ - # Use zip to seperate x and y points into tuples x, y = zip(*xy_points)