Restructured text fixes so that pydoctor would return without an error
This commit is contained in:
parent
58b056988a
commit
a84a916719
31 changed files with 269 additions and 256 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -58,8 +58,8 @@ pytest_report.xml
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Sphinx documentation
|
# Documentation
|
||||||
docs/_build/
|
apidocs
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
target/
|
target/
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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/"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue