Add punctuation to docstrings
This commit is contained in:
parent
4dc41bb008
commit
80beeea07b
34 changed files with 232 additions and 235 deletions
|
|
@ -18,7 +18,7 @@ logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
def _test_exposure_time_drift(desired_time):
|
def _test_exposure_time_drift(desired_time):
|
||||||
"""Capture 10 full resolution images and check that the exposure time remains constant
|
"""Capture 10 full resolution images and check that the exposure time remains constant.
|
||||||
|
|
||||||
This confirms that automatic exposure time adjustment is fully turned off
|
This confirms that automatic exposure time adjustment is fully turned off
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
def test_sensor_mode():
|
def test_sensor_mode():
|
||||||
"""Test capturing raw arrays in two different sensor modes"""
|
"""Test capturing raw arrays in two different sensor modes."""
|
||||||
cam = StreamingPiCamera2()
|
cam = StreamingPiCamera2()
|
||||||
server = ThingServer()
|
server = ThingServer()
|
||||||
server.add_thing(cam, "/camera/")
|
server.add_thing(cam, "/camera/")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Tests that check that a tuning file can be reloaded"""
|
"""Tests that check that a tuning file can be reloaded."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ SERVER_CMD: list[str] = [
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Set up the server, run basic checks, shutdown, check for graceful exit
|
"""Set up the server, run basic checks, shutdown, check for graceful exit.
|
||||||
|
|
||||||
The basic checks include checks that:
|
The basic checks include checks that:
|
||||||
- The server boots
|
- The server boots
|
||||||
|
|
@ -69,7 +69,7 @@ def main() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_client_connection() -> None:
|
def test_client_connection() -> None:
|
||||||
"""Check a ThingClient can interact with the simulation microscope camera"""
|
"""Check a ThingClient can interact with the simulation microscope camera."""
|
||||||
print("Connecting Python client to microscope, and capturing image")
|
print("Connecting Python client to microscope, and capturing image")
|
||||||
cam_client = lt.ThingClient.from_url("http://localhost:5000/camera/")
|
cam_client = lt.ThingClient.from_url("http://localhost:5000/camera/")
|
||||||
img = Image.open(cam_client.grab_jpeg().open())
|
img = Image.open(cam_client.grab_jpeg().open())
|
||||||
|
|
@ -178,7 +178,7 @@ def error_if_server_not_started(
|
||||||
|
|
||||||
|
|
||||||
def check_for_graceful_shutdown(server_process: subprocess.Popen) -> None:
|
def check_for_graceful_shutdown(server_process: subprocess.Popen) -> None:
|
||||||
"""Check the server shutdown gracefully
|
"""Check the server shutdown gracefully.
|
||||||
|
|
||||||
Check the subprocess is not running
|
Check the subprocess is not running
|
||||||
Check the logs have no errors
|
Check the logs have no errors
|
||||||
|
|
@ -201,7 +201,7 @@ def check_for_graceful_shutdown(server_process: subprocess.Popen) -> None:
|
||||||
|
|
||||||
|
|
||||||
def read_process_buffers(process: subprocess.Popen) -> list[str]:
|
def read_process_buffers(process: subprocess.Popen) -> list[str]:
|
||||||
"""Return STDOUT from a process"""
|
"""Return STDOUT from a process."""
|
||||||
stdout = []
|
stdout = []
|
||||||
|
|
||||||
while line := process.stdout.readline():
|
while line := process.stdout.readline():
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,8 @@ ignore = [
|
||||||
"E501", # Ignore long lines for now
|
"E501", # Ignore long lines for now
|
||||||
"D203", # incompatible with D204
|
"D203", # incompatible with D204
|
||||||
"D213", # incompatible with D212
|
"D213", # incompatible with D212
|
||||||
"D400", # A stricter version of #415
|
"D400", # A stricter version of #415 that doesn't allow !
|
||||||
# Below are checkers to be turned on gradually
|
# The checkers below should be turned on as they complain about missing docstrings.
|
||||||
"D415",
|
|
||||||
"D200",
|
|
||||||
# These should be turned on before this MR is complete
|
|
||||||
"D100",
|
"D100",
|
||||||
"D102",
|
"D102",
|
||||||
"D101",
|
"D101",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def parse_arguments() -> Namespace:
|
||||||
|
|
||||||
|
|
||||||
def script_directory(path):
|
def script_directory(path):
|
||||||
"""Resolve path to directory of the current script"""
|
"""Resolve path to directory of the current script."""
|
||||||
return os.path.join(os.path.dirname(os.path.realpath(__file__)), path)
|
return os.path.join(os.path.dirname(os.path.realpath(__file__)), path)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class OFMHandler(logging.Handler):
|
||||||
self._log.pop(0)
|
self._log.pop(0)
|
||||||
|
|
||||||
def emit(self, record):
|
def emit(self, record):
|
||||||
"""Emit will save the logged record to the log"""
|
"""Emit will save the logged record to the log."""
|
||||||
# Basic filter for now that simply stops uvicorn.access logs
|
# Basic filter for now that simply stops uvicorn.access logs
|
||||||
# These are the logs each time an API endpoint is accessed
|
# These are the logs each time an API endpoint is accessed
|
||||||
# This is only the log for the UI.
|
# This is only the log for the UI.
|
||||||
|
|
@ -105,7 +105,7 @@ class OFMHandler(logging.Handler):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def log_history(self):
|
def log_history(self):
|
||||||
"""Return the log history up to the maximum number of logs"""
|
"""Return the log history up to the maximum number of logs."""
|
||||||
return "\n".join(self._log)
|
return "\n".join(self._log)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class NotEnoughFreeSpaceError(IOError):
|
||||||
|
|
||||||
|
|
||||||
class ScanInfo(BaseModel):
|
class ScanInfo(BaseModel):
|
||||||
"""Summary information about a scan folder"""
|
"""Summary information about a scan folder."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
created: float
|
created: float
|
||||||
|
|
@ -31,7 +31,7 @@ class ScanInfo(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ScanDirectoryManager:
|
class ScanDirectoryManager:
|
||||||
"""A class for managing interactions with scan directories"""
|
"""A class for managing interactions with scan directories."""
|
||||||
|
|
||||||
_base_scan_dir: str
|
_base_scan_dir: str
|
||||||
|
|
||||||
|
|
@ -42,22 +42,22 @@ class ScanDirectoryManager:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def base_dir(self) -> str:
|
def base_dir(self) -> str:
|
||||||
"""The base directory scans are saved to"""
|
"""The base directory scans are saved to."""
|
||||||
return self._base_scan_dir
|
return self._base_scan_dir
|
||||||
|
|
||||||
def exists(self, scan_name: str) -> bool:
|
def exists(self, scan_name: str) -> bool:
|
||||||
"""Return True if scan of this name exists on disk"""
|
"""Return True if scan of this name exists on disk."""
|
||||||
return os.path.isdir(self.path_for(scan_name))
|
return os.path.isdir(self.path_for(scan_name))
|
||||||
|
|
||||||
def path_for(self, scan_name: str) -> str:
|
def path_for(self, scan_name: str) -> str:
|
||||||
"""Return the path for a given scan name
|
"""Return the path for a given scan name.
|
||||||
|
|
||||||
Returns the path even if it doesn't exist
|
Returns the path even if it doesn't exist
|
||||||
"""
|
"""
|
||||||
return os.path.join(self._base_scan_dir, scan_name)
|
return os.path.join(self._base_scan_dir, scan_name)
|
||||||
|
|
||||||
def img_dir_for(self, scan_name: str) -> str:
|
def img_dir_for(self, scan_name: str) -> str:
|
||||||
"""Return the path for the image dir for a given scan name
|
"""Return the path for the image dir for a given scan name.
|
||||||
|
|
||||||
Returns the path even if it doesn't exist
|
Returns the path even if it doesn't exist
|
||||||
"""
|
"""
|
||||||
|
|
@ -66,7 +66,7 @@ class ScanDirectoryManager:
|
||||||
def get_file_path_from(
|
def get_file_path_from(
|
||||||
self, scan_name: str, filename: str, check_exists: bool = False
|
self, scan_name: str, filename: str, check_exists: bool = False
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Return the full file path for the file within a scan directory
|
"""Return the full file path for the file within a scan directory.
|
||||||
|
|
||||||
If check_exists is True then None will be returned if the file does
|
If check_exists is True then None will be returned if the file does
|
||||||
not exist.
|
not exist.
|
||||||
|
|
@ -80,7 +80,7 @@ class ScanDirectoryManager:
|
||||||
def get_file_path_from_img_dir(
|
def get_file_path_from_img_dir(
|
||||||
self, scan_name: str, filename: str, check_exists: bool = False
|
self, scan_name: str, filename: str, check_exists: bool = False
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Return the full file path for the file within a scan directory
|
"""Return the full file path for the file within a scan directory.
|
||||||
|
|
||||||
If check_exists is True, None is returned if the file does not exist. If False
|
If check_exists is True, None is returned if the file does not exist. If False
|
||||||
then the path is returned anyway
|
then the path is returned anyway
|
||||||
|
|
@ -103,18 +103,18 @@ class ScanDirectoryManager:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def all_scans(self) -> list[str]:
|
def all_scans(self) -> list[str]:
|
||||||
"""Return a list of the scan names in the base directory"""
|
"""Return a list of the scan names in the base directory."""
|
||||||
return [f.name for f in os.scandir(self._base_scan_dir) if f.is_dir()]
|
return [f.name for f in os.scandir(self._base_scan_dir) if f.is_dir()]
|
||||||
|
|
||||||
def all_scans_info(self) -> list[ScanInfo]:
|
def all_scans_info(self) -> list[ScanInfo]:
|
||||||
"""Return a lists of ScanInfo objects for each scan"""
|
"""Return a lists of ScanInfo objects for each scan."""
|
||||||
all_info: list[ScanInfo] = []
|
all_info: list[ScanInfo] = []
|
||||||
for scan_name in self.all_scans:
|
for scan_name in self.all_scans:
|
||||||
all_info.append(ScanDirectory(scan_name, self.base_dir).scan_info())
|
all_info.append(ScanDirectory(scan_name, self.base_dir).scan_info())
|
||||||
return all_info
|
return all_info
|
||||||
|
|
||||||
def _unique_scan_name(self, scan_name: str) -> str:
|
def _unique_scan_name(self, scan_name: str) -> str:
|
||||||
"""Get the next unique scan name starting with the given name
|
"""Get the next unique scan name starting with the given name.
|
||||||
|
|
||||||
For more explanation on the scan naming see `new_scan_dir`
|
For more explanation on the scan naming see `new_scan_dir`
|
||||||
"""
|
"""
|
||||||
|
|
@ -143,7 +143,7 @@ class ScanDirectoryManager:
|
||||||
return f"{scan_name}_{scan_num:0{SCAN_ZERO_PAD_DIGITS}d}"
|
return f"{scan_name}_{scan_num:0{SCAN_ZERO_PAD_DIGITS}d}"
|
||||||
|
|
||||||
def new_scan_dir(self, scan_name: str) -> "ScanDirectory":
|
def new_scan_dir(self, scan_name: str) -> "ScanDirectory":
|
||||||
"""Get a unique name for this scan and create a directory for it
|
"""Get a unique name for this scan and create a directory for it.
|
||||||
|
|
||||||
The scan will be named ``{scan_name}_0001`` where the number is
|
The scan will be named ``{scan_name}_0001`` where the number is
|
||||||
zero-padded to be SCAN_ZERO_PAD_DIGITS digits long (to allow correct sorting if the
|
zero-padded to be SCAN_ZERO_PAD_DIGITS digits long (to allow correct sorting if the
|
||||||
|
|
@ -165,11 +165,11 @@ class ScanDirectoryManager:
|
||||||
return ScanDirectory(full_scan_name, self.base_dir)
|
return ScanDirectory(full_scan_name, self.base_dir)
|
||||||
|
|
||||||
def delete_scan(self, scan_name: str) -> None:
|
def delete_scan(self, scan_name: str) -> None:
|
||||||
"""Delete a scan"""
|
"""Delete a scan."""
|
||||||
shutil.rmtree(self.path_for(scan_name))
|
shutil.rmtree(self.path_for(scan_name))
|
||||||
|
|
||||||
def zip_scan(self, scan_name: str, final_version: bool = False) -> "ScanDirectory":
|
def zip_scan(self, scan_name: str, final_version: bool = False) -> "ScanDirectory":
|
||||||
"""Zips any images from the scan not yet zipped, return full path to zip
|
"""Zips any images from the scan not yet zipped, return full path to zip.
|
||||||
|
|
||||||
``final_version`` Set true to stitch all files not just the scan images
|
``final_version`` Set true to stitch all files not just the scan images
|
||||||
this should only be done at the end as it is not possible to update a file
|
this should only be done at the end as it is not possible to update a file
|
||||||
|
|
@ -178,7 +178,7 @@ class ScanDirectoryManager:
|
||||||
return ScanDirectory(scan_name, self.base_dir).zip_files(final_version)
|
return ScanDirectory(scan_name, self.base_dir).zip_files(final_version)
|
||||||
|
|
||||||
def check_free_disk_space(self, min_space: int = 500000000) -> None:
|
def check_free_disk_space(self, min_space: int = 500000000) -> None:
|
||||||
"""Raise an exception if there is not enough free disk space to continue scanning
|
"""Raise an exception if there is not enough free disk space to continue scanning.
|
||||||
|
|
||||||
:param min_space: the minimum space required in bytes.
|
:param min_space: the minimum space required in bytes.
|
||||||
Default = 500,000,000 (500MB)
|
Default = 500,000,000 (500MB)
|
||||||
|
|
@ -194,7 +194,7 @@ class ScanDirectoryManager:
|
||||||
|
|
||||||
|
|
||||||
class ScanDirectory:
|
class ScanDirectory:
|
||||||
"""A class for handling interactions with scan directories
|
"""A class for handling interactions with scan directories.
|
||||||
|
|
||||||
Initalisation parameters:
|
Initalisation parameters:
|
||||||
name: the name of the scan (the scan directory basename)
|
name: the name of the scan (the scan directory basename)
|
||||||
|
|
@ -214,12 +214,12 @@ class ScanDirectory:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
"""The name of the scan"""
|
"""The name of the scan."""
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dir_path(self) -> str:
|
def dir_path(self) -> str:
|
||||||
"""The full path to the scan directory"""
|
"""The full path to the scan directory."""
|
||||||
return os.path.join(self._base_scan_dir, self._name)
|
return os.path.join(self._base_scan_dir, self._name)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -235,17 +235,17 @@ class ScanDirectory:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def created_time(self) -> float:
|
def created_time(self) -> float:
|
||||||
"""The time the directory was created on disk"""
|
"""The time the directory was created on disk."""
|
||||||
return os.path.getctime(self.dir_path)
|
return os.path.getctime(self.dir_path)
|
||||||
|
|
||||||
def get_scan_files(self):
|
def get_scan_files(self):
|
||||||
"""Return a list of the files in the images dir"""
|
"""Return a list of the files in the images dir."""
|
||||||
if self.images_dir is None:
|
if self.images_dir is None:
|
||||||
return []
|
return []
|
||||||
return os.listdir(self.images_dir)
|
return os.listdir(self.images_dir)
|
||||||
|
|
||||||
def _extract_scan_images(self, file_list: list[str]):
|
def _extract_scan_images(self, file_list: list[str]):
|
||||||
"""Extract files which match the naming convention for scan images
|
"""Extract files which match the naming convention for scan images.
|
||||||
|
|
||||||
:param file_list: The list of files to search. Normally this would be
|
:param file_list: The list of files to search. Normally this would be
|
||||||
``self.get_scan_files()``
|
``self.get_scan_files()``
|
||||||
|
|
@ -255,7 +255,7 @@ class ScanDirectory:
|
||||||
return [i for i in file_list if IMAGE_REGEX.search(i)]
|
return [i for i in file_list if IMAGE_REGEX.search(i)]
|
||||||
|
|
||||||
def _extract_final_stitches(self, file_list: list[str]):
|
def _extract_final_stitches(self, file_list: list[str]):
|
||||||
"""Extract files which match the naming convention for final stitches
|
"""Extract files which match the naming convention for final stitches.
|
||||||
|
|
||||||
:param file_list: The list of files to search.
|
:param file_list: The list of files to search.
|
||||||
|
|
||||||
|
|
@ -264,7 +264,7 @@ class ScanDirectory:
|
||||||
return [i for i in file_list if STITCH_REGEX.search(i)]
|
return [i for i in file_list if STITCH_REGEX.search(i)]
|
||||||
|
|
||||||
def _extract_dzi_files(self, file_list: list[str]):
|
def _extract_dzi_files(self, file_list: list[str]):
|
||||||
"""Extract files which match the naming convention for dzi_files
|
"""Extract files which match the naming convention for dzi_files.
|
||||||
|
|
||||||
:param file_list: The list of files to search.
|
:param file_list: The list of files to search.
|
||||||
|
|
||||||
|
|
@ -273,7 +273,7 @@ class ScanDirectory:
|
||||||
return [i for i in file_list if i.endswith("dzi")]
|
return [i for i in file_list if i.endswith("dzi")]
|
||||||
|
|
||||||
def get_final_stitch_name(self) -> Optional[str]:
|
def get_final_stitch_name(self) -> Optional[str]:
|
||||||
"""Return the filename for the final stitch (in the images dir)
|
"""Return the filename for the final stitch (in the images dir).
|
||||||
|
|
||||||
If no final stitch is found, return None
|
If no final stitch is found, return None
|
||||||
"""
|
"""
|
||||||
|
|
@ -283,11 +283,11 @@ class ScanDirectory:
|
||||||
return stitches[0]
|
return stitches[0]
|
||||||
|
|
||||||
def get_modified_time(self) -> float:
|
def get_modified_time(self) -> float:
|
||||||
"""Return the modified time of the directory"""
|
"""Return the modified time of the directory."""
|
||||||
return max(os.stat(root).st_mtime for root, _, _ in os.walk(self.dir_path))
|
return max(os.stat(root).st_mtime for root, _, _ in os.walk(self.dir_path))
|
||||||
|
|
||||||
def scan_info(self) -> ScanInfo:
|
def scan_info(self) -> ScanInfo:
|
||||||
"""Return the information for the scan directory as a ScanInfo object"""
|
"""Return the information for the scan directory as a ScanInfo object."""
|
||||||
scan_files = self.get_scan_files()
|
scan_files = self.get_scan_files()
|
||||||
scan_images = self._extract_scan_images(scan_files)
|
scan_images = self._extract_scan_images(scan_files)
|
||||||
stitches = self._extract_final_stitches(scan_files)
|
stitches = self._extract_final_stitches(scan_files)
|
||||||
|
|
@ -325,7 +325,7 @@ class ScanDirectory:
|
||||||
return files
|
return files
|
||||||
|
|
||||||
def zip_files(self, final_version: bool = False) -> str:
|
def zip_files(self, final_version: bool = False) -> str:
|
||||||
"""Zips any images from the scan not yet zipped, return full path to zip
|
"""Zips any images from the scan not yet zipped, return full path to zip.
|
||||||
|
|
||||||
``final_version`` Set true to stitch all files not just the scan images
|
``final_version`` Set true to stitch all files not just the scan images
|
||||||
this should only be done at the end as it is not possible to update a file
|
this should only be done at the end as it is not possible to update a file
|
||||||
|
|
@ -357,6 +357,6 @@ class ScanDirectory:
|
||||||
|
|
||||||
|
|
||||||
def get_files_in_zip(zip_path):
|
def get_files_in_zip(zip_path):
|
||||||
"""List the relative paths of all files and folders in the zip folder specified"""
|
"""List the relative paths of all files and folders in the zip folder specified."""
|
||||||
scan_zip = zipfile.ZipFile(zip_path)
|
scan_zip = zipfile.ZipFile(zip_path)
|
||||||
return [os.path.normpath(i) for i in scan_zip.namelist()]
|
return [os.path.normpath(i) for i in scan_zip.namelist()]
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ class ScanPlanner:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def imaged_locations(self) -> XYZPosList:
|
def imaged_locations(self) -> XYZPosList:
|
||||||
"""Property to access a copy of the imaged_locations"""
|
"""Property to access a copy of the imaged_locations."""
|
||||||
return copy(self._imaged_locations)
|
return copy(self._imaged_locations)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -145,12 +145,12 @@ class ScanPlanner:
|
||||||
raise NotImplementedError("Did you call the ScanPlanner base class?")
|
raise NotImplementedError("Did you call the ScanPlanner base class?")
|
||||||
|
|
||||||
def position_visited(self, position: XYPos) -> bool:
|
def position_visited(self, position: XYPos) -> bool:
|
||||||
"""Return True if input xy position has been visited before"""
|
"""Return True if input xy position has been visited before."""
|
||||||
# Ensure tuple for correct matching!
|
# Ensure tuple for correct matching!
|
||||||
return tuple(position) in self._path_history
|
return tuple(position) in self._path_history
|
||||||
|
|
||||||
def position_planned(self, position: XYPos) -> bool:
|
def position_planned(self, position: XYPos) -> bool:
|
||||||
"""Return True if input xy position is planned"""
|
"""Return True if input xy position is planned."""
|
||||||
# Ensure tuple for correct matching!
|
# Ensure tuple for correct matching!
|
||||||
return tuple(position) in self._remaining_locations
|
return tuple(position) in self._remaining_locations
|
||||||
|
|
||||||
|
|
@ -205,7 +205,7 @@ class ScanPlanner:
|
||||||
def mark_location_visited(
|
def mark_location_visited(
|
||||||
self, xyz_pos: XYZPos, imaged: bool, focused: bool
|
self, xyz_pos: XYZPos, imaged: bool, focused: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mark the location as visited
|
"""Mark the location as visited.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
xyz_pos: the x_y_z position
|
xyz_pos: the x_y_z position
|
||||||
|
|
@ -282,7 +282,7 @@ class SmartSpiral(ScanPlanner):
|
||||||
def mark_location_visited(
|
def mark_location_visited(
|
||||||
self, xyz_pos: XYZPos, imaged: bool = True, focused: bool = True
|
self, xyz_pos: XYZPos, imaged: bool = True, focused: bool = True
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mark the location as visited. Adjust extra positions accordingly
|
"""Mark the location as visited. Adjust extra positions accordingly.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
xyz_pos: the x_y_z position
|
xyz_pos: the x_y_z position
|
||||||
|
|
@ -327,7 +327,7 @@ class SmartSpiral(ScanPlanner):
|
||||||
self._remaining_locations.append(new_pos)
|
self._remaining_locations.append(new_pos)
|
||||||
|
|
||||||
def _re_sort_remaining_locations(self, current_pos: XYPos) -> None:
|
def _re_sort_remaining_locations(self, current_pos: XYPos) -> None:
|
||||||
"""Sort the remaining positions besed on the current location"""
|
"""Sort the remaining positions besed on the current location."""
|
||||||
|
|
||||||
# Defined rather than use a lambda for readability
|
# Defined rather than use a lambda for readability
|
||||||
def sort_key(pos):
|
def sort_key(pos):
|
||||||
|
|
@ -421,7 +421,7 @@ class SmartSpiral(ScanPlanner):
|
||||||
def distance_between(
|
def distance_between(
|
||||||
current_pos: XYPos | np.ndarray, next_pos: XYPos | np.ndarray
|
current_pos: XYPos | np.ndarray, next_pos: XYPos | np.ndarray
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Calculate the distance between the two xy positions
|
"""Calculate the distance between the two xy positions.
|
||||||
|
|
||||||
This was previously called ``distance_to_site``
|
This was previously called ``distance_to_site``
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from ..logging import configure_logging, retrieve_log, retrieve_log_from_file
|
||||||
|
|
||||||
|
|
||||||
def set_shutdown_function(shutdown_function: Callable[[], None]):
|
def set_shutdown_function(shutdown_function: Callable[[], None]):
|
||||||
"""Ensure a function is called before the shutdown
|
"""Ensure a function is called before the shutdown.
|
||||||
|
|
||||||
This monkey patches the Uvicorn Server's handle_exit. This is needed because
|
This monkey patches the Uvicorn Server's handle_exit. This is needed because
|
||||||
the uvicorn ``lifecycle`` events and FastAPI ``shutdown`` events only fire once
|
the uvicorn ``lifecycle`` events and FastAPI ``shutdown`` events only fire once
|
||||||
|
|
@ -65,7 +65,7 @@ def _get_scans_dir(config: dict) -> Optional[str]:
|
||||||
|
|
||||||
|
|
||||||
def serve_from_cli(argv: Optional[list[str]] = None):
|
def serve_from_cli(argv: Optional[list[str]] = None):
|
||||||
"""Start the server from the command line"""
|
"""Start the server from the command line."""
|
||||||
args = lt.cli.parse_args(argv)
|
args = lt.cli.parse_args(argv)
|
||||||
|
|
||||||
log_config = copy(uvicorn.config.LOGGING_CONFIG)
|
log_config = copy(uvicorn.config.LOGGING_CONFIG)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def add_static_file(app: FastAPI, fname: str, folder: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
def add_static_files(app: FastAPI, scans_folder: Optional[str]) -> None:
|
def add_static_files(app: FastAPI, scans_folder: Optional[str]) -> None:
|
||||||
"""Add the static files responsible for the webapp app to the FastAPI app
|
"""Add the static files responsible for the webapp app to the FastAPI app.
|
||||||
|
|
||||||
app: The FastAPI app to add to, in this case the OpenFlexure server
|
app: The FastAPI app to add to, in this case the OpenFlexure server
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class RecentringThing(lt.Thing):
|
||||||
max_steps=15,
|
max_steps=15,
|
||||||
lateral_distance=5000,
|
lateral_distance=5000,
|
||||||
):
|
):
|
||||||
"""Recentre the stage, based on the focal plane
|
"""Recentre the stage, based on the focal plane.
|
||||||
|
|
||||||
Autofocuses at multiple points around the sample to
|
Autofocuses at multiple points around the sample to
|
||||||
find the overall maximum (or minimum) height, which
|
find the overall maximum (or minimum) height, which
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""OpenFlexure Microscope autofocus module
|
"""OpenFlexure Microscope autofocus module.
|
||||||
|
|
||||||
This module defines a Thing that is responsible for using the stage and
|
This module defines a Thing that is responsible for using the stage and
|
||||||
camera together to perform an autofocus routine.
|
camera together to perform an autofocus routine.
|
||||||
|
|
@ -26,7 +26,7 @@ from .stage import StageDependency as Stage
|
||||||
|
|
||||||
|
|
||||||
class StackParams:
|
class StackParams:
|
||||||
"""A class for holding for scan parameters
|
"""A class for holding for scan parameters.
|
||||||
|
|
||||||
All arguments are keyword only
|
All arguments are keyword only
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@ class StackParams:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def stack_z_range(self) -> int:
|
def stack_z_range(self) -> int:
|
||||||
"""The range of the z stack, in steps
|
"""The range of the z stack, in steps.
|
||||||
|
|
||||||
Note that this is the range of the minimum number of images captured,
|
Note that this is the range of the minimum number of images captured,
|
||||||
which is also the range of the images stored in memory that can be
|
which is also the range of the images stored in memory that can be
|
||||||
|
|
@ -107,7 +107,7 @@ class StackParams:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def steps_undershoot(self) -> int:
|
def steps_undershoot(self) -> int:
|
||||||
"""The distance to deliberately undershoot the estimated optimal starting point"""
|
"""The distance to deliberately undershoot the estimated optimal starting point."""
|
||||||
# Starting too low by "steps_undershoot" makes smart stacking faster.
|
# Starting too low by "steps_undershoot" makes smart stacking faster.
|
||||||
# Starting a stack too high requires it to move to the start,
|
# Starting a stack too high requires it to move to the start,
|
||||||
# autofocus and then re-stack. Starting slightly too low only
|
# autofocus and then re-stack. Starting slightly too low only
|
||||||
|
|
@ -116,14 +116,14 @@ class StackParams:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_images_to_test(self) -> int:
|
def max_images_to_test(self) -> int:
|
||||||
"""The maximum number of images that will be captured and tested in a stack
|
"""The maximum number of images that will be captured and tested in a stack.
|
||||||
|
|
||||||
This is 15 images more then the minimum number that are captured.
|
This is 15 images more then the minimum number that are captured.
|
||||||
"""
|
"""
|
||||||
return self.min_images_to_test + 15
|
return self.min_images_to_test + 15
|
||||||
|
|
||||||
def slice_to_save(self, sharpest_index):
|
def slice_to_save(self, sharpest_index):
|
||||||
"""Return the slice of images to save given the index of the sharpest image"""
|
"""Return the slice of images to save given the index of the sharpest image."""
|
||||||
images_each_side = (self.images_to_save - 1) // 2
|
images_each_side = (self.images_to_save - 1) // 2
|
||||||
return slice(
|
return slice(
|
||||||
max(sharpest_index - images_each_side, 0),
|
max(sharpest_index - images_each_side, 0),
|
||||||
|
|
@ -133,7 +133,7 @@ class StackParams:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CaptureInfo:
|
class CaptureInfo:
|
||||||
"""The information from a capture in a z_stack"""
|
"""The information from a capture in a z_stack."""
|
||||||
|
|
||||||
buffer_id: int
|
buffer_id: int
|
||||||
position: dict[str, int]
|
position: dict[str, int]
|
||||||
|
|
@ -141,7 +141,7 @@ class CaptureInfo:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self) -> str:
|
def filename(self) -> str:
|
||||||
"""The filename for this image generated from the position"""
|
"""The filename for this image generated from the position."""
|
||||||
return f"{self.position['x']}_{self.position['y']}_{self.position['z']}.jpeg"
|
return f"{self.position['x']}_{self.position['y']}_{self.position['z']}.jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -195,7 +195,7 @@ class JPEGSharpnessMonitor:
|
||||||
running = False
|
running = False
|
||||||
|
|
||||||
async def monitor_sharpness(self):
|
async def monitor_sharpness(self):
|
||||||
"""Start monitoring the frame sizes"""
|
"""Start monitoring the frame sizes."""
|
||||||
self.running = True
|
self.running = True
|
||||||
async for frame in self.camera.lores_mjpeg_stream.frame_async_generator():
|
async for frame in self.camera.lores_mjpeg_stream.frame_async_generator():
|
||||||
self.jpeg_times.append(time.time())
|
self.jpeg_times.append(time.time())
|
||||||
|
|
@ -205,7 +205,7 @@ class JPEGSharpnessMonitor:
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Context manager, during which we will monitor sharpness from the camera"""
|
"""Context manager, during which we will monitor sharpness from the camera."""
|
||||||
self.portal.start_task_soon(self.monitor_sharpness)
|
self.portal.start_task_soon(self.monitor_sharpness)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
|
|
@ -233,7 +233,7 @@ class JPEGSharpnessMonitor:
|
||||||
def move_data(
|
def move_data(
|
||||||
self, istart: int, istop: Optional[int] = None
|
self, istart: int, istop: Optional[int] = None
|
||||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
"""Extract sharpness as a function of (interpolated) z"""
|
"""Extract sharpness as a function of (interpolated) z."""
|
||||||
if istop is None:
|
if istop is None:
|
||||||
istop = istart + 2
|
istop = istart + 2
|
||||||
jpeg_times: np.ndarray = np.array(self.jpeg_times)
|
jpeg_times: np.ndarray = np.array(self.jpeg_times)
|
||||||
|
|
@ -261,7 +261,7 @@ class JPEGSharpnessMonitor:
|
||||||
return jpeg_times, jpeg_zs, jpeg_sizes[start:stop]
|
return jpeg_times, jpeg_zs, jpeg_sizes[start:stop]
|
||||||
|
|
||||||
def sharpest_z_on_move(self, index: int) -> int:
|
def sharpest_z_on_move(self, index: int) -> int:
|
||||||
"""Return the z position of the sharpest image on a given move"""
|
"""Return the z position of the sharpest image on a given move."""
|
||||||
_, jz, js = self.move_data(index)
|
_, jz, js = self.move_data(index)
|
||||||
if len(js) == 0:
|
if len(js) == 0:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
@ -270,7 +270,7 @@ class JPEGSharpnessMonitor:
|
||||||
return jz[np.argmax(js)]
|
return jz[np.argmax(js)]
|
||||||
|
|
||||||
def data_dict(self) -> SharpnessDataArrays:
|
def data_dict(self) -> SharpnessDataArrays:
|
||||||
"""Return the gathered data as a single convenient dictionary"""
|
"""Return the gathered data as a single convenient dictionary."""
|
||||||
data = {}
|
data = {}
|
||||||
for k in ["jpeg_times", "jpeg_sizes", "stage_times", "stage_positions"]:
|
for k in ["jpeg_times", "jpeg_sizes", "stage_times", "stage_positions"]:
|
||||||
data[k] = getattr(self, k)
|
data[k] = getattr(self, k)
|
||||||
|
|
@ -295,7 +295,7 @@ class AutofocusThing(lt.Thing):
|
||||||
dz: int = 2000,
|
dz: int = 2000,
|
||||||
start: str = "centre",
|
start: str = "centre",
|
||||||
) -> SharpnessDataArrays:
|
) -> SharpnessDataArrays:
|
||||||
"""Sweep the stage up and down, then move to the sharpest point
|
"""Sweep the stage up and down, then move to the sharpest point.
|
||||||
|
|
||||||
This method will will move down by dz/2, sweep up by dz, and then evaluate
|
This method will will move down by dz/2, sweep up by dz, and then evaluate
|
||||||
the position where the image was sharpest. We'll then move back down, and
|
the position where the image was sharpest. We'll then move back down, and
|
||||||
|
|
@ -325,7 +325,7 @@ class AutofocusThing(lt.Thing):
|
||||||
dz: Sequence[int],
|
dz: Sequence[int],
|
||||||
wait: float = 0,
|
wait: float = 0,
|
||||||
) -> SharpnessDataArrays:
|
) -> SharpnessDataArrays:
|
||||||
"""Make a move (or a series of moves) and monitor sharpness
|
"""Make a move (or a series of moves) and monitor sharpness.
|
||||||
|
|
||||||
This method will will make a series of relative moves in z, and
|
This method will will make a series of relative moves in z, and
|
||||||
return the sharpness (JPEG size) vs time, along with timestamps
|
return the sharpness (JPEG size) vs time, along with timestamps
|
||||||
|
|
@ -561,7 +561,7 @@ class AutofocusThing(lt.Thing):
|
||||||
cam: WrappedCamera,
|
cam: WrappedCamera,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
) -> tuple[bool, list[CaptureInfo], Optional[int]]:
|
) -> tuple[bool, list[CaptureInfo], Optional[int]]:
|
||||||
"""Capture a series of images checking that sharpest image central
|
"""Capture a series of images checking that sharpest image central.
|
||||||
|
|
||||||
The images are seperated in z offset by stack_parameters.stack_dz, as they
|
The images are seperated in z offset by stack_parameters.stack_dz, as they
|
||||||
are captured the last stack_parameters.min_images_to_test images are checked
|
are captured the last stack_parameters.min_images_to_test images are checked
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class BackgroundDetectThing(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_setting
|
@lt.thing_setting
|
||||||
def background_distributions(self) -> Optional[ChannelDistributions]:
|
def background_distributions(self) -> Optional[ChannelDistributions]:
|
||||||
"""The statistics of the background image"""
|
"""The statistics of the background image."""
|
||||||
bd = self._background_distributions
|
bd = self._background_distributions
|
||||||
if bd is None:
|
if bd is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -56,7 +56,7 @@ class BackgroundDetectThing(lt.Thing):
|
||||||
)
|
)
|
||||||
|
|
||||||
def background_mask(self, image: np.ndarray) -> np.ndarray:
|
def background_mask(self, image: np.ndarray) -> np.ndarray:
|
||||||
"""Calculate a binary image, showing whether each pixel is background
|
"""Calculate a binary image, showing whether each pixel is background.
|
||||||
|
|
||||||
The image should be in LUV format, the ouput will be binary with the
|
The image should be in LUV format, the ouput will be binary with the
|
||||||
same shape in the first two dimensions.
|
same shape in the first two dimensions.
|
||||||
|
|
@ -78,7 +78,7 @@ class BackgroundDetectThing(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def background_fraction(self, cam: CamDep) -> float:
|
def background_fraction(self, cam: CamDep) -> float:
|
||||||
"""Determine what fraction of the current image is background
|
"""Determine what fraction of the current image is background.
|
||||||
|
|
||||||
This action will acquire a new image from the preview stream, then
|
This action will acquire a new image from the preview stream, then
|
||||||
evaluate whether it is foreground or background, by comparing it
|
evaluate whether it is foreground or background, by comparing it
|
||||||
|
|
@ -96,7 +96,7 @@ class BackgroundDetectThing(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def image_is_sample(self, cam: CamDep) -> bool:
|
def image_is_sample(self, cam: CamDep) -> bool:
|
||||||
"""Label the current image as either background or sample"""
|
"""Label the current image as either background or sample."""
|
||||||
b_fraction = self.background_fraction(cam)
|
b_fraction = self.background_fraction(cam)
|
||||||
fraction_threshold = self.fraction
|
fraction_threshold = self.fraction
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ class BackgroundDetectThing(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def set_background(self, cam: CamDep):
|
def set_background(self, cam: CamDep):
|
||||||
"""Grab an image, and use its statistics to set the background
|
"""Grab an image, and use its statistics to set the background.
|
||||||
|
|
||||||
This should be run when the microscope is looking at an empty region,
|
This should be run when the microscope is looking at an empty region,
|
||||||
and will calculate the mean and standard deviation of the pixel values
|
and will calculate the mean and standard deviation of the pixel values
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""OpenFlexure Microscope Camera
|
"""OpenFlexure Microscope Camera.
|
||||||
|
|
||||||
This module defines the interface for cameras. Any compatible lt.Thing
|
This module defines the interface for cameras. Any compatible lt.Thing
|
||||||
should enabe the server to work.
|
should enabe the server to work.
|
||||||
|
|
@ -23,23 +23,23 @@ class JPEGBlob(lt.blob.Blob):
|
||||||
|
|
||||||
|
|
||||||
class PNGBlob(lt.blob.Blob):
|
class PNGBlob(lt.blob.Blob):
|
||||||
"""A class representing a PNG image as a LabThings FastAPI Blob"""
|
"""A class representing a PNG image as a LabThings FastAPI Blob."""
|
||||||
|
|
||||||
media_type: str = "image/png"
|
media_type: str = "image/png"
|
||||||
|
|
||||||
|
|
||||||
class ArrayModel(RootModel):
|
class ArrayModel(RootModel):
|
||||||
"""A model for an array"""
|
"""A model for an array."""
|
||||||
|
|
||||||
root: NDArray
|
root: NDArray
|
||||||
|
|
||||||
|
|
||||||
class CaptureError(RuntimeError):
|
class CaptureError(RuntimeError):
|
||||||
"""An error trying to capture from a CameraThing"""
|
"""An error trying to capture from a CameraThing."""
|
||||||
|
|
||||||
|
|
||||||
class NoImageInMemoryError(RuntimeError):
|
class NoImageInMemoryError(RuntimeError):
|
||||||
"""An error called if no image in in memory when an method is called to use that image"""
|
"""An error called if no image in in memory when an method is called to use that image."""
|
||||||
|
|
||||||
|
|
||||||
class CameraMemoryBuffer:
|
class CameraMemoryBuffer:
|
||||||
|
|
@ -61,7 +61,7 @@ class CameraMemoryBuffer:
|
||||||
def add_image(
|
def add_image(
|
||||||
self, image: Any, metadata: Optional[dict] = None, buffer_max: int = 1
|
self, image: Any, metadata: Optional[dict] = None, buffer_max: int = 1
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Add an image to the Memory buffer
|
"""Add an image to the Memory buffer.
|
||||||
|
|
||||||
This will add an image to the memory buffer. By default the buffer will
|
This will add an image to the memory buffer. By default the buffer will
|
||||||
be cleared. To allow saving multiple images the buffer_max must be set
|
be cleared. To allow saving multiple images the buffer_max must be set
|
||||||
|
|
@ -114,7 +114,7 @@ class CameraMemoryBuffer:
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""Clear all images from memory"""
|
"""Clear all images from memory."""
|
||||||
self._storage.clear()
|
self._storage.clear()
|
||||||
|
|
||||||
def _create_space(self, buffer_max: int) -> None:
|
def _create_space(self, buffer_max: int) -> None:
|
||||||
|
|
@ -140,7 +140,7 @@ class CameraMemoryBuffer:
|
||||||
|
|
||||||
|
|
||||||
class BaseCamera(lt.Thing):
|
class BaseCamera(lt.Thing):
|
||||||
"""The base class for all cameras. All cameras must directly inherit from this class"""
|
"""The base class for all cameras. All cameras must directly inherit from this class."""
|
||||||
|
|
||||||
mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
|
mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
|
||||||
lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
|
lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
|
||||||
|
|
@ -181,7 +181,7 @@ class BaseCamera(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def stream_active(self) -> bool:
|
def stream_active(self) -> bool:
|
||||||
"""Whether the MJPEG stream is active"""
|
"""Whether the MJPEG stream is active."""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"CameraThings must define their own stream_active method"
|
"CameraThings must define their own stream_active method"
|
||||||
)
|
)
|
||||||
|
|
@ -203,7 +203,7 @@ class BaseCamera(lt.Thing):
|
||||||
resolution: Literal["lores", "main", "full"] = "main",
|
resolution: Literal["lores", "main", "full"] = "main",
|
||||||
wait: Optional[float] = 5,
|
wait: Optional[float] = 5,
|
||||||
) -> JPEGBlob:
|
) -> JPEGBlob:
|
||||||
"""Acquire one image from the camera and return as a JPEG blob"""
|
"""Acquire one image from the camera and return as a JPEG blob."""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"CameraThings must define their own capture_jpeg method"
|
"CameraThings must define their own capture_jpeg method"
|
||||||
)
|
)
|
||||||
|
|
@ -214,7 +214,7 @@ class BaseCamera(lt.Thing):
|
||||||
portal: lt.deps.BlockingPortal,
|
portal: lt.deps.BlockingPortal,
|
||||||
stream_name: Literal["main", "lores"] = "main",
|
stream_name: Literal["main", "lores"] = "main",
|
||||||
) -> JPEGBlob:
|
) -> JPEGBlob:
|
||||||
"""Acquire one image from the preview stream and return as an array
|
"""Acquire one image from the preview stream and return as an array.
|
||||||
|
|
||||||
This differs from ``capture_jpeg`` in that it does not pause the MJPEG
|
This differs from ``capture_jpeg`` in that it does not pause the MJPEG
|
||||||
preview stream. Instead, we simply return the next frame from that
|
preview stream. Instead, we simply return the next frame from that
|
||||||
|
|
@ -233,7 +233,7 @@ class BaseCamera(lt.Thing):
|
||||||
portal: lt.deps.BlockingPortal,
|
portal: lt.deps.BlockingPortal,
|
||||||
stream_name: Literal["main", "lores"] = "main",
|
stream_name: Literal["main", "lores"] = "main",
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Acquire one image from the preview stream and return its size"""
|
"""Acquire one image from the preview stream and return its size."""
|
||||||
stream = (
|
stream = (
|
||||||
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
||||||
)
|
)
|
||||||
|
|
@ -245,7 +245,7 @@ class BaseCamera(lt.Thing):
|
||||||
stream_name: Literal["main", "lores", "raw"],
|
stream_name: Literal["main", "lores", "raw"],
|
||||||
wait: Optional[float],
|
wait: Optional[float],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Capture a PIL image from stream stream_name with timeout wait"""
|
"""Capture a PIL image from stream stream_name with timeout wait."""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"CameraThings must define their own capture_image method"
|
"CameraThings must define their own capture_image method"
|
||||||
)
|
)
|
||||||
|
|
@ -258,7 +258,7 @@ class BaseCamera(lt.Thing):
|
||||||
metadata_getter: lt.deps.GetThingStates,
|
metadata_getter: lt.deps.GetThingStates,
|
||||||
save_resolution: Optional[Tuple[int, int]] = None,
|
save_resolution: Optional[Tuple[int, int]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Capture an image and save it to disk
|
"""Capture an image and save it to disk.
|
||||||
|
|
||||||
:param jpeg_path: The path to save the file to
|
:param jpeg_path: The path to save the file to
|
||||||
:param logger: This should be injected automatically by Labthings FastAPI
|
:param logger: This should be injected automatically by Labthings FastAPI
|
||||||
|
|
@ -288,7 +288,7 @@ class BaseCamera(lt.Thing):
|
||||||
metadata_getter: lt.deps.GetThingStates,
|
metadata_getter: lt.deps.GetThingStates,
|
||||||
buffer_max: int = 1,
|
buffer_max: int = 1,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Capture an image to memory. This can be saved later with ``save_from_memory``
|
"""Capture an image to memory. This can be saved later with ``save_from_memory``.
|
||||||
|
|
||||||
Note that only one image is held in memory so this will overwrite any image
|
Note that only one image is held in memory so this will overwrite any image
|
||||||
in memory.
|
in memory.
|
||||||
|
|
@ -336,7 +336,7 @@ class BaseCamera(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def clear_buffers(self) -> None:
|
def clear_buffers(self) -> None:
|
||||||
"""Clear all images in memory"""
|
"""Clear all images in memory."""
|
||||||
self._memory_buffer.clear()
|
self._memory_buffer.clear()
|
||||||
|
|
||||||
def _robust_image_capture(
|
def _robust_image_capture(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""OpenFlexure Microscope OpenCV Camera
|
"""OpenFlexure Microscope OpenCV Camera.
|
||||||
|
|
||||||
This module defines a camera Thing that uses OpenCV's
|
This module defines a camera Thing that uses OpenCV's
|
||||||
``VideoCapture``.
|
``VideoCapture``.
|
||||||
|
|
@ -23,7 +23,7 @@ from . import BaseCamera, JPEGBlob
|
||||||
|
|
||||||
|
|
||||||
class OpenCVCamera(BaseCamera):
|
class OpenCVCamera(BaseCamera):
|
||||||
"""A Thing representing an OpenCV camera"""
|
"""A Thing representing an OpenCV camera."""
|
||||||
|
|
||||||
def __init__(self, camera_index: int = 0):
|
def __init__(self, camera_index: int = 0):
|
||||||
self.camera_index = camera_index
|
self.camera_index = camera_index
|
||||||
|
|
@ -45,7 +45,7 @@ class OpenCVCamera(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def stream_active(self) -> bool:
|
def stream_active(self) -> bool:
|
||||||
"""Whether the MJPEG stream is active"""
|
"""Whether the MJPEG stream is active."""
|
||||||
if self._capture_enabled and self._capture_thread:
|
if self._capture_enabled and self._capture_thread:
|
||||||
return self._capture_thread.is_alive()
|
return self._capture_thread.is_alive()
|
||||||
return False
|
return False
|
||||||
|
|
@ -71,7 +71,7 @@ class OpenCVCamera(BaseCamera):
|
||||||
self,
|
self,
|
||||||
resolution: Literal["main", "full"] = "full",
|
resolution: Literal["main", "full"] = "full",
|
||||||
) -> NDArray:
|
) -> NDArray:
|
||||||
"""Acquire one image from the camera and return as an array
|
"""Acquire one image from the camera and return as an array.
|
||||||
|
|
||||||
This function will produce a nested list containing an uncompressed RGB image.
|
This function will produce a nested list containing an uncompressed RGB image.
|
||||||
It's likely to be highly inefficient - raw and/or uncompressed captures using
|
It's likely to be highly inefficient - raw and/or uncompressed captures using
|
||||||
|
|
@ -91,7 +91,7 @@ class OpenCVCamera(BaseCamera):
|
||||||
metadata_getter: lt.deps.GetThingStates,
|
metadata_getter: lt.deps.GetThingStates,
|
||||||
resolution: Literal["main", "full"] = "main",
|
resolution: Literal["main", "full"] = "main",
|
||||||
) -> JPEGBlob:
|
) -> JPEGBlob:
|
||||||
"""Acquire one image from the camera and return as a JPEG blob
|
"""Acquire one image from the camera and return as a JPEG blob.
|
||||||
|
|
||||||
This function will produce a JPEG image.
|
This function will produce a JPEG image.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,10 @@ from . import BaseCamera, JPEGBlob, ArrayModel
|
||||||
|
|
||||||
|
|
||||||
class PicameraStreamOutput(Output):
|
class PicameraStreamOutput(Output):
|
||||||
"""An Output class that sends frames to a stream"""
|
"""An Output class that sends frames to a stream."""
|
||||||
|
|
||||||
def __init__(self, stream: lt.outputs.MJPEGStream, portal: lt.deps.BlockingPortal):
|
def __init__(self, stream: lt.outputs.MJPEGStream, portal: lt.deps.BlockingPortal):
|
||||||
"""Create an output that puts frames in an MJPEGStream
|
"""Create an output that puts frames in an MJPEGStream.
|
||||||
|
|
||||||
We need to pass the stream object, and also the blocking portal, because
|
We need to pass the stream object, and also the blocking portal, because
|
||||||
new frame notifications happen in the anyio event loop and frames are
|
new frame notifications happen in the anyio event loop and frames are
|
||||||
|
|
@ -57,7 +57,7 @@ class PicameraStreamOutput(Output):
|
||||||
def outputframe(
|
def outputframe(
|
||||||
self, frame, _keyframe=True, _timestamp=None, _packet=None, _audio=False
|
self, frame, _keyframe=True, _timestamp=None, _packet=None, _audio=False
|
||||||
):
|
):
|
||||||
"""Add a frame to the stream's ringbuffer"""
|
"""Add a frame to the stream's ringbuffer."""
|
||||||
self.stream.add_frame(frame, self.portal)
|
self.stream.add_frame(frame, self.portal)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ class LensShading(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class StreamingPiCamera2(BaseCamera):
|
class StreamingPiCamera2(BaseCamera):
|
||||||
"""A Thing that represents an OpenCV camera"""
|
"""A Thing that represents an OpenCV camera."""
|
||||||
|
|
||||||
def __init__(self, camera_num: int = 0):
|
def __init__(self, camera_num: int = 0):
|
||||||
self._setting_save_in_progress = False
|
self._setting_save_in_progress = False
|
||||||
|
|
@ -244,7 +244,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def sensor_modes(self) -> list[SensorMode]:
|
def sensor_modes(self) -> list[SensorMode]:
|
||||||
"""All the available modes the current sensor supports"""
|
"""All the available modes the current sensor supports."""
|
||||||
if not self._sensor_modes:
|
if not self._sensor_modes:
|
||||||
with self._streaming_picamera() as cam:
|
with self._streaming_picamera() as cam:
|
||||||
self._sensor_modes = cam.sensor_modes
|
self._sensor_modes = cam.sensor_modes
|
||||||
|
|
@ -254,14 +254,14 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def sensor_mode(self) -> Optional[SensorModeSelector]:
|
def sensor_mode(self) -> Optional[SensorModeSelector]:
|
||||||
"""The intended sensor mode of the camera"""
|
"""The intended sensor mode of the camera."""
|
||||||
if self._sensor_mode is None:
|
if self._sensor_mode is None:
|
||||||
return None
|
return None
|
||||||
return SensorModeSelector(**self._sensor_mode)
|
return SensorModeSelector(**self._sensor_mode)
|
||||||
|
|
||||||
@sensor_mode.setter
|
@sensor_mode.setter
|
||||||
def sensor_mode(self, new_mode: Optional[SensorModeSelector | dict]):
|
def sensor_mode(self, new_mode: Optional[SensorModeSelector | dict]):
|
||||||
"""Change the sensor mode used"""
|
"""Change the sensor mode used."""
|
||||||
if new_mode is None:
|
if new_mode is None:
|
||||||
self._sensor_mode = None
|
self._sensor_mode = None
|
||||||
elif isinstance(new_mode, SensorModeSelector):
|
elif isinstance(new_mode, SensorModeSelector):
|
||||||
|
|
@ -277,7 +277,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def sensor_resolution(self) -> Optional[tuple[int, int]]:
|
def sensor_resolution(self) -> Optional[tuple[int, int]]:
|
||||||
"""The native resolution of the camera's sensor"""
|
"""The native resolution of the camera's sensor."""
|
||||||
with self._streaming_picamera() as cam:
|
with self._streaming_picamera() as cam:
|
||||||
return cam.sensor_resolution
|
return cam.sensor_resolution
|
||||||
|
|
||||||
|
|
@ -324,7 +324,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def streaming(self) -> bool:
|
def streaming(self) -> bool:
|
||||||
"""True if the camera is streaming"""
|
"""True if the camera is streaming."""
|
||||||
return self._picamera is not None and self._picamera.started
|
return self._picamera is not None and self._picamera.started
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|
@ -432,7 +432,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def stop_streaming(self, stop_web_stream: bool = True) -> None:
|
def stop_streaming(self, stop_web_stream: bool = True) -> None:
|
||||||
"""Stop the MJPEG stream"""
|
"""Stop the MJPEG stream."""
|
||||||
with self._streaming_picamera() as picam:
|
with self._streaming_picamera() as picam:
|
||||||
try:
|
try:
|
||||||
picam.stop_recording() # This should also stop the extra lores encoder
|
picam.stop_recording() # This should also stop the extra lores encoder
|
||||||
|
|
@ -474,7 +474,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
stream_name: Literal["main", "lores", "raw", "full"] = "main",
|
stream_name: Literal["main", "lores", "raw", "full"] = "main",
|
||||||
wait: Optional[float] = 0.9,
|
wait: Optional[float] = 0.9,
|
||||||
) -> ArrayModel:
|
) -> ArrayModel:
|
||||||
"""Acquire one image from the camera and return as an array
|
"""Acquire one image from the camera and return as an array.
|
||||||
|
|
||||||
This function will produce a nested list containing an uncompressed RGB image.
|
This function will produce a nested list containing an uncompressed RGB image.
|
||||||
It's likely to be highly inefficient - raw and/or uncompressed captures using
|
It's likely to be highly inefficient - raw and/or uncompressed captures using
|
||||||
|
|
@ -496,7 +496,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def camera_configuration(self) -> Mapping:
|
def camera_configuration(self) -> Mapping:
|
||||||
"""The "configuration" dictionary of the picamera2 object
|
"""The "configuration" dictionary of the picamera2 object.
|
||||||
|
|
||||||
The "configuration" sets the resolution and format of the camera's streams.
|
The "configuration" sets the resolution and format of the camera's streams.
|
||||||
Together with the "tuning" it determines how the sensor is configured and
|
Together with the "tuning" it determines how the sensor is configured and
|
||||||
|
|
@ -516,7 +516,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
resolution: Literal["lores", "main", "full"] = "main",
|
resolution: Literal["lores", "main", "full"] = "main",
|
||||||
wait: Optional[float] = 0.9,
|
wait: Optional[float] = 0.9,
|
||||||
) -> JPEGBlob:
|
) -> JPEGBlob:
|
||||||
"""Acquire one image from the camera as a JPEG
|
"""Acquire one image from the camera as a JPEG.
|
||||||
|
|
||||||
The JPEG will be acquired using ``Picamera2.capture_file``. If the
|
The JPEG will be acquired using ``Picamera2.capture_file``. If the
|
||||||
``resolution`` parameter is ``main`` or ``lores``, it will be captured
|
``resolution`` parameter is ``main`` or ``lores``, it will be captured
|
||||||
|
|
@ -567,7 +567,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def capture_metadata(self) -> dict:
|
def capture_metadata(self) -> dict:
|
||||||
"""Return the metadata from the camera"""
|
"""Return the metadata from the camera."""
|
||||||
with self._streaming_picamera() as cam:
|
with self._streaming_picamera() as cam:
|
||||||
return cam.capture_metadata()
|
return cam.capture_metadata()
|
||||||
|
|
||||||
|
|
@ -577,7 +577,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
target_white_level: int = 700,
|
target_white_level: int = 700,
|
||||||
percentile: float = 99.9,
|
percentile: float = 99.9,
|
||||||
):
|
):
|
||||||
"""Adjust exposure until a the target white level is reached
|
"""Adjust exposure until a the target white level is reached.
|
||||||
|
|
||||||
Starting from the minimum exposure, gradually increase exposure until
|
Starting from the minimum exposure, gradually increase exposure until
|
||||||
the image reaches the specified white level.
|
the image reaches the specified white level.
|
||||||
|
|
@ -603,7 +603,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
method: Literal["percentile", "centre"] = "centre",
|
method: Literal["percentile", "centre"] = "centre",
|
||||||
luminance_power: float = 1.0,
|
luminance_power: float = 1.0,
|
||||||
):
|
):
|
||||||
"""Correct the white balance of the image
|
"""Correct the white balance of the image.
|
||||||
|
|
||||||
This calibration requires a neutral image, such that the 99th centile
|
This calibration requires a neutral image, such that the 99th centile
|
||||||
of each colour channel should correspond to white. We calculate the
|
of each colour channel should correspond to white. We calculate the
|
||||||
|
|
@ -711,7 +711,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def full_auto_calibrate(self) -> None:
|
def full_auto_calibrate(self) -> None:
|
||||||
"""Perform a full auto-calibration
|
"""Perform a full auto-calibration.
|
||||||
|
|
||||||
This function will call the other calibration actions in sequence:
|
This function will call the other calibration actions in sequence:
|
||||||
|
|
||||||
|
|
@ -729,7 +729,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def flat_lens_shading(self) -> None:
|
def flat_lens_shading(self) -> None:
|
||||||
"""Disable flat-field correction
|
"""Disable flat-field correction.
|
||||||
|
|
||||||
This method will set a completely flat lens shading table. It is not the
|
This method will set a completely flat lens shading table. It is not the
|
||||||
same as the default behaviour, which is to use an adaptive lens shading
|
same as the default behaviour, which is to use an adaptive lens shading
|
||||||
|
|
@ -748,7 +748,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def lens_shading_tables(self) -> Optional[LensShading]:
|
def lens_shading_tables(self) -> Optional[LensShading]:
|
||||||
"""The current lens shading (i.e. flat-field correction)
|
"""The current lens shading (i.e. flat-field correction).
|
||||||
|
|
||||||
Return the current lens shading correction, as three 2D lists each with
|
Return the current lens shading correction, as three 2D lists each with
|
||||||
dimensions 16x12, if a static lens shading table is in use.
|
dimensions 16x12, if a static lens shading table is in use.
|
||||||
|
|
@ -770,7 +770,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def reshape_lst(lin: list[float]) -> list[list[float]]:
|
def reshape_lst(lin: list[float]) -> list[list[float]]:
|
||||||
"""Reshape the 192 element list into a 2D 16x12 list"""
|
"""Reshape the 192 element list into a 2D 16x12 list."""
|
||||||
w, h = 16, 12
|
w, h = 16, 12
|
||||||
return [lin[w * i : w * (i + 1)] for i in range(h)]
|
return [lin[w * i : w * (i + 1)] for i in range(h)]
|
||||||
|
|
||||||
|
|
@ -782,7 +782,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lens_shading_tables.setter
|
@lens_shading_tables.setter
|
||||||
def lens_shading_tables(self, lst: LensShading) -> None:
|
def lens_shading_tables(self, lst: LensShading) -> None:
|
||||||
"""Set the lens shading tables"""
|
"""Set the lens shading tables."""
|
||||||
with self._streaming_picamera(pause_stream=True):
|
with self._streaming_picamera(pause_stream=True):
|
||||||
recalibrate_utils.set_static_lst(
|
recalibrate_utils.set_static_lst(
|
||||||
self.tuning,
|
self.tuning,
|
||||||
|
|
@ -795,7 +795,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
def correct_colour_gains_for_lens_shading(
|
def correct_colour_gains_for_lens_shading(
|
||||||
self, colour_gains: tuple[float, float]
|
self, colour_gains: tuple[float, float]
|
||||||
) -> tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
"""Correct white balance gains for the effect of lens shading
|
"""Correct white balance gains for the effect of lens shading.
|
||||||
|
|
||||||
The white balance algorithm we use assumes the brightest pixels
|
The white balance algorithm we use assumes the brightest pixels
|
||||||
should be white, and that the only thing affecting the colour of
|
should be white, and that the only thing affecting the colour of
|
||||||
|
|
@ -825,7 +825,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def flat_lens_shading_chrominance(self) -> None:
|
def flat_lens_shading_chrominance(self) -> None:
|
||||||
"""Disable flat-field correction
|
"""Disable flat-field correction.
|
||||||
|
|
||||||
This method will set the chrominance of the lens shading table to be
|
This method will set the chrominance of the lens shading table to be
|
||||||
flat, i.e. we'll correct vignetting of intensity, but not any change in
|
flat, i.e. we'll correct vignetting of intensity, but not any change in
|
||||||
|
|
@ -840,7 +840,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def reset_lens_shading(self) -> None:
|
def reset_lens_shading(self) -> None:
|
||||||
"""Revert to default lens shading settings
|
"""Revert to default lens shading settings.
|
||||||
|
|
||||||
This method will restore the default "adaptive" lens shading method used
|
This method will restore the default "adaptive" lens shading method used
|
||||||
by the Raspberry Pi camera.
|
by the Raspberry Pi camera.
|
||||||
|
|
@ -851,7 +851,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def lens_shading_is_static(self) -> bool:
|
def lens_shading_is_static(self) -> bool:
|
||||||
"""Whether the lens shading is static
|
"""Whether the lens shading is static.
|
||||||
|
|
||||||
This property is true if the lens shading correction has been set to use
|
This property is true if the lens shading correction has been set to use
|
||||||
a static table (i.e. the number of automatic correction iterations is zero).
|
a static table (i.e. the number of automatic correction iterations is zero).
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Functions to set up a Raspberry Pi Camera v2 for scientific use
|
"""Functions to set up a Raspberry Pi Camera v2 for scientific use.
|
||||||
|
|
||||||
This module provides slower, simpler functions to set the
|
This module provides slower, simpler functions to set the
|
||||||
gain, exposure, and white balance of a Raspberry Pi camera, using
|
gain, exposure, and white balance of a Raspberry Pi camera, using
|
||||||
|
|
@ -52,7 +52,7 @@ LensShadingTables = tuple[np.ndarray, np.ndarray, np.ndarray]
|
||||||
|
|
||||||
|
|
||||||
def load_default_tuning(cam: Picamera2) -> dict:
|
def load_default_tuning(cam: Picamera2) -> dict:
|
||||||
"""Load the default tuning file for the camera
|
"""Load the default tuning file for the camera.
|
||||||
|
|
||||||
This will open and close the camera to determine its model. If you are
|
This will open and close the camera to determine its model. If you are
|
||||||
using a model that's supported by ``picamera2`` it should have a tuning
|
using a model that's supported by ``picamera2`` it should have a tuning
|
||||||
|
|
@ -76,7 +76,7 @@ def load_default_tuning(cam: Picamera2) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def set_minimum_exposure(camera: Picamera2) -> None:
|
def set_minimum_exposure(camera: Picamera2) -> None:
|
||||||
"""Enable manual exposure, with low gain and shutter speed
|
"""Enable manual exposure, with low gain and shutter speed.
|
||||||
|
|
||||||
We set exposure mode to manual, analog and digital gain
|
We set exposure mode to manual, analog and digital gain
|
||||||
to 1, and shutter speed to the minimum (8us for Pi Camera v2)
|
to 1, and shutter speed to the minimum (8us for Pi Camera v2)
|
||||||
|
|
@ -93,7 +93,7 @@ def set_minimum_exposure(camera: Picamera2) -> None:
|
||||||
|
|
||||||
|
|
||||||
class ExposureTest(BaseModel):
|
class ExposureTest(BaseModel):
|
||||||
"""Record the results of testing the camera's current exposure settings"""
|
"""Record the results of testing the camera's current exposure settings."""
|
||||||
|
|
||||||
level: int
|
level: int
|
||||||
exposure_time: int
|
exposure_time: int
|
||||||
|
|
@ -101,7 +101,7 @@ class ExposureTest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
def test_exposure_settings(camera: Picamera2, percentile: float) -> ExposureTest:
|
def test_exposure_settings(camera: Picamera2, percentile: float) -> ExposureTest:
|
||||||
"""Evaluate current exposure settings using a raw image
|
"""Evaluate current exposure settings using a raw image.
|
||||||
|
|
||||||
CAMERA SHOULD BE STARTED!
|
CAMERA SHOULD BE STARTED!
|
||||||
|
|
||||||
|
|
@ -136,7 +136,7 @@ def test_exposure_settings(camera: Picamera2, percentile: float) -> ExposureTest
|
||||||
|
|
||||||
|
|
||||||
def check_convergence(test: ExposureTest, target: int, tolerance: float) -> bool:
|
def check_convergence(test: ExposureTest, target: int, tolerance: float) -> bool:
|
||||||
"""Check whether the brightness is within the specified target range"""
|
"""Check whether the brightness is within the specified target range."""
|
||||||
return abs(test.level - target) < target * tolerance
|
return abs(test.level - target) < target * tolerance
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -321,7 +321,7 @@ def channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray:
|
||||||
|
|
||||||
|
|
||||||
def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray:
|
def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray:
|
||||||
"""Compresses channel down to a 16x12 grid - from libcamera
|
"""Compresses channel down to a 16x12 grid - from libcamera.
|
||||||
|
|
||||||
This is taken from
|
This is taken from
|
||||||
https://git.linuxtv.org/libcamera.git/tree/utils/raspberrypi/ctt/ctt_alsc.py
|
https://git.linuxtv.org/libcamera.git/tree/utils/raspberrypi/ctt/ctt_alsc.py
|
||||||
|
|
@ -343,7 +343,7 @@ def get_16x12_grid(chan: np.ndarray, dx: int, dy: int) -> np.ndarray:
|
||||||
|
|
||||||
|
|
||||||
def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray:
|
def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray:
|
||||||
"""Zoom an image in the last two dimensions
|
"""Zoom an image in the last two dimensions.
|
||||||
|
|
||||||
This is effectively the inverse operation of ``get_16x12_grid``
|
This is effectively the inverse operation of ``get_16x12_grid``
|
||||||
"""
|
"""
|
||||||
|
|
@ -354,7 +354,7 @@ def upsample_channels(grids: np.ndarray, shape: tuple[int]) -> np.ndarray:
|
||||||
|
|
||||||
|
|
||||||
def downsampled_channels(channels: np.ndarray, blacklevel=64) -> list[np.ndarray]:
|
def downsampled_channels(channels: np.ndarray, blacklevel=64) -> list[np.ndarray]:
|
||||||
"""Generate a downsampled, un-normalised image from which to calculate the LST
|
"""Generate a downsampled, un-normalised image from which to calculate the LST.
|
||||||
|
|
||||||
TODO: blacklevel probably ought to be determined from the camera...
|
TODO: blacklevel probably ought to be determined from the camera...
|
||||||
"""
|
"""
|
||||||
|
|
@ -382,7 +382,7 @@ def lst_from_channels(channels: np.ndarray) -> LensShadingTables:
|
||||||
|
|
||||||
|
|
||||||
def lst_from_grids(grids: np.ndarray) -> LensShadingTables:
|
def lst_from_grids(grids: np.ndarray) -> LensShadingTables:
|
||||||
"""Given 4 downsampled grids, generate the luminance and chrominance tables
|
"""Given 4 downsampled grids, generate the luminance and chrominance tables.
|
||||||
|
|
||||||
The grids are the 4 BAYER channels RGGB
|
The grids are the 4 BAYER channels RGGB
|
||||||
|
|
||||||
|
|
@ -408,7 +408,7 @@ def lst_from_grids(grids: np.ndarray) -> LensShadingTables:
|
||||||
|
|
||||||
|
|
||||||
def grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarray:
|
def grids_from_lst(lum: np.ndarray, Cr: np.ndarray, Cb: np.ndarray) -> np.ndarray:
|
||||||
"""Convert form luminance/chrominance dict to four RGGB channels
|
"""Convert form luminance/chrominance dict to four RGGB channels.
|
||||||
|
|
||||||
Note that these will be normalised - the maximum green value is always 1.
|
Note that these will be normalised - the maximum green value is always 1.
|
||||||
Also, note that the channels are BGGR, to be consistent with the
|
Also, note that the channels are BGGR, to be consistent with the
|
||||||
|
|
@ -462,13 +462,13 @@ def set_static_ccm(
|
||||||
|
|
||||||
|
|
||||||
def get_static_ccm(tuning: dict) -> None:
|
def get_static_ccm(tuning: dict) -> None:
|
||||||
"""Get the ``rpi.ccm`` section of a camera tuning dict"""
|
"""Get the ``rpi.ccm`` section of a camera tuning dict."""
|
||||||
ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm")
|
ccm = Picamera2.find_tuning_algo(tuning, "rpi.ccm")
|
||||||
return ccm["ccms"]
|
return ccm["ccms"]
|
||||||
|
|
||||||
|
|
||||||
def lst_is_static(tuning: dict) -> bool:
|
def lst_is_static(tuning: dict) -> bool:
|
||||||
"""Whether the lens shading table is set to static"""
|
"""Whether the lens shading table is set to static."""
|
||||||
alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc")
|
alsc = Picamera2.find_tuning_algo(tuning, "rpi.alsc")
|
||||||
return alsc["n_iter"] == 0
|
return alsc["n_iter"] == 0
|
||||||
|
|
||||||
|
|
@ -493,13 +493,13 @@ def set_static_geq(
|
||||||
|
|
||||||
|
|
||||||
def _geq_is_static(tuning: dict) -> bool:
|
def _geq_is_static(tuning: dict) -> bool:
|
||||||
"""Whether the green equalisation is set to static"""
|
"""Whether the green equalisation is set to static."""
|
||||||
geq = Picamera2.find_tuning_algo(tuning, "rpi.geq")
|
geq = Picamera2.find_tuning_algo(tuning, "rpi.geq")
|
||||||
return geq["offset"] == 65535
|
return geq["offset"] == 65535
|
||||||
|
|
||||||
|
|
||||||
def index_of_algorithm(algorithms: list[dict], algorithm: str) -> int:
|
def index_of_algorithm(algorithms: list[dict], algorithm: str) -> int:
|
||||||
"""Find the index of an algorithm's section in the tuning file"""
|
"""Find the index of an algorithm's section in the tuning file."""
|
||||||
for i, a in enumerate(algorithms):
|
for i, a in enumerate(algorithms):
|
||||||
if algorithm in a:
|
if algorithm in a:
|
||||||
return i
|
return i
|
||||||
|
|
@ -557,5 +557,5 @@ def recreate_camera_manager() -> None:
|
||||||
|
|
||||||
|
|
||||||
def as_flat_rounded_list(array: np.ndarray, round_to: int = 3) -> list[float]:
|
def as_flat_rounded_list(array: np.ndarray, round_to: int = 3) -> list[float]:
|
||||||
"""Flatten array, round, and then convert to list"""
|
"""Flatten array, round, and then convert to list."""
|
||||||
return np.reshape(array, -1).round(round_to).tolist()
|
return np.reshape(array, -1).round(round_to).tolist()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""OpenFlexure Microscope OpenCV Camera
|
"""OpenFlexure Microscope OpenCV Camera.
|
||||||
|
|
||||||
This module defines a Thing that is responsible for using the stage and
|
This module defines a Thing that is responsible for using the stage and
|
||||||
camera together to perform an autofocus routine.
|
camera together to perform an autofocus routine.
|
||||||
|
|
@ -30,7 +30,7 @@ RATIO = 0.2
|
||||||
|
|
||||||
|
|
||||||
class SimulatedCamera(BaseCamera):
|
class SimulatedCamera(BaseCamera):
|
||||||
"""A Thing representing an OpenCV camera"""
|
"""A Thing representing an OpenCV camera."""
|
||||||
|
|
||||||
_stage: Optional[BaseStage] = None
|
_stage: Optional[BaseStage] = None
|
||||||
_server: Optional[lt.ThingServer] = None
|
_server: Optional[lt.ThingServer] = None
|
||||||
|
|
@ -53,7 +53,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
self.generate_canvas()
|
self.generate_canvas()
|
||||||
|
|
||||||
def generate_sprites(self):
|
def generate_sprites(self):
|
||||||
"""Generate sprites to populate the image"""
|
"""Generate sprites to populate the image."""
|
||||||
self.sprites = []
|
self.sprites = []
|
||||||
black = np.zeros(self.glyph_shape, dtype=np.uint8)
|
black = np.zeros(self.glyph_shape, dtype=np.uint8)
|
||||||
x = np.arange(black.shape[0])
|
x = np.arange(black.shape[0])
|
||||||
|
|
@ -65,7 +65,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
self.sprites.append(sprite)
|
self.sprites.append(sprite)
|
||||||
|
|
||||||
def generate_blobs(self, n_blobs: int = 1000):
|
def generate_blobs(self, n_blobs: int = 1000):
|
||||||
"""Generate coordinates of blobs
|
"""Generate coordinates of blobs.
|
||||||
|
|
||||||
Blobs are characterised by X, Y, sprite
|
Blobs are characterised by X, Y, sprite
|
||||||
We also generate a KD tree to rapidly find blobs in an image
|
We also generate a KD tree to rapidly find blobs in an image
|
||||||
|
|
@ -78,7 +78,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
self.blobs[:, 2] = rng.choice(len(self.sprites), n_blobs)
|
self.blobs[:, 2] = rng.choice(len(self.sprites), n_blobs)
|
||||||
|
|
||||||
def generate_canvas(self):
|
def generate_canvas(self):
|
||||||
"""Generate a blank canvas"""
|
"""Generate a blank canvas."""
|
||||||
self.canvas = np.zeros(self.canvas_shape, dtype=np.uint8)
|
self.canvas = np.zeros(self.canvas_shape, dtype=np.uint8)
|
||||||
self.canvas[...] = 255
|
self.canvas[...] = 255
|
||||||
w, h, _ = self.glyph_shape
|
w, h, _ = self.glyph_shape
|
||||||
|
|
@ -89,7 +89,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
] -= self.sprites[int(sprite)]
|
] -= self.sprites[int(sprite)]
|
||||||
|
|
||||||
def generate_image(self, pos: tuple[int, int, int]):
|
def generate_image(self, pos: tuple[int, int, int]):
|
||||||
"""Generate an image with blobs based on supplied coordinates"""
|
"""Generate an image with blobs based on supplied coordinates."""
|
||||||
canvas_width, canvas_height, _ = self.canvas_shape
|
canvas_width, canvas_height, _ = self.canvas_shape
|
||||||
image_width, image_height, _ = self.shape
|
image_width, image_height, _ = self.shape
|
||||||
pos = tuple(x * RATIO for x in pos)
|
pos = tuple(x * RATIO for x in pos)
|
||||||
|
|
@ -124,7 +124,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
return self._stage.instantaneous_position
|
return self._stage.instantaneous_position
|
||||||
|
|
||||||
def generate_frame(self):
|
def generate_frame(self):
|
||||||
"""Generate a frame with blobs based on the stage coordinates"""
|
"""Generate a frame with blobs based on the stage coordinates."""
|
||||||
try:
|
try:
|
||||||
pos = self.get_stage_position()
|
pos = self.get_stage_position()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -145,7 +145,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def stream_active(self) -> bool:
|
def stream_active(self) -> bool:
|
||||||
"""Whether the MJPEG stream is active"""
|
"""Whether the MJPEG stream is active."""
|
||||||
if self._capture_enabled and self._capture_thread:
|
if self._capture_enabled and self._capture_thread:
|
||||||
return self._capture_thread.is_alive()
|
return self._capture_thread.is_alive()
|
||||||
return False
|
return False
|
||||||
|
|
@ -170,7 +170,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
self,
|
self,
|
||||||
resolution: Literal["main", "full"] = "full",
|
resolution: Literal["main", "full"] = "full",
|
||||||
) -> ArrayModel:
|
) -> ArrayModel:
|
||||||
"""Acquire one image from the camera and return as an array
|
"""Acquire one image from the camera and return as an array.
|
||||||
|
|
||||||
This function will produce a nested list containing an uncompressed RGB image.
|
This function will produce a nested list containing an uncompressed RGB image.
|
||||||
It's likely to be highly inefficient - raw and/or uncompressed captures using
|
It's likely to be highly inefficient - raw and/or uncompressed captures using
|
||||||
|
|
@ -185,7 +185,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
metadata_getter: lt.deps.GetThingStates,
|
metadata_getter: lt.deps.GetThingStates,
|
||||||
resolution: Literal["main", "full"] = "main",
|
resolution: Literal["main", "full"] = "main",
|
||||||
) -> JPEGBlob:
|
) -> JPEGBlob:
|
||||||
"""Acquire one image from the camera and return as a JPEG blob
|
"""Acquire one image from the camera and return as a JPEG blob.
|
||||||
|
|
||||||
This function will produce a JPEG image.
|
This function will produce a JPEG image.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""OpenFlexure Microscope API extension for stage calibration
|
"""OpenFlexure Microscope API extension for stage calibration.
|
||||||
|
|
||||||
This file contains the HTTP API for camera/stage calibration. It
|
This file contains the HTTP API for camera/stage calibration. It
|
||||||
includes calibration functions that measure the relationship between
|
includes calibration functions that measure the relationship between
|
||||||
|
|
@ -53,7 +53,7 @@ class HardwareInterfaceModel(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
def downsample(factor: int, image: np.ndarray) -> np.ndarray:
|
def downsample(factor: int, image: np.ndarray) -> np.ndarray:
|
||||||
"""Downsample an image by taking the mean of each nxn region
|
"""Downsample an image by taking the mean of each nxn region.
|
||||||
|
|
||||||
This should be very efficient: we calculate the mean of each
|
This should be very efficient: we calculate the mean of each
|
||||||
``factor * factor`` square, no interpolation. If the image is
|
``factor * factor`` square, no interpolation. If the image is
|
||||||
|
|
@ -79,7 +79,7 @@ DEFAULT_SETTLING_TIME = 0.2
|
||||||
def make_hardware_interface(
|
def make_hardware_interface(
|
||||||
stage: Stage, camera: Camera, downsample_factor: int = 2
|
stage: Stage, camera: Camera, downsample_factor: int = 2
|
||||||
) -> HardwareInterfaceModel:
|
) -> HardwareInterfaceModel:
|
||||||
"""Construct the functions we need to interface with the hardware"""
|
"""Construct the functions we need to interface with the hardware."""
|
||||||
axes = stage.axis_names
|
axes = stage.axis_names
|
||||||
|
|
||||||
def pos2dict(pos: Sequence[float]) -> Mapping[str, float]:
|
def pos2dict(pos: Sequence[float]) -> Mapping[str, float]:
|
||||||
|
|
@ -144,7 +144,7 @@ class LoggingMoveWrapper:
|
||||||
self.clear_history()
|
self.clear_history()
|
||||||
|
|
||||||
def __call__(self, new_position: CoordinateType, *args, **kwargs):
|
def __call__(self, new_position: CoordinateType, *args, **kwargs):
|
||||||
"""Move to a new position, and record it"""
|
"""Move to a new position, and record it."""
|
||||||
self._history.append((time.time(), self._current_position))
|
self._history.append((time.time(), self._current_position))
|
||||||
self._move_function(new_position, *args, **kwargs)
|
self._move_function(new_position, *args, **kwargs)
|
||||||
self._current_position = new_position
|
self._current_position = new_position
|
||||||
|
|
@ -152,13 +152,13 @@ class LoggingMoveWrapper:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def history(self) -> MoveHistory:
|
def history(self) -> MoveHistory:
|
||||||
"""The history, as a numpy array of times and another of positions"""
|
"""The history, as a numpy array of times and another of positions."""
|
||||||
times: List[float] = [t for t, p in self._history if p is not None]
|
times: List[float] = [t for t, p in self._history if p is not None]
|
||||||
positions: List[CoordinateType] = [p for t, p in self._history if p is not None]
|
positions: List[CoordinateType] = [p for t, p in self._history if p is not None]
|
||||||
return MoveHistory(times, positions)
|
return MoveHistory(times, positions)
|
||||||
|
|
||||||
def clear_history(self):
|
def clear_history(self):
|
||||||
"""Reset our history to be an empty list"""
|
"""Reset our history to be an empty list."""
|
||||||
self._history: List[Tuple[float, Optional[CoordinateType]]] = []
|
self._history: List[Tuple[float, Optional[CoordinateType]]] = []
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -175,7 +175,7 @@ class CSMUncalibratedError(HTTPException):
|
||||||
|
|
||||||
|
|
||||||
class CameraStageMapper(lt.Thing):
|
class CameraStageMapper(lt.Thing):
|
||||||
"""A Thing to manage mapping between image and stage coordinates"""
|
"""A Thing to manage mapping between image and stage coordinates."""
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def calibrate_1d(
|
def calibrate_1d(
|
||||||
|
|
@ -185,7 +185,7 @@ class CameraStageMapper(lt.Thing):
|
||||||
logger: lt.deps.InvocationLogger,
|
logger: lt.deps.InvocationLogger,
|
||||||
direction: Tuple[float, float, float],
|
direction: Tuple[float, float, float],
|
||||||
) -> DenumpifyingDict:
|
) -> DenumpifyingDict:
|
||||||
"""Move a microscope's stage in 1D, and figure out the relationship with the camera"""
|
"""Move a microscope's stage in 1D, and figure out the relationship with the camera."""
|
||||||
move = LoggingMoveWrapper(
|
move = LoggingMoveWrapper(
|
||||||
hw.move
|
hw.move
|
||||||
) # log positions and times for stage calibration
|
) # log positions and times for stage calibration
|
||||||
|
|
@ -214,7 +214,7 @@ class CameraStageMapper(lt.Thing):
|
||||||
def calibrate_xy(
|
def calibrate_xy(
|
||||||
self, hw: HardwareInterfaceDep, stage: Stage, logger: lt.deps.InvocationLogger
|
self, hw: HardwareInterfaceDep, stage: Stage, logger: lt.deps.InvocationLogger
|
||||||
) -> DenumpifyingDict:
|
) -> DenumpifyingDict:
|
||||||
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera
|
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera.
|
||||||
|
|
||||||
This performs two 1d calibrations in x and y, then combines their results.
|
This performs two 1d calibrations in x and y, then combines their results.
|
||||||
"""
|
"""
|
||||||
|
|
@ -291,13 +291,13 @@ class CameraStageMapper(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def image_resolution(self) -> Optional[Tuple[float, float]]:
|
def image_resolution(self) -> Optional[Tuple[float, float]]:
|
||||||
"""The image size used to calibrate the image_to_stage_displacement_matrix"""
|
"""The image size used to calibrate the image_to_stage_displacement_matrix."""
|
||||||
if self.last_calibration is None:
|
if self.last_calibration is None:
|
||||||
return None
|
return None
|
||||||
return self.last_calibration["image_resolution"]
|
return self.last_calibration["image_resolution"]
|
||||||
|
|
||||||
def assert_calibrated(self):
|
def assert_calibrated(self):
|
||||||
"""Raise an exception if the image_to_stage_displacement matrix is not set"""
|
"""Raise an exception if the image_to_stage_displacement matrix is not set."""
|
||||||
if self.image_to_stage_displacement_matrix is None:
|
if self.image_to_stage_displacement_matrix is None:
|
||||||
# Disable check of no message in raised exception as the message is explicitly
|
# Disable check of no message in raised exception as the message is explicitly
|
||||||
# added by CSMUncalibratedError
|
# added by CSMUncalibratedError
|
||||||
|
|
@ -310,7 +310,7 @@ class CameraStageMapper(lt.Thing):
|
||||||
x: float,
|
x: float,
|
||||||
y: float,
|
y: float,
|
||||||
):
|
):
|
||||||
"""Move by a given number of pixels on the camera
|
"""Move by a given number of pixels on the camera.
|
||||||
|
|
||||||
NB x and y here refer to what is usually understood to be the horizontal and
|
NB x and y here refer to what is usually understood to be the horizontal and
|
||||||
vertical axes of the image. In many toolkits, "matrix indices" are used, which
|
vertical axes of the image. In many toolkits, "matrix indices" are used, which
|
||||||
|
|
@ -329,7 +329,7 @@ class CameraStageMapper(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def thing_state(self) -> dict[str, Any]:
|
def thing_state(self) -> dict[str, Any]:
|
||||||
"""Summary metadata describing the current state of the Thing"""
|
"""Summary metadata describing the current state of the Thing."""
|
||||||
return {
|
return {
|
||||||
k: getattr(self, k)
|
k: getattr(self, k)
|
||||||
for k in ["image_to_stage_displacement_matrix", "image_resolution"]
|
for k in ["image_to_stage_displacement_matrix", "image_resolution"]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""OpenFlexure Settings Management
|
"""OpenFlexure Settings Management.
|
||||||
|
|
||||||
This module provides some settings management across the other Things, and
|
This module provides some settings management across the other Things, and
|
||||||
for code that currently lives in clients but needs to persist settings on
|
for code that currently lives in clients but needs to persist settings on
|
||||||
|
|
@ -21,7 +21,7 @@ ThingServerDep = Annotated[lt.ThingServer, Depends(thing_server_from_request)]
|
||||||
|
|
||||||
|
|
||||||
def recursive_update(old_dict: MutableMapping, update: Mapping):
|
def recursive_update(old_dict: MutableMapping, update: Mapping):
|
||||||
"""Update a dictionary recursively"""
|
"""Update a dictionary recursively."""
|
||||||
for k, v in update.items():
|
for k, v in update.items():
|
||||||
if isinstance(v, Mapping):
|
if isinstance(v, Mapping):
|
||||||
if k in old_dict and isinstance(old_dict[k], MutableMapping):
|
if k in old_dict and isinstance(old_dict[k], MutableMapping):
|
||||||
|
|
@ -33,7 +33,7 @@ def recursive_update(old_dict: MutableMapping, update: Mapping):
|
||||||
|
|
||||||
|
|
||||||
def nested_dict_get(data: dict, key: Sequence[str], create=False) -> Any:
|
def nested_dict_get(data: dict, key: Sequence[str], create=False) -> Any:
|
||||||
"""Index a dict-of-dicts with a sequence of strings"""
|
"""Index a dict-of-dicts with a sequence of strings."""
|
||||||
subdict = data
|
subdict = data
|
||||||
for k in key:
|
for k in key:
|
||||||
if k not in subdict and create:
|
if k not in subdict and create:
|
||||||
|
|
@ -100,7 +100,7 @@ class SettingsManager(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_setting
|
@lt.thing_setting
|
||||||
def microscope_id(self) -> UUID:
|
def microscope_id(self) -> UUID:
|
||||||
"""A unique identifier for this microscope"""
|
"""A unique identifier for this microscope."""
|
||||||
if self._microscope_id is None:
|
if self._microscope_id is None:
|
||||||
self._microscope_id = str(uuid4())
|
self._microscope_id = str(uuid4())
|
||||||
return UUID(self._microscope_id)
|
return UUID(self._microscope_id)
|
||||||
|
|
@ -117,7 +117,7 @@ class SettingsManager(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping:
|
def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping:
|
||||||
"""Metadata summarising the current state of all Things in the server"""
|
"""Metadata summarising the current state of all Things in the server."""
|
||||||
return metadata_getter()
|
return metadata_getter()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ STITCHING_RESOLUTION = (820, 616)
|
||||||
|
|
||||||
|
|
||||||
class ScanNotRunningError(RuntimeError):
|
class ScanNotRunningError(RuntimeError):
|
||||||
"""Exception called when scan not running that requires a scan to be running"""
|
"""Exception called when scan not running that requires a scan to be running."""
|
||||||
|
|
||||||
|
|
||||||
def _scan_running(method):
|
def _scan_running(method):
|
||||||
|
|
@ -170,7 +170,7 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
@_scan_running
|
@_scan_running
|
||||||
def _check_background_and_csm_set(self):
|
def _check_background_and_csm_set(self):
|
||||||
"""Before starting a scan, check that background and camera-stage-mapping are set
|
"""Before starting a scan, check that background and camera-stage-mapping are set.
|
||||||
|
|
||||||
Raise error if:
|
Raise error if:
|
||||||
- background is to be skipped but is not set
|
- background is to be skipped but is not set
|
||||||
|
|
@ -229,7 +229,7 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
@_scan_running
|
@_scan_running
|
||||||
def _calc_displacement_from_test_image(self, overlap: int) -> tuple[int, int]:
|
def _calc_displacement_from_test_image(self, overlap: int) -> tuple[int, int]:
|
||||||
"""Take a test image and use camera stage mapping to calculate x and y displacement
|
"""Take a test image and use camera stage mapping to calculate x and y displacement.
|
||||||
|
|
||||||
:param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means
|
:param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means
|
||||||
that each image should overlap its nearest neighbour by 50%.
|
that each image should overlap its nearest neighbour by 50%.
|
||||||
|
|
@ -520,7 +520,7 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
@_scan_running
|
@_scan_running
|
||||||
def _return_to_starting_position(self):
|
def _return_to_starting_position(self):
|
||||||
"""Return to the initial scan position, if set"""
|
"""Return to the initial scan position, if set."""
|
||||||
self._scan_logger.info("Returning to starting position.")
|
self._scan_logger.info("Returning to starting position.")
|
||||||
if self._starting_position is not None:
|
if self._starting_position is not None:
|
||||||
self._stage.move_absolute(
|
self._stage.move_absolute(
|
||||||
|
|
@ -529,7 +529,7 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
@_scan_running
|
@_scan_running
|
||||||
def _perform_final_stitch(self):
|
def _perform_final_stitch(self):
|
||||||
"""Update the scan zip and perform final stitch of the data"""
|
"""Update the scan zip and perform final stitch of the data."""
|
||||||
if self._scan_images_taken <= 3:
|
if self._scan_images_taken <= 3:
|
||||||
self._scan_logger.info("Not performing a stitch as 3 or fewer images taken")
|
self._scan_logger.info("Not performing a stitch as 3 or fewer images taken")
|
||||||
return
|
return
|
||||||
|
|
@ -626,7 +626,7 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def scans(self) -> list[scan_directories.ScanInfo]:
|
def scans(self) -> list[scan_directories.ScanInfo]:
|
||||||
"""All the available scans
|
"""All the available scans.
|
||||||
|
|
||||||
Each scan has a name (which can be used to access it), along with
|
Each scan has a name (which can be used to access it), along with
|
||||||
its modified and created times (according to the filesystem) and
|
its modified and created times (according to the filesystem) and
|
||||||
|
|
@ -687,7 +687,7 @@ class SmartScanThing(lt.Thing):
|
||||||
"scans",
|
"scans",
|
||||||
)
|
)
|
||||||
def delete_all_scans(self, logger: lt.deps.InvocationLogger) -> None:
|
def delete_all_scans(self, logger: lt.deps.InvocationLogger) -> None:
|
||||||
"""Delete all the scans on the microscope
|
"""Delete all the scans on the microscope.
|
||||||
|
|
||||||
**This will irreversibly remove all scanned data from the
|
**This will irreversibly remove all scanned data from the
|
||||||
microscope!**
|
microscope!**
|
||||||
|
|
@ -698,7 +698,7 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def purge_empty_scans(self, logger: lt.deps.InvocationLogger) -> None:
|
def purge_empty_scans(self, logger: lt.deps.InvocationLogger) -> None:
|
||||||
"""Delete all scan folders containing no images at the top level"""
|
"""Delete all scan folders containing no images at the top level."""
|
||||||
# JSON is ignored as it's created before any images are captured
|
# JSON is ignored as it's created before any images are captured
|
||||||
for scan_info in self._scan_dir_manager.all_scans_info():
|
for scan_info in self._scan_dir_manager.all_scans_info():
|
||||||
if scan_info.number_of_images == 0:
|
if scan_info.number_of_images == 0:
|
||||||
|
|
@ -722,7 +722,7 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def latest_preview_stitch_path(self) -> Optional[str]:
|
def latest_preview_stitch_path(self) -> Optional[str]:
|
||||||
"""The path of the latest preview stitched image, or None if not available"""
|
"""The path of the latest preview stitched image, or None if not available."""
|
||||||
if not self.latest_scan_name:
|
if not self.latest_scan_name:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -732,7 +732,7 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def latest_preview_stitch_time(self) -> Optional[float]:
|
def latest_preview_stitch_time(self) -> Optional[float]:
|
||||||
"""The modification time of the latest preview image, to allow live updating
|
"""The modification time of the latest preview image, to allow live updating.
|
||||||
|
|
||||||
This will return None (``null`` to JS) if there is no preview image to return.
|
This will return None (``null`` to JS) if there is no preview image to return.
|
||||||
|
|
||||||
|
|
@ -795,7 +795,7 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
@_scan_running
|
@_scan_running
|
||||||
def _preview_stitch_running(self) -> bool:
|
def _preview_stitch_running(self) -> bool:
|
||||||
"""Whether there is a preview stitch running in a subprocess"""
|
"""Whether there is a preview stitch running in a subprocess."""
|
||||||
with self._preview_stitch_popen_lock:
|
with self._preview_stitch_popen_lock:
|
||||||
if self._preview_stitch_popen is None:
|
if self._preview_stitch_popen is None:
|
||||||
return False
|
return False
|
||||||
|
|
@ -805,7 +805,7 @@ class SmartScanThing(lt.Thing):
|
||||||
|
|
||||||
@_scan_running
|
@_scan_running
|
||||||
def _preview_stitch_wait(self):
|
def _preview_stitch_wait(self):
|
||||||
"""Wait for an ongoing preview stitch to return"""
|
"""Wait for an ongoing preview stitch to return."""
|
||||||
if self._preview_stitch_running():
|
if self._preview_stitch_running():
|
||||||
with self._preview_stitch_popen_lock:
|
with self._preview_stitch_popen_lock:
|
||||||
self._preview_stitch_popen.wait()
|
self._preview_stitch_popen.wait()
|
||||||
|
|
@ -816,7 +816,7 @@ class SmartScanThing(lt.Thing):
|
||||||
cancel: lt.deps.CancelHook,
|
cancel: lt.deps.CancelHook,
|
||||||
cmd: list[str],
|
cmd: list[str],
|
||||||
) -> CompletedProcess:
|
) -> CompletedProcess:
|
||||||
"""Run a subprocess and log any output
|
"""Run a subprocess and log any output.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ChildProcessError if exit code is not zero
|
ChildProcessError if exit code is not zero
|
||||||
|
|
@ -870,7 +870,7 @@ class SmartScanThing(lt.Thing):
|
||||||
stitch_resize: Optional[float] = None,
|
stitch_resize: Optional[float] = None,
|
||||||
overlap: float = 0.0,
|
overlap: float = 0.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Generate a stitched image based on stage position metadata
|
"""Generate a stitched image based on stage position metadata.
|
||||||
|
|
||||||
Note that as this is a lt.thing_action it needs the logger passed as
|
Note that as this is a lt.thing_action it needs the logger passed as
|
||||||
a variable if called from another thing action
|
a variable if called from another thing action
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import labthings_fastapi as lt
|
||||||
|
|
||||||
|
|
||||||
class BaseStage(lt.Thing):
|
class BaseStage(lt.Thing):
|
||||||
"""A base stage class for OpenFlexure translation stages
|
"""A base stage class for OpenFlexure translation stages.
|
||||||
|
|
||||||
This can't be used directly but should reduce boilerplate code when
|
This can't be used directly but should reduce boilerplate code when
|
||||||
implementing new stages. A minimal working stage must implement
|
implementing new stages. A minimal working stage must implement
|
||||||
|
|
@ -38,7 +38,7 @@ class BaseStage(lt.Thing):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def thing_state(self):
|
def thing_state(self):
|
||||||
"""Summary metadata describing the current state of the stage"""
|
"""Summary metadata describing the current state of the stage."""
|
||||||
return {"position": self.position}
|
return {"position": self.position}
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
|
|
@ -67,7 +67,7 @@ class BaseStage(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def set_zero_position(self):
|
def set_zero_position(self):
|
||||||
"""Make the current position zero in all axes
|
"""Make the current position zero in all axes.
|
||||||
|
|
||||||
This action does not move the stage, but resets the position to zero.
|
This action does not move the stage, but resets the position to zero.
|
||||||
It is intended for use after manually or automatically recentring the
|
It is intended for use after manually or automatically recentring the
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from . import BaseStage
|
||||||
|
|
||||||
|
|
||||||
class DummyStage(BaseStage):
|
class DummyStage(BaseStage):
|
||||||
"""A dummy stage for testing purposes
|
"""A dummy stage for testing purposes.
|
||||||
|
|
||||||
This stage should work similarly to a Sangaboard stage, but without any
|
This stage should work similarly to a Sangaboard stage, but without any
|
||||||
hardware attached.
|
hardware attached.
|
||||||
|
|
@ -83,7 +83,7 @@ class DummyStage(BaseStage):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def set_zero_position(self):
|
def set_zero_position(self):
|
||||||
"""Make the current position zero in all axes
|
"""Make the current position zero in all axes.
|
||||||
|
|
||||||
This action does not move the stage, but resets the position to zero.
|
This action does not move the stage, but resets the position to zero.
|
||||||
It is intended for use after manually or automatically recentring the
|
It is intended for use after manually or automatically recentring the
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from . import BaseStage
|
||||||
|
|
||||||
|
|
||||||
class SangaboardThing(BaseStage):
|
class SangaboardThing(BaseStage):
|
||||||
"""A Thing to manage a Sangaboard motor controller
|
"""A Thing to manage a Sangaboard motor controller.
|
||||||
|
|
||||||
Internally, this uses the ``pysangaboard`` package from PyPi. This imports
|
Internally, this uses the ``pysangaboard`` package from PyPi. This imports
|
||||||
as ``sangaboard``. As ``pysangaboard`` does not support some features added
|
as ``sangaboard``. As ``pysangaboard`` does not support some features added
|
||||||
|
|
@ -115,7 +115,7 @@ class SangaboardThing(BaseStage):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def set_zero_position(self) -> None:
|
def set_zero_position(self) -> None:
|
||||||
"""Make the current position zero in all axes
|
"""Make the current position zero in all axes.
|
||||||
|
|
||||||
This action does not move the stage, but resets the position to zero.
|
This action does not move the stage, but resets the position to zero.
|
||||||
It is intended for use after manually or automatically recentring the
|
It is intended for use after manually or automatically recentring the
|
||||||
|
|
@ -132,7 +132,7 @@ class SangaboardThing(BaseStage):
|
||||||
dt: float = 0.5,
|
dt: float = 0.5,
|
||||||
led_channel: Literal["cc"] = "cc",
|
led_channel: Literal["cc"] = "cc",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Flash the LED to identify the board
|
"""Flash the LED to identify the board.
|
||||||
|
|
||||||
This is intended to be useful in situations where there are multiple
|
This is intended to be useful in situations where there are multiple
|
||||||
Sangaboards in use, and it is necessary to identify which one is
|
Sangaboards in use, and it is necessary to identify which one is
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""OpenFlexure Microscope system control Thing
|
"""OpenFlexure Microscope system control Thing.
|
||||||
|
|
||||||
This module defines a Thing that can shut down or restart the host computer.
|
This module defines a Thing that can shut down or restart the host computer.
|
||||||
"""
|
"""
|
||||||
|
|
@ -15,11 +15,11 @@ class CommandOutput(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SystemControlThing(lt.Thing):
|
class SystemControlThing(lt.Thing):
|
||||||
"""Attempt to shutdown the device"""
|
"""Attempt to shutdown the device."""
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def shutdown(self) -> CommandOutput:
|
def shutdown(self) -> CommandOutput:
|
||||||
"""Attempt to shutdown the device"""
|
"""Attempt to shutdown the device."""
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
["sudo", "shutdown", "-h", "now"],
|
["sudo", "shutdown", "-h", "now"],
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
|
|
@ -36,7 +36,7 @@ class SystemControlThing(lt.Thing):
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def reboot(self) -> CommandOutput:
|
def reboot(self) -> CommandOutput:
|
||||||
"""Attempt to reboot the device"""
|
"""Attempt to reboot the device."""
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
["sudo", "shutdown", "-r", "now"],
|
["sudo", "shutdown", "-r", "now"],
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class ErrorCapturingThread(Thread):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, group=None, target=None, args=None, kwargs=None, daemon=None):
|
def __init__(self, group=None, target=None, args=None, kwargs=None, daemon=None):
|
||||||
"""Initialise with the same arguments as Thread"""
|
"""Initialise with the same arguments as Thread."""
|
||||||
# As all inputs are keywords we need to set the default values for args and kwargs:
|
# As all inputs are keywords we need to set the default values for args and kwargs:
|
||||||
if args is None:
|
if args is None:
|
||||||
args = ()
|
args = ()
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,5 @@ class MockAutoFocusThing:
|
||||||
mock_call_count = {"looping_autofocus": 0}
|
mock_call_count = {"looping_autofocus": 0}
|
||||||
|
|
||||||
def looping_autofocus(self, dz: int = 2000, start: str = "centre") -> None: # noqa: ARG002
|
def looping_autofocus(self, dz: int = 2000, start: str = "centre") -> None: # noqa: ARG002
|
||||||
"""Mock autofocus with no return"""
|
"""Mock autofocus with no return."""
|
||||||
self.mock_call_count["looping_autofocus"] += 1
|
self.mock_call_count["looping_autofocus"] += 1
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ RANDOM_GENERATOR = np.random.default_rng()
|
||||||
|
|
||||||
|
|
||||||
def random_image():
|
def random_image():
|
||||||
"""Create a random image"""
|
"""Create a random image."""
|
||||||
imarray = RANDOM_GENERATOR.integers(
|
imarray = RANDOM_GENERATOR.integers(
|
||||||
low=0, high=255, size=(100, 100, 3), dtype="uint8"
|
low=0, high=255, size=(100, 100, 3), dtype="uint8"
|
||||||
)
|
)
|
||||||
|
|
@ -24,14 +24,14 @@ def random_image():
|
||||||
|
|
||||||
|
|
||||||
def random_metadata():
|
def random_metadata():
|
||||||
"""Create a misc dictionary to pretend to be metadata"""
|
"""Create a misc dictionary to pretend to be metadata."""
|
||||||
# Not very metadata like, but we are just checking that the same dict it
|
# Not very metadata like, but we are just checking that the same dict it
|
||||||
# is returned
|
# is returned
|
||||||
return {"a": randint(1, 100), "b": randint(1, 100)}
|
return {"a": randint(1, 100), "b": randint(1, 100)}
|
||||||
|
|
||||||
|
|
||||||
def test_add_and_get_image():
|
def test_add_and_get_image():
|
||||||
"""Check images can be captured and retrieved"""
|
"""Check images can be captured and retrieved."""
|
||||||
mem_buf = CameraMemoryBuffer()
|
mem_buf = CameraMemoryBuffer()
|
||||||
misc_image = random_image()
|
misc_image = random_image()
|
||||||
buffer_id = mem_buf.add_image(misc_image)
|
buffer_id = mem_buf.add_image(misc_image)
|
||||||
|
|
@ -44,7 +44,7 @@ def test_add_and_get_image():
|
||||||
|
|
||||||
|
|
||||||
def test_add_and_get_image_twice():
|
def test_add_and_get_image_twice():
|
||||||
"""Check images can be retrieved twice if remove flag set false"""
|
"""Check images can be retrieved twice if remove flag set false."""
|
||||||
mem_buf = CameraMemoryBuffer()
|
mem_buf = CameraMemoryBuffer()
|
||||||
misc_image = random_image()
|
misc_image = random_image()
|
||||||
buffer_id = mem_buf.add_image(misc_image)
|
buffer_id = mem_buf.add_image(misc_image)
|
||||||
|
|
@ -60,7 +60,7 @@ def test_add_and_get_image_twice():
|
||||||
|
|
||||||
|
|
||||||
def test_get_without_id():
|
def test_get_without_id():
|
||||||
"""Check images can be captured and retrieved without ID"""
|
"""Check images can be captured and retrieved without ID."""
|
||||||
mem_buf = CameraMemoryBuffer()
|
mem_buf = CameraMemoryBuffer()
|
||||||
misc_image = random_image()
|
misc_image = random_image()
|
||||||
mem_buf.add_image(misc_image)
|
mem_buf.add_image(misc_image)
|
||||||
|
|
@ -92,7 +92,7 @@ def test_get_two_images():
|
||||||
|
|
||||||
|
|
||||||
def test_get_two_images_without_setting_buffer_size():
|
def test_get_two_images_without_setting_buffer_size():
|
||||||
"""Check two images can't be retrieved if the buffer size isn't set"""
|
"""Check two images can't be retrieved if the buffer size isn't set."""
|
||||||
mem_buf = CameraMemoryBuffer()
|
mem_buf = CameraMemoryBuffer()
|
||||||
misc_image1 = random_image()
|
misc_image1 = random_image()
|
||||||
misc_image2 = random_image()
|
misc_image2 = random_image()
|
||||||
|
|
@ -106,7 +106,7 @@ def test_get_two_images_without_setting_buffer_size():
|
||||||
|
|
||||||
|
|
||||||
def test_buffer_size_changing():
|
def test_buffer_size_changing():
|
||||||
"""Check buffer size resets back to 1 when if not set"""
|
"""Check buffer size resets back to 1 when if not set."""
|
||||||
mem_buf = CameraMemoryBuffer()
|
mem_buf = CameraMemoryBuffer()
|
||||||
misc_image1 = random_image()
|
misc_image1 = random_image()
|
||||||
misc_image2 = random_image()
|
misc_image2 = random_image()
|
||||||
|
|
@ -126,7 +126,7 @@ def test_buffer_size_changing():
|
||||||
|
|
||||||
|
|
||||||
def test_capture_two_images_get_without_id():
|
def test_capture_two_images_get_without_id():
|
||||||
"""Check that all images are deleted when getting without id"""
|
"""Check that all images are deleted when getting without id."""
|
||||||
mem_buf = CameraMemoryBuffer()
|
mem_buf = CameraMemoryBuffer()
|
||||||
misc_image1 = random_image()
|
misc_image1 = random_image()
|
||||||
misc_image2 = random_image()
|
misc_image2 = random_image()
|
||||||
|
|
@ -142,7 +142,7 @@ def test_capture_two_images_get_without_id():
|
||||||
|
|
||||||
|
|
||||||
def test_buffer_size_respected():
|
def test_buffer_size_respected():
|
||||||
"""Capture 10 images with a buffer size of 5. Check only last 5 exist"""
|
"""Capture 10 images with a buffer size of 5. Check only last 5 exist."""
|
||||||
mem_buf = CameraMemoryBuffer()
|
mem_buf = CameraMemoryBuffer()
|
||||||
|
|
||||||
images = []
|
images = []
|
||||||
|
|
@ -163,7 +163,7 @@ def test_buffer_size_respected():
|
||||||
|
|
||||||
|
|
||||||
def test_clear_buffer():
|
def test_clear_buffer():
|
||||||
"""Capture 10 images clear the buffer and check they are gone"""
|
"""Capture 10 images clear the buffer and check they are gone."""
|
||||||
mem_buf = CameraMemoryBuffer()
|
mem_buf = CameraMemoryBuffer()
|
||||||
|
|
||||||
images = []
|
images = []
|
||||||
|
|
@ -184,7 +184,7 @@ def test_clear_buffer():
|
||||||
|
|
||||||
|
|
||||||
def test_get_metadata_too():
|
def test_get_metadata_too():
|
||||||
"""Capture 10 images with metadata and check metadata is returned as expected"""
|
"""Capture 10 images with metadata and check metadata is returned as expected."""
|
||||||
mem_buf = CameraMemoryBuffer()
|
mem_buf = CameraMemoryBuffer()
|
||||||
|
|
||||||
images = []
|
images = []
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,13 @@ BASE_SCAN_DIR = os.path.join(tempfile.gettempdir(), "scans")
|
||||||
|
|
||||||
|
|
||||||
def _clear_scan_dir() -> None:
|
def _clear_scan_dir() -> None:
|
||||||
"""Delete the scan dir"""
|
"""Delete the scan dir."""
|
||||||
if os.path.exists(BASE_SCAN_DIR):
|
if os.path.exists(BASE_SCAN_DIR):
|
||||||
shutil.rmtree(BASE_SCAN_DIR)
|
shutil.rmtree(BASE_SCAN_DIR)
|
||||||
|
|
||||||
|
|
||||||
def _add_fake_image(scan_dir: ScanDirectory) -> None:
|
def _add_fake_image(scan_dir: ScanDirectory) -> None:
|
||||||
"""Make a fake image on disk in the scan directory"""
|
"""Make a fake image on disk in the scan directory."""
|
||||||
unique = False
|
unique = False
|
||||||
while not unique:
|
while not unique:
|
||||||
x_pos = random.randint(-10000, 10000)
|
x_pos = random.randint(-10000, 10000)
|
||||||
|
|
@ -63,7 +63,7 @@ def _add_fake_file(
|
||||||
|
|
||||||
|
|
||||||
def _make_fake_dzi(scan_dir: ScanDirectory, n_layers: int = 8) -> None:
|
def _make_fake_dzi(scan_dir: ScanDirectory, n_layers: int = 8) -> None:
|
||||||
"""Create a fake DZI in a scan
|
"""Create a fake DZI in a scan.
|
||||||
|
|
||||||
:param n_layers: The number of layers of tiles. I.e. tile directories numbered
|
:param n_layers: The number of layers of tiles. I.e. tile directories numbered
|
||||||
0...(n_layers-1) will be created. Default 8
|
0...(n_layers-1) will be created. Default 8
|
||||||
|
|
@ -96,7 +96,7 @@ def _make_fake_dzi(scan_dir: ScanDirectory, n_layers: int = 8) -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_basic_directory_operations():
|
def test_basic_directory_operations():
|
||||||
"""Test some basic operations
|
"""Test some basic operations.
|
||||||
|
|
||||||
Test some basic operations, including:
|
Test some basic operations, including:
|
||||||
- ScanDirectoryManager creates a scan directory
|
- ScanDirectoryManager creates a scan directory
|
||||||
|
|
@ -156,7 +156,7 @@ def test_basic_directory_operations():
|
||||||
|
|
||||||
|
|
||||||
def test_scan_sequence_and_listing():
|
def test_scan_sequence_and_listing():
|
||||||
"""Check created scans are added in order and listed correctly"""
|
"""Check created scans are added in order and listed correctly."""
|
||||||
_clear_scan_dir()
|
_clear_scan_dir()
|
||||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||||
# Make 4 scans
|
# Make 4 scans
|
||||||
|
|
@ -183,7 +183,7 @@ def test_scan_sequence_and_listing():
|
||||||
|
|
||||||
|
|
||||||
def test_scan_name_non_sequential():
|
def test_scan_name_non_sequential():
|
||||||
"""Check new scan has the correct name if the directories are not sequential"""
|
"""Check new scan has the correct name if the directories are not sequential."""
|
||||||
_clear_scan_dir()
|
_clear_scan_dir()
|
||||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0001"))
|
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0001"))
|
||||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0002"))
|
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0002"))
|
||||||
|
|
@ -217,7 +217,7 @@ def test_all_scan_names_taken():
|
||||||
|
|
||||||
|
|
||||||
def test_no_scan_names_given():
|
def test_no_scan_names_given():
|
||||||
"""Check correct default scan name is used if empty string is given"""
|
"""Check correct default scan name is used if empty string is given."""
|
||||||
_clear_scan_dir()
|
_clear_scan_dir()
|
||||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||||
scan_dir = scan_dir_manager.new_scan_dir("")
|
scan_dir = scan_dir_manager.new_scan_dir("")
|
||||||
|
|
@ -225,7 +225,7 @@ def test_no_scan_names_given():
|
||||||
|
|
||||||
|
|
||||||
def test_scan_info():
|
def test_scan_info():
|
||||||
"""Test the scan info is correct even using fake scan data"""
|
"""Test the scan info is correct even using fake scan data."""
|
||||||
_clear_scan_dir()
|
_clear_scan_dir()
|
||||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||||
|
|
@ -252,7 +252,7 @@ def test_scan_info():
|
||||||
|
|
||||||
|
|
||||||
def test_get_final_stitch():
|
def test_get_final_stitch():
|
||||||
"""Check that the final stitch can be retrieved"""
|
"""Check that the final stitch can be retrieved."""
|
||||||
_clear_scan_dir()
|
_clear_scan_dir()
|
||||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||||
|
|
@ -280,7 +280,7 @@ def test_get_final_stitch():
|
||||||
|
|
||||||
|
|
||||||
def test_empty_scan_info():
|
def test_empty_scan_info():
|
||||||
"""Test the scan info is correct even if the scan is empty"""
|
"""Test the scan info is correct even if the scan is empty."""
|
||||||
_clear_scan_dir()
|
_clear_scan_dir()
|
||||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||||
|
|
@ -298,7 +298,7 @@ def test_empty_scan_info():
|
||||||
|
|
||||||
|
|
||||||
def test_zipping_scan_data():
|
def test_zipping_scan_data():
|
||||||
"""Test zipping the scan images with fake image data"""
|
"""Test zipping the scan images with fake image data."""
|
||||||
# Run twice, once calling the ScanDirectory directly,
|
# Run twice, once calling the ScanDirectory directly,
|
||||||
# Once calling the ScanDirectoryManager
|
# Once calling the ScanDirectoryManager
|
||||||
for caller in ["scan_dir", "manager"]:
|
for caller in ["scan_dir", "manager"]:
|
||||||
|
|
@ -338,7 +338,7 @@ def test_zipping_scan_data():
|
||||||
|
|
||||||
|
|
||||||
def test_all_files():
|
def test_all_files():
|
||||||
"""Test all_files returns the path, and respects skipped directories"""
|
"""Test all_files returns the path, and respects skipped directories."""
|
||||||
_clear_scan_dir()
|
_clear_scan_dir()
|
||||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||||
|
|
@ -393,7 +393,7 @@ def test_none_returned_for_missing_images_dir():
|
||||||
|
|
||||||
|
|
||||||
def test_extracting_files():
|
def test_extracting_files():
|
||||||
"""Test the private _find_files method of ScanDirectories
|
"""Test the private _find_files method of ScanDirectories.
|
||||||
|
|
||||||
Add files to directory and check expected returns.
|
Add files to directory and check expected returns.
|
||||||
"""
|
"""
|
||||||
|
|
@ -445,7 +445,7 @@ DiskUsage = namedtuple("DiskUsage", ["total", "used", "free"])
|
||||||
|
|
||||||
@pytest.mark.parametrize("free_space", [500_000_001, 700_000_000, 5_000_000_000])
|
@pytest.mark.parametrize("free_space", [500_000_001, 700_000_000, 5_000_000_000])
|
||||||
def test_disk_not_full(mocker, free_space):
|
def test_disk_not_full(mocker, free_space):
|
||||||
"""Check no error thrown if disk has over 500MB of space"""
|
"""Check no error thrown if disk has over 500MB of space."""
|
||||||
total_space = 16_000_000_000
|
total_space = 16_000_000_000
|
||||||
# Mock the disk_usage
|
# Mock the disk_usage
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
|
|
@ -461,7 +461,7 @@ def test_disk_not_full(mocker, free_space):
|
||||||
|
|
||||||
@pytest.mark.parametrize("free_space", [100_000_000, 499_999_999])
|
@pytest.mark.parametrize("free_space", [100_000_000, 499_999_999])
|
||||||
def test_disk_full(mocker, free_space):
|
def test_disk_full(mocker, free_space):
|
||||||
"""Check error thrown if disk has under 500MB of space"""
|
"""Check error thrown if disk has under 500MB of space."""
|
||||||
total_space = 16_000_000_000
|
total_space = 16_000_000_000
|
||||||
# Mock the disk_usage
|
# Mock the disk_usage
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,14 @@ SCAN_DIR = os.path.join(tempfile.gettempdir(), "scans")
|
||||||
|
|
||||||
|
|
||||||
def _clear_scan_dir() -> None:
|
def _clear_scan_dir() -> None:
|
||||||
"""Delete the scan dir"""
|
"""Delete the scan dir."""
|
||||||
if os.path.exists(SCAN_DIR):
|
if os.path.exists(SCAN_DIR):
|
||||||
shutil.rmtree(SCAN_DIR)
|
shutil.rmtree(SCAN_DIR)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def smart_scan_thing():
|
def smart_scan_thing():
|
||||||
"""Return a smart scan thing as a fixture"""
|
"""Return a smart scan thing as a fixture."""
|
||||||
return SmartScanThing(SCAN_DIR)
|
return SmartScanThing(SCAN_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ def test_inaccessible_scan_methods(smart_scan_thing):
|
||||||
|
|
||||||
|
|
||||||
def test_private_delete_scan(smart_scan_thing, caplog):
|
def test_private_delete_scan(smart_scan_thing, caplog):
|
||||||
"""Test the private _delete_scan method deletes directories or warns if it can't"""
|
"""Test the private _delete_scan method deletes directories or warns if it can't."""
|
||||||
_clear_scan_dir()
|
_clear_scan_dir()
|
||||||
with caplog.at_level(logging.INFO):
|
with caplog.at_level(logging.INFO):
|
||||||
fake_scan_name = "fake_scan_0001"
|
fake_scan_name = "fake_scan_0001"
|
||||||
|
|
@ -101,7 +101,7 @@ def test_private_delete_scan(smart_scan_thing, caplog):
|
||||||
|
|
||||||
|
|
||||||
def test_public_delete_scan(smart_scan_thing, caplog):
|
def test_public_delete_scan(smart_scan_thing, caplog):
|
||||||
"""Test the delete_scan API call deletes directories or warns if it can't"""
|
"""Test the delete_scan API call deletes directories or warns if it can't."""
|
||||||
_clear_scan_dir()
|
_clear_scan_dir()
|
||||||
with caplog.at_level(logging.INFO):
|
with caplog.at_level(logging.INFO):
|
||||||
fake_scan_name = "fake_scan_0001"
|
fake_scan_name = "fake_scan_0001"
|
||||||
|
|
@ -152,7 +152,7 @@ def test_delete_all_scans(smart_scan_thing, caplog):
|
||||||
|
|
||||||
|
|
||||||
def _run_only_outer_scan(adjust_inital_state: Optional[Callable] = None):
|
def _run_only_outer_scan(adjust_inital_state: Optional[Callable] = None):
|
||||||
"""Create a subclass of SmartScanThing to mock _run_scan and run sample_scan
|
"""Create a subclass of SmartScanThing to mock _run_scan and run sample_scan.
|
||||||
|
|
||||||
This should do all the set up for a scan, move into the mocked
|
This should do all the set up for a scan, move into the mocked
|
||||||
_run_scan method where this can be tested. Once this is done
|
_run_scan method where this can be tested. Once this is done
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ def test_stack_params_not_enough_test_images(save_ims, extra_ims):
|
||||||
extra_ims=even_integers(min_value=0, max_value=10),
|
extra_ims=even_integers(min_value=0, max_value=10),
|
||||||
)
|
)
|
||||||
def test_stack_params_negative_images_to_save(save_ims, extra_ims):
|
def test_stack_params_negative_images_to_save(save_ims, extra_ims):
|
||||||
"""save_ims is negative so images_to_save is negative, failing validation
|
"""save_ims is negative so images_to_save is negative, failing validation.
|
||||||
|
|
||||||
For arguments see test_stack_params_validation
|
For arguments see test_stack_params_validation
|
||||||
"""
|
"""
|
||||||
|
|
@ -110,7 +110,7 @@ def test_stack_params_negative_images_to_save(save_ims, extra_ims):
|
||||||
extra_ims=odd_integers(min_value=0, max_value=10),
|
extra_ims=odd_integers(min_value=0, max_value=10),
|
||||||
)
|
)
|
||||||
def test_even_min_images_to_test(save_ims, extra_ims):
|
def test_even_min_images_to_test(save_ims, extra_ims):
|
||||||
"""extra_ims is odd so min_images_to_test is even, failing validation
|
"""extra_ims is odd so min_images_to_test is even, failing validation.
|
||||||
|
|
||||||
For arguments see test_stack_params_validation
|
For arguments see test_stack_params_validation
|
||||||
"""
|
"""
|
||||||
|
|
@ -130,7 +130,7 @@ def test_even_min_images_to_test(save_ims, extra_ims):
|
||||||
extra_ims=odd_integers(min_value=0, max_value=10),
|
extra_ims=odd_integers(min_value=0, max_value=10),
|
||||||
)
|
)
|
||||||
def test_even_images_to_save(save_ims, extra_ims):
|
def test_even_images_to_save(save_ims, extra_ims):
|
||||||
"""save_ims is even so images_to_save is even, failing validation
|
"""save_ims is even so images_to_save is even, failing validation.
|
||||||
|
|
||||||
For arguments see test_stack_params_validation
|
For arguments see test_stack_params_validation
|
||||||
"""
|
"""
|
||||||
|
|
@ -146,7 +146,7 @@ def test_even_images_to_save(save_ims, extra_ims):
|
||||||
|
|
||||||
|
|
||||||
def test_computed_stack_params():
|
def test_computed_stack_params():
|
||||||
"""Test StackParams computed properties are as expected
|
"""Test StackParams computed properties are as expected.
|
||||||
|
|
||||||
Not using hypothesis or we will just copy in the same formulas.
|
Not using hypothesis or we will just copy in the same formulas.
|
||||||
"""
|
"""
|
||||||
|
|
@ -187,7 +187,7 @@ def test_computed_stack_params():
|
||||||
|
|
||||||
|
|
||||||
def random_capture(set_id: Optional[int] = None):
|
def random_capture(set_id: Optional[int] = None):
|
||||||
"""Create a capture with random values
|
"""Create a capture with random values.
|
||||||
|
|
||||||
:param set_id: Optional, use to set a fixed id rather than a random one
|
:param set_id: Optional, use to set a fixed id rather than a random one
|
||||||
"""
|
"""
|
||||||
|
|
@ -204,14 +204,14 @@ def random_capture(set_id: Optional[int] = None):
|
||||||
|
|
||||||
|
|
||||||
def test_capture_filename_matches_regex():
|
def test_capture_filename_matches_regex():
|
||||||
"""For 100 random captures check the image always matches the regex"""
|
"""For 100 random captures check the image always matches the regex."""
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
assert IMAGE_REGEX.search(random_capture().filename)
|
assert IMAGE_REGEX.search(random_capture().filename)
|
||||||
|
|
||||||
|
|
||||||
@given(st.integers(min_value=0, max_value=5000))
|
@given(st.integers(min_value=0, max_value=5000))
|
||||||
def test_retrieval_of_captures(start):
|
def test_retrieval_of_captures(start):
|
||||||
"""For 20 random captures, check each can be retrieved correctly by id"""
|
"""For 20 random captures, check each can be retrieved correctly by id."""
|
||||||
captures = [random_capture(start + i) for i in range(20)]
|
captures = [random_capture(start + i) for i in range(20)]
|
||||||
|
|
||||||
for i, capture in enumerate(captures):
|
for i, capture in enumerate(captures):
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from collections.abc import Hashable
|
||||||
|
|
||||||
|
|
||||||
class SizedIterableHashable(Iterable[Hashable], Protocol):
|
class SizedIterableHashable(Iterable[Hashable], Protocol):
|
||||||
"""A protocol for sized iterable of hashable objects"""
|
"""A protocol for sized iterable of hashable objects."""
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
"""Add a len function to protocol so Python knows the object is sized."""
|
"""Add a len function to protocol so Python knows the object is sized."""
|
||||||
|
|
|
||||||
|
|
@ -44,14 +44,14 @@ class FakeSample:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def patch(self) -> PathPatch:
|
def patch(self) -> PathPatch:
|
||||||
"""The sample as a matplotlib patch for plotting"""
|
"""The sample as a matplotlib patch for plotting."""
|
||||||
patch = PathPatch(self._sample_perimeter)
|
patch = PathPatch(self._sample_perimeter)
|
||||||
patch.set(color=(1.0, 0.8, 1.0, 1.0))
|
patch.set(color=(1.0, 0.8, 1.0, 1.0))
|
||||||
return patch
|
return patch
|
||||||
|
|
||||||
|
|
||||||
def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Figure:
|
def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Figure:
|
||||||
"""For a given sample and scanner object return a matplotlib figure of the scan"""
|
"""For a given sample and scanner object return a matplotlib figure of the scan."""
|
||||||
fig, ax = plt.subplots(figsize=(8, 8))
|
fig, ax = plt.subplots(figsize=(8, 8))
|
||||||
ax.add_artist(sample.patch)
|
ax.add_artist(sample.patch)
|
||||||
xh, yh = zip(*planner._path_history)
|
xh, yh = zip(*planner._path_history)
|
||||||
|
|
@ -184,7 +184,7 @@ def get_expected_result_for_example_smart_spiral(
|
||||||
|
|
||||||
|
|
||||||
def load_sample_points(sample_name: str):
|
def load_sample_points(sample_name: str):
|
||||||
"""Return the points to generate the FakeSample corresponding to the given input name
|
"""Return the points to generate the FakeSample corresponding to the given input name.
|
||||||
|
|
||||||
Options are "lobed", "regular", and "core".
|
Options are "lobed", "regular", and "core".
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue