diff --git a/.gitignore b/.gitignore index ab3442c4..8314e00e 100644 --- a/.gitignore +++ b/.gitignore @@ -60,9 +60,8 @@ docs/_build/ target/ #Big-o files -capture/ -record/ *.data +tests/images/out/ #IDE files .vscode/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 15483610..8941318e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,6 @@ stages: - analysis + - testing - build - package - deploy @@ -36,7 +37,24 @@ pylint: <<: *poetry-install script: - - poetry run pylint ./openflexure_microscope/ + - poetry run poe pylint + + only: + - branches + - merge_requests + - tags + - web + +# Python type checking with Mypy +mypy: + stage: analysis + image: python:3.7 + retry: 1 + + <<: *poetry-install + + script: + - poetry run poe mypy only: - branches @@ -49,12 +67,30 @@ black: stage: analysis image: python:3.7 retry: 1 + allow_failure: true <<: *poetry-install script: # Run static build script - - poetry run black --check . + - poetry run poe black_check + + only: + - branches + - merge_requests + - tags + - web + +# Python unit tests with PyTest +pytest: + stage: testing + image: python:3.7 + retry: 1 + + <<: *poetry-install + + script: + - poetry run poe test only: - branches diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 5d5425e6..00000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,11 +0,0 @@ -repos: - - repo: https://github.com/psf/black - rev: 19.10b0 - hooks: - - id: black - - repo: https://github.com/timothycrosley/isort - rev: 5.4.2 - hooks: - - id: isort - additional_dependencies: [toml] - exclude: ^.*/?setup\.py$ diff --git a/.tarignore b/.tarignore index 66a3d39a..81cedeab 100644 --- a/.tarignore +++ b/.tarignore @@ -2,6 +2,7 @@ node_modules .gitlab-ci.yml .gitlab ./dist +tests/ !openflexure_microscope/api/static/dist # VCS files diff --git a/README.md b/README.md index 35f45e7b..fce94990 100644 --- a/README.md +++ b/README.md @@ -22,34 +22,48 @@ This includes installing the server in a mode better suited for active developme * `npm install` * `npm run build` -## Formatting and linting +## Formatting, linting, and tests -We use 3 main code analysis and formatting libraries in this project. **Please run all of these before submitting a merge request.** +### Tl;dr + +**Before committing** + +Run `poetry run poe format` + +Auto-formats the code + +**Before submitting a merge request/merging** + +Run `poetry run poe check` + +Formats code, lints, runs static analysis, and runs unit tests. +### Details + +We use several code analysis and formatting libraries in this project. **Please run all of these before submitting a merge request.** Our CI will check each of these automatically, so ensuring they pass locally will save you time. * **Black** - Code formatting with minimal configuration. * While sometimes it's not perfect, its fine 90% of the time and prevents arguments about formatting. * Automatically formats your code - * `poetry run black .` -* **Isort** - Import sorting - * Automatically organises your imports to stop things getting out of hand - * `poetry run isort .` + * `poetry run poe black` * **Pylint** - Static code analysis * Analyses your code, failing if issues are detected. * We've disabled some less severe warnings, so _if anything fails your merge request will be blocked_ - * `poetry run pylint openflexure_microscope` + * `poetry run poe pylint` +* **Mypy** - Type checking + * Analyses your type hints and annotations to flag up potential bugs + * Where possible, use type hints in your code. Even if dependencies don't support it, it'll help identify issues. + * `poetry run poe mypy` +* **Pytest** - Unit testing + * While unit testing is of limited use due to our dependence on real hardware, some simple isolated functions can (and should) be unit tested. + * `poetry run poe test` -### Pre-commit hooks +Though not in the CI, our `format` script also runs isort: -We support pre-commit hooks to run code formatting before committing to the codebase. - -The simplest way to ensure this works is to install pre-commits into your current Python environment: - -* `pip3 install pre-commit` -* `pre-commit install` - -To run pre-commit analysis manually, you can run `pre-commit run`. +* **Isort** - Import sorting + * Automatically organises your imports to stop things getting out of hand + * `poetry run isort .` ## Build-system diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..1215375e --- /dev/null +++ b/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +ignore_missing_imports = True \ No newline at end of file diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 0c85c99f..e87f29f2 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -39,36 +39,44 @@ from openflexure_microscope.paths import ( logs_file_path, ) + +# Custom RotatingFileHandler subclass +class CustomRotatingFileHandler(logging.handlers.RotatingFileHandler): + """ + A custom class for a rotating file log handler, with defaults we like. + 1MB per file, maximum of 5 historic files. + Non-propagating logs (so we can separate access logs from error logs) + Optional debugging level. + """ + + def __init__(self, filename: str, debug: bool = False) -> None: + super().__init__(filename, maxBytes=1_000_000, backupCount=5) + # Set formatter + self.setFormatter( + logging.Formatter( + "[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s" + ) + ) + # Never propagate + self.propagate = False + # Conditionally enable debugging + if debug: + self.setLevel(logging.DEBUG) + + # Log files ROOT_LOGFILE = logs_file_path("openflexure_microscope.log") ACCESS_LOGFILE = logs_file_path("openflexure_microscope.access.log") -# Basic log format -formatter = logging.Formatter( - "[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s" -) - # Our WSGI server uses Werkzeug, so use that for the access log access_log = logging.getLogger("werkzeug") # Block the access logs from propagating up to the root logger access_log.propagate = False # Create error log file handler -fh = logging.handlers.RotatingFileHandler( - ROOT_LOGFILE, maxBytes=1_000_000, backupCount=5 -) -fh.setFormatter(formatter) -fh.setLevel(logging.DEBUG) -fh.propagate = False - +fh = CustomRotatingFileHandler(ROOT_LOGFILE, debug=debug_app) # Create access log file handler -afh = logging.handlers.RotatingFileHandler( - ACCESS_LOGFILE, maxBytes=1_000_000, backupCount=5 -) -afh.setFormatter(formatter) -afh.setLevel(logging.DEBUG) -afh.propagate = False - +afh = CustomRotatingFileHandler(ACCESS_LOGFILE, debug=debug_app) # Add file handler to root logger root_log.addHandler(fh) access_log.addHandler(afh) diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index a9e3f0e4..4ed1a672 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -1,6 +1,7 @@ import logging import time from contextlib import contextmanager +from typing import Callable, List, Optional, Tuple import numpy as np from labthings import current_action, fields, find_component @@ -8,24 +9,27 @@ from labthings.extensions import BaseExtension from labthings.views import ActionView, View from scipy import ndimage +from openflexure_microscope.camera.base import BaseCamera from openflexure_microscope.devel import abort +from openflexure_microscope.microscope import Microscope +from openflexure_microscope.stage.base import BaseStage from openflexure_microscope.utilities import set_properties ### Autofocus utilities class JPEGSharpnessMonitor: - def __init__(self, microscope): - self.microscope = microscope - self.camera = microscope.camera - self.stage = microscope.stage + def __init__(self, microscope: Microscope): + self.microscope: Microscope = microscope + self.camera: BaseCamera = microscope.camera + self.stage: BaseStage = microscope.stage - self.recording_start_time = None + self.recording_start_time: Optional[float] = None - self.stage_positions = [] - self.stage_times = [] - self.jpeg_times = [] - self.jpeg_sizes = [] + self.stage_positions: List[Tuple[int, int, int]] = [] + self.stage_times: List[float] = [] + self.jpeg_times: List[float] = [] + self.jpeg_sizes: List[int] = [] def start(self): # Log the recording start time @@ -35,14 +39,14 @@ class JPEGSharpnessMonitor: self.camera.stream.stop_tracking() self.camera.stream.reset_tracking() - def focus_rel(self, dz, backlash=False, **kwargs): + def focus_rel(self, dz: int, backlash: bool = False, **kwargs): # Store the start time and position self.camera.stream.start_tracking() self.stage_times.append(time.time()) self.stage_positions.append(self.stage.position) # Main move - self.stage.move_rel([0, 0, dz], backlash=backlash, **kwargs) + self.stage.move_rel((0, 0, dz), backlash=backlash, **kwargs) # Store the end time and position self.camera.stream.stop_tracking() @@ -63,7 +67,7 @@ class JPEGSharpnessMonitor: final_z_position = self.stage_positions[-1][2] return data_index, final_z_position - def move_data(self, istart, istop=None): + def move_data(self, istart: int, istop: Optional[int] = None): """Extract sharpness as a function of (interpolated) z""" if istop is None: istop = istart + 2 @@ -88,7 +92,7 @@ class JPEGSharpnessMonitor: jpeg_zs = np.interp(jpeg_times, stage_times, stage_zs) return jpeg_times, jpeg_zs, jpeg_sizes[start:stop] - def sharpest_z_on_move(self, index): + def sharpest_z_on_move(self, index: int): """Return the z position of the sharpest image on a given move""" _, jz, js = self.move_data(index) if len(js) == 0: @@ -105,23 +109,14 @@ class JPEGSharpnessMonitor: return data -def decimate_to(shape, image): - """Decimate an image to reduce its size if it's too big.""" - decimation = np.max( - np.ceil(np.array(image.shape, dtype=np.float)[: len(shape)] / np.array(shape)) - ) - return image[:: int(decimation), :: int(decimation), ...] - - -def sharpness_sum_lap2(rgb_image): +def sharpness_sum_lap2(rgb_image: np.ndarray): """Return an image sharpness metric: sum(laplacian(image)**")""" - # image_bw=np.mean(decimate_to((1000,1000), rgb_image),2) image_bw = np.mean(rgb_image, 2) image_lap = ndimage.filters.laplace(image_bw) return np.mean(image_lap.astype(np.float) ** 4) -def sharpness_edge(image): +def sharpness_edge(image: np.ndarray): """Return a sharpness metric optimised for vertical lines""" gray = np.mean(image.astype(float), 2) n = 20 @@ -134,13 +129,23 @@ def sharpness_edge(image): ### Autofocus extension -def measure_sharpness(microscope, metric_fn=sharpness_sum_lap2): +def measure_sharpness(microscope: Microscope, metric_fn: Callable = sharpness_sum_lap2): """Measure the sharpness of the camera's current view.""" - if hasattr(microscope.camera, "array"): - return metric_fn(microscope.camera.array(use_video_port=True)) + + if hasattr(microscope.camera, "array") and callable( + getattr(microscope.camera, "array") + ): + return metric_fn(getattr(microscope.camera, "array")(use_video_port=True)) + else: + raise RuntimeError(f"Object {microscope.camera} has no method `array`") -def autofocus(microscope, dz, settle=0.5, metric_fn=sharpness_sum_lap2): +def autofocus( + microscope: Microscope, + dz: List[int], + settle: float = 0.5, + metric_fn: Callable = sharpness_sum_lap2, +): """Perform a simple autofocus routine. The stage is moved to z positions (relative to current position) in dz, and at each position an image is captured and the sharpness function @@ -154,7 +159,10 @@ def autofocus(microscope, dz, settle=0.5, metric_fn=sharpness_sum_lap2): with set_properties(stage, backlash=256), stage.lock, camera.lock: sharpnesses = [] positions = [] - camera.annotate_text = "" + + # Some cameras may not have annotate_text. Reset if it does + if getattr(camera, "annotate_text"): + setattr(camera, "annotate_text", "") for _ in stage.scan_z(dz, return_to_start=False): if current_action() and current_action().stopped: @@ -164,13 +172,13 @@ def autofocus(microscope, dz, settle=0.5, metric_fn=sharpness_sum_lap2): sharpnesses.append(measure_sharpness(microscope, metric_fn)) newposition = positions[np.argmax(sharpnesses)] - stage.move_rel([0, 0, newposition - stage.position[2]]) + stage.move_rel((0, 0, newposition - stage.position[2])) return positions, sharpnesses @contextmanager -def monitor_sharpness(microscope): +def monitor_sharpness(microscope: Microscope): m = JPEGSharpnessMonitor(microscope) m.start() try: @@ -179,21 +187,21 @@ def monitor_sharpness(microscope): m.stop() -def move_and_find_focus(microscope, dz): +def move_and_find_focus(microscope: Microscope, dz: int): """Make a relative Z move and return the peak sharpness position""" with monitor_sharpness(microscope) as m: m.focus_rel(dz) return m.sharpest_z_on_move(0) -def move_and_measure(microscope, dz): +def move_and_measure(microscope: Microscope, dz: int): """Make a relative Z move and return the sharpness data""" with monitor_sharpness(microscope) as m: m.focus_rel(dz) return m.data_dict() -def fast_autofocus(microscope, dz=2000): +def fast_autofocus(microscope: Microscope, dz: int = 2000): """Perform a down-up-down-up autofocus""" with microscope.camera.lock, microscope.stage.lock: with monitor_sharpness(microscope) as m: @@ -216,7 +224,11 @@ def fast_autofocus(microscope, dz=2000): def fast_up_down_up_autofocus( - microscope, dz=2000, target_z=0, initial_move_up=True, mini_backlash=25 + microscope: Microscope, + dz: int = 2000, + target_z: int = 0, + initial_move_up: bool = True, + mini_backlash: int = 25, ): """Autofocus by measuring on the way down, and moving back up with feedback. @@ -256,7 +268,7 @@ def fast_up_down_up_autofocus( with microscope.camera.lock, microscope.stage.lock: with monitor_sharpness(microscope) as m: # Ensure the MJPEG stream has started - microscope.camera.start_stream_recording() + microscope.camera.start_stream() logging.debug("Initial move") if initial_move_up: diff --git a/openflexure_microscope/api/default_extensions/autostorage.py b/openflexure_microscope/api/default_extensions/autostorage.py index e54f3aea..7d97d766 100644 --- a/openflexure_microscope/api/default_extensions/autostorage.py +++ b/openflexure_microscope/api/default_extensions/autostorage.py @@ -1,5 +1,6 @@ import logging import os +from typing import Dict, List, Optional, Tuple import psutil from flask import abort @@ -9,32 +10,43 @@ from labthings.marshalling import use_args from labthings.views import PropertyView, View from openflexure_microscope.api.utilities.gui import build_gui -from openflexure_microscope.captures.capture_manager import BASE_CAPTURE_PATH +from openflexure_microscope.captures.capture_manager import ( + BASE_CAPTURE_PATH, + CaptureManager, +) +from openflexure_microscope.microscope import Microscope from openflexure_microscope.paths import check_rw, settings_file_path AS_SETTINGS_PATH = settings_file_path("autostorage_settings.json") -def get_partitions(): +def get_partitions() -> List[str]: return [disk.mountpoint for disk in psutil.disk_partitions() if "rw" in disk.opts] -def get_permissive_partitions(): +def get_permissive_partitions() -> List[str]: return [partition for partition in get_partitions() if check_rw(partition)] -def get_permissive_locations(): +def get_permissive_locations() -> List[Tuple[str, str]]: return [ (partition, os.path.join(partition, "openflexure", "data", "micrographs")) for partition in get_permissive_partitions() ] -def get_current_location(capture_manager): - return capture_manager.paths.get("default") +def get_current_location(capture_manager: Optional[CaptureManager]) -> str: + if capture_manager: + return capture_manager.paths.get("default", BASE_CAPTURE_PATH) + return BASE_CAPTURE_PATH -def set_current_location(capture_manager, location: str): +def set_current_location(capture_manager: Optional[CaptureManager], location: str): + if not capture_manager: + logging.warning( + "Cannot set_current_location of a missing capture_manager. Skipping." + ) + return if not os.path.isdir(location): os.makedirs(location) logging.debug("Updating location...") @@ -44,12 +56,12 @@ def set_current_location(capture_manager, location: str): logging.debug("Capture location changed successfully.") -def get_default_location(): +def get_default_location() -> str: return BASE_CAPTURE_PATH -def get_all_locations(): - locations = {} +def get_all_locations() -> Dict[str, str]: + locations: Dict[str, str] = {} # If default is not already listed (e.g. if it's currently set) if get_default_location() not in locations.values(): locations["Default"] = get_default_location() @@ -71,29 +83,23 @@ def get_all_locations(): return {k: v for k, v in locations.items() if v} -class CaptureStorageLocation: - def __init__(self, mountpoint): - pass - - class AutostorageExtension(BaseExtension): def __init__(self): - BaseExtension.__init__( - self, + super().__init__( "org.openflexure.autostorage", version="2.0.0", description="Handle switching capture storage devices", ) # We'll store a reference to a CaptureManager object, who's capture paths will be modified - self.capture_manager = None + self.capture_manager: Optional[CaptureManager] = None - self.initial_location = get_default_location() + self.initial_location: str = get_default_location() # Register the on_microscope function to run when the microscope is attached self.on_component("org.openflexure.microscope", self.on_microscope) - def on_microscope(self, microscope_obj): + def on_microscope(self, microscope_obj: Microscope): """Function to automatically call when the parent LabThing has a microscope attached.""" logging.debug("Autostorage extension found microscope %s", microscope_obj) if hasattr(microscope_obj, "captures"): @@ -110,10 +116,15 @@ class AutostorageExtension(BaseExtension): self.check_location(self.initial_location) logging.debug(self.get_locations()) + else: + raise RuntimeError( + "Attached a microscope with no `captures` capture manager. Skipping extension." + ) - def check_location(self, location=None): + def check_location(self, location: Optional[str] = None) -> bool: + location = location or get_current_location(self.capture_manager) if not location: - location = get_current_location(self.capture_manager) + return False # If preferred path does not exist, or cannot be written to if not (os.path.isdir(location) and check_rw(location)): logging.error( @@ -122,8 +133,9 @@ class AutostorageExtension(BaseExtension): ) # Reset the storage location to default set_current_location(self.capture_manager, get_default_location()) + return True - def get_locations(self): + def get_locations(self) -> Dict[str, str]: if self.capture_manager: locations = get_all_locations() @@ -135,7 +147,7 @@ class AutostorageExtension(BaseExtension): else: return {} - def get_preferred_key(self): + def get_preferred_key(self) -> Optional[str]: current = get_current_location(self.capture_manager) locations = self.get_locations() @@ -146,22 +158,28 @@ class AutostorageExtension(BaseExtension): "Multiple path matches found. Weird, but carrying on using zeroth." ) - return matches[0] + if matches: + return matches[0] + else: + logging.warning("No matches found. Skipping.") + return None def set_preferred_key(self, new_path_key: str): if not new_path_key in self.get_locations().keys(): raise KeyError(f"No location named {new_path_key}") location = self.get_locations().get(new_path_key) - set_current_location(self.capture_manager, location) + if location: + set_current_location(self.capture_manager, location) - def key_to_title(self, path_key: str): + def key_to_title(self, path_key: Optional[str]) -> Optional[str]: + if not path_key: + return None if not path_key in self.get_locations().keys(): raise KeyError(f"No location named {path_key}") - return f"{path_key} ({self.get_locations().get(path_key)})" - def title_to_key(self, path_title: str): + def title_to_key(self, path_title: str) -> str: matches = [] for loc_key in self.get_locations().keys(): if path_title.startswith(loc_key): @@ -174,10 +192,15 @@ class AutostorageExtension(BaseExtension): return matches[0] - def get_titles(self): - return [self.key_to_title(key) for key in self.get_locations().keys()] + def get_titles(self) -> List[str]: + titles: List[str] = [] + for key in self.get_locations().keys(): + title: Optional[str] = self.key_to_title(key) + if title: + titles.append(title) + return titles - def get_preferred_title(self): + def get_preferred_title(self) -> Optional[str]: return self.key_to_title(self.get_preferred_key()) diff --git a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py index b763119b..c10491c4 100644 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py +++ b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py @@ -1,14 +1,15 @@ import logging from contextlib import contextmanager -# Type hinting -from typing import Tuple - +import picamerax from flask import abort from labthings import find_component from labthings.extensions import BaseExtension from labthings.views import ActionView +from openflexure_microscope.camera.base import BaseCamera +from openflexure_microscope.microscope import Microscope + from .recalibrate_utils import ( auto_expose_and_freeze_settings, flat_lens_shading_table, @@ -17,7 +18,7 @@ from .recalibrate_utils import ( @contextmanager -def pause_stream(scamera, resolution: Tuple[int, int] = None): +def pause_stream(scamera: BaseCamera): """This context manager locks a streaming camera, and pauses the stream. The stream is re-enabled, with the original resolution, once the with @@ -28,20 +29,20 @@ def pause_stream(scamera, resolution: Tuple[int, int] = None): not scamera.record_active ), "We can't pause the camera's video stream while a recording is in progress." streaming = scamera.stream_active - old_resolution = scamera.camera.resolution + old_resolution = scamera.stream_resolution if streaming: logging.info("Stopping stream in pause_stream context manager") - scamera.stop_stream_recording(resolution=resolution) + scamera.stop_stream() try: yield scamera finally: - scamera.camera.resolution = old_resolution + scamera.stream_resolution = old_resolution if streaming: logging.info("Restarting stream in pause_stream context manager") - scamera.start_stream_recording() + scamera.start_stream() -def recalibrate(microscope): +def recalibrate(microscope: Microscope): """Reset the camera's settings. This generates new gains, exposure time, and lens shading @@ -49,11 +50,15 @@ def recalibrate(microscope): with a gray level of 230. It takes a little while to run. """ with pause_stream(microscope.camera) as scamera: - auto_expose_and_freeze_settings( - scamera.camera - ) # scamera.camera is the PiCamera object - recalibrate_camera(scamera.camera) - microscope.save_settings() + if hasattr(scamera, "picamera"): + picamera_obj: picamerax.PiCamera = getattr(scamera, "picamera") + auto_expose_and_freeze_settings(picamera_obj) + recalibrate_camera(picamera_obj) + microscope.save_settings() + else: + raise RuntimeError( + "Recalibrate can only be used with a Raspberry Pi camera" + ) class RecalibrateView(ActionView): diff --git a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py index fd18cf46..94d22a90 100644 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py +++ b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/recalibrate_utils.py @@ -1,19 +1,21 @@ import logging import time +from fractions import Fraction +from typing import List, Optional, Tuple import numpy as np from picamerax import PiCamera from picamerax.array import PiBayerArray, PiRGBArray -def rgb_image(camera, resize=None, **kwargs): +def rgb_image(camera: PiCamera, resize: Optional[Tuple[int, int]] = None, **kwargs): """Capture an image and return an RGB numpy array""" with PiRGBArray(camera, size=resize) as output: camera.capture(output, format="rgb", resize=resize, **kwargs) return output.array -def flat_lens_shading_table(camera): +def flat_lens_shading_table(camera: PiCamera): """Return a flat (i.e. unity gain) lens shading table. This is mostly useful because it makes it easy to get the size @@ -24,14 +26,11 @@ def flat_lens_shading_table(camera): raise ImportError( "This program requires the forked picamera library with lens shading support" ) - return ( - # pylint: disable=W0212 - np.zeros(camera._lens_shading_table_shape(), dtype=np.uint8) - + 32 - ) + # pylint: disable=protected-access + return np.zeros(camera._lens_shading_table_shape(), dtype=np.uint8) + 32 -def adjust_exposure_to_setpoint(camera, setpoint): +def adjust_exposure_to_setpoint(camera: PiCamera, setpoint: int): """Adjust the camera's exposure time until the maximum pixel value is .""" print("Adjusting shutter speed to hit setpoint {}".format(setpoint), end="") for _ in range(3): @@ -43,7 +42,7 @@ def adjust_exposure_to_setpoint(camera, setpoint): print("done") -def auto_expose_and_freeze_settings(camera): +def auto_expose_and_freeze_settings(camera: PiCamera): """Freeze the settings after auto-exposing to white illumination""" logging.info("Allowing the camera to auto-expose") if "greyworld" in camera.AWB_MODES: @@ -66,7 +65,7 @@ def auto_expose_and_freeze_settings(camera): logging.info("Shutter speed = %s", (camera.shutter_speed)) camera.exposure_mode = "off" logging.info("Auto exposure disabled") - g = camera.awb_gains + g: Tuple[Fraction, Fraction] = camera.awb_gains camera.awb_mode = "off" camera.awb_gains = g logging.info("Auto white balance disabled, gains are %s", (g)) @@ -76,10 +75,10 @@ def auto_expose_and_freeze_settings(camera): adjust_exposure_to_setpoint(camera, 215) -def channels_from_bayer_array(bayer_array): +def channels_from_bayer_array(bayer_array: np.ndarray) -> np.ndarray: """Given the 'array' from a PiBayerArray, return the 4 channels.""" - bayer_pattern = [(i // 2, i % 2) for i in range(4)] - channels = np.zeros( + bayer_pattern: List[Tuple[int, int]] = [(0, 0), (0, 1), (1, 0), (1, 1)] + channels: np.ndarray = np.zeros( (4, bayer_array.shape[0] // 2, bayer_array.shape[1] // 2), dtype=bayer_array.dtype, ) @@ -92,14 +91,19 @@ def channels_from_bayer_array(bayer_array): return channels -def lst_from_channels(channels): +def lst_from_channels(channels: np.ndarray) -> np.ndarray: """Given the 4 Bayer colour channels from a white image, generate a LST.""" - full_resolution = np.array(channels.shape[1:]) * 2 # channels have been binned - # lst_resolution = list(np.ceil(full_resolution / 64.0).astype(int)) - lst_resolution = [(r // 64) + 1 for r in full_resolution] - # NB the size of the LST is 1/64th of the image, but rounded UP. + full_resolution: np.ndarray = np.array( + channels.shape[1:] + ) * 2 # channels have been binned + + # NOTE: the size of the LST is 1/64th of the image, but rounded UP. + lst_resolution: List[int] = [(r // 64) + 1 for r in full_resolution] + logging.info("Generating a lens shading table at %sx%s", *lst_resolution) - lens_shading = np.zeros([channels.shape[0]] + lst_resolution, dtype=np.float) + lens_shading: np.ndarray = np.zeros( + [channels.shape[0]] + lst_resolution, dtype=np.float + ) for i in range(lens_shading.shape[0]): image_channel = channels[i, :, :] iw, ih = image_channel.shape @@ -153,7 +157,7 @@ def lst_from_channels(channels): return lens_shading_table[::-1, :, :].copy() -def recalibrate_camera(camera): +def recalibrate_camera(camera: PiCamera): """Reset the lens shading table and exposure settings. This method first resets to a flat lens shading table, then auto-exposes, diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index 4e3bd809..6cf5abd8 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -3,7 +3,7 @@ import logging import time import uuid from functools import reduce -from typing import Tuple +from typing import Dict, List, Optional, Tuple from labthings import ( current_action, @@ -14,20 +14,32 @@ from labthings import ( ) from labthings.extensions import BaseExtension from labthings.views import ActionView +from typing_extensions import Literal from openflexure_microscope.api.v2.views.actions.camera import FullCaptureArgs from openflexure_microscope.captures.capture_manager import generate_basename from openflexure_microscope.devel import abort +from openflexure_microscope.microscope import Microscope + +# Type alias for convenience +XyCoordinate = Tuple[int, int] +XyzCoordinate = Tuple[int, int, int] + ### Grid construction -def construct_grid(initial, step_sizes, n_steps, style="raster"): +def construct_grid( + initial: XyCoordinate, + step_sizes: XyCoordinate, + n_steps: XyCoordinate, + style: Literal["raster", "snake", "spiral"] = "raster", +): """ Given an initial position, step sizes, and number of steps, construct a 2-dimensional list of scan x-y positions. """ - arr = [] + arr: List[List[XyCoordinate]] = [] # 2D array of coordinates if style == "spiral": # deal with the centre image immediately @@ -35,31 +47,48 @@ def construct_grid(initial, step_sizes, n_steps, style="raster"): arr.append([initial]) # for spiral, n_steps is the number of shells, and so only requires n_steps[0] for i in range(2, n_steps[0] + 1): - arr.append([]) + arr.append([]) # Append new shell holder side_length = (2 * i) - 1 - # iteratively generate the next location to append - coord = [coord[ax] + [-1, 1][ax] * step_sizes[ax] for ax in range(2)] + # Iteratively generate the next location to append + + # Start coordinate of the shell + # We create a copy of coord so that the new value of coord doesn't depend on itself + # Otherwise we create a generator, not a tuple, which makes type checking angry + last_coordinate: XyCoordinate = coord + coord = ( + last_coordinate[0] + [-1, 1][0] * step_sizes[0], + last_coordinate[1] + [-1, 1][1] * step_sizes[1], + ) for direction in ([1, 0], [0, -1], [-1, 0], [0, 1]): for _ in range(side_length - 1): - coord = [ - coord[ax] + direction[ax] * step_sizes[ax] for ax in range(2) - ] - arr[i - 1].append(tuple(coord)) + last_coordinate = coord + coord = ( + last_coordinate[0] + direction[0] * step_sizes[0], + last_coordinate[1] + direction[1] * step_sizes[1], + ) + arr[i - 1].append(coord) + # If raster or snake else: for i in range(n_steps[0]): # x axis arr.append([]) for j in range(n_steps[1]): # y axis - # Create a coordinate array - coord = [initial[ax] + [i, j][ax] * step_sizes[ax] for ax in range(2)] + # Create a coordinate tuple + coord = ( + initial[0] + [i, j][0] * step_sizes[0], + initial[1] + [i, j][1] * step_sizes[1], + ) # Append coordinate array to position grid - arr[i].append(tuple(coord)) + arr[i].append(coord) # Style modifiers if style == "snake": + # For each line (row) in the coordinate array for i, line in enumerate(arr): + # If it's an odd row if i % 2 != 0: + # Reverse the list of coordinates line.reverse() return arr @@ -76,16 +105,17 @@ class ScanExtension(BaseExtension): def capture( self, - microscope, - basename, + microscope: Microscope, + basename: Optional[str], namemode: str = "coordinates", temporary: bool = False, use_video_port: bool = False, - resize: Tuple[int, int] = None, + resize: Optional[Tuple[int, int]] = None, bayer: bool = False, - metadata: dict = None, - annotations: dict = None, - tags: list = None, + metadata: Optional[dict] = None, + annotations: Optional[Dict[str, str]] = None, + tags: Optional[List[str]] = None, + dataset: Optional[Dict[str, str]] = None, ): metadata = metadata or {} annotations = annotations or {} @@ -113,6 +143,7 @@ class ScanExtension(BaseExtension): bayer=bayer, annotations=annotations, tags=tags, + dataset=dataset, metadata=metadata, cache_key=folder, ) @@ -125,21 +156,21 @@ class ScanExtension(BaseExtension): ### Scanning def tile( self, - microscope, - basename: str = None, + microscope: Microscope, + basename: Optional[str] = None, namemode: str = "coordinates", temporary: bool = False, - stride_size: list = (2000, 1500, 100), - grid: list = (3, 3, 5), + stride_size: XyzCoordinate = (2000, 1500, 100), + grid: XyzCoordinate = (3, 3, 5), style="raster", autofocus_dz: int = 50, use_video_port: bool = False, - resize: Tuple[int, int] = None, + resize: Optional[Tuple[int, int]] = None, bayer: bool = False, - fast_autofocus=False, - metadata: dict = None, - annotations: dict = None, - tags: list = None, + fast_autofocus: bool = False, + metadata: Optional[dict] = None, + annotations: Optional[Dict[str, str]] = None, + tags: Optional[List[str]] = None, ): metadata = metadata or {} annotations = annotations or {} @@ -160,16 +191,14 @@ class ScanExtension(BaseExtension): # Add dataset metadata dataset_d = { - "dataset": { - "id": uuid.uuid4(), - "type": "xyzScan", - "name": basename, - "acquisitionDate": datetime.datetime.now().isoformat(), - "strideSize": stride_size, - "grid": grid, - "style": style, - "autofocusDz": autofocus_dz, - } + "id": uuid.uuid4(), + "type": "xyzScan", + "name": basename, + "acquisitionDate": datetime.datetime.now().isoformat(), + "strideSize": stride_size, + "grid": grid, + "style": style, + "autofocusDz": autofocus_dz, } # Check if autofocus is enabled @@ -186,7 +215,7 @@ class ScanExtension(BaseExtension): # Construct an x-y grid (worry about z later) x_y_grid = construct_grid( - initial_position, stride_size[:2], grid[:2], style=style + initial_position[:2], stride_size[:2], grid[:2], style=style ) # Keep the initial Z position the same as our current position @@ -201,13 +230,13 @@ class ScanExtension(BaseExtension): next_z = initial_z # Reset z position at start of each new row logging.debug("Returning to initial z position") microscope.stage.move_abs( - [line[0][0], line[0][1], next_z] + (line[0][0], line[0][1], next_z) ) # RWB: I think this line is redundant for x_y in line: # Move to new grid position without changing z logging.debug("Moving to step %s", ([x_y[0], x_y[1], next_z])) - microscope.stage.move_abs([x_y[0], x_y[1], next_z]) + microscope.stage.move_abs((x_y[0], x_y[1], next_z)) # Refocus if autofocus_enabled: if fast_autofocus: @@ -232,7 +261,7 @@ class ScanExtension(BaseExtension): use_video_port=use_video_port, resize=resize, bayer=bayer, - metadata=dataset_d, + dataset=dataset_d, annotations=annotations, tags=tags, ) @@ -251,7 +280,7 @@ class ScanExtension(BaseExtension): use_video_port=use_video_port, resize=resize, bayer=bayer, - metadata=dataset_d, + dataset=dataset_d, annotations=annotations, tags=tags, ) @@ -268,19 +297,20 @@ class ScanExtension(BaseExtension): def stack( self, - microscope, - basename: str = None, + microscope: Microscope, + basename: Optional[str] = None, namemode: str = "coordinates", temporary: bool = False, step_size: int = 100, steps: int = 5, return_to_start: bool = True, use_video_port: bool = False, - resize: Tuple[int, int] = None, + resize: Optional[Tuple[int, int]] = None, bayer: bool = False, - metadata: dict = None, - annotations: dict = None, - tags: list = None, + metadata: Optional[dict] = None, + annotations: Optional[Dict[str, str]] = None, + dataset: Optional[Dict[str, str]] = None, + tags: Optional[List[str]] = None, ): metadata = metadata or {} annotations = annotations or {} @@ -293,7 +323,7 @@ class ScanExtension(BaseExtension): with microscope.lock: # Move to center scan logging.debug("Moving to z-stack starting position") - microscope.stage.move_rel([0, 0, int((-step_size * steps) / 2)]) + microscope.stage.move_rel((0, 0, int((-step_size * steps) / 2))) logging.debug("Starting scan from position %s", microscope.stage.position) for i in range(steps): @@ -309,6 +339,7 @@ class ScanExtension(BaseExtension): bayer=bayer, metadata=metadata, annotations=annotations, + dataset=dataset, tags=tags, ) # Update task progress @@ -319,7 +350,7 @@ class ScanExtension(BaseExtension): if i != steps - 1: logging.debug("Moving z by %s", (step_size)) - microscope.stage.move_rel([0, 0, step_size]) + microscope.stage.move_rel((0, 0, step_size)) if return_to_start: logging.debug("Returning to %s", (initial_position)) microscope.stage.move_abs(initial_position) diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 5db25a70..93da215d 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -3,6 +3,7 @@ import os import tempfile import uuid import zipfile +from typing import IO, List, Optional from flask import abort, send_file, url_for from labthings import fields, find_component, update_action_progress @@ -11,6 +12,9 @@ from labthings.schema import Schema, pre_dump from labthings.utilities import description_from_view from labthings.views import ActionView, PropertyView, View +from openflexure_microscope.captures import CaptureObject +from openflexure_microscope.microscope import Microscope + class ZipObjectSchema(Schema): id = fields.String() @@ -32,11 +36,13 @@ class ZipObjectSchema(Schema): class ZipObjectDescription: - def __init__(self, id_, file_pointer, data_size=None): - self.id = id_ - self.fp = file_pointer - self.data_size = data_size - self.zip_size = os.path.getsize(self.fp.name) * 1e-6 + def __init__( + self, id_: str, file_pointer: IO[bytes], data_size: Optional[float] = None + ): + self.id: str = id_ + self.fp: IO[bytes] = file_pointer + self.data_size: Optional[float] = data_size + self.zip_size: float = os.path.getsize(self.fp.name) * 1e-6 def close(self): logging.debug(self.fp.name) @@ -60,22 +66,26 @@ class ZipManager: self.session_zips = {} - def build_zip_from_capture_ids(self, microscope, capture_id_list): + def build_zip_from_capture_ids( + self, microscope: Microscope, capture_id_list: List[str] + ): logging.debug(capture_id_list) # Get array of captures from IDs - capture_list = [ + optional_capture_list: List[Optional[CaptureObject]] = [ microscope.captures.images.get(capture_id) for capture_id in capture_id_list ] # Remove Nones from list (missing/invalid captures) - capture_list = [capture for capture in capture_list if capture] + capture_list: List[CaptureObject] = [ + capture for capture in optional_capture_list if capture + ] # Get size (in bytes) of each capture - capture_sizes = [ + capture_sizes: List[float] = [ os.path.getsize(capture_obj.file) for capture_obj in capture_list ] # Calculate size of input data in megabytes - data_size_megabytes = sum(capture_sizes) * 1e-6 + data_size_megabytes: float = sum(capture_sizes) * 1e-6 # If more than 1GB if data_size_megabytes > 1000: @@ -85,10 +95,10 @@ class ZipManager: ) # Number of files to add (used for task progress) - n_files = len(capture_id_list) + n_files: int = len(capture_id_list) # Create temporary file - fp = tempfile.NamedTemporaryFile(delete=False) + fp: IO[bytes] = tempfile.NamedTemporaryFile(delete=False) # Open temp file as a ZIP file with zipfile.ZipFile(fp, "w") as zipObj: @@ -102,8 +112,8 @@ class ZipManager: # Update task progress update_action_progress(int((index / n_files) * 100)) - session_id = str(uuid.uuid4()) - session_description = ZipObjectDescription( + session_id: str = str(uuid.uuid4()) + session_description: ZipObjectDescription = ZipObjectDescription( session_id, fp, data_size=data_size_megabytes ) self.session_zips[session_id] = session_description @@ -113,7 +123,7 @@ class ZipManager: def marshaled_build_zip_from_capture_ids(self, *args, **kwargs): return ZipObjectSchema().dump(self.build_zip_from_capture_ids(*args, **kwargs)) - def zip_fp_from_id(self, session_id): + def zip_fp_from_id(self, session_id: str): return self.session_zips[session_id].fp def __del__(self): diff --git a/openflexure_microscope/api/dev_extensions/tools.py b/openflexure_microscope/api/dev_extensions/tools.py index ddbadfef..81231bef 100644 --- a/openflexure_microscope/api/dev_extensions/tools.py +++ b/openflexure_microscope/api/dev_extensions/tools.py @@ -16,7 +16,7 @@ class SleepFor(ActionView): args = {"time": fields.Float(description="Time to sleep, in seconds", example=0.5)} def post(self, args): - sleep_time = args.get("time") + sleep_time: int = args.get("time", 0) logging.info("Going to sleep for %s...", sleep_time) start = time.time() time.sleep(sleep_time) diff --git a/openflexure_microscope/api/static/package-lock.json b/openflexure_microscope/api/static/package-lock.json index f4abe921..47f04d31 100644 --- a/openflexure_microscope/api/static/package-lock.json +++ b/openflexure_microscope/api/static/package-lock.json @@ -2917,6 +2917,16 @@ "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", "dev": true }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "optional": true, + "requires": { + "color-convert": "^2.0.1" + } + }, "browserslist": { "version": "4.14.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.14.7.tgz", @@ -2962,12 +2972,36 @@ "integrity": "sha512-gOerH9Wz2IRZ2ZPdMfBvyOi3cjaz4O4dgNwPGzx8EhqAs4+2IL/O+fJsbt+znSigujoZG8bVcIAUM/I/E5K3MA==", "dev": true }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "optional": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "optional": true + }, "electron-to-chromium": { "version": "1.3.592", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.592.tgz", "integrity": "sha512-kGNowksvqQiPb1pUSQKpd8JFoGPLxYOwduNRCqCxGh/2Q1qE2JdmwouCW41lUzDxOb/2RIV4lR0tVIfboWlO9A==", "dev": true }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "optional": true + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3001,6 +3035,35 @@ "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", "dev": true }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "optional": true + }, + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "dev": true, + "optional": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "loader-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", + "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", + "dev": true, + "optional": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, "locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -3134,6 +3197,16 @@ "ansi-regex": "^5.0.0" } }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "optional": true, + "requires": { + "has-flag": "^4.0.0" + } + }, "terser-webpack-plugin": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.8.tgz", @@ -3160,6 +3233,31 @@ "psl": "^1.1.28", "punycode": "^2.1.1" } + }, + "vue-loader-v16": { + "version": "npm:vue-loader@16.1.0", + "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.1.0.tgz", + "integrity": "sha512-fTtCdI7VeyNK0HP4q4y9Z9ts8TUeaF+2/FjKx8CJ/7/Oem1rCX7zIJe+d+jLrVnVNQjENd3gqmANraLcdRWwnQ==", + "dev": true, + "optional": true, + "requires": { + "chalk": "^4.1.0", + "hash-sum": "^2.0.0", + "loader-utils": "^2.0.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "optional": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } } } }, @@ -13759,104 +13857,6 @@ } } }, - "vue-loader-v16": { - "version": "npm:vue-loader@16.0.0-rc.2", - "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.0.0-rc.2.tgz", - "integrity": "sha512-cz8GK4dgIf1UTC+do80pGvh8BHcCRHLIQVHV9ONVQ8wtoqS9t/+H02rKcQP+TVNg7khgLyQV2+8eHUq7/AFq3g==", - "dev": true, - "optional": true, - "requires": { - "chalk": "^4.1.0", - "hash-sum": "^2.0.0", - "loader-utils": "^2.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "optional": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "optional": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "optional": true - }, - "emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, - "optional": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "optional": true - }, - "json5": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", - "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", - "dev": true, - "optional": true, - "requires": { - "minimist": "^1.2.5" - } - }, - "loader-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", - "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", - "dev": true, - "optional": true, - "requires": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "optional": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, "vue-observe-visibility": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-0.4.6.tgz", diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/scanCard.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/scanCard.vue index 9255143d..1c8d0f49 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/scanCard.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/galleryComponents/scanCard.vue @@ -80,6 +80,10 @@ export default { type: String, required: true }, + captures: { + type: Array, + required: true + }, tags: { type: Array, required: false, @@ -92,7 +96,7 @@ export default { computed: { allURLs: function() { var urls = []; - for (var capture of this.scanState.captures) { + for (var capture of this.captures) { urls.push(capture.links.self.href); } return urls; @@ -115,6 +119,7 @@ export default { deleteAll: function() { axios.all(this.allURLs.map(l => axios.delete(l))).then(() => { + console.log("Delete finished") // Emit signal to update capture list this.$root.$emit("globalUpdateCaptures"); }); diff --git a/openflexure_microscope/api/static/src/components/tabContentComponents/galleryContent.vue b/openflexure_microscope/api/static/src/components/tabContentComponents/galleryContent.vue index 185398ea..3ae91b76 100644 --- a/openflexure_microscope/api/static/src/components/tabContentComponents/galleryContent.vue +++ b/openflexure_microscope/api/static/src/components/tabContentComponents/galleryContent.vue @@ -105,6 +105,7 @@ :time="item.time" :tags="item.tags" :type="item.type" + :captures="item.captures" :thumbnail="item.thumbnail" @selectFolder="selectFolder" /> diff --git a/openflexure_microscope/api/utilities/__init__.py b/openflexure_microscope/api/utilities/__init__.py index 8297cebc..5460580a 100644 --- a/openflexure_microscope/api/utilities/__init__.py +++ b/openflexure_microscope/api/utilities/__init__.py @@ -1,38 +1,51 @@ import errno import logging import os +from typing import Union -from flask import Blueprint, current_app, url_for -from werkzeug.exceptions import BadRequest +from flask import Blueprint, Flask, current_app, url_for +from flask.views import View from . import gui +__all__ = [ + "gui", + "view_class_from_endpoint", + "blueprint_for_module", + "get_bool", + "list_routes", + "create_file", + "init_default_extensions", +] -def view_class_from_endpoint(endpoint: str): + +def view_class_from_endpoint(endpoint: str) -> View: return current_app.view_functions[endpoint].view_class -def blueprint_for_module(module_name, api_ver=2, suffix=""): +def blueprint_for_module(module_name: str, api_ver: int = 2, suffix: str = ""): return Blueprint( blueprint_name_for_module(module_name, api_ver=api_ver, suffix=suffix), module_name, ) -def blueprint_name_for_module(module_name, api_ver=2, suffix=""): +def blueprint_name_for_module(module_name: str, api_ver: int = 2, suffix: str = ""): bp_name = module_name.split(".")[-1] return f"v{api_ver}_{bp_name}_blueprint{suffix}" -def get_bool(get_arg): +def get_bool(get_arg: Union[bool, str]): """Convert GET request argument string to a Python bool""" - if get_arg == "true" or get_arg == "True" or get_arg == "1": + if isinstance(get_arg, bool): + return get_arg + elif get_arg == "true" or get_arg == "True" or get_arg == "1": return True else: return False -def list_routes(app): +def list_routes(app: Flask): output = {} for rule in app.url_map.iter_rules(): @@ -49,7 +62,7 @@ def list_routes(app): return output -def create_file(config_path): +def create_file(config_path: str): if not os.path.exists(os.path.dirname(config_path)): try: os.makedirs(os.path.dirname(config_path)) @@ -58,7 +71,7 @@ def create_file(config_path): raise -def init_default_extensions(extension_dir): +def init_default_extensions(extension_dir: str): os.makedirs(extension_dir, exist_ok=True) default_ext_path = os.path.join(extension_dir, "defaults.py") diff --git a/openflexure_microscope/api/utilities/gui.py b/openflexure_microscope/api/utilities/gui.py index 1fc03c1f..b6d238c2 100644 --- a/openflexure_microscope/api/utilities/gui.py +++ b/openflexure_microscope/api/utilities/gui.py @@ -1,6 +1,9 @@ import copy import logging from functools import wraps +from typing import Callable, Union + +from labthings.extensions import BaseExtension def clean_rule(rule: str): @@ -9,13 +12,12 @@ def clean_rule(rule: str): return f"{rule}" -def build_gui_from_dict(gui_description, extension_object): +def build_gui_from_dict(gui_description: dict, extension_object: BaseExtension): # Make a working copy of GUI description api_gui = copy.deepcopy(gui_description) # Grab the extensions rules dictionary - # NOTE: The LabThings extension object should probably have a non-private - # way to get the rules dictionary. For now we need to ignore pylint W0212 - ext_rules = extension_object._rules # pylint: disable=W0212 + # TODO: Public property should be added to LabThings + ext_rules = extension_object._rules # pylint: disable=protected-access # Expand shorthand routes into full relative URLs if "forms" in gui_description and isinstance(api_gui["forms"], list): @@ -35,7 +37,7 @@ def build_gui_from_dict(gui_description, extension_object): return api_gui -def build_gui_from_func(func, extension_object): +def build_gui_from_func(func: Callable, extension_object: BaseExtension): @wraps(func) def wrapped(*args, **kwargs): return build_gui_from_dict(func(*args, **kwargs), extension_object) @@ -43,7 +45,7 @@ def build_gui_from_func(func, extension_object): return wrapped -def build_gui(gui_description, extension_object): +def build_gui(gui_description: Union[dict, Callable], extension_object: BaseExtension): # If given a function that generates a GUI dictionary if callable(gui_description): # Wrap in the route expander diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 7c1f1fee..b5dab436 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -1,5 +1,6 @@ import io import logging +from typing import Dict, Optional, Tuple from flask import send_file from labthings import Schema, fields, find_component @@ -42,12 +43,14 @@ class CaptureAPI(ActionView): """ microscope = find_component("org.openflexure.microscope") - resize = args.get("resize", None) - if resize: - resize = ( - int(resize["width"]), - int(resize["height"]), + resize_dict: Optional[Dict[str, int]] = args.get("resize", None) + if resize_dict: + resize: Optional[Tuple[int, int]] = ( + int(resize_dict["width"]), + int(resize_dict["height"]), ) # Convert dict to tuple + else: + resize = None # Explicitally acquire lock (prevents empty files being created if lock is unavailable) with microscope.camera.lock: @@ -76,12 +79,14 @@ class RAMCaptureAPI(ActionView): """ microscope = find_component("org.openflexure.microscope") - resize = args.get("resize", None) - if resize: - resize = ( - int(resize["width"]), - int(resize["height"]), + resize_dict: Optional[Dict[str, int]] = args.get("resize", None) + if resize_dict: + resize: Optional[Tuple[int, int]] = ( + int(resize_dict["width"]), + int(resize_dict["height"]), ) # Convert dict to tuple + else: + resize = None # Open a BytesIO stream to be destroyed once request has returned with microscope.camera.lock, io.BytesIO() as stream: @@ -113,15 +118,18 @@ class GPUPreviewStartAPI(ActionView): """ microscope = find_component("org.openflexure.microscope") - window = args.get("window") - logging.debug(window) + # Get window argument from request + window_arg = args.get("window") + logging.debug(window_arg) - if len(window) != 4: - fullscreen = True - window = None - else: + # Default to no window + fullscreen: bool = True + window: Optional[Tuple[int, int, int, int]] = None + + # If request argument is well formed, use that + if len(window_arg) == 4: fullscreen = False - window = [int(w) for w in window] + window = (int(w) for w in window_arg) microscope.camera.start_preview(fullscreen=fullscreen, window=window) return microscope.state diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index 53e0b0b1..a87e100c 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -1,4 +1,5 @@ import logging +from typing import List, Tuple from labthings import fields, find_component from labthings.views import ActionView @@ -24,11 +25,11 @@ class MoveStageAPI(ActionView): # Handle absolute positioning (calculate a relative move from current position and target) if (args.get("absolute")) and (microscope.stage): # Only if stage exists - target_position = axes_to_array(args, ["x", "y", "z"]) + target_position: List[int] = axes_to_array(args, ["x", "y", "z"]) logging.debug("TARGET: %s", (target_position)) - position = [ + position: Tuple[int, int, int] = ( target_position[i] - microscope.stage.position[i] for i in range(3) - ] + ) logging.debug("DELTA: %s", (position)) else: diff --git a/openflexure_microscope/api/v2/views/actions/system.py b/openflexure_microscope/api/v2/views/actions/system.py index 5461e053..246b0fec 100644 --- a/openflexure_microscope/api/v2/views/actions/system.py +++ b/openflexure_microscope/api/v2/views/actions/system.py @@ -4,7 +4,7 @@ import subprocess from labthings.views import ActionView -def is_raspberrypi(): +def is_raspberrypi() -> bool: """ Checks if Raspberry Pi. """ diff --git a/openflexure_microscope/api/v2/views/camera.py b/openflexure_microscope/api/v2/views/camera.py index c1fe4a89..5b96b0f8 100644 --- a/openflexure_microscope/api/v2/views/camera.py +++ b/openflexure_microscope/api/v2/views/camera.py @@ -34,17 +34,18 @@ class LSTImageProperty(PropertyView): logging.warning("PiCamera.lens_shading_table returned as None") return ("", 204) else: + # Get the LST Numpy array from the camera's memoryview lst = np.asarray(microscope.camera.camera.lens_shading_table) - # R next to G1 + # Create array of R next to G1 top = np.concatenate(lst[:2], axis=1) - # G2 next to B + # Create array of G2 next to B bottom = np.concatenate(lst[2:], axis=1) - # Combined into a 2x2 grid of greyscale images + # Create combined array of 2x2 grid of greyscale images all_channels = np.concatenate((top, bottom), axis=0) # Create a PNG - img_bytes = BytesIO() + img_bytes: BytesIO = BytesIO() Image.fromarray(np.uint8(all_channels), "L").save(img_bytes, "PNG") img_bytes.seek(0) # Return the image - fname = f"lst-{datetime.date.today().isoformat()}.png" + fname: str = f"lst-{datetime.date.today().isoformat()}.png" return send_file(img_bytes, as_attachment=True, attachment_filename=fname) diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 9de19506..75b8cc51 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -1,3 +1,7 @@ +from io import BytesIO +from typing import List, Optional, Union +from uuid import UUID + from flask import abort, redirect, request, send_file, url_for from labthings import Schema, fields, find_component from labthings.marshalling import marshal_with, use_args @@ -6,6 +10,7 @@ from labthings.views import PropertyView, View from marshmallow import pre_dump from openflexure_microscope.api.utilities import get_bool +from openflexure_microscope.captures import CaptureObject # SCHEMAS @@ -61,10 +66,10 @@ class CaptureSchema(ImageSchema): links = fields.Dict() @pre_dump - def generate_links(self, data, **_): + def generate_links(self, data: Union[dict, CaptureObject], **_): if isinstance(data, dict): - capture_id = data.get("id") - capture_name = data.get("name") + capture_id: Optional[Union[str, UUID]] = data.get("id") + capture_name: Optional[str] = data.get("name") else: capture_id = data.id capture_name = data.name @@ -102,7 +107,7 @@ class CaptureSchema(ImageSchema): if isinstance(data, dict): data["links"] = links else: - data.links = links + setattr(data, "links", links) return data @@ -129,7 +134,7 @@ class CaptureList(PropertyView): List all image captures """ microscope = find_component("org.openflexure.microscope") - image_list = microscope.captures.images.values() + image_list: List[CaptureObject] = microscope.captures.images.values() return image_list @@ -142,7 +147,7 @@ class CaptureView(View): Description of a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.captures.images.get(id_) + capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_) if not capture_obj: return abort(404) # 404 Not Found @@ -154,7 +159,7 @@ class CaptureView(View): Delete a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.captures.images.get(id_) + capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_) if not capture_obj: return abort(404) # 404 Not Found @@ -171,17 +176,17 @@ class CaptureDownload(View): tags = ["captures"] responses = {200: {"content_type": "image/jpeg"}} - def get(self, id_, filename): + def get(self, id_, filename: Optional[str]): """ Image data for a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.captures.images.get(id_) + capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_) if not capture_obj: return abort(404) # 404 Not Found - thumbnail = get_bool(request.args.get("thumbnail")) + thumbnail: bool = get_bool(request.args.get("thumbnail", "")) # If no filename is specified, redirect to the capture's currently set filename if not filename: @@ -189,7 +194,7 @@ class CaptureDownload(View): url_for( "DownloadAPI", id=id_, - filename=capture_obj.filename, + filename=capture_obj.name, thumbnail=thumbnail, ), code=307, @@ -197,10 +202,14 @@ class CaptureDownload(View): # Download the image data using the requested filename if thumbnail: - img = capture_obj.thumbnail + img: Optional[BytesIO] = capture_obj.thumbnail else: img = capture_obj.data + # If we can't get any data, return 404 + if not img: + return abort(404) # 404 Not Found + return send_file(img, mimetype="image/jpeg") @@ -212,7 +221,7 @@ class CaptureTags(View): Get tags associated with a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.captures.images.get(id_) + capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_) if not capture_obj: return abort(404) # 404 Not Found @@ -225,7 +234,7 @@ class CaptureTags(View): Add tags to a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.captures.images.get(id_) + capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_) if not capture_obj: return abort(404) # 404 Not Found @@ -240,7 +249,7 @@ class CaptureTags(View): Delete tags from a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.captures.images.get(id_) + capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_) if not capture_obj: return abort(404) # 404 Not Found @@ -259,7 +268,7 @@ class CaptureAnnotations(View): Get annotations associated with a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.captures.images.get(id_) + capture_obj: Optional[CaptureObject] = microscope.captures.images.get(id_) if not capture_obj: return abort(404) # 404 Not Found diff --git a/openflexure_microscope/api/v2/views/instrument.py b/openflexure_microscope/api/v2/views/instrument.py index c441240e..28d9ea8a 100644 --- a/openflexure_microscope/api/v2/views/instrument.py +++ b/openflexure_microscope/api/v2/views/instrument.py @@ -1,4 +1,5 @@ import logging +from typing import Any, List from flask import abort from labthings import fields, find_component @@ -35,29 +36,29 @@ class NestedSettingsProperty(View): tags = ["properties"] responses = {404: {"description": "Settings key cannot be found"}} - def get(self, route): + def get(self, route: str): """ Show a nested section of the current microscope settings """ microscope = find_component("org.openflexure.microscope") - keys = route.split("/") + keys: List[str] = route.split("/") try: - value = get_by_path(microscope.read_settings(), keys) + value: Any = get_by_path(microscope.read_settings(), keys) except KeyError: return abort(404) return value @use_args(fields.Dict()) - def put(self, args, route): + def put(self, args: Any, route: str): """ Update a nested section of the current microscope settings """ microscope = find_component("org.openflexure.microscope") - keys = route.split("/") + keys: List[str] = route.split("/") - dictionary = create_from_path(keys) + dictionary: dict = create_from_path(keys) set_by_path(dictionary, keys, args) microscope.update_settings(dictionary) @@ -84,10 +85,10 @@ class NestedStateProperty(View): Show a nested section of the current microscope state """ microscope = find_component("org.openflexure.microscope") - keys = route.split("/") + keys: List[str] = route.split("/") try: - value = get_by_path(microscope.state, keys) + value: Any = get_by_path(microscope.state, keys) except KeyError: return abort(404) @@ -112,10 +113,10 @@ class NestedConfigurationProperty(View): Show a nested section of the current microscope state """ microscope = find_component("org.openflexure.microscope") - keys = route.split("/") + keys: List[str] = route.split("/") try: - value = get_by_path(microscope.configuration, keys) + value: Any = get_by_path(microscope.configuration, keys) except KeyError: return abort(404) diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index ef144ee5..38a781c2 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -7,7 +7,7 @@ def gen(camera): """Video streaming generator function.""" while True: # the obtained frame is a jpeg - frame = camera.stream.getframe() + frame: bytes = camera.stream.getframe() yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n") diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 8ef4804c..0251b5c2 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -4,6 +4,8 @@ import logging import time from abc import ABCMeta, abstractmethod from collections import namedtuple +from types import TracebackType +from typing import BinaryIO, List, Optional, Tuple, Type, Union from labthings import ClientEvent, StrictLock @@ -27,16 +29,29 @@ class FrameStream(io.BytesIO): # Array of TrackerFrame objects io.BytesIO.__init__(self, *args, **kwargs) # Array of TrackerFramer objects - self.frames = [] + self.frames: List[TrackerFrame] = [] # Last acquired TrackerFramer object - self.last = None + self.last: Optional[TrackerFrame] = None # Are we currently tracking frame sizes? - self.tracking = False + self.tracking: bool = False # Event to track if a new frame is available since the last getvalue() call # We use a ClientEvent so that each thread can call getvalue() independantly - self.new_frame = ClientEvent() + self.new_frame: ClientEvent = ClientEvent() + + def __enter__(self): + self.start_tracking() + return super().__enter__() + + def __exit__( + self, + t: Optional[Type[BaseException]], + value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> Optional[bool]: + self.stop_tracking() + return super().__exit__(t, value, traceback) def start_tracking(self): """Start tracking frame sizes""" @@ -92,13 +107,16 @@ class BaseCamera(metaclass=ABCMeta): def __init__(self): #: :py:class:`labthings.StrictLock`: Access lock for the camera - self.lock = StrictLock(name="Camera", timeout=None) + self.lock: StrictLock = StrictLock(name="Camera", timeout=None) #: :py:class:`FrameStream`: Streaming and analysis frame buffer - self.stream = FrameStream() + self.stream: FrameStream = FrameStream() - self.stream_active = False - self.record_active = False - self.preview_active = False + self.stream_active: bool = False + self.record_active: bool = False + self.preview_active: bool = False + + self.image_resolution: Tuple[int, int] = (1312, 976) + self.stream_resolution: Tuple[int, int] = (640, 480) @property @abstractmethod @@ -114,6 +132,14 @@ class BaseCamera(metaclass=ABCMeta): def settings(self): return self.read_settings() + @abstractmethod + def start_stream(self): + """Ensure the frame stream is actively running""" + + @abstractmethod + def stop_stream(self): + """Stop the active stream, if possible""" + @abstractmethod def update_settings(self, config: dict): """Update settings from a config dictionary""" @@ -122,6 +148,28 @@ class BaseCamera(metaclass=ABCMeta): def read_settings(self) -> dict: """Return the current settings as a dictionary""" + @abstractmethod + def capture( + self, + output: Union[str, BinaryIO], + fmt: str = "jpeg", + use_video_port: bool = False, + resize: Optional[Tuple[int, int]] = None, + bayer: bool = True, + thumbnail: Optional[Tuple[int, int, int]] = None, + ): + """ + Perform a basic capture to output + + Args: + output: String or file-like object to write capture data to + fmt: Format of the capture. + use_video_port: Capture from the video port used for streaming. Lower resolution, faster. + resize: Resize the captured image. + bayer: Store raw bayer data in capture + thumbnail: Dimensions and quality (x, y, quality) of a thumbnail to generate, if supported + """ + def __enter__(self): """Create camera on context enter.""" return self diff --git a/openflexure_microscope/camera/mock.py b/openflexure_microscope/camera/mock.py index 6c03d23d..dd0eb850 100644 --- a/openflexure_microscope/camera/mock.py +++ b/openflexure_microscope/camera/mock.py @@ -8,6 +8,8 @@ import time from datetime import datetime # Type hinting +from typing import BinaryIO, Optional, Tuple, Union + from PIL import Image, ImageDraw from openflexure_microscope.camera.base import BaseCamera @@ -26,17 +28,17 @@ class MissingCamera(BaseCamera): BaseCamera.__init__(self) # Update config properties - self.image_resolution = (1312, 976) - self.stream_resolution = (640, 480) - self.numpy_resolution = (1312, 976) - self.jpeg_quality = 75 - self.framerate = 10 + self.image_resolution: Tuple[int, int] = (1312, 976) + self.stream_resolution: Tuple[int, int] = (640, 480) + self.numpy_resolution: Tuple[int, int] = (1312, 976) + self.jpeg_quality: int = 75 + self.framerate: int = 10 # Generate an initial dummy image self.generate_new_dummy_image() # Start streaming - self.stop = False # Used to indicate that the stream loop should break + self.stop: bool = False # Used to indicate that the stream loop should break self.start_worker() # Wait until frames are available logging.info("Waiting for frames") @@ -178,7 +180,11 @@ class MissingCamera(BaseCamera): """ logging.warning("Zoom not implemented in mock camera") - # LAUNCH ACTIONS + def start_stream(self): + pass + + def stop_stream(self): + pass def start_preview(self, *_, **__): """Start the on board GPU camera preview.""" @@ -211,7 +217,15 @@ class MissingCamera(BaseCamera): with self.lock: logging.warning("Recording not implemented in mock camera") - def capture(self, output, *_, **__): + def capture( + self, + output: Union[str, BinaryIO], + fmt: str = "jpeg", + use_video_port: bool = False, + resize: Optional[Tuple[int, int]] = None, + bayer: bool = True, + thumbnail: Optional[Tuple[int, int, int]] = None, + ): """ Capture a still image to a StreamObject. diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index a7546c10..9f3bf1cd 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -30,7 +30,7 @@ import logging import time # Type hinting -from typing import Tuple +from typing import BinaryIO, Tuple, Union import numpy as np @@ -77,57 +77,64 @@ class PiCameraStreamer(BaseCamera): BaseCamera.__init__(self) #: :py:class:`picamerax.PiCamera`: Attached Picamera object - self.camera = picamerax.PiCamera() + self.picamera: picamerax.PiCamera = picamerax.PiCamera() # Store state of PiCameraStreamer - self.preview_active = False + self.preview_active: bool = False # Reset variable states self.set_zoom(1.0) #: tuple: Resolution for image captures - self.image_resolution = tuple(self.camera.MAX_RESOLUTION) + self.image_resolution: Tuple[int, int] = tuple(self.picamera.MAX_RESOLUTION) #: tuple: Resolution for stream and video captures - self.stream_resolution = (832, 624) + self.stream_resolution: Tuple[int, int] = (832, 624) #: tuple: Resolution for numpy array captures - self.numpy_resolution = (1312, 976) + self.numpy_resolution: Tuple[int, int] = (1312, 976) - self.jpeg_quality = 100 #: int: JPEG quality - self.mjpeg_quality = 75 #: int: MJPEG quality + self.jpeg_quality: int = 100 #: int: JPEG quality + self.mjpeg_quality: int = 75 #: int: MJPEG quality # Start stream recording (and set resolution) - self.start_stream_recording() + self.start_stream() # Wait until frames are available logging.debug("Waiting for frames...") self.stream.new_frame.wait() logging.debug("Camera initialised") @property - def configuration(self): - """The current camera configuration.""" - return {"board": self.camera.revision} + def camera(self): + logging.warning( + "PiCameraStreamer.camera is deprecated. Replace with PiCameraStreamer.picamera" + ) + return self.picamera @property - def state(self): + def configuration(self) -> dict: + """The current camera configuration.""" + return {"board": self.picamera.revision} + + @property + def state(self) -> dict: """The current read-only camera state.""" return {} def close(self): """Close the Raspberry Pi PiCameraStreamer.""" # Stop stream recording - self.stop_stream_recording() + self.stop_stream() # Run BaseCamera close method super().close() # Detach Pi camera - if self.camera: - self.camera.close() + if self.picamera: + self.picamera.close() # HANDLE SETTINGS def read_settings(self) -> dict: """ Return config dictionary of the PiCameraStreamer. """ - conf_dict = { + conf_dict: dict = { "stream_resolution": self.stream_resolution, "image_resolution": self.image_resolution, "numpy_resolution": self.numpy_resolution, @@ -139,7 +146,7 @@ class PiCameraStreamer(BaseCamera): # Include a subset of picamera properties. Excludes lens shading table for key in PiCameraStreamer.picamera_settings_keys: try: - value = getattr(self.camera, key) + value = getattr(self.picamera, key) logging.debug("Reading PiCamera().%s: %s", key, value) conf_dict["picamera"][key] = value except AttributeError: @@ -147,11 +154,11 @@ class PiCameraStreamer(BaseCamera): # Include a serialised lens shading table if ( - hasattr(self.camera, "lens_shading_table") - and getattr(self.camera, "lens_shading_table") is not None + hasattr(self.picamera, "lens_shading_table") + and getattr(self.picamera, "lens_shading_table") is not None ): conf_dict["picamera"]["lens_shading_table"] = ndarray_to_json( - getattr(self.camera, "lens_shading_table") + getattr(self.picamera, "lens_shading_table") ) return conf_dict @@ -179,7 +186,7 @@ class PiCameraStreamer(BaseCamera): # Pause stream while changing settings if self.stream_active: # If stream is active logging.info("Pausing stream to update config.") - self.stop_stream_recording() # Pause stream + self.stop_stream() # Pause stream paused_stream = True # Remember to unpause stream when done # PiCamera parameters @@ -190,11 +197,11 @@ class PiCameraStreamer(BaseCamera): # Handle lens shading if camera supports it if ( - hasattr(self.camera, "lens_shading_table") + hasattr(self.picamera, "lens_shading_table") and "lens_shading_table" in config["picamera"] ): try: - self.camera.lens_shading_table = json_to_ndarray( + self.picamera.lens_shading_table = json_to_ndarray( config["picamera"].get("lens_shading_table") ) except KeyError as e: @@ -208,7 +215,7 @@ class PiCameraStreamer(BaseCamera): # If stream was paused to update config, unpause if paused_stream: logging.info("Resuming stream.") - self.start_stream_recording() + self.start_stream() else: raise Exception( @@ -229,47 +236,47 @@ class PiCameraStreamer(BaseCamera): logging.debug( "Applying exposure_mode: %s", (settings_dict["exposure_mode"]) ) - self.camera.exposure_mode = settings_dict["exposure_mode"] + self.picamera.exposure_mode = settings_dict["exposure_mode"] # Apply gains and let them settle if "analog_gain" in settings_dict: logging.debug("Applying analog_gain: %s", (settings_dict["analog_gain"])) - set_analog_gain(self.camera, float(settings_dict["analog_gain"])) + set_analog_gain(self.picamera, float(settings_dict["analog_gain"])) if "digital_gain" in settings_dict: logging.debug("Applying digital_gain: %s", (settings_dict["digital_gain"])) - set_digital_gain(self.camera, float(settings_dict["digital_gain"])) + set_digital_gain(self.picamera, float(settings_dict["digital_gain"])) # Apply shutter speed if "shutter_speed" in settings_dict: logging.debug( "Applying shutter_speed: %s", (settings_dict["shutter_speed"]) ) - self.camera.shutter_speed = int(settings_dict["shutter_speed"]) + self.picamera.shutter_speed = int(settings_dict["shutter_speed"]) time.sleep(0.2) # Let gains settle # Handle AWB in a half-smart way if "awb_gains" in settings_dict: logging.debug("Applying awb_mode: off") - self.camera.awb_mode = "off" + self.picamera.awb_mode = "off" logging.debug("Applying awb_gains: %s", (settings_dict["awb_gains"])) - self.camera.awb_gains = settings_dict["awb_gains"] + self.picamera.awb_gains = settings_dict["awb_gains"] elif "awb_mode" in settings_dict: logging.debug("Applying awb_mode: %s", (settings_dict["awb_mode"])) - self.camera.awb_mode = settings_dict["awb_mode"] + self.picamera.awb_mode = settings_dict["awb_mode"] # Handle some properties that can be quickly applied batched_keys = ["framerate", "saturation"] for key in batched_keys: - if (key in settings_dict) and hasattr(self.camera, key): + if (key in settings_dict) and hasattr(self.picamera, key): logging.debug("Applying %s: %s", key, settings_dict[key]) - setattr(self.camera, key, settings_dict[key]) + setattr(self.picamera, key, settings_dict[key]) # Final optional pause to settle if pause_for_effect: time.sleep(0.2) - def set_zoom(self, zoom_value: float = 1.0) -> None: + def set_zoom(self, zoom_value: Union[float, int] = 1.0) -> None: """ Change the camera zoom, handling re-centering and scaling. """ @@ -278,7 +285,7 @@ class PiCameraStreamer(BaseCamera): if self.zoom_value < 1: self.zoom_value = 1 # Richard's code for zooming ! - fov = self.camera.zoom + fov = self.picamera.zoom centre = np.array([fov[0] + fov[2] / 2.0, fov[1] + fov[3] / 2.0]) size = 1.0 / self.zoom_value # If the new zoom value would be invalid, move the centre to @@ -289,23 +296,23 @@ class PiCameraStreamer(BaseCamera): centre[i] = 0.5 + (1.0 - size) / 2 * np.sign(centre[i] - 0.5) logging.info("setting zoom, centre %s, size %s", centre, size) new_fov = (centre[0] - size / 2, centre[1] - size / 2, size, size) - self.camera.zoom = new_fov + self.picamera.zoom = new_fov - # LAUNCH ACTIONS - - def start_preview(self, fullscreen=True, window=None): + def start_preview( + self, fullscreen: bool = True, window: Tuple[int, int, int, int] = None + ): """Start the on board GPU camera preview.""" with self.lock(timeout=1): try: - if not self.camera.preview: + if not self.picamera.preview: logging.debug("Starting preview") - self.camera.start_preview(fullscreen=fullscreen, window=window) + self.picamera.start_preview(fullscreen=fullscreen, window=window) else: logging.debug("Resizing preview") if window: - self.camera.preview.window = window + self.picamera.preview.window = window if fullscreen: - self.camera.preview.fullscreen = fullscreen + self.picamera.preview.fullscreen = fullscreen self.preview_active = True except picamerax.exc.PiCameraMMALError as e: logging.error( @@ -320,11 +327,13 @@ class PiCameraStreamer(BaseCamera): def stop_preview(self): """Stop the on board GPU camera preview.""" with self.lock(timeout=1): - if self.camera.preview: - self.camera.stop_preview() + if self.picamera.preview: + self.picamera.stop_preview() self.preview_active = False - def start_recording(self, output, fmt: str = "h264", quality: int = 15): + def start_recording( + self, output: Union[str, BinaryIO], fmt: str = "h264", quality: int = 15 + ): """Start recording. Start a new video recording, writing to a output object. @@ -345,7 +354,7 @@ class PiCameraStreamer(BaseCamera): # Start the camera video recording on port 2 logging.info("Recording to %s", (output)) - self.camera.start_recording( + self.picamera.start_recording( output, format=fmt, splitter_port=2, @@ -370,80 +379,67 @@ class PiCameraStreamer(BaseCamera): with self.lock(timeout=5): # Stop the camera video recording on port 2 logging.info("Stopping recording") - self.camera.stop_recording(splitter_port=2) + self.picamera.stop_recording(splitter_port=2) logging.info("Recording stopped") # Update state self.record_active = False - def start_stream_recording(self, splitter_port: int = 1, **kwargs) -> None: + def start_stream(self) -> None: """ Sets the camera resolution to the video/stream resolution, and starts recording if the stream should be active. - - Args: - splitter_port (int): Splitter port to start recording on """ - for k in kwargs.keys(): - logging.warning( - "Warning, kwarg %s is invalid for stop_stream_recording.", k - ) with self.lock(timeout=None): # Reduce the resolution for video streaming try: - self.camera._check_recording_stopped() # pylint: disable=W0212 + self.picamera._check_recording_stopped() # pylint: disable=W0212 except picamerax.exc.PiCameraRuntimeError: logging.info( "Error while changing resolution: Recording already running." ) else: - self.camera.resolution = self.stream_resolution + self.picamera.resolution = self.stream_resolution # Sprinkled a sleep to prevent camera getting confused by rapid commands time.sleep(0.2) # If the stream should be active try: # Start recording on stream port - self.camera.start_recording( + self.picamera.start_recording( self.stream, format="mjpeg", quality=self.mjpeg_quality, bitrate=-1, # RWB: disable bitrate control # (bitrate control makes JPEG size less good as a focus # metric) - splitter_port=splitter_port, + splitter_port=1, ) except picamerax.exc.PiCameraAlreadyRecording: logging.info("Error while starting preview: Recording already running.") else: self.stream_active = True logging.debug( - "Started MJPEG stream at %s on port %s", - self.stream_resolution, - splitter_port, + "Started MJPEG stream at %s on port %s", self.stream_resolution, 1 ) - def stop_stream_recording(self, splitter_port: int = 1, **kwargs) -> None: + def stop_stream(self) -> None: """ Sets the camera resolution to the still-image resolution, and stops recording if the stream is active. Args: splitter_port (int): Splitter port to stop recording on """ - for k in kwargs.keys(): - logging.warning( - "Warning, kwarg %s is invalid for stop_stream_recording.", k - ) with self.lock: # Stop the camera video recording on port 1 try: - self.camera.stop_recording(splitter_port=splitter_port) + self.picamera.stop_recording(splitter_port=1) except picamerax.exc.PiCameraNotRecording: - logging.info("Not recording on splitter_port %s", (splitter_port)) + logging.info("Not recording on splitter_port %s", (1)) else: self.stream_active = False logging.info( "Stopped MJPEG stream on port %s. Switching to %s.", - splitter_port, + 1, self.image_resolution, ) @@ -451,16 +447,16 @@ class PiCameraStreamer(BaseCamera): time.sleep( 0.2 ) # Sprinkled a sleep to prevent camera getting confused by rapid commands - self.camera.resolution = self.image_resolution + self.picamera.resolution = self.image_resolution def capture( self, - output, + output: Union[str, BinaryIO], fmt: str = "jpeg", use_video_port: bool = False, resize: Tuple[int, int] = None, bayer: bool = True, - thumbnail: tuple = None, + thumbnail: Tuple[int, int, int] = None, ): """ Capture a still image to a StreamObject. @@ -470,10 +466,11 @@ class PiCameraStreamer(BaseCamera): Args: output: String or file-like object to write capture data to - fmt (str): Format of the capture. - use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster. - resize ((int, int)): Resize the captured image. - bayer (bool): Store raw bayer data in capture + fmt: Format of the capture. + use_video_port: Capture from the video port used for streaming. Lower resolution, faster. + resize: Resize the captured image. + bayer: Store raw bayer data in capture + thumbnail: Dimensions and quality (x, y, quality) of a thumbnail to generate, if supported Returns: output_object (str/BytesIO): Target object. @@ -483,9 +480,9 @@ class PiCameraStreamer(BaseCamera): # Set resolution and stop stream recording if necessary if not use_video_port: - self.stop_stream_recording() + self.stop_stream() - self.camera.capture( + self.picamera.capture( output, format=fmt, quality=self.jpeg_quality, @@ -497,11 +494,11 @@ class PiCameraStreamer(BaseCamera): # Set resolution and start stream recording if necessary if not use_video_port: - self.start_stream_recording() + self.start_stream() return output - def array(self, use_video_port=True) -> np.ndarray: + def array(self, use_video_port: bool = True) -> np.ndarray: """Capture an uncompressed still RGB image to a Numpy array. Args: @@ -513,7 +510,9 @@ class PiCameraStreamer(BaseCamera): """ with self.lock: logging.debug("Creating PiRGBArray") - with picamerax.array.PiRGBArray(self.camera) as output: + with picamerax.array.PiRGBArray(self.picamera) as output: logging.info("Capturing to %s", (output)) - self.camera.capture(output, format="rgb", use_video_port=use_video_port) + self.picamera.capture( + output, format="rgb", use_video_port=use_video_port + ) return output.array diff --git a/openflexure_microscope/camera/set_picamera_gain.py b/openflexure_microscope/camera/set_picamera_gain.py index fc04f851..cc952c94 100644 --- a/openflexure_microscope/camera/set_picamera_gain.py +++ b/openflexure_microscope/camera/set_picamera_gain.py @@ -2,16 +2,17 @@ from __future__ import print_function import logging import time +from typing import Union import picamerax from picamerax import exc, mmal from picamerax.mmalobj import to_rational -MMAL_PARAMETER_ANALOG_GAIN = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x59 -MMAL_PARAMETER_DIGITAL_GAIN = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x5A +MMAL_PARAMETER_ANALOG_GAIN: int = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x59 +MMAL_PARAMETER_DIGITAL_GAIN: int = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x5A -def set_gain(camera, gain, value): +def set_gain(camera: picamerax.PiCamera, gain: int, value: Union[int, float]): """Set the analog gain of a PiCamera. camera: the picamerax.PiCamera() instance you are configuring @@ -32,12 +33,12 @@ def set_gain(camera, gain, value): raise exc.PiCameraMMALError(ret) -def set_analog_gain(camera, value): +def set_analog_gain(camera: picamerax.PiCamera, value: Union[int, float]): """Set the gain of a PiCamera object to a given value.""" set_gain(camera, MMAL_PARAMETER_ANALOG_GAIN, value) -def set_digital_gain(camera, value): +def set_digital_gain(camera: picamerax.PiCamera, value: Union[int, float]): """Set the digital gain of a PiCamera object to a given value.""" set_gain(camera, MMAL_PARAMETER_DIGITAL_GAIN, value) diff --git a/openflexure_microscope/captures/__init__.py b/openflexure_microscope/captures/__init__.py index 7d15ca40..c7d7b95f 100644 --- a/openflexure_microscope/captures/__init__.py +++ b/openflexure_microscope/captures/__init__.py @@ -1,3 +1,11 @@ from . import capture, capture_manager from .capture import THUMBNAIL_SIZE, CaptureObject from .capture_manager import CaptureManager + +__all__ = [ + "capture", + "capture_manager", + "THUMBNAIL_SIZE", + "CaptureObject", + "CaptureManager", +] diff --git a/openflexure_microscope/captures/capture.py b/openflexure_microscope/captures/capture.py index 36d252b4..110f5dbe 100644 --- a/openflexure_microscope/captures/capture.py +++ b/openflexure_microscope/captures/capture.py @@ -6,12 +6,13 @@ import logging import os import uuid from collections import OrderedDict +from typing import Callable, Dict, List, Optional import dateutil.parser +import piexif +from piexif import InvalidImageDataError from PIL import Image -from openflexure_microscope.captures import piexif -from openflexure_microscope.captures.piexif import InvalidImageDataError from openflexure_microscope.json import JSONEncoder EXIF_FORMATS = ["JPG", "JPEG", "TIF", "TIFF"] @@ -39,7 +40,7 @@ def build_captures_from_exif(capture_path): logging.debug("Reloading capture %s...", (f)) capture = capture_from_path(f) if capture: - captures[capture.id] = capture + captures[str(capture.id)] = capture logging.info("%s capture files successfully reloaded", (len(captures))) @@ -68,33 +69,42 @@ class CaptureObject(object): Serves to simplify modifying properties of on-disk capture data. """ - def __init__(self, filepath) -> None: + def __init__(self, filepath: str) -> None: """Create a new StreamObject, to manage capture data.""" # Stream for buffering capture data - self.stream = io.BytesIO() + self.stream: io.BytesIO = io.BytesIO() # Store a nice ID - self.id = uuid.uuid4() #: str: Unique capture ID + self.id: uuid.UUID = uuid.uuid4() #: str: Unique capture ID logging.debug("Created CaptureObject %s", (self.id)) - self.time = datetime.datetime.now() + self.time: datetime.datetime = datetime.datetime.now() # Create file name. Default to UUID - self.format = None - self.file = filepath + self.file: str = filepath + # Placeholders for split file info + self.format: str = "" + self.basename: str = "" + self.filefolder: str = "" + self.name: str = "" + # Replace placeholders with actual split file info self.split_file_path(self.file) if not os.path.exists(self.filefolder): os.makedirs(self.filefolder) # Dataset information - self._dataset = None + self.dataset: Dict[str, str] = {} + # Dictionary for storing custom annotations # Can be modified via the web API - self.annotations = {} + self.annotations: Dict[str, str] = {} # List for storing tags # Can be modified via the web API - self.tags = [] + self.tags: List[str] = [] + + # On-delete callback + self.on_delete: Optional[Callable] = None def write(self, s): logging.debug("Writing to %s", self) @@ -112,7 +122,7 @@ class CaptureObject(object): def open(self, mode): return open(self.file, mode) - def split_file_path(self, filepath): + def split_file_path(self, filepath: str): """ Take a full file path, and split it into separated class properties. @@ -125,10 +135,10 @@ class CaptureObject(object): self.basename = os.path.splitext(self.name)[0] self.format = self.name.split(".")[-1] - def _read_exif(self): + def _read_exif(self) -> dict: return piexif.load(self.file) - def _decode_usercomment(self, exif_dict: dict): + def _decode_usercomment(self, exif_dict: dict) -> dict: if "Exif" not in exif_dict: raise InvalidImageDataError if piexif.ExifIFD.UserComment not in exif_dict["Exif"]: @@ -150,15 +160,17 @@ class CaptureObject(object): ) def sync_basic_metadata(self): - exif_dict = self._read_exif() + exif_dict: dict = self._read_exif() - metadata_dict = self._decode_usercomment(exif_dict) or {} - image_metadata = metadata_dict.get("image") + metadata_dict: dict = self._decode_usercomment(exif_dict) or {} + self.dataset = metadata_dict.get("dataset") + + image_metadata: Optional[dict] = metadata_dict.get("image") if not image_metadata: raise InvalidImageDataError("No capture metadata found") - self.id = image_metadata.get("id") + self.id = uuid.UUID(image_metadata.get("id")) self.format = image_metadata.get("format") self.time = dateutil.parser.isoparse( image_metadata.get("time") or image_metadata.get("acquisitionDate") @@ -166,24 +178,11 @@ class CaptureObject(object): self.tags = image_metadata.get("tags") self.annotations = image_metadata.get("annotations") - dataset = metadata_dict.get("dataset") - if dataset: - self._dataset = { - "id": dataset.get("id"), - "name": dataset.get("name"), - "type": dataset.get("type"), - } - - def read_full_metadata(self): + def read_full_metadata(self) -> dict: logging.info("Reading full capture metadata from %s...", self.file) exif_dict = self._read_exif() return self._decode_usercomment(exif_dict) - @property - def dataset(self): - """Read-only dataset property""" - return self._dataset - @property def exists(self) -> bool: """Check if capture data file exists on disk.""" @@ -193,7 +192,7 @@ class CaptureObject(object): return False # HANDLE TAGS - def put_tags(self, tags: list): + def put_tags(self, tags: List[str]): """ Add a new tag to the ``tags`` list attribute. @@ -218,7 +217,7 @@ class CaptureObject(object): # HANDLE ANNOTATIONS - def put_annotations(self, data: dict) -> None: + def put_annotations(self, data: Dict[str, str]): """ Merge annotations from a passed dictionary into the capture metadata, and saves. @@ -227,7 +226,7 @@ class CaptureObject(object): """ self.put_and_save(annotations=data) - def delete_annotation(self, key: str) -> None: + def delete_annotation(self, key: str): # Update in-memory annotations list if key in self.annotations: del self.annotations[key] @@ -237,7 +236,7 @@ class CaptureObject(object): # HANDLE METADATA - def put_metadata(self, data: dict) -> None: + def put_metadata(self, data: dict): """ Merge root metadata from a passed dictionary into the capture metadata, and saves. @@ -246,10 +245,19 @@ class CaptureObject(object): """ self.put_and_save(metadata=data) + # HANDLE DATASET + + def put_dataset(self, data: dict): + self.put_and_save(dataset=data) + # BULK OPERATIONS def put_and_save( - self, tags: list = None, annotations: dict = None, metadata: dict = None + self, + tags: Optional[List[str]] = None, + annotations: Optional[Dict[str, str]] = None, + dataset: Optional[Dict[str, str]] = None, + metadata: Optional[dict] = None, ): """ Batch-write tags, metadata, and annotations in a single disk operation @@ -258,6 +266,8 @@ class CaptureObject(object): tags = [] if not annotations: annotations = {} + if not dataset: + dataset = {} if not metadata: metadata = {} @@ -269,6 +279,9 @@ class CaptureObject(object): # Update in-memory annotations dictionary self.annotations.update(annotations) + # Update in-memory dataset dictionary + self.dataset.update(dataset) + # Write new data to file EXIF, if supported if self.format.upper() in EXIF_FORMATS and self.exists: logging.info("Writing Exif data to %s", self.file) @@ -281,6 +294,8 @@ class CaptureObject(object): metadata_dict.get("image", {})["tags"] = self.tags # Add new annotations to exif dictionary metadata_dict.get("image", {})["annotations"] = self.annotations + # Set new dataset info in exif dictionary + metadata_dict["dataset"] = self.dataset # Add new custom metadata to exif dictionary metadata_dict.update(metadata) @@ -310,7 +325,7 @@ class CaptureObject(object): return self.read_full_metadata() @property - def data(self) -> io.BytesIO: + def data(self) -> Optional[io.BytesIO]: """ Return a BytesIO object of the capture data. """ @@ -321,16 +336,17 @@ class CaptureObject(object): d = io.BytesIO(f.read()) # Load bytes from file d.seek(0) # Rewind loaded bytestream # Create a copy of the bytestream bytes - data = io.BytesIO(d.getbuffer()) + return io.BytesIO(d.getbuffer()) else: - data = None - - return data # Read and return bytes data + return None @property - def binary(self) -> bytes: + def binary(self) -> Optional[bytes]: """Return a byte string of the capture data.""" - return self.data.getvalue() + if self.data: + return self.data.getvalue() + else: + return None @property def thumbnail(self) -> io.BytesIO: @@ -354,7 +370,7 @@ class CaptureObject(object): # FILE MANAGEMENT - def save(self) -> None: + def save(self): """Write stream to file, and save/update metadata file""" # If a stream OR file exists, save the metadata file if self.exists: @@ -367,6 +383,9 @@ class CaptureObject(object): logging.info("Deleting file %s", (self.file)) os.remove(self.file) + if self.on_delete: + self.on_delete(self, self.id) + return True else: return False diff --git a/openflexure_microscope/captures/capture_manager.py b/openflexure_microscope/captures/capture_manager.py index d636f7a4..ceb0aa46 100644 --- a/openflexure_microscope/captures/capture_manager.py +++ b/openflexure_microscope/captures/capture_manager.py @@ -3,11 +3,12 @@ import logging import os import shutil from collections import OrderedDict +from typing import Dict, List, MutableMapping, Optional, Union, ValuesView +from uuid import UUID from labthings import StrictLock from openflexure_microscope.paths import data_file_path -from openflexure_microscope.utilities import entry_by_uuid from .capture import CaptureObject, build_captures_from_exif @@ -15,40 +16,18 @@ BASE_CAPTURE_PATH = data_file_path("micrographs") TEMP_CAPTURE_PATH = os.path.join(BASE_CAPTURE_PATH, "tmp") -def last_entry(object_list: list): - """Return the last entry of a list, if the list contains items.""" - if object_list: # If any images have been captured - return object_list[-1] # Return the latest captured image - else: - return None - - -def generate_basename(): - """Return a default filename based on the capture datetime""" - return datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - - -def generate_numbered_basename(obj_list: list) -> str: - initial_basename = generate_basename() - basename = initial_basename - # Handle clashing - iterator = 1 - while basename in [obj.basename for obj in obj_list]: - basename = initial_basename + "_{}".format(iterator) - iterator += 1 - - return basename - - class CaptureManager: def __init__(self): - self.paths = {"default": BASE_CAPTURE_PATH, "temp": TEMP_CAPTURE_PATH} + self.paths: Dict[str, str] = { + "default": BASE_CAPTURE_PATH, + "temp": TEMP_CAPTURE_PATH, + } - self.lock = StrictLock(timeout=1, name="Captures") + self.lock: StrictLock = StrictLock(timeout=1, name="Captures") # Capture data - self.images = OrderedDict() - self.videos = OrderedDict() + self.images: MutableMapping[str, CaptureObject] = OrderedDict() + self.videos: MutableMapping[str, CaptureObject] = OrderedDict() # FILE MANAGEMENT @@ -96,41 +75,28 @@ class CaptureManager: # RETURNING CAPTURES - @property - def image(self): - """Return the latest captured image.""" - return last_entry(self.images.values()) - - @property - def video(self): - """Return the latest recorded video.""" - return last_entry(self.videos.values()) - - def image_from_id(self, image_id): + def image_from_id(self, image_id: Union[str, int, UUID]) -> Optional[CaptureObject]: """Return an image StreamObject with a matching ID.""" logging.warning("image_from_id is deprecated. Access captures as a dictionary.") - return entry_by_uuid(image_id, self.images.values()) + return entry_by_uuid(image_id, self.images) - def video_from_id(self, video_id): + def video_from_id(self, video_id: Union[str, int, UUID]) -> Optional[CaptureObject]: """Return a video StreamObject with a matching ID.""" logging.warning("video_from_id is deprecated. Access captures as a dictionary.") - return entry_by_uuid(video_id, self.videos.values()) + return entry_by_uuid(video_id, self.videos) # CREATING NEW CAPTURES - def _new_output(self, temporary, filename, folder, fmt): - # Generate file name - if not filename: - filename = generate_numbered_basename(self.images.values()) - logging.debug(filename) + def _new_output(self, temporary: bool, filename: str, folder: str, fmt: str): + filename = "{}.{}".format(filename, fmt) # Generate folder - base_folder = self.paths["temp"] if temporary else self.paths["default"] + base_folder: str = self.paths["temp"] if temporary else self.paths["default"] folder = os.path.join(base_folder, folder) # Generate file path - filepath = os.path.join(folder, filename) + filepath: str = os.path.join(folder, filename) # Create capture object output = CaptureObject(filepath=filepath) @@ -143,7 +109,7 @@ class CaptureManager: def new_image( self, temporary: bool = True, - filename: str = None, + filename: Optional[str] = None, folder: str = "", fmt: str = "jpeg", ): @@ -158,23 +124,28 @@ class CaptureManager: folder (str): Name of the folder in which to store the capture. fmt (str): Format of the capture. """ + # Generate file name + if not filename: + filename = generate_numbered_basename(self.images.values()) + # Create a new output object output = self._new_output(temporary, filename, folder, fmt) + # Add an on-delete callback + output.on_delete = self.remove_image # Update capture list - capture_key = str(output.id) - logging.debug("Adding image %s with key %s", output, capture_key) - self.images[capture_key] = output + logging.debug("Adding image %s with key %s", output, output.id) + self.images[str(output.id)] = output return output def new_video( self, temporary: bool = False, - filename: str = None, + filename: Optional[str] = None, folder: str = "", fmt: str = "h264", - ): + ) -> CaptureObject: """ Create a new video capture object. @@ -186,12 +157,72 @@ class CaptureManager: folder (str): Name of the folder in which to store the capture. fmt (str): Format of the capture. """ + # Generate file name + if not filename: + filename = generate_numbered_basename(self.videos.values()) + # Create a new output object output = self._new_output(temporary, filename, folder, fmt) + # Add an on-delete callback + output.on_delete = self.remove_video # Update capture list - capture_key = str(output.id) - logging.debug("Adding video %s with key %s", output, capture_key) - self.videos[capture_key] = output + logging.debug("Adding video %s with key %s", output, output.id) + self.videos[str(output.id)] = output return output + + def remove_image(self, capture_obj: CaptureObject, capture_id: str): + logging.info("Deleting capture %s", capture_id) + if capture_id in self.images: + logging.info("Deleting capture object %s", capture_obj) + del self.images[capture_id] + + def remove_video(self, capture_obj: CaptureObject, capture_id: str): + logging.info("Deleting capture %s", capture_id) + if capture_id in self.images: + logging.info("Deleting capture object %s", capture_obj) + del self.videos[capture_id] + + +def entry_by_uuid( + entry_id: Union[str, int, UUID], object_dict: MutableMapping[str, CaptureObject] +) -> Optional[CaptureObject]: + """Return an object from a list, if .id matches id argument.""" + if isinstance(entry_id, str): + key: str = entry_id + elif isinstance(entry_id, UUID): + key = str(entry_id) + elif isinstance(entry_id, int): + key = str(UUID(int=entry_id)) + else: + raise TypeError("Argument entry_id must be a string, integer, or UUID object.") + + return object_dict.get(key) + + +def generate_basename() -> str: + """Return a default filename based on the capture datetime""" + return datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + + +def generate_numbered_basename( + obj_list: Union[ValuesView[CaptureObject], List[CaptureObject]] +) -> str: + """ + This function prevents rapid captures from having clashing generated names. + + Our generated names are a datetime string going as far as seconds, + so if you create 2 captures within 1 second then they would have a name + clash. This method handles appending sequential integers to names + that would clash. + """ + initial_basename = generate_basename() + basename = initial_basename + # Handle clashing + iterator = 1 + while basename in [obj.basename for obj in obj_list]: + basename = initial_basename + "_{}".format(iterator) + iterator += 1 + + return basename diff --git a/openflexure_microscope/captures/piexif/LICENSE.txt b/openflexure_microscope/captures/piexif/LICENSE.txt deleted file mode 100644 index be0bbced..00000000 --- a/openflexure_microscope/captures/piexif/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014, 2015 hMatoba - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/openflexure_microscope/captures/piexif/__init__.py b/openflexure_microscope/captures/piexif/__init__.py deleted file mode 100644 index 94b55939..00000000 --- a/openflexure_microscope/captures/piexif/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from ._dump import dump -from ._exceptions import * -from ._exif import * -from ._insert import insert -from ._load import load -from ._remove import remove -from ._transplant import transplant - -VERSION = "1.1.2" diff --git a/openflexure_microscope/captures/piexif/_common.py b/openflexure_microscope/captures/piexif/_common.py deleted file mode 100644 index 9cad2620..00000000 --- a/openflexure_microscope/captures/piexif/_common.py +++ /dev/null @@ -1,97 +0,0 @@ -import struct - -from ._exceptions import InvalidImageDataError - - -def split_into_segments(data): - """Slices JPEG meta data into a list from JPEG binary data. - """ - if data[0:2] != b"\xff\xd8": - raise InvalidImageDataError("Given data isn't JPEG.") - - head = 2 - segments = [b"\xff\xd8"] - while 1: - if data[head : head + 2] == b"\xff\xda": - segments.append(data[head:]) - break - else: - length = struct.unpack(">H", data[head + 2 : head + 4])[0] - endPoint = head + length + 2 - seg = data[head:endPoint] - segments.append(seg) - head = endPoint - - if head >= len(data): - raise InvalidImageDataError("Wrong JPEG data.") - return segments - - -def read_exif_from_file(filename): - """Slices JPEG meta data into a list from JPEG binary data. - """ - f = open(filename, "rb") - data = f.read(6) - - if data[0:2] != b"\xff\xd8": - raise InvalidImageDataError("Given data isn't JPEG.") - - head = data[2:6] - HEAD_LENGTH = 4 - exif = None - while len(head) == HEAD_LENGTH: - length = struct.unpack(">H", head[2:4])[0] - - if head[:2] == b"\xff\xe1": - segment_data = f.read(length - 2) - if segment_data[:4] != b"Exif": - head = f.read(HEAD_LENGTH) - continue - exif = head + segment_data - break - elif head[0:1] == b"\xff": - f.read(length - 2) - head = f.read(HEAD_LENGTH) - else: - break - - f.close() - return exif - - -def get_exif_seg(segments): - """Returns Exif from JPEG meta data list - """ - for seg in segments: - if seg[0:2] == b"\xff\xe1" and seg[4:10] == b"Exif\x00\x00": - return seg - return None - - -def merge_segments(segments, exif=b""): - """Merges Exif with APP0 and APP1 manipulations. - """ - if ( - segments[1][0:2] == b"\xff\xe0" - and segments[2][0:2] == b"\xff\xe1" - and segments[2][4:10] == b"Exif\x00\x00" - ): - if exif: - segments[2] = exif - segments.pop(1) - elif exif is None: - segments.pop(2) - else: - segments.pop(1) - elif segments[1][0:2] == b"\xff\xe0": - if exif: - segments[1] = exif - elif segments[1][0:2] == b"\xff\xe1" and segments[1][4:10] == b"Exif\x00\x00": - if exif: - segments[1] = exif - elif exif is None: - segments.pop(1) - else: - if exif: - segments.insert(1, exif) - return b"".join(segments) diff --git a/openflexure_microscope/captures/piexif/_dump.py b/openflexure_microscope/captures/piexif/_dump.py deleted file mode 100644 index cac70b52..00000000 --- a/openflexure_microscope/captures/piexif/_dump.py +++ /dev/null @@ -1,369 +0,0 @@ -import copy -import numbers -import struct - -from ._common import split_into_segments -from ._exif import TAGS, TYPES, ExifIFD, ImageIFD - -TIFF_HEADER_LENGTH = 8 - - -def dump(exif_dict_original): - """ - py:function:: piexif.load(data) - - Return exif as bytes. - - :param dict exif: Exif data({"0th":dict, "Exif":dict, "GPS":dict, "Interop":dict, "1st":dict, "thumbnail":bytes}) - :return: Exif - :rtype: bytes - """ - exif_dict = copy.deepcopy(exif_dict_original) - header = b"Exif\x00\x00\x4d\x4d\x00\x2a\x00\x00\x00\x08" - exif_is = False - gps_is = False - interop_is = False - first_is = False - - if "0th" in exif_dict: - zeroth_ifd = exif_dict["0th"] - else: - zeroth_ifd = {} - - if ( - ("Exif" in exif_dict) - and len(exif_dict["Exif"]) - or ("Interop" in exif_dict) - and len(exif_dict["Interop"]) - ): - zeroth_ifd[ImageIFD.ExifTag] = 1 - exif_is = True - exif_ifd = exif_dict["Exif"] - if ("Interop" in exif_dict) and len(exif_dict["Interop"]): - exif_ifd[ExifIFD.InteroperabilityTag] = 1 - interop_is = True - interop_ifd = exif_dict["Interop"] - elif ExifIFD.InteroperabilityTag in exif_ifd: - exif_ifd.pop(ExifIFD.InteroperabilityTag) - elif ImageIFD.ExifTag in zeroth_ifd: - zeroth_ifd.pop(ImageIFD.ExifTag) - - if ("GPS" in exif_dict) and len(exif_dict["GPS"]): - zeroth_ifd[ImageIFD.GPSTag] = 1 - gps_is = True - gps_ifd = exif_dict["GPS"] - elif ImageIFD.GPSTag in zeroth_ifd: - zeroth_ifd.pop(ImageIFD.GPSTag) - - if ( - ("1st" in exif_dict) - and ("thumbnail" in exif_dict) - and (exif_dict["thumbnail"] is not None) - ): - first_is = True - exif_dict["1st"][ImageIFD.JPEGInterchangeFormat] = 1 - exif_dict["1st"][ImageIFD.JPEGInterchangeFormatLength] = 1 - first_ifd = exif_dict["1st"] - - zeroth_set = _dict_to_bytes(zeroth_ifd, "0th", 0) - zeroth_length = ( - len(zeroth_set[0]) + exif_is * 12 + gps_is * 12 + 4 + len(zeroth_set[1]) - ) - - if exif_is: - exif_set = _dict_to_bytes(exif_ifd, "Exif", zeroth_length) - exif_length = len(exif_set[0]) + interop_is * 12 + len(exif_set[1]) - else: - exif_bytes = b"" - exif_length = 0 - if gps_is: - gps_set = _dict_to_bytes(gps_ifd, "GPS", zeroth_length + exif_length) - gps_bytes = b"".join(gps_set) - gps_length = len(gps_bytes) - else: - gps_bytes = b"" - gps_length = 0 - if interop_is: - offset = zeroth_length + exif_length + gps_length - interop_set = _dict_to_bytes(interop_ifd, "Interop", offset) - interop_bytes = b"".join(interop_set) - interop_length = len(interop_bytes) - else: - interop_bytes = b"" - interop_length = 0 - if first_is: - offset = zeroth_length + exif_length + gps_length + interop_length - first_set = _dict_to_bytes(first_ifd, "1st", offset) - thumbnail = _get_thumbnail(exif_dict["thumbnail"]) - thumbnail_max_size = 64000 - if len(thumbnail) > thumbnail_max_size: - raise ValueError("Given thumbnail is too large. max 64kB") - else: - first_bytes = b"" - if exif_is: - pointer_value = TIFF_HEADER_LENGTH + zeroth_length - pointer_str = struct.pack(">I", pointer_value) - key = ImageIFD.ExifTag - key_str = struct.pack(">H", key) - type_str = struct.pack(">H", TYPES.Long) - length_str = struct.pack(">I", 1) - exif_pointer = key_str + type_str + length_str + pointer_str - else: - exif_pointer = b"" - if gps_is: - pointer_value = TIFF_HEADER_LENGTH + zeroth_length + exif_length - pointer_str = struct.pack(">I", pointer_value) - key = ImageIFD.GPSTag - key_str = struct.pack(">H", key) - type_str = struct.pack(">H", TYPES.Long) - length_str = struct.pack(">I", 1) - gps_pointer = key_str + type_str + length_str + pointer_str - else: - gps_pointer = b"" - if interop_is: - pointer_value = TIFF_HEADER_LENGTH + zeroth_length + exif_length + gps_length - pointer_str = struct.pack(">I", pointer_value) - key = ExifIFD.InteroperabilityTag - key_str = struct.pack(">H", key) - type_str = struct.pack(">H", TYPES.Long) - length_str = struct.pack(">I", 1) - interop_pointer = key_str + type_str + length_str + pointer_str - else: - interop_pointer = b"" - if first_is: - pointer_value = ( - TIFF_HEADER_LENGTH - + zeroth_length - + exif_length - + gps_length - + interop_length - ) - first_ifd_pointer = struct.pack(">L", pointer_value) - thumbnail_pointer = ( - pointer_value + len(first_set[0]) + 24 + 4 + len(first_set[1]) - ) - thumbnail_p_bytes = b"\x02\x01\x00\x04\x00\x00\x00\x01" + struct.pack( - ">L", thumbnail_pointer - ) - thumbnail_length_bytes = b"\x02\x02\x00\x04\x00\x00\x00\x01" + struct.pack( - ">L", len(thumbnail) - ) - first_bytes = ( - first_set[0] - + thumbnail_p_bytes - + thumbnail_length_bytes - + b"\x00\x00\x00\x00" - + first_set[1] - + thumbnail - ) - else: - first_ifd_pointer = b"\x00\x00\x00\x00" - - zeroth_bytes = ( - zeroth_set[0] + exif_pointer + gps_pointer + first_ifd_pointer + zeroth_set[1] - ) - if exif_is: - exif_bytes = exif_set[0] + interop_pointer + exif_set[1] - - return header + zeroth_bytes + exif_bytes + gps_bytes + interop_bytes + first_bytes - - -def _get_thumbnail(jpeg): - segments = split_into_segments(jpeg) - while b"\xff\xe0" <= segments[1][0:2] <= b"\xff\xef": - segments.pop(1) - thumbnail = b"".join(segments) - return thumbnail - - -def _pack_byte(*args): - return struct.pack("B" * len(args), *args) - - -def _pack_signed_byte(*args): - return struct.pack("b" * len(args), *args) - - -def _pack_short(*args): - return struct.pack(">" + "H" * len(args), *args) - - -def _pack_signed_short(*args): - return struct.pack(">" + "h" * len(args), *args) - - -def _pack_long(*args): - return struct.pack(">" + "L" * len(args), *args) - - -def _pack_slong(*args): - return struct.pack(">" + "l" * len(args), *args) - - -def _pack_float(*args): - return struct.pack(">" + "f" * len(args), *args) - - -def _pack_double(*args): - return struct.pack(">" + "d" * len(args), *args) - - -def _value_to_bytes(raw_value, value_type, offset): - four_bytes_over = b"" - value_str = b"" - - if value_type == TYPES.Byte: - length = len(raw_value) - if length <= 4: - value_str = _pack_byte(*raw_value) + b"\x00" * (4 - length) - else: - value_str = struct.pack(">I", offset) - four_bytes_over = _pack_byte(*raw_value) - elif value_type == TYPES.Short: - length = len(raw_value) - if length <= 2: - value_str = _pack_short(*raw_value) + b"\x00\x00" * (2 - length) - else: - value_str = struct.pack(">I", offset) - four_bytes_over = _pack_short(*raw_value) - elif value_type == TYPES.Long: - length = len(raw_value) - if length <= 1: - value_str = _pack_long(*raw_value) - else: - value_str = struct.pack(">I", offset) - four_bytes_over = _pack_long(*raw_value) - elif value_type == TYPES.SLong: - length = len(raw_value) - if length <= 1: - value_str = _pack_slong(*raw_value) - else: - value_str = struct.pack(">I", offset) - four_bytes_over = _pack_slong(*raw_value) - elif value_type == TYPES.Ascii: - try: - new_value = raw_value.encode("latin1") + b"\x00" - except: # pylint: disable=W0702 - try: - new_value = raw_value + b"\x00" - except TypeError as e: - raise ValueError("Got invalid type to convert.") from e - length = len(new_value) - if length > 4: - value_str = struct.pack(">I", offset) - four_bytes_over = new_value - else: - value_str = new_value + b"\x00" * (4 - length) - elif value_type == TYPES.Rational: - if isinstance(raw_value[0], numbers.Integral): - length = 1 - num, den = raw_value - new_value = struct.pack(">L", num) + struct.pack(">L", den) - elif isinstance(raw_value[0], tuple): - length = len(raw_value) - new_value = b"" - for _, val in enumerate(raw_value): - num, den = val - new_value += struct.pack(">L", num) + struct.pack(">L", den) - value_str = struct.pack(">I", offset) - four_bytes_over = new_value - elif value_type == TYPES.SRational: - if isinstance(raw_value[0], numbers.Integral): - length = 1 - num, den = raw_value - new_value = struct.pack(">l", num) + struct.pack(">l", den) - elif isinstance(raw_value[0], tuple): - length = len(raw_value) - new_value = b"" - for _, val in enumerate(raw_value): - num, den = val - new_value += struct.pack(">l", num) + struct.pack(">l", den) - value_str = struct.pack(">I", offset) - four_bytes_over = new_value - elif value_type == TYPES.Undefined: - length = len(raw_value) - if length > 4: - value_str = struct.pack(">I", offset) - try: - four_bytes_over = b"" + raw_value - except TypeError as e: - raise ValueError("Got invalid type to convert.") from e - else: - try: - value_str = raw_value + b"\x00" * (4 - length) - except TypeError as e: - raise ValueError("Got invalid type to convert.") from e - elif value_type == TYPES.SByte: # Signed Byte - length = len(raw_value) - if length <= 4: - value_str = _pack_signed_byte(*raw_value) + b"\x00" * (4 - length) - else: - value_str = struct.pack(">I", offset) - four_bytes_over = _pack_signed_byte(*raw_value) - elif value_type == TYPES.SShort: # Signed Short - length = len(raw_value) - if length <= 2: - value_str = _pack_signed_short(*raw_value) + b"\x00\x00" * (2 - length) - else: - value_str = struct.pack(">I", offset) - four_bytes_over = _pack_signed_short(*raw_value) - elif value_type == TYPES.Float: - length = len(raw_value) - if length <= 1: - value_str = _pack_float(*raw_value) - else: - value_str = struct.pack(">I", offset) - four_bytes_over = _pack_float(*raw_value) - elif value_type == TYPES.DFloat: # Double - length = len(raw_value) - value_str = struct.pack(">I", offset) - four_bytes_over = _pack_double(*raw_value) - - length_str = struct.pack(">I", length) - return length_str, value_str, four_bytes_over - - -def _dict_to_bytes(ifd_dict, ifd, ifd_offset): - tag_count = len(ifd_dict) - entry_header = struct.pack(">H", tag_count) - if ifd in ("0th", "1st"): - entries_length = 2 + tag_count * 12 + 4 - else: - entries_length = 2 + tag_count * 12 - entries = b"" - values = b"" - - for _, key in enumerate(sorted(ifd_dict)): - if (ifd == "0th") and (key in (ImageIFD.ExifTag, ImageIFD.GPSTag)): - continue - elif (ifd == "Exif") and (key == ExifIFD.InteroperabilityTag): - continue - elif (ifd == "1st") and ( - key - in (ImageIFD.JPEGInterchangeFormat, ImageIFD.JPEGInterchangeFormatLength) - ): - continue - - raw_value = ifd_dict[key] - key_str = struct.pack(">H", key) - value_type = TAGS[ifd][key]["type"] - type_str = struct.pack(">H", value_type) - four_bytes_over = b"" - - if isinstance(raw_value, numbers.Integral) or isinstance(raw_value, float): - raw_value = (raw_value,) - offset = TIFF_HEADER_LENGTH + entries_length + ifd_offset + len(values) - - try: - length_str, value_str, four_bytes_over = _value_to_bytes( - raw_value, value_type, offset - ) - except ValueError as e: - raise ValueError( - '"dump" got wrong type of exif value.\n' - + "{0} in {1} IFD. Got as {2}.".format(key, ifd, type(ifd_dict[key])) - ) from e - - entries += key_str + type_str + length_str + value_str - values += four_bytes_over - return (entry_header + entries, values) diff --git a/openflexure_microscope/captures/piexif/_exceptions.py b/openflexure_microscope/captures/piexif/_exceptions.py deleted file mode 100644 index bde09086..00000000 --- a/openflexure_microscope/captures/piexif/_exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class InvalidImageDataError(ValueError): - pass diff --git a/openflexure_microscope/captures/piexif/_exif.py b/openflexure_microscope/captures/piexif/_exif.py deleted file mode 100644 index 3c25003c..00000000 --- a/openflexure_microscope/captures/piexif/_exif.py +++ /dev/null @@ -1,649 +0,0 @@ -class TYPES: - Byte = 1 - Ascii = 2 - Short = 3 - Long = 4 - Rational = 5 - SByte = 6 - Undefined = 7 - SShort = 8 - SLong = 9 - SRational = 10 - Float = 11 - DFloat = 12 - - -TAGS = { - "Image": { - 11: {"name": "ProcessingSoftware", "type": TYPES.Ascii}, - 254: {"name": "NewSubfileType", "type": TYPES.Long}, - 255: {"name": "SubfileType", "type": TYPES.Short}, - 256: {"name": "ImageWidth", "type": TYPES.Long}, - 257: {"name": "ImageLength", "type": TYPES.Long}, - 258: {"name": "BitsPerSample", "type": TYPES.Short}, - 259: {"name": "Compression", "type": TYPES.Short}, - 262: {"name": "PhotometricInterpretation", "type": TYPES.Short}, - 263: {"name": "Threshholding", "type": TYPES.Short}, - 264: {"name": "CellWidth", "type": TYPES.Short}, - 265: {"name": "CellLength", "type": TYPES.Short}, - 266: {"name": "FillOrder", "type": TYPES.Short}, - 269: {"name": "DocumentName", "type": TYPES.Ascii}, - 270: {"name": "ImageDescription", "type": TYPES.Ascii}, - 271: {"name": "Make", "type": TYPES.Ascii}, - 272: {"name": "Model", "type": TYPES.Ascii}, - 273: {"name": "StripOffsets", "type": TYPES.Long}, - 274: {"name": "Orientation", "type": TYPES.Short}, - 277: {"name": "SamplesPerPixel", "type": TYPES.Short}, - 278: {"name": "RowsPerStrip", "type": TYPES.Long}, - 279: {"name": "StripByteCounts", "type": TYPES.Long}, - 282: {"name": "XResolution", "type": TYPES.Rational}, - 283: {"name": "YResolution", "type": TYPES.Rational}, - 284: {"name": "PlanarConfiguration", "type": TYPES.Short}, - 290: {"name": "GrayResponseUnit", "type": TYPES.Short}, - 291: {"name": "GrayResponseCurve", "type": TYPES.Short}, - 292: {"name": "T4Options", "type": TYPES.Long}, - 293: {"name": "T6Options", "type": TYPES.Long}, - 296: {"name": "ResolutionUnit", "type": TYPES.Short}, - 301: {"name": "TransferFunction", "type": TYPES.Short}, - 305: {"name": "Software", "type": TYPES.Ascii}, - 306: {"name": "DateTime", "type": TYPES.Ascii}, - 315: {"name": "Artist", "type": TYPES.Ascii}, - 316: {"name": "HostComputer", "type": TYPES.Ascii}, - 317: {"name": "Predictor", "type": TYPES.Short}, - 318: {"name": "WhitePoint", "type": TYPES.Rational}, - 319: {"name": "PrimaryChromaticities", "type": TYPES.Rational}, - 320: {"name": "ColorMap", "type": TYPES.Short}, - 321: {"name": "HalftoneHints", "type": TYPES.Short}, - 322: {"name": "TileWidth", "type": TYPES.Short}, - 323: {"name": "TileLength", "type": TYPES.Short}, - 324: {"name": "TileOffsets", "type": TYPES.Short}, - 325: {"name": "TileByteCounts", "type": TYPES.Short}, - 330: {"name": "SubIFDs", "type": TYPES.Long}, - 332: {"name": "InkSet", "type": TYPES.Short}, - 333: {"name": "InkNames", "type": TYPES.Ascii}, - 334: {"name": "NumberOfInks", "type": TYPES.Short}, - 336: {"name": "DotRange", "type": TYPES.Byte}, - 337: {"name": "TargetPrinter", "type": TYPES.Ascii}, - 338: {"name": "ExtraSamples", "type": TYPES.Short}, - 339: {"name": "SampleFormat", "type": TYPES.Short}, - 340: {"name": "SMinSampleValue", "type": TYPES.Short}, - 341: {"name": "SMaxSampleValue", "type": TYPES.Short}, - 342: {"name": "TransferRange", "type": TYPES.Short}, - 343: {"name": "ClipPath", "type": TYPES.Byte}, - 344: {"name": "XClipPathUnits", "type": TYPES.Long}, - 345: {"name": "YClipPathUnits", "type": TYPES.Long}, - 346: {"name": "Indexed", "type": TYPES.Short}, - 347: {"name": "JPEGTables", "type": TYPES.Undefined}, - 351: {"name": "OPIProxy", "type": TYPES.Short}, - 512: {"name": "JPEGProc", "type": TYPES.Long}, - 513: {"name": "JPEGInterchangeFormat", "type": TYPES.Long}, - 514: {"name": "JPEGInterchangeFormatLength", "type": TYPES.Long}, - 515: {"name": "JPEGRestartInterval", "type": TYPES.Short}, - 517: {"name": "JPEGLosslessPredictors", "type": TYPES.Short}, - 518: {"name": "JPEGPointTransforms", "type": TYPES.Short}, - 519: {"name": "JPEGQTables", "type": TYPES.Long}, - 520: {"name": "JPEGDCTables", "type": TYPES.Long}, - 521: {"name": "JPEGACTables", "type": TYPES.Long}, - 529: {"name": "YCbCrCoefficients", "type": TYPES.Rational}, - 530: {"name": "YCbCrSubSampling", "type": TYPES.Short}, - 531: {"name": "YCbCrPositioning", "type": TYPES.Short}, - 532: {"name": "ReferenceBlackWhite", "type": TYPES.Rational}, - 700: {"name": "XMLPacket", "type": TYPES.Byte}, - 18246: {"name": "Rating", "type": TYPES.Short}, - 18249: {"name": "RatingPercent", "type": TYPES.Short}, - 32781: {"name": "ImageID", "type": TYPES.Ascii}, - 33421: {"name": "CFARepeatPatternDim", "type": TYPES.Short}, - 33422: {"name": "CFAPattern", "type": TYPES.Byte}, - 33423: {"name": "BatteryLevel", "type": TYPES.Rational}, - 33432: {"name": "Copyright", "type": TYPES.Ascii}, - 33434: {"name": "ExposureTime", "type": TYPES.Rational}, - 34377: {"name": "ImageResources", "type": TYPES.Byte}, - 34665: {"name": "ExifTag", "type": TYPES.Long}, - 34675: {"name": "InterColorProfile", "type": TYPES.Undefined}, - 34853: {"name": "GPSTag", "type": TYPES.Long}, - 34857: {"name": "Interlace", "type": TYPES.Short}, - 34858: {"name": "TimeZoneOffset", "type": TYPES.Long}, - 34859: {"name": "SelfTimerMode", "type": TYPES.Short}, - 37387: {"name": "FlashEnergy", "type": TYPES.Rational}, - 37388: {"name": "SpatialFrequencyResponse", "type": TYPES.Undefined}, - 37389: {"name": "Noise", "type": TYPES.Undefined}, - 37390: {"name": "FocalPlaneXResolution", "type": TYPES.Rational}, - 37391: {"name": "FocalPlaneYResolution", "type": TYPES.Rational}, - 37392: {"name": "FocalPlaneResolutionUnit", "type": TYPES.Short}, - 37393: {"name": "ImageNumber", "type": TYPES.Long}, - 37394: {"name": "SecurityClassification", "type": TYPES.Ascii}, - 37395: {"name": "ImageHistory", "type": TYPES.Ascii}, - 37397: {"name": "ExposureIndex", "type": TYPES.Rational}, - 37398: {"name": "TIFFEPStandardID", "type": TYPES.Byte}, - 37399: {"name": "SensingMethod", "type": TYPES.Short}, - 40091: {"name": "XPTitle", "type": TYPES.Byte}, - 40092: {"name": "XPComment", "type": TYPES.Byte}, - 40093: {"name": "XPAuthor", "type": TYPES.Byte}, - 40094: {"name": "XPKeywords", "type": TYPES.Byte}, - 40095: {"name": "XPSubject", "type": TYPES.Byte}, - 50341: {"name": "PrintImageMatching", "type": TYPES.Undefined}, - 50706: {"name": "DNGVersion", "type": TYPES.Byte}, - 50707: {"name": "DNGBackwardVersion", "type": TYPES.Byte}, - 50708: {"name": "UniqueCameraModel", "type": TYPES.Ascii}, - 50709: {"name": "LocalizedCameraModel", "type": TYPES.Byte}, - 50710: {"name": "CFAPlaneColor", "type": TYPES.Byte}, - 50711: {"name": "CFALayout", "type": TYPES.Short}, - 50712: {"name": "LinearizationTable", "type": TYPES.Short}, - 50713: {"name": "BlackLevelRepeatDim", "type": TYPES.Short}, - 50714: {"name": "BlackLevel", "type": TYPES.Rational}, - 50715: {"name": "BlackLevelDeltaH", "type": TYPES.SRational}, - 50716: {"name": "BlackLevelDeltaV", "type": TYPES.SRational}, - 50717: {"name": "WhiteLevel", "type": TYPES.Short}, - 50718: {"name": "DefaultScale", "type": TYPES.Rational}, - 50719: {"name": "DefaultCropOrigin", "type": TYPES.Short}, - 50720: {"name": "DefaultCropSize", "type": TYPES.Short}, - 50721: {"name": "ColorMatrix1", "type": TYPES.SRational}, - 50722: {"name": "ColorMatrix2", "type": TYPES.SRational}, - 50723: {"name": "CameraCalibration1", "type": TYPES.SRational}, - 50724: {"name": "CameraCalibration2", "type": TYPES.SRational}, - 50725: {"name": "ReductionMatrix1", "type": TYPES.SRational}, - 50726: {"name": "ReductionMatrix2", "type": TYPES.SRational}, - 50727: {"name": "AnalogBalance", "type": TYPES.Rational}, - 50728: {"name": "AsShotNeutral", "type": TYPES.Short}, - 50729: {"name": "AsShotWhiteXY", "type": TYPES.Rational}, - 50730: {"name": "BaselineExposure", "type": TYPES.SRational}, - 50731: {"name": "BaselineNoise", "type": TYPES.Rational}, - 50732: {"name": "BaselineSharpness", "type": TYPES.Rational}, - 50733: {"name": "BayerGreenSplit", "type": TYPES.Long}, - 50734: {"name": "LinearResponseLimit", "type": TYPES.Rational}, - 50735: {"name": "CameraSerialNumber", "type": TYPES.Ascii}, - 50736: {"name": "LensInfo", "type": TYPES.Rational}, - 50737: {"name": "ChromaBlurRadius", "type": TYPES.Rational}, - 50738: {"name": "AntiAliasStrength", "type": TYPES.Rational}, - 50739: {"name": "ShadowScale", "type": TYPES.SRational}, - 50740: {"name": "DNGPrivateData", "type": TYPES.Byte}, - 50741: {"name": "MakerNoteSafety", "type": TYPES.Short}, - 50778: {"name": "CalibrationIlluminant1", "type": TYPES.Short}, - 50779: {"name": "CalibrationIlluminant2", "type": TYPES.Short}, - 50780: {"name": "BestQualityScale", "type": TYPES.Rational}, - 50781: {"name": "RawDataUniqueID", "type": TYPES.Byte}, - 50827: {"name": "OriginalRawFileName", "type": TYPES.Byte}, - 50828: {"name": "OriginalRawFileData", "type": TYPES.Undefined}, - 50829: {"name": "ActiveArea", "type": TYPES.Short}, - 50830: {"name": "MaskedAreas", "type": TYPES.Short}, - 50831: {"name": "AsShotICCProfile", "type": TYPES.Undefined}, - 50832: {"name": "AsShotPreProfileMatrix", "type": TYPES.SRational}, - 50833: {"name": "CurrentICCProfile", "type": TYPES.Undefined}, - 50834: {"name": "CurrentPreProfileMatrix", "type": TYPES.SRational}, - 50879: {"name": "ColorimetricReference", "type": TYPES.Short}, - 50931: {"name": "CameraCalibrationSignature", "type": TYPES.Byte}, - 50932: {"name": "ProfileCalibrationSignature", "type": TYPES.Byte}, - 50934: {"name": "AsShotProfileName", "type": TYPES.Byte}, - 50935: {"name": "NoiseReductionApplied", "type": TYPES.Rational}, - 50936: {"name": "ProfileName", "type": TYPES.Byte}, - 50937: {"name": "ProfileHueSatMapDims", "type": TYPES.Long}, - 50938: {"name": "ProfileHueSatMapData1", "type": TYPES.Float}, - 50939: {"name": "ProfileHueSatMapData2", "type": TYPES.Float}, - 50940: {"name": "ProfileToneCurve", "type": TYPES.Float}, - 50941: {"name": "ProfileEmbedPolicy", "type": TYPES.Long}, - 50942: {"name": "ProfileCopyright", "type": TYPES.Byte}, - 50964: {"name": "ForwardMatrix1", "type": TYPES.SRational}, - 50965: {"name": "ForwardMatrix2", "type": TYPES.SRational}, - 50966: {"name": "PreviewApplicationName", "type": TYPES.Byte}, - 50967: {"name": "PreviewApplicationVersion", "type": TYPES.Byte}, - 50968: {"name": "PreviewSettingsName", "type": TYPES.Byte}, - 50969: {"name": "PreviewSettingsDigest", "type": TYPES.Byte}, - 50970: {"name": "PreviewColorSpace", "type": TYPES.Long}, - 50971: {"name": "PreviewDateTime", "type": TYPES.Ascii}, - 50972: {"name": "RawImageDigest", "type": TYPES.Undefined}, - 50973: {"name": "OriginalRawFileDigest", "type": TYPES.Undefined}, - 50974: {"name": "SubTileBlockSize", "type": TYPES.Long}, - 50975: {"name": "RowInterleaveFactor", "type": TYPES.Long}, - 50981: {"name": "ProfileLookTableDims", "type": TYPES.Long}, - 50982: {"name": "ProfileLookTableData", "type": TYPES.Float}, - 51008: {"name": "OpcodeList1", "type": TYPES.Undefined}, - 51009: {"name": "OpcodeList2", "type": TYPES.Undefined}, - 51022: {"name": "OpcodeList3", "type": TYPES.Undefined}, - 60606: {"name": "ZZZTestSlong1", "type": TYPES.SLong}, - 60607: {"name": "ZZZTestSlong2", "type": TYPES.SLong}, - 60608: {"name": "ZZZTestSByte", "type": TYPES.SByte}, - 60609: {"name": "ZZZTestSShort", "type": TYPES.SShort}, - 60610: {"name": "ZZZTestDFloat", "type": TYPES.DFloat}, - }, - "Exif": { - 33434: {"name": "ExposureTime", "type": TYPES.Rational}, - 33437: {"name": "FNumber", "type": TYPES.Rational}, - 34850: {"name": "ExposureProgram", "type": TYPES.Short}, - 34852: {"name": "SpectralSensitivity", "type": TYPES.Ascii}, - 34855: {"name": "ISOSpeedRatings", "type": TYPES.Short}, - 34856: {"name": "OECF", "type": TYPES.Undefined}, - 34864: {"name": "SensitivityType", "type": TYPES.Short}, - 34865: {"name": "StandardOutputSensitivity", "type": TYPES.Long}, - 34866: {"name": "RecommendedExposureIndex", "type": TYPES.Long}, - 34867: {"name": "ISOSpeed", "type": TYPES.Long}, - 34868: {"name": "ISOSpeedLatitudeyyy", "type": TYPES.Long}, - 34869: {"name": "ISOSpeedLatitudezzz", "type": TYPES.Long}, - 36864: {"name": "ExifVersion", "type": TYPES.Undefined}, - 36867: {"name": "DateTimeOriginal", "type": TYPES.Ascii}, - 36868: {"name": "DateTimeDigitized", "type": TYPES.Ascii}, - 36880: {"name": "OffsetTime", "type": TYPES.Ascii}, - 36881: {"name": "OffsetTimeOriginal", "type": TYPES.Ascii}, - 36882: {"name": "OffsetTimeDigitized", "type": TYPES.Ascii}, - 37121: {"name": "ComponentsConfiguration", "type": TYPES.Undefined}, - 37122: {"name": "CompressedBitsPerPixel", "type": TYPES.Rational}, - 37377: {"name": "ShutterSpeedValue", "type": TYPES.SRational}, - 37378: {"name": "ApertureValue", "type": TYPES.Rational}, - 37379: {"name": "BrightnessValue", "type": TYPES.SRational}, - 37380: {"name": "ExposureBiasValue", "type": TYPES.SRational}, - 37381: {"name": "MaxApertureValue", "type": TYPES.Rational}, - 37382: {"name": "SubjectDistance", "type": TYPES.Rational}, - 37383: {"name": "MeteringMode", "type": TYPES.Short}, - 37384: {"name": "LightSource", "type": TYPES.Short}, - 37385: {"name": "Flash", "type": TYPES.Short}, - 37386: {"name": "FocalLength", "type": TYPES.Rational}, - 37396: {"name": "SubjectArea", "type": TYPES.Short}, - 37500: {"name": "MakerNote", "type": TYPES.Undefined}, - 37510: {"name": "UserComment", "type": TYPES.Undefined}, - 37520: {"name": "SubSecTime", "type": TYPES.Ascii}, - 37521: {"name": "SubSecTimeOriginal", "type": TYPES.Ascii}, - 37522: {"name": "SubSecTimeDigitized", "type": TYPES.Ascii}, - 37888: {"name": "Temperature", "type": TYPES.SRational}, - 37889: {"name": "Humidity", "type": TYPES.Rational}, - 37890: {"name": "Pressure", "type": TYPES.Rational}, - 37891: {"name": "WaterDepth", "type": TYPES.SRational}, - 37892: {"name": "Acceleration", "type": TYPES.Rational}, - 37893: {"name": "CameraElevationAngle", "type": TYPES.SRational}, - 40960: {"name": "FlashpixVersion", "type": TYPES.Undefined}, - 40961: {"name": "ColorSpace", "type": TYPES.Short}, - 40962: {"name": "PixelXDimension", "type": TYPES.Long}, - 40963: {"name": "PixelYDimension", "type": TYPES.Long}, - 40964: {"name": "RelatedSoundFile", "type": TYPES.Ascii}, - 40965: {"name": "InteroperabilityTag", "type": TYPES.Long}, - 41483: {"name": "FlashEnergy", "type": TYPES.Rational}, - 41484: {"name": "SpatialFrequencyResponse", "type": TYPES.Undefined}, - 41486: {"name": "FocalPlaneXResolution", "type": TYPES.Rational}, - 41487: {"name": "FocalPlaneYResolution", "type": TYPES.Rational}, - 41488: {"name": "FocalPlaneResolutionUnit", "type": TYPES.Short}, - 41492: {"name": "SubjectLocation", "type": TYPES.Short}, - 41493: {"name": "ExposureIndex", "type": TYPES.Rational}, - 41495: {"name": "SensingMethod", "type": TYPES.Short}, - 41728: {"name": "FileSource", "type": TYPES.Undefined}, - 41729: {"name": "SceneType", "type": TYPES.Undefined}, - 41730: {"name": "CFAPattern", "type": TYPES.Undefined}, - 41985: {"name": "CustomRendered", "type": TYPES.Short}, - 41986: {"name": "ExposureMode", "type": TYPES.Short}, - 41987: {"name": "WhiteBalance", "type": TYPES.Short}, - 41988: {"name": "DigitalZoomRatio", "type": TYPES.Rational}, - 41989: {"name": "FocalLengthIn35mmFilm", "type": TYPES.Short}, - 41990: {"name": "SceneCaptureType", "type": TYPES.Short}, - 41991: {"name": "GainControl", "type": TYPES.Short}, - 41992: {"name": "Contrast", "type": TYPES.Short}, - 41993: {"name": "Saturation", "type": TYPES.Short}, - 41994: {"name": "Sharpness", "type": TYPES.Short}, - 41995: {"name": "DeviceSettingDescription", "type": TYPES.Undefined}, - 41996: {"name": "SubjectDistanceRange", "type": TYPES.Short}, - 42016: {"name": "ImageUniqueID", "type": TYPES.Ascii}, - 42032: {"name": "CameraOwnerName", "type": TYPES.Ascii}, - 42033: {"name": "BodySerialNumber", "type": TYPES.Ascii}, - 42034: {"name": "LensSpecification", "type": TYPES.Rational}, - 42035: {"name": "LensMake", "type": TYPES.Ascii}, - 42036: {"name": "LensModel", "type": TYPES.Ascii}, - 42037: {"name": "LensSerialNumber", "type": TYPES.Ascii}, - 42240: {"name": "Gamma", "type": TYPES.Rational}, - }, - "GPS": { - 0: {"name": "GPSVersionID", "type": TYPES.Byte}, - 1: {"name": "GPSLatitudeRef", "type": TYPES.Ascii}, - 2: {"name": "GPSLatitude", "type": TYPES.Rational}, - 3: {"name": "GPSLongitudeRef", "type": TYPES.Ascii}, - 4: {"name": "GPSLongitude", "type": TYPES.Rational}, - 5: {"name": "GPSAltitudeRef", "type": TYPES.Byte}, - 6: {"name": "GPSAltitude", "type": TYPES.Rational}, - 7: {"name": "GPSTimeStamp", "type": TYPES.Rational}, - 8: {"name": "GPSSatellites", "type": TYPES.Ascii}, - 9: {"name": "GPSStatus", "type": TYPES.Ascii}, - 10: {"name": "GPSMeasureMode", "type": TYPES.Ascii}, - 11: {"name": "GPSDOP", "type": TYPES.Rational}, - 12: {"name": "GPSSpeedRef", "type": TYPES.Ascii}, - 13: {"name": "GPSSpeed", "type": TYPES.Rational}, - 14: {"name": "GPSTrackRef", "type": TYPES.Ascii}, - 15: {"name": "GPSTrack", "type": TYPES.Rational}, - 16: {"name": "GPSImgDirectionRef", "type": TYPES.Ascii}, - 17: {"name": "GPSImgDirection", "type": TYPES.Rational}, - 18: {"name": "GPSMapDatum", "type": TYPES.Ascii}, - 19: {"name": "GPSDestLatitudeRef", "type": TYPES.Ascii}, - 20: {"name": "GPSDestLatitude", "type": TYPES.Rational}, - 21: {"name": "GPSDestLongitudeRef", "type": TYPES.Ascii}, - 22: {"name": "GPSDestLongitude", "type": TYPES.Rational}, - 23: {"name": "GPSDestBearingRef", "type": TYPES.Ascii}, - 24: {"name": "GPSDestBearing", "type": TYPES.Rational}, - 25: {"name": "GPSDestDistanceRef", "type": TYPES.Ascii}, - 26: {"name": "GPSDestDistance", "type": TYPES.Rational}, - 27: {"name": "GPSProcessingMethod", "type": TYPES.Undefined}, - 28: {"name": "GPSAreaInformation", "type": TYPES.Undefined}, - 29: {"name": "GPSDateStamp", "type": TYPES.Ascii}, - 30: {"name": "GPSDifferential", "type": TYPES.Short}, - 31: {"name": "GPSHPositioningError", "type": TYPES.Rational}, - }, - "Interop": {1: {"name": "InteroperabilityIndex", "type": TYPES.Ascii}}, -} - -TAGS["0th"] = TAGS["Image"] -TAGS["1st"] = TAGS["Image"] - - -class ImageIFD: - """Exif tag number reference - 0th IFD""" - - ProcessingSoftware = 11 - NewSubfileType = 254 - SubfileType = 255 - ImageWidth = 256 - ImageLength = 257 - BitsPerSample = 258 - Compression = 259 - PhotometricInterpretation = 262 - Threshholding = 263 - CellWidth = 264 - CellLength = 265 - FillOrder = 266 - DocumentName = 269 - ImageDescription = 270 - Make = 271 - Model = 272 - StripOffsets = 273 - Orientation = 274 - SamplesPerPixel = 277 - RowsPerStrip = 278 - StripByteCounts = 279 - XResolution = 282 - YResolution = 283 - PlanarConfiguration = 284 - GrayResponseUnit = 290 - GrayResponseCurve = 291 - T4Options = 292 - T6Options = 293 - ResolutionUnit = 296 - TransferFunction = 301 - Software = 305 - DateTime = 306 - Artist = 315 - HostComputer = 316 - Predictor = 317 - WhitePoint = 318 - PrimaryChromaticities = 319 - ColorMap = 320 - HalftoneHints = 321 - TileWidth = 322 - TileLength = 323 - TileOffsets = 324 - TileByteCounts = 325 - SubIFDs = 330 - InkSet = 332 - InkNames = 333 - NumberOfInks = 334 - DotRange = 336 - TargetPrinter = 337 - ExtraSamples = 338 - SampleFormat = 339 - SMinSampleValue = 340 - SMaxSampleValue = 341 - TransferRange = 342 - ClipPath = 343 - XClipPathUnits = 344 - YClipPathUnits = 345 - Indexed = 346 - JPEGTables = 347 - OPIProxy = 351 - JPEGProc = 512 - JPEGInterchangeFormat = 513 - JPEGInterchangeFormatLength = 514 - JPEGRestartInterval = 515 - JPEGLosslessPredictors = 517 - JPEGPointTransforms = 518 - JPEGQTables = 519 - JPEGDCTables = 520 - JPEGACTables = 521 - YCbCrCoefficients = 529 - YCbCrSubSampling = 530 - YCbCrPositioning = 531 - ReferenceBlackWhite = 532 - XMLPacket = 700 - Rating = 18246 - RatingPercent = 18249 - ImageID = 32781 - CFARepeatPatternDim = 33421 - CFAPattern = 33422 - BatteryLevel = 33423 - Copyright = 33432 - ExposureTime = 33434 - ImageResources = 34377 - ExifTag = 34665 - InterColorProfile = 34675 - GPSTag = 34853 - Interlace = 34857 - TimeZoneOffset = 34858 - SelfTimerMode = 34859 - FlashEnergy = 37387 - SpatialFrequencyResponse = 37388 - Noise = 37389 - FocalPlaneXResolution = 37390 - FocalPlaneYResolution = 37391 - FocalPlaneResolutionUnit = 37392 - ImageNumber = 37393 - SecurityClassification = 37394 - ImageHistory = 37395 - ExposureIndex = 37397 - TIFFEPStandardID = 37398 - SensingMethod = 37399 - XPTitle = 40091 - XPComment = 40092 - XPAuthor = 40093 - XPKeywords = 40094 - XPSubject = 40095 - PrintImageMatching = 50341 - DNGVersion = 50706 - DNGBackwardVersion = 50707 - UniqueCameraModel = 50708 - LocalizedCameraModel = 50709 - CFAPlaneColor = 50710 - CFALayout = 50711 - LinearizationTable = 50712 - BlackLevelRepeatDim = 50713 - BlackLevel = 50714 - BlackLevelDeltaH = 50715 - BlackLevelDeltaV = 50716 - WhiteLevel = 50717 - DefaultScale = 50718 - DefaultCropOrigin = 50719 - DefaultCropSize = 50720 - ColorMatrix1 = 50721 - ColorMatrix2 = 50722 - CameraCalibration1 = 50723 - CameraCalibration2 = 50724 - ReductionMatrix1 = 50725 - ReductionMatrix2 = 50726 - AnalogBalance = 50727 - AsShotNeutral = 50728 - AsShotWhiteXY = 50729 - BaselineExposure = 50730 - BaselineNoise = 50731 - BaselineSharpness = 50732 - BayerGreenSplit = 50733 - LinearResponseLimit = 50734 - CameraSerialNumber = 50735 - LensInfo = 50736 - ChromaBlurRadius = 50737 - AntiAliasStrength = 50738 - ShadowScale = 50739 - DNGPrivateData = 50740 - MakerNoteSafety = 50741 - CalibrationIlluminant1 = 50778 - CalibrationIlluminant2 = 50779 - BestQualityScale = 50780 - RawDataUniqueID = 50781 - OriginalRawFileName = 50827 - OriginalRawFileData = 50828 - ActiveArea = 50829 - MaskedAreas = 50830 - AsShotICCProfile = 50831 - AsShotPreProfileMatrix = 50832 - CurrentICCProfile = 50833 - CurrentPreProfileMatrix = 50834 - ColorimetricReference = 50879 - CameraCalibrationSignature = 50931 - ProfileCalibrationSignature = 50932 - AsShotProfileName = 50934 - NoiseReductionApplied = 50935 - ProfileName = 50936 - ProfileHueSatMapDims = 50937 - ProfileHueSatMapData1 = 50938 - ProfileHueSatMapData2 = 50939 - ProfileToneCurve = 50940 - ProfileEmbedPolicy = 50941 - ProfileCopyright = 50942 - ForwardMatrix1 = 50964 - ForwardMatrix2 = 50965 - PreviewApplicationName = 50966 - PreviewApplicationVersion = 50967 - PreviewSettingsName = 50968 - PreviewSettingsDigest = 50969 - PreviewColorSpace = 50970 - PreviewDateTime = 50971 - RawImageDigest = 50972 - OriginalRawFileDigest = 50973 - SubTileBlockSize = 50974 - RowInterleaveFactor = 50975 - ProfileLookTableDims = 50981 - ProfileLookTableData = 50982 - OpcodeList1 = 51008 - OpcodeList2 = 51009 - OpcodeList3 = 51022 - NoiseProfile = 51041 - ZZZTestSlong1 = 60606 - ZZZTestSlong2 = 60607 - ZZZTestSByte = 60608 - ZZZTestSShort = 60609 - ZZZTestDFloat = 60610 - - -class ExifIFD: - """Exif tag number reference - Exif IFD""" - - ExposureTime = 33434 - FNumber = 33437 - ExposureProgram = 34850 - SpectralSensitivity = 34852 - ISOSpeedRatings = 34855 - OECF = 34856 - SensitivityType = 34864 - StandardOutputSensitivity = 34865 - RecommendedExposureIndex = 34866 - ISOSpeed = 34867 - ISOSpeedLatitudeyyy = 34868 - ISOSpeedLatitudezzz = 34869 - ExifVersion = 36864 - DateTimeOriginal = 36867 - DateTimeDigitized = 36868 - OffsetTime = 36880 - OffsetTimeOriginal = 36881 - OffsetTimeDigitized = 36882 - ComponentsConfiguration = 37121 - CompressedBitsPerPixel = 37122 - ShutterSpeedValue = 37377 - ApertureValue = 37378 - BrightnessValue = 37379 - ExposureBiasValue = 37380 - MaxApertureValue = 37381 - SubjectDistance = 37382 - MeteringMode = 37383 - LightSource = 37384 - Flash = 37385 - FocalLength = 37386 - Temperature = 37888 - Humidity = 37889 - Pressure = 37890 - WaterDepth = 37891 - Acceleration = 37892 - CameraElevationAngle = 37893 - SubjectArea = 37396 - MakerNote = 37500 - UserComment = 37510 - SubSecTime = 37520 - SubSecTimeOriginal = 37521 - SubSecTimeDigitized = 37522 - FlashpixVersion = 40960 - ColorSpace = 40961 - PixelXDimension = 40962 - PixelYDimension = 40963 - RelatedSoundFile = 40964 - InteroperabilityTag = 40965 - FlashEnergy = 41483 - SpatialFrequencyResponse = 41484 - FocalPlaneXResolution = 41486 - FocalPlaneYResolution = 41487 - FocalPlaneResolutionUnit = 41488 - SubjectLocation = 41492 - ExposureIndex = 41493 - SensingMethod = 41495 - FileSource = 41728 - SceneType = 41729 - CFAPattern = 41730 - CustomRendered = 41985 - ExposureMode = 41986 - WhiteBalance = 41987 - DigitalZoomRatio = 41988 - FocalLengthIn35mmFilm = 41989 - SceneCaptureType = 41990 - GainControl = 41991 - Contrast = 41992 - Saturation = 41993 - Sharpness = 41994 - DeviceSettingDescription = 41995 - SubjectDistanceRange = 41996 - ImageUniqueID = 42016 - CameraOwnerName = 42032 - BodySerialNumber = 42033 - LensSpecification = 42034 - LensMake = 42035 - LensModel = 42036 - LensSerialNumber = 42037 - Gamma = 42240 - - -class GPSIFD: - """Exif tag number reference - GPS IFD""" - - GPSVersionID = 0 - GPSLatitudeRef = 1 - GPSLatitude = 2 - GPSLongitudeRef = 3 - GPSLongitude = 4 - GPSAltitudeRef = 5 - GPSAltitude = 6 - GPSTimeStamp = 7 - GPSSatellites = 8 - GPSStatus = 9 - GPSMeasureMode = 10 - GPSDOP = 11 - GPSSpeedRef = 12 - GPSSpeed = 13 - GPSTrackRef = 14 - GPSTrack = 15 - GPSImgDirectionRef = 16 - GPSImgDirection = 17 - GPSMapDatum = 18 - GPSDestLatitudeRef = 19 - GPSDestLatitude = 20 - GPSDestLongitudeRef = 21 - GPSDestLongitude = 22 - GPSDestBearingRef = 23 - GPSDestBearing = 24 - GPSDestDistanceRef = 25 - GPSDestDistance = 26 - GPSProcessingMethod = 27 - GPSAreaInformation = 28 - GPSDateStamp = 29 - GPSDifferential = 30 - GPSHPositioningError = 31 - - -class InteropIFD: - """Exif tag number reference - Interoperability IFD""" - - InteroperabilityIndex = 1 diff --git a/openflexure_microscope/captures/piexif/_insert.py b/openflexure_microscope/captures/piexif/_insert.py deleted file mode 100644 index 098bd7cc..00000000 --- a/openflexure_microscope/captures/piexif/_insert.py +++ /dev/null @@ -1,61 +0,0 @@ -import io -import struct -import sys - -from . import _webp -from ._common import merge_segments, split_into_segments -from ._exceptions import InvalidImageDataError - - -def insert(exif, image, new_file=None): - """ - py:function:: piexif.insert(exif_bytes, filename) - - Insert exif into JPEG. - - :param bytes exif_bytes: Exif as bytes - :param str filename: JPEG - """ - if exif[0:6] != b"\x45\x78\x69\x66\x00\x00": - raise ValueError("Given data is not exif data") - - output_file = False - # Prevents "UnicodeWarning: Unicode equal comparison failed" warnings on Python 2 - maybe_image = sys.version_info >= (3, 0, 0) or isinstance(image, str) - - if maybe_image and image[0:2] == b"\xff\xd8": - image_data = image - file_type = "jpeg" - elif maybe_image and image[0:4] == b"RIFF" and image[8:12] == b"WEBP": - image_data = image - file_type = "webp" - else: - with open(image, "rb") as f: - image_data = f.read() - if image_data[0:2] == b"\xff\xd8": - file_type = "jpeg" - elif image_data[0:4] == b"RIFF" and image_data[8:12] == b"WEBP": - file_type = "webp" - else: - raise InvalidImageDataError - output_file = True - - if file_type == "jpeg": - exif = b"\xff\xe1" + struct.pack(">H", len(exif) + 2) + exif - segments = split_into_segments(image_data) - new_data = merge_segments(segments, exif) - elif file_type == "webp": - exif = exif[6:] - new_data = _webp.insert(image_data, exif) - - if isinstance(new_file, io.BytesIO): - new_file.write(new_data) - new_file.seek(0) - elif new_file: - with open(new_file, "wb+") as f: - f.write(new_data) - elif output_file: - with open(image, "wb+") as f: - f.write(new_data) - else: - raise ValueError("Give a 3rd argument to 'insert' to output file") diff --git a/openflexure_microscope/captures/piexif/_load.py b/openflexure_microscope/captures/piexif/_load.py deleted file mode 100644 index 179aa80e..00000000 --- a/openflexure_microscope/captures/piexif/_load.py +++ /dev/null @@ -1,324 +0,0 @@ -import struct -import sys - -from . import _webp -from ._common import get_exif_seg, read_exif_from_file, split_into_segments -from ._exceptions import InvalidImageDataError -from ._exif import TAGS, TYPES, ExifIFD, ImageIFD - -LITTLE_ENDIAN = b"\x49\x49" - - -def load(input_data, key_is_name=False): - """ - py:function:: piexif.load(filename) - - Return exif data as dict. Keys(IFD name), be contained, are "0th", "Exif", "GPS", "Interop", "1st", and "thumbnail". Without "thumbnail", the value is dict(tag name/tag value). "thumbnail" value is JPEG as bytes. - - :param str filename: JPEG or TIFF - :return: Exif data({"0th":dict, "Exif":dict, "GPS":dict, "Interop":dict, "1st":dict, "thumbnail":bytes}) - :rtype: dict - """ - exif_dict = { - "0th": {}, - "Exif": {}, - "GPS": {}, - "Interop": {}, - "1st": {}, - "thumbnail": None, - } - exifReader = _ExifReader(input_data) - if exifReader.tiftag is None: - return exif_dict - - if exifReader.tiftag[0:2] == LITTLE_ENDIAN: - exifReader.endian_mark = "<" - else: - exifReader.endian_mark = ">" - - pointer = struct.unpack(exifReader.endian_mark + "L", exifReader.tiftag[4:8])[0] - exif_dict["0th"] = exifReader.get_ifd_dict(pointer, "0th") - first_ifd_pointer = exif_dict["0th"].pop("first_ifd_pointer") - if ImageIFD.ExifTag in exif_dict["0th"]: - pointer = exif_dict["0th"][ImageIFD.ExifTag] - exif_dict["Exif"] = exifReader.get_ifd_dict(pointer, "Exif") - if ImageIFD.GPSTag in exif_dict["0th"]: - pointer = exif_dict["0th"][ImageIFD.GPSTag] - exif_dict["GPS"] = exifReader.get_ifd_dict(pointer, "GPS") - if ExifIFD.InteroperabilityTag in exif_dict["Exif"]: - pointer = exif_dict["Exif"][ExifIFD.InteroperabilityTag] - exif_dict["Interop"] = exifReader.get_ifd_dict(pointer, "Interop") - if first_ifd_pointer != b"\x00\x00\x00\x00": - pointer = struct.unpack(exifReader.endian_mark + "L", first_ifd_pointer)[0] - exif_dict["1st"] = exifReader.get_ifd_dict(pointer, "1st") - if ( - ImageIFD.JPEGInterchangeFormat in exif_dict["1st"] - and ImageIFD.JPEGInterchangeFormatLength in exif_dict["1st"] - ): - end = ( - exif_dict["1st"][ImageIFD.JPEGInterchangeFormat] - + exif_dict["1st"][ImageIFD.JPEGInterchangeFormatLength] - ) - thumb = exifReader.tiftag[ - exif_dict["1st"][ImageIFD.JPEGInterchangeFormat] : end - ] - exif_dict["thumbnail"] = thumb - - if key_is_name: - exif_dict = _get_key_name_dict(exif_dict) - return exif_dict - - -class _ExifReader(object): - def __init__(self, data): - # Prevents "UnicodeWarning: Unicode equal comparison failed" warnings on Python 2 - maybe_image = sys.version_info >= (3, 0, 0) or isinstance(data, str) - - if maybe_image and data[0:2] == b"\xff\xd8": # JPEG - segments = split_into_segments(data) - app1 = get_exif_seg(segments) - if app1: - self.tiftag = app1[10:] - else: - self.tiftag = None - elif maybe_image and data[0:2] in (b"\x49\x49", b"\x4d\x4d"): # TIFF - self.tiftag = data - elif maybe_image and data[0:4] == b"RIFF" and data[8:12] == b"WEBP": - self.tiftag = _webp.get_exif(data) - elif maybe_image and data[0:4] == b"Exif": # Exif - self.tiftag = data[6:] - else: - with open(data, "rb") as f: - magic_number = f.read(2) - if magic_number == b"\xff\xd8": # JPEG - app1 = read_exif_from_file(data) - if app1: - self.tiftag = app1[10:] - else: - self.tiftag = None - elif magic_number in (b"\x49\x49", b"\x4d\x4d"): # TIFF - with open(data, "rb") as f: - self.tiftag = f.read() - else: - with open(data, "rb") as f: - header = f.read(12) - if header[0:4] == b"RIFF" and header[8:12] == b"WEBP": - with open(data, "rb") as f: - file_data = f.read() - self.tiftag = _webp.get_exif(file_data) - else: - raise InvalidImageDataError("Given file is neither JPEG nor TIFF.") - - self.endian_mark = None - - def get_ifd_dict(self, pointer, ifd_name, read_unknown=False): - ifd_dict = {} - tag_count = struct.unpack( - self.endian_mark + "H", self.tiftag[pointer : pointer + 2] - )[0] - offset = pointer + 2 - if ifd_name in ["0th", "1st"]: - t = "Image" - else: - t = ifd_name - p_and_value = [] - for x in range(tag_count): - pointer = offset + 12 * x - tag = struct.unpack( - self.endian_mark + "H", self.tiftag[pointer : pointer + 2] - )[0] - value_type = struct.unpack( - self.endian_mark + "H", self.tiftag[pointer + 2 : pointer + 4] - )[0] - value_num = struct.unpack( - self.endian_mark + "L", self.tiftag[pointer + 4 : pointer + 8] - )[0] - value = self.tiftag[pointer + 8 : pointer + 12] - p_and_value.append((pointer, value_type, value_num, value)) - v_set = (value_type, value_num, value, tag) - if tag in TAGS[t]: - ifd_dict[tag] = self.convert_value(v_set) - elif read_unknown: - ifd_dict[tag] = (v_set[0], v_set[1], v_set[2], self.tiftag) - # else: - # pass - - if ifd_name == "0th": - pointer = offset + 12 * tag_count - ifd_dict["first_ifd_pointer"] = self.tiftag[pointer : pointer + 4] - return ifd_dict - - def convert_value(self, val): - data = None - t = val[0] - length = val[1] - value = val[2] - - if t == TYPES.Byte: # BYTE - if length > 4: - pointer = struct.unpack(self.endian_mark + "L", value)[0] - data = struct.unpack( - "B" * length, self.tiftag[pointer : pointer + length] - ) - else: - data = struct.unpack("B" * length, value[0:length]) - elif t == TYPES.Ascii: # ASCII - if length > 4: - pointer = struct.unpack(self.endian_mark + "L", value)[0] - data = self.tiftag[pointer : pointer + length - 1] - else: - data = value[0 : length - 1] - elif t == TYPES.Short: # SHORT - if length > 2: - pointer = struct.unpack(self.endian_mark + "L", value)[0] - data = struct.unpack( - self.endian_mark + "H" * length, - self.tiftag[pointer : pointer + length * 2], - ) - else: - data = struct.unpack( - self.endian_mark + "H" * length, value[0 : length * 2] - ) - elif t == TYPES.Long: # LONG - if length > 1: - pointer = struct.unpack(self.endian_mark + "L", value)[0] - data = struct.unpack( - self.endian_mark + "L" * length, - self.tiftag[pointer : pointer + length * 4], - ) - else: - data = struct.unpack(self.endian_mark + "L" * length, value) - elif t == TYPES.Rational: # RATIONAL - pointer = struct.unpack(self.endian_mark + "L", value)[0] - if length > 1: - data = tuple( - ( - struct.unpack( - self.endian_mark + "L", - self.tiftag[pointer + x * 8 : pointer + 4 + x * 8], - )[0], - struct.unpack( - self.endian_mark + "L", - self.tiftag[pointer + 4 + x * 8 : pointer + 8 + x * 8], - )[0], - ) - for x in range(length) - ) - else: - data = ( - struct.unpack( - self.endian_mark + "L", self.tiftag[pointer : pointer + 4] - )[0], - struct.unpack( - self.endian_mark + "L", self.tiftag[pointer + 4 : pointer + 8] - )[0], - ) - elif t == TYPES.SByte: # SIGNED BYTES - if length > 4: - pointer = struct.unpack(self.endian_mark + "L", value)[0] - data = struct.unpack( - "b" * length, self.tiftag[pointer : pointer + length] - ) - else: - data = struct.unpack("b" * length, value[0:length]) - elif t == TYPES.Undefined: # UNDEFINED BYTES - if length > 4: - pointer = struct.unpack(self.endian_mark + "L", value)[0] - data = self.tiftag[pointer : pointer + length] - else: - data = value[0:length] - elif t == TYPES.SShort: # SIGNED SHORT - if length > 2: - pointer = struct.unpack(self.endian_mark + "L", value)[0] - data = struct.unpack( - self.endian_mark + "h" * length, - self.tiftag[pointer : pointer + length * 2], - ) - else: - data = struct.unpack( - self.endian_mark + "h" * length, value[0 : length * 2] - ) - elif t == TYPES.SLong: # SLONG - if length > 1: - pointer = struct.unpack(self.endian_mark + "L", value)[0] - data = struct.unpack( - self.endian_mark + "l" * length, - self.tiftag[pointer : pointer + length * 4], - ) - else: - data = struct.unpack(self.endian_mark + "l" * length, value) - elif t == TYPES.SRational: # SRATIONAL - pointer = struct.unpack(self.endian_mark + "L", value)[0] - if length > 1: - data = tuple( - ( - struct.unpack( - self.endian_mark + "l", - self.tiftag[pointer + x * 8 : pointer + 4 + x * 8], - )[0], - struct.unpack( - self.endian_mark + "l", - self.tiftag[pointer + 4 + x * 8 : pointer + 8 + x * 8], - )[0], - ) - for x in range(length) - ) - else: - data = ( - struct.unpack( - self.endian_mark + "l", self.tiftag[pointer : pointer + 4] - )[0], - struct.unpack( - self.endian_mark + "l", self.tiftag[pointer + 4 : pointer + 8] - )[0], - ) - elif t == TYPES.Float: # FLOAT - if length > 1: - pointer = struct.unpack(self.endian_mark + "L", value)[0] - data = struct.unpack( - self.endian_mark + "f" * length, - self.tiftag[pointer : pointer + length * 4], - ) - else: - data = struct.unpack(self.endian_mark + "f" * length, value) - elif t == TYPES.DFloat: # DOUBLE - pointer = struct.unpack(self.endian_mark + "L", value)[0] - data = struct.unpack( - self.endian_mark + "d" * length, - self.tiftag[pointer : pointer + length * 8], - ) - else: - raise ValueError( - "Exif might be wrong. Got incorrect value " - + "type to decode.\n" - + "tag: " - + str(val[3]) - + "\ntype: " - + str(t) - ) - - if isinstance(data, tuple) and (len(data) == 1): - return data[0] - else: - return data - - -def _get_key_name_dict(exif_dict): - new_dict = { - "0th": { - TAGS["Image"][n]["name"]: value for n, value in exif_dict["0th"].items() - }, - "Exif": { - TAGS["Exif"][n]["name"]: value for n, value in exif_dict["Exif"].items() - }, - "1st": { - TAGS["Image"][n]["name"]: value for n, value in exif_dict["1st"].items() - }, - "GPS": {TAGS["GPS"][n]["name"]: value for n, value in exif_dict["GPS"].items()}, - "Interop": { - TAGS["Interop"][n]["name"]: value - for n, value in exif_dict["Interop"].items() - }, - "thumbnail": exif_dict["thumbnail"], - } - return new_dict diff --git a/openflexure_microscope/captures/piexif/_remove.py b/openflexure_microscope/captures/piexif/_remove.py deleted file mode 100644 index aba71dad..00000000 --- a/openflexure_microscope/captures/piexif/_remove.py +++ /dev/null @@ -1,57 +0,0 @@ -import io - -from . import _webp -from ._common import get_exif_seg, split_into_segments - - -def remove(src, new_file=None): - """ - py:function:: piexif.remove(filename) - - Remove exif from JPEG. - - :param str filename: JPEG - """ - output_is_file = False - if src[0:2] == b"\xff\xd8": - src_data = src - file_type = "jpeg" - elif src[0:4] == b"RIFF" and src[8:12] == b"WEBP": - src_data = src - file_type = "webp" - else: - with open(src, "rb") as f: - src_data = f.read() - output_is_file = True - if src_data[0:2] == b"\xff\xd8": - file_type = "jpeg" - elif src_data[0:4] == b"RIFF" and src_data[8:12] == b"WEBP": - file_type = "webp" - - if file_type == "jpeg": - segments = split_into_segments(src_data) - exif = get_exif_seg(segments) - if exif: - new_data = src_data.replace(exif, b"") - else: - new_data = src_data - elif file_type == "webp": - try: - new_data = _webp.remove(src_data) - except ValueError: - new_data = src_data - except Exception as e: - print(e) - raise ValueError("Error occurred.") from e - - if isinstance(new_file, io.BytesIO): - new_file.write(new_data) - new_file.seek(0) - elif new_file: - with open(new_file, "wb+") as f: - f.write(new_data) - elif output_is_file: - with open(src, "wb+") as f: - f.write(new_data) - else: - raise ValueError("Give a second argument to 'remove' to output file") diff --git a/openflexure_microscope/captures/piexif/_transplant.py b/openflexure_microscope/captures/piexif/_transplant.py deleted file mode 100644 index 330a1722..00000000 --- a/openflexure_microscope/captures/piexif/_transplant.py +++ /dev/null @@ -1,45 +0,0 @@ -import io - -from ._common import get_exif_seg, merge_segments, split_into_segments - - -def transplant(exif_src, image, new_file=None): - """ - py:function:: piexif.transplant(filename1, filename2) - - Transplant exif from filename1 to filename2. - - :param str filename1: JPEG - :param str filename2: JPEG - """ - if exif_src[0:2] == b"\xff\xd8": - src_data = exif_src - else: - with open(exif_src, "rb") as f: - src_data = f.read() - segments = split_into_segments(src_data) - exif = get_exif_seg(segments) - if exif is None: - raise ValueError("not found exif in input") - - output_file = False - if image[0:2] == b"\xff\xd8": - image_data = image - else: - with open(image, "rb") as f: - image_data = f.read() - output_file = True - segments = split_into_segments(image_data) - new_data = merge_segments(segments, exif) - - if isinstance(new_file, io.BytesIO): - new_file.write(new_data) - new_file.seek(0) - elif new_file: - with open(new_file, "wb+") as f: - f.write(new_data) - elif output_file: - with open(image, "wb+") as f: - f.write(new_data) - else: - raise ValueError("Give a 3rd argument to 'transplant' to output file") diff --git a/openflexure_microscope/captures/piexif/_webp.py b/openflexure_microscope/captures/piexif/_webp.py deleted file mode 100644 index ab833abe..00000000 --- a/openflexure_microscope/captures/piexif/_webp.py +++ /dev/null @@ -1,270 +0,0 @@ -import struct - - -def split(data): - if data[0:4] != b"RIFF" or data[8:12] != b"WEBP": - raise ValueError("Not WebP") - - webp_length_bytes = data[4:8] - webp_length = struct.unpack("> 5 - 1 & ord(b"\x01") - contains = 1 * flag - return contains - - -def _get_size_from_vp8L(chunk): - b1 = chunk["data"][1:2] - b2 = chunk["data"][2:3] - b3 = chunk["data"][3:4] - b4 = chunk["data"][4:5] - - width_minus_one = (ord(b2) & ord(b"\x3F")) << 8 | ord(b1) - width = width_minus_one + 1 - - height_minus_one = ( - (ord(b4) & ord(b"\x0F")) << 10 | ord(b3) << 2 | (ord(b2) & ord(b"\xC0")) >> 6 - ) - height = height_minus_one + 1 - - return (width, height) - - -def _get_size_from_anmf(chunk): - width_minus_one_bytes = chunk["data"][6:9] + b"\x00" - width_minus_one = struct.unpack(" bool: """ Check if a real (non-mock) camera is currently attached. """ @@ -184,7 +192,7 @@ class Microscope: # Create unified state @property - def state(self): + def state(self) -> dict: """Dictionary of the basic microscope state. Return: @@ -224,7 +232,7 @@ class Microscope: for key in settings.keys(): logging.warning("Key %s is unused and was ignored", key) - def read_settings(self, full: bool = True): + def read_settings(self, full: bool = True) -> dict: """ Get an updated settings dictionary. @@ -270,7 +278,7 @@ class Microscope: # Save config to file self.settings_file.save(current_config, backup=True) - def force_get_configuration(self): + def force_get_configuration(self) -> dict: initial_configuration = self.configuration_file.load() current_configuration = { @@ -293,7 +301,7 @@ class Microscope: initial_configuration.update(current_configuration) return initial_configuration - def get_configuration(self, cache_key=None): + def get_configuration(self, cache_key: Optional[str] = None) -> dict: if cache_key: cached_config = self.configuration_cache.get(cache_key, None) if cached_config: @@ -308,7 +316,7 @@ class Microscope: def configuration(self): return self.get_configuration() - def force_get_metadata(self): + def force_get_metadata(self) -> dict: """ Read cachable bits of microscope metadata. Currently ID, settings, and configuration can be cached @@ -321,7 +329,7 @@ class Microscope: return system_metadata - def get_metadata(self, cache_key=None): + def get_metadata(self, cache_key: Optional[str] = None): """ Read microscope metadata, with partial caching """ @@ -345,22 +353,23 @@ class Microscope: return metadata @property - def metadata(self): + def metadata(self) -> dict: return self.get_metadata() def capture( self, - filename: str = None, + filename: Optional[str] = None, folder: str = "", temporary: bool = False, use_video_port: bool = False, resize: Tuple[int, int] = None, bayer: bool = True, fmt: str = "jpeg", - annotations: dict = None, - tags: list = None, - metadata: dict = None, - cache_key: str = None, + annotations: Optional[Dict[str, str]] = None, + tags: Optional[List[str]] = None, + dataset: Optional[Dict[str, str]] = None, + metadata: Optional[dict] = None, + cache_key: Optional[str] = None, ): logging.debug("Microscope capturing to %s", filename) if not annotations: @@ -381,9 +390,6 @@ class Microscope: ) # Capture to output object - extras = {} - if fmt == "jpeg": - extras["thumbnail"] = (*THUMBNAIL_SIZE, 85) logging.info("Starting microscope capture %s", output.file) self.camera.capture( output, @@ -391,10 +397,10 @@ class Microscope: resize=resize, bayer=bayer, fmt=fmt, - **extras, + thumbnail=(*THUMBNAIL_SIZE, 85), ) - output.put_and_save(tags, annotations, full_metadata) + output.put_and_save(tags, annotations, dataset, full_metadata) logging.debug("Finished capture to %s", output.file) return output diff --git a/openflexure_microscope/paths.py b/openflexure_microscope/paths.py index 38998580..aab25c40 100644 --- a/openflexure_microscope/paths.py +++ b/openflexure_microscope/paths.py @@ -3,11 +3,11 @@ import os # UTILITIES -def check_rw(path): +def check_rw(path: str) -> bool: return os.access(path, os.W_OK) and os.access(path, os.R_OK) -def settings_file_path(filename: str): +def settings_file_path(filename: str) -> str: """Generate a full file path for a filename to be stored in server settings folder""" settings_dir = os.path.join(OPENFLEXURE_VAR_PATH, "settings") if not os.path.exists(settings_dir): @@ -15,7 +15,7 @@ def settings_file_path(filename: str): return os.path.join(settings_dir, filename) -def data_file_path(filename: str): +def data_file_path(filename: str) -> str: """Generate a full file path for a filename to be stored in server data folder""" data_dir = os.path.join(OPENFLEXURE_VAR_PATH, "data") if not os.path.exists(data_dir): @@ -23,7 +23,7 @@ def data_file_path(filename: str): return os.path.join(data_dir, filename) -def extensions_file_path(filename: str): +def extensions_file_path(filename: str) -> str: """Generate a full file path for a folder to be stored in server extensions""" ext_dir = os.path.join(OPENFLEXURE_VAR_PATH, "extensions") if not os.path.exists(ext_dir): @@ -31,7 +31,7 @@ def extensions_file_path(filename: str): return os.path.join(ext_dir, filename) -def logs_file_path(filename: str): +def logs_file_path(filename: str) -> str: """Generate a full file path for a filename to be stored in server logs""" logs_dir = os.path.join(OPENFLEXURE_VAR_PATH, "logs") if not os.path.exists(logs_dir): @@ -42,14 +42,14 @@ def logs_file_path(filename: str): # BASE PATHS if os.name == "nt": - PREFERRED_VAR_PATH = os.getenv("PROGRAMDATA") or "C:\\ProgramData" - FALLBACK_VAR_PATH = os.path.expanduser("~") + PREFERRED_VAR_PATH: str = os.getenv("PROGRAMDATA") or "C:\\ProgramData" + FALLBACK_VAR_PATH: str = os.path.expanduser("~") else: PREFERRED_VAR_PATH = "/var" FALLBACK_VAR_PATH = os.path.expanduser("~") -PREFERRED_OPENFLEXURE_VAR_PATH = os.path.join(PREFERRED_VAR_PATH, "openflexure") -FALLBACK_OPENFLEXURE_VAR_PATH = os.path.join(FALLBACK_VAR_PATH, "openflexure") +PREFERRED_OPENFLEXURE_VAR_PATH: str = os.path.join(PREFERRED_VAR_PATH, "openflexure") +FALLBACK_OPENFLEXURE_VAR_PATH: str = os.path.join(FALLBACK_VAR_PATH, "openflexure") if not os.path.exists(PREFERRED_OPENFLEXURE_VAR_PATH) and check_rw(PREFERRED_VAR_PATH): os.makedirs(PREFERRED_OPENFLEXURE_VAR_PATH) @@ -65,8 +65,8 @@ else: # SERVER PATHS #: Path of microscope settings file -SETTINGS_FILE_PATH = settings_file_path("microscope_settings.json") +SETTINGS_FILE_PATH: str = settings_file_path("microscope_settings.json") #: Path of microscope configuration file -CONFIGURATION_FILE_PATH = settings_file_path("microscope_configuration.json") +CONFIGURATION_FILE_PATH: str = settings_file_path("microscope_configuration.json") #: Path of microscope extensions directory -OPENFLEXURE_EXTENSIONS_PATH = extensions_file_path("microscope_extensions") +OPENFLEXURE_EXTENSIONS_PATH: str = extensions_file_path("microscope_extensions") diff --git a/openflexure_microscope/rescue/check_settings.py b/openflexure_microscope/rescue/check_settings.py index 64fffb92..6ca7d0c1 100644 --- a/openflexure_microscope/rescue/check_settings.py +++ b/openflexure_microscope/rescue/check_settings.py @@ -1,9 +1,10 @@ import json import logging +from typing import List from .error_sources import ErrorSource -ERROR_SOURCES = [] +ERROR_SOURCES: List[ErrorSource] = [] def trace_config_exceptions(): diff --git a/openflexure_microscope/stage/base.py b/openflexure_microscope/stage/base.py index 98ed516d..b38d2ee6 100644 --- a/openflexure_microscope/stage/base.py +++ b/openflexure_microscope/stage/base.py @@ -1,7 +1,9 @@ from abc import ABCMeta, abstractmethod +from typing import Dict, List, Optional, Tuple, Union import numpy as np from labthings import StrictLock +from typing_extensions import Literal class BaseStage(metaclass=ABCMeta): @@ -39,11 +41,11 @@ class BaseStage(metaclass=ABCMeta): @property @abstractmethod - def position(self): + def position(self) -> Tuple[int, int, int]: """The current position, as a list""" @property - def position_map(self): + def position_map(self) -> Dict[str, int]: return {"x": self.position[0], "y": self.position[1], "z": self.position[2]} @property @@ -52,19 +54,27 @@ class BaseStage(metaclass=ABCMeta): """Get the distance used for backlash compensation.""" @backlash.setter - @abstractmethod def backlash(self): """Set the distance used for backlash compensation.""" + # See: https://github.com/python/mypy/issues/4165 + # Since we can't also decorate this with abstract method we want to be + # sure that the setter doesn't actually get used as a noop. + raise NotImplementedError @abstractmethod - def move_rel(self, displacement: list, axis=None, backlash=True): + def move_rel( + self, + displacement: Union[int, Tuple[int, int, int]], + axis: Optional[Literal["x", "y", "z"]] = None, + backlash: bool = True, + ): """Make a relative move, optionally correcting for backlash. displacement: integer or array/list of 3 integers backlash: (default: True) whether to correct for backlash. """ @abstractmethod - def move_abs(self, final, **kwargs): + def move_abs(self, final: Tuple[int, int, int], **kwargs): """Make an absolute move to a position""" @abstractmethod @@ -75,7 +85,12 @@ class BaseStage(metaclass=ABCMeta): def close(self): """Cleanly close communication with the stage""" - def scan_linear(self, rel_positions, backlash=True, return_to_start=True): + def scan_linear( + self, + rel_positions: List[Tuple[int, int, int]], + backlash: bool = True, + return_to_start: bool = True, + ): """ Scan through a list of (relative) positions (generator fn) rel_positions should be an nx3-element array (or list of 3 element arrays). @@ -86,15 +101,15 @@ class BaseStage(metaclass=ABCMeta): exception occurs during the scan.. """ starting_position = self.position - rel_positions = np.array(rel_positions) - assert rel_positions.shape[1] == 3, ValueError( + rel_positions_array: np.ndarray = np.array(rel_positions) + assert rel_positions_array.shape[1] == 3, ValueError( "Positions should be 3 elements long." ) try: - self.move_rel(rel_positions[0], backlash=backlash) + self.move_rel(rel_positions_array[0], backlash=backlash) yield 0 - for i, step in enumerate(np.diff(rel_positions, axis=0)): + for i, step in enumerate(np.diff(rel_positions_array, axis=0)): self.move_rel(step, backlash=backlash) yield i + 1 except Exception as e: @@ -104,11 +119,11 @@ class BaseStage(metaclass=ABCMeta): if return_to_start: self.move_abs(starting_position, backlash=backlash) - def scan_z(self, dz, **kwargs): + def scan_z(self, dz: List[int], **kwargs): """Scan through a list of (relative) z positions (generator fn) This function takes a 1D numpy array of Z positions, relative to the position at the start of the scan, and converts it into an array of 3D positions with x=y=0. This, along with all the keyword arguments, is then passed to ``scan_linear``. """ - return self.scan_linear([[0, 0, z] for z in dz], **kwargs) + return self.scan_linear([(0, 0, z) for z in dz], **kwargs) diff --git a/openflexure_microscope/stage/mock.py b/openflexure_microscope/stage/mock.py index 9e9eb20f..8b67b196 100644 --- a/openflexure_microscope/stage/mock.py +++ b/openflexure_microscope/stage/mock.py @@ -1,8 +1,10 @@ import logging import time from collections.abc import Iterable +from typing import Optional, Tuple, Union import numpy as np +from typing_extensions import Literal from openflexure_microscope.stage.base import BaseStage from openflexure_microscope.utilities import axes_to_array @@ -15,8 +17,6 @@ class MissingStage(BaseStage): self._n_axis = 3 self._backlash = None - self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis - @property def state(self): """The general state dictionary of the board.""" @@ -66,14 +66,27 @@ class MissingStage(BaseStage): else: self._backlash = np.array([int(blsh)] * self.n_axes, dtype=np.int) - def move_rel(self, displacement: list, axis=None, backlash=True): + def move_rel( + self, + displacement: Union[int, Tuple[int, int, int]], + axis: Optional[Literal["x", "y", "z"]] = None, + backlash: bool = True, + ): time.sleep(0.5) - if axis is not None: - assert axis in self.axis_names, "axis must be one of {}".format( - self.axis_names + if axis: + # Displacement MUST be an integer if axis name is specified + if not isinstance(displacement, int): + raise TypeError( + "Displacement must be an integer when axis is specified" + ) + # Axis name MUST be x, y, or z + if axis not in ("x", "y", "z"): + raise ValueError("axis must be one of x, y, or z") + move = ( + displacement if axis == "x" else 0, + displacement if axis == "y" else 0, + displacement if axis == "z" else 0, ) - move = np.zeros(self.n_axes, dtype=np.int) - move[np.argmax(np.array(self.axis_names) == axis)] = int(displacement) displacement = move initial_move = np.array(displacement, dtype=np.int) diff --git a/openflexure_microscope/stage/sanga.py b/openflexure_microscope/stage/sanga.py index c3a6c939..2b8e4c92 100644 --- a/openflexure_microscope/stage/sanga.py +++ b/openflexure_microscope/stage/sanga.py @@ -1,9 +1,11 @@ import logging import time from collections.abc import Iterable +from typing import Optional, Tuple, Union import numpy as np from sangaboard import Sangaboard +from typing_extensions import Literal from openflexure_microscope.stage.base import BaseStage from openflexure_microscope.utilities import axes_to_array @@ -32,7 +34,6 @@ class SangaStage(BaseStage): None # Initialise backlash storage, used by property setter/getter ) self.settle_time = 0.2 # Default move settle time - self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis self._position_on_enter = None @@ -52,7 +53,7 @@ class SangaStage(BaseStage): @property def n_axes(self): """The number of axes this stage has.""" - return len(self.board.axis_names) + return 3 @property def position(self): @@ -111,7 +112,12 @@ class SangaStage(BaseStage): return config - def move_rel(self, displacement: list, axis=None, backlash=True): + def move_rel( + self, + displacement: Union[int, Tuple[int, int, int]], + axis: Optional[Literal["x", "y", "z"]] = None, + backlash: bool = True, + ): """Make a relative move, optionally correcting for backlash. displacement: integer or array/list of 3 integers axis: None (for 3-axis moves) or one of 'x','y','z' @@ -121,14 +127,21 @@ class SangaStage(BaseStage): logging.debug("Moving sangaboard by %s", displacement) if not backlash or self.backlash is None: return self.board.move_rel(displacement, axis=axis) - if axis is not None: - # backlash correction is easier if we're always in 3D - # so this code just converts single-axis moves into all-axis moves. - assert axis in self.axis_names, "axis must be one of {}".format( - self.axis_names + # If we specify an axis name and a displacement int, convert to a displacement tuple + if axis: + # Displacement MUST be an integer if axis name is specified + if not isinstance(displacement, int): + raise TypeError( + "Displacement must be an integer when axis is specified" + ) + # Axis name MUST be x, y, or z + if axis not in ("x", "y", "z"): + raise ValueError("axis must be one of x, y, or z") + move = ( + displacement if axis == "x" else 0, + displacement if axis == "y" else 0, + displacement if axis == "z" else 0, ) - move = np.zeros(self.n_axes, dtype=np.int) - move[np.argmax(np.array(self.axis_names) == axis)] = int(displacement) displacement = move initial_move = np.array(displacement, dtype=np.int) @@ -154,7 +167,7 @@ class SangaStage(BaseStage): # can just take over before settling time.sleep(self.settle_time) - def move_abs(self, final, **kwargs): + def move_abs(self, final: Tuple[int, int, int], **kwargs): """Make an absolute move to a position """ with self.lock: diff --git a/openflexure_microscope/utilities.py b/openflexure_microscope/utilities.py index 475687f8..58d816ca 100644 --- a/openflexure_microscope/utilities.py +++ b/openflexure_microscope/utilities.py @@ -3,7 +3,7 @@ import copy import logging import time from contextlib import contextmanager -from uuid import UUID +from typing import Dict, List, Optional import numpy as np @@ -22,12 +22,12 @@ class Timer(object): logging.debug("%s time: %s", self.name, self.end - self.start) -def deserialise_array_b64(b64_string, dtype, shape): +def deserialise_array_b64(b64_string: str, dtype: str, shape: List[int]): flat_arr = np.frombuffer(base64.b64decode(b64_string), dtype) return flat_arr.reshape(shape) -def serialise_array_b64(npy_arr): +def serialise_array_b64(npy_arr: np.ndarray): b64_string = base64.b64encode(npy_arr).decode("ascii") dtype = str(npy_arr.dtype) shape = npy_arr.shape @@ -50,9 +50,14 @@ def json_to_ndarray(json_dict: dict): if not json_dict.get(required_param): raise KeyError(f"Missing required key {required_param}") - return deserialise_array_b64( - json_dict.get("base64"), json_dict.get("dtype"), json_dict.get("shape") - ) + b64_string: Optional[str] = json_dict.get("base64") + dtype: Optional[str] = json_dict.get("dtype") + shape: Optional[List[int]] = json_dict.get("shape") + + if b64_string and dtype and shape: + return deserialise_array_b64(b64_string, dtype, shape) + else: + raise ValueError("Required parameters for decoding are missing") @contextmanager @@ -82,8 +87,11 @@ def set_properties(obj, **kwargs): def axes_to_array( - coordinate_dictionary, axis_keys=("x", "y", "z"), base_array=None, asint=True -): + coordinate_dictionary: Dict[str, int], + axis_keys=("x", "y", "z"), + base_array: Optional[List[int]] = None, + asint: bool = True, +) -> List[int]: """Takes key-value pairs of a JSON value, and maps onto an array""" # If no base array is given if not base_array: @@ -101,21 +109,3 @@ def axes_to_array( ) return base_array - - -def entry_by_uuid(entry_id: str, object_list: list): - """Return an object from a list, if .id matches id argument.""" - found = None - if type(entry_id) == str: - converter = str - elif type(entry_id) == int: - converter = int - elif isinstance(entry_id, UUID): - converter = int - else: - raise TypeError("Argument entry_id must be a string, integer, or UUID object.") - for o in object_list: - # Convert to strings (in case of UUID objects, for example) - if converter(o.id) == converter(entry_id): - found = o - return found diff --git a/poetry.lock b/poetry.lock index 26facc8a..d7a4f526 100644 --- a/poetry.lock +++ b/poetry.lock @@ -67,7 +67,7 @@ wrapt = ">=1.11,<2.0" name = "atomicwrites" version = "1.4.0" description = "Atomic file writes." -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -75,7 +75,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "attrs" version = "20.3.0" description = "Classes Without Boilerplate" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -156,7 +156,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" name = "colorama" version = "0.4.4" description = "Cross-platform colored terminal text." -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -172,6 +172,17 @@ python-versions = "*" doc = ["sphinx"] test = ["pytest", "coverage", "mock"] +[[package]] +name = "coverage" +version = "5.3" +description = "Code coverage measurement for Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +toml = ["toml"] + [[package]] name = "docutils" version = "0.16" @@ -222,6 +233,17 @@ python-versions = "*" Flask = ">=0.9" Six = "*" +[[package]] +name = "freezegun" +version = "1.0.0" +description = "Let your Python tests travel through time" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "future" version = "0.18.2" @@ -258,7 +280,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "importlib-metadata" version = "3.1.0" description = "Read metadata from Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -273,7 +295,7 @@ testing = ["packaging", "pep517", "unittest2", "importlib-resources (>=1.3)"] name = "iniconfig" version = "1.1.1" description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" +category = "main" optional = false python-versions = "*" @@ -314,21 +336,28 @@ i18n = ["Babel (>=0.8)"] [[package]] name = "labthings" -version = "1.1.4" +version = "1.1.5" description = "Python implementation of LabThings, based on the Flask microframework" category = "main" optional = false -python-versions = ">=3.6,<4.0" +python-versions = "^3.6" +develop = true [package.dependencies] -apispec = ">=3.2.0,<4.0.0" -apispec_webframeworks = ">=0.5.2,<0.6.0" -Flask = ">=1.1.1,<2.0.0" -flask-cors = ">=3.0.8,<4.0.0" -marshmallow = ">=3.4.0,<4.0.0" -webargs = ">=6.0.0,<7.0.0" +apispec = "^3.2.0" +apispec_webframeworks = "^0.5.2" +Flask = "^1.1.1" +flask-cors = "^3.0.8" +marshmallow = "^3.4.0" +webargs = "^6.0.0" zeroconf = ">=0.24.5,<0.29.0" +[package.source] +type = "git" +url = "https://github.com/labthings/python-labthings" +reference = "master" +resolved_reference = "e9ed2444c08e3854ebefdadaa753700401392c97" + [[package]] name = "lazy-object-proxy" version = "1.4.3" @@ -367,6 +396,30 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "mypy" +version = "0.790" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "numpy" version = "1.19.2" @@ -390,7 +443,7 @@ numpy = ">=1.17.3" name = "packaging" version = "20.4" description = "Core utilities for Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -398,6 +451,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" pyparsing = ">=2.0.2" six = "*" +[[package]] +name = "pastel" +version = "0.2.1" +description = "Bring colors to your terminal." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "picamerax" version = "20.9.1" @@ -414,6 +475,14 @@ array = ["numpy"] doc = ["sphinx"] test = ["coverage", "pytest", "mock", "pillow", "numpy"] +[[package]] +name = "piexif" +version = "1.1.3" +description = "To simplify exif manipulations with python. Writing, reading, and more..." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + [[package]] name = "pillow" version = "7.2.0" @@ -426,7 +495,7 @@ python-versions = ">=3.5" name = "pluggy" version = "0.13.1" description = "plugin and hook calling mechanisms for python" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -436,6 +505,18 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] +[[package]] +name = "poethepoet" +version = "0.9.0" +description = "A task runner that works well with poetry." +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +pastel = ">=0.2.0,<0.3.0" +tomlkit = ">=0.6.0,<1.0.0" + [[package]] name = "psutil" version = "5.7.3" @@ -451,7 +532,7 @@ test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"] name = "py" version = "1.9.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -495,7 +576,7 @@ tests = ["check-manifest (>=0.25)", "coverage (>=4.0)", "isort (>=4.2.2)", "pydo name = "pyparsing" version = "2.4.7" description = "Python parsing module" -category = "dev" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" @@ -514,7 +595,7 @@ cp2110 = ["hidapi"] name = "pytest" version = "6.1.2" description = "pytest: simple powerful testing with Python" -category = "dev" +category = "main" optional = false python-versions = ">=3.5" @@ -533,6 +614,21 @@ toml = "*" checkqa_mypy = ["mypy (==0.780)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +[[package]] +name = "pytest-cov" +version = "2.10.1" +description = "Pytest plugin for measuring coverage." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] + [[package]] name = "python-dateutil" version = "2.8.1" @@ -752,10 +848,18 @@ test = ["pytest"] name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "tomlkit" +version = "0.7.0" +description = "Style preserving TOML library" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + [[package]] name = "typed-ast" version = "1.4.1" @@ -764,6 +868,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "typing-extensions" +version = "3.7.4.3" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "urllib3" version = "1.26.2" @@ -830,7 +942,7 @@ ifaddr = ">=0.1.7" name = "zipp" version = "3.4.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -843,8 +955,8 @@ rpi = ["RPi.GPIO"] [metadata] lock-version = "1.1" -python-versions = "^3.6.1" -content-hash = "394961d14d2bd06da0a05b6c34db8db97d88ff2b3d12e0e149da4e2706169cab" +python-versions = "^3.7.3" +content-hash = "e2f1011751b49fc5837f475f5304822216724a89f877ee5daf9c281cd8830673" [metadata.files] alabaster = [ @@ -907,6 +1019,42 @@ colorzero = [ {file = "colorzero-1.1-py2.py3-none-any.whl", hash = "sha256:e3c36d15b293de2b2f77ff54a5bd243fffac941ed0a5332d0697a6612a26a0a3"}, {file = "colorzero-1.1.tar.gz", hash = "sha256:acba47119b5d8555680d3cda9afe6ccc5481385ccc3c00084dd973f7aa184599"}, ] +coverage = [ + {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"}, + {file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9"}, + {file = "coverage-5.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729"}, + {file = "coverage-5.3-cp27-cp27m-win32.whl", hash = "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d"}, + {file = "coverage-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9"}, + {file = "coverage-5.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5"}, + {file = "coverage-5.3-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097"}, + {file = "coverage-5.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9"}, + {file = "coverage-5.3-cp35-cp35m-win32.whl", hash = "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636"}, + {file = "coverage-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f"}, + {file = "coverage-5.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54"}, + {file = "coverage-5.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7"}, + {file = "coverage-5.3-cp36-cp36m-win32.whl", hash = "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a"}, + {file = "coverage-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d"}, + {file = "coverage-5.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f"}, + {file = "coverage-5.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c"}, + {file = "coverage-5.3-cp37-cp37m-win32.whl", hash = "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751"}, + {file = "coverage-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709"}, + {file = "coverage-5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f"}, + {file = "coverage-5.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259"}, + {file = "coverage-5.3-cp38-cp38-win32.whl", hash = "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82"}, + {file = "coverage-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221"}, + {file = "coverage-5.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21"}, + {file = "coverage-5.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24"}, + {file = "coverage-5.3-cp39-cp39-win32.whl", hash = "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7"}, + {file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"}, + {file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"}, +] docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, @@ -922,6 +1070,10 @@ flask-cors = [ {file = "Flask-Cors-3.0.9.tar.gz", hash = "sha256:6bcfc100288c5d1bcb1dbb854babd59beee622ffd321e444b05f24d6d58466b8"}, {file = "Flask_Cors-3.0.9-py2.py3-none-any.whl", hash = "sha256:cee4480aaee421ed029eaa788f4049e3e26d15b5affb6a880dade6bafad38324"}, ] +freezegun = [ + {file = "freezegun-1.0.0-py2.py3-none-any.whl", hash = "sha256:02b35de52f4699a78f6ac4518e4cd3390dddc43b0aeb978335a8f270a2d9668b"}, + {file = "freezegun-1.0.0.tar.gz", hash = "sha256:1cf08e441f913ff5e59b19cc065a8faa9dd1ddc442eaf0375294f344581a0643"}, +] future = [ {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, ] @@ -957,10 +1109,7 @@ jinja2 = [ {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, ] -labthings = [ - {file = "labthings-1.1.4-py3-none-any.whl", hash = "sha256:18febba20cb552af9e5c0f8f47b06acd9649dce7de7205734b9e1fd3334e2d79"}, - {file = "labthings-1.1.4.tar.gz", hash = "sha256:69ca83d28720597327296d3e145bf57e68af17d245c0051c274db928e0a13e76"}, -] +labthings = [] lazy-object-proxy = [ {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"}, @@ -1027,6 +1176,26 @@ mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] +mypy = [ + {file = "mypy-0.790-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669"}, + {file = "mypy-0.790-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802"}, + {file = "mypy-0.790-cp35-cp35m-win_amd64.whl", hash = "sha256:e86bdace26c5fe9cf8cb735e7cedfe7850ad92b327ac5d797c656717d2ca66de"}, + {file = "mypy-0.790-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e97e9c13d67fbe524be17e4d8025d51a7dca38f90de2e462243ab8ed8a9178d1"}, + {file = "mypy-0.790-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0d34d6b122597d48a36d6c59e35341f410d4abfa771d96d04ae2c468dd201abc"}, + {file = "mypy-0.790-cp36-cp36m-win_amd64.whl", hash = "sha256:72060bf64f290fb629bd4a67c707a66fd88ca26e413a91384b18db3876e57ed7"}, + {file = "mypy-0.790-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:eea260feb1830a627fb526d22fbb426b750d9f5a47b624e8d5e7e004359b219c"}, + {file = "mypy-0.790-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c614194e01c85bb2e551c421397e49afb2872c88b5830e3554f0519f9fb1c178"}, + {file = "mypy-0.790-cp37-cp37m-win_amd64.whl", hash = "sha256:0a0d102247c16ce93c97066443d11e2d36e6cc2a32d8ccc1f705268970479324"}, + {file = "mypy-0.790-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4e7bf7f1214826cf7333627cb2547c0db7e3078723227820d0a2490f117a01"}, + {file = "mypy-0.790-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:af4e9ff1834e565f1baa74ccf7ae2564ae38c8df2a85b057af1dbbc958eb6666"}, + {file = "mypy-0.790-cp38-cp38-win_amd64.whl", hash = "sha256:da56dedcd7cd502ccd3c5dddc656cb36113dd793ad466e894574125945653cea"}, + {file = "mypy-0.790-py3-none-any.whl", hash = "sha256:2842d4fbd1b12ab422346376aad03ff5d0805b706102e475e962370f874a5122"}, + {file = "mypy-0.790.tar.gz", hash = "sha256:2b21ba45ad9ef2e2eb88ce4aeadd0112d0f5026418324176fd494a6824b74975"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] numpy = [ {file = "numpy-1.19.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b594f76771bc7fc8a044c5ba303427ee67c17a09b36e1fa32bde82f5c419d17a"}, {file = "numpy-1.19.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:e6ddbdc5113628f15de7e4911c02aed74a4ccff531842c583e5032f6e5a179bd"}, @@ -1077,10 +1246,18 @@ packaging = [ {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, ] +pastel = [ + {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, + {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, +] picamerax = [ {file = "picamerax-20.9.1-py3-none-any.whl", hash = "sha256:8dc45542644ed9c67e3b331e90bcffa04098e8b056a42d0fedb25769b0b1f17e"}, {file = "picamerax-20.9.1.tar.gz", hash = "sha256:fccb201ad9e2ab67946111d6bf875289c141ef900fd5875de6d435d219c59440"}, ] +piexif = [ + {file = "piexif-1.1.3-py2.py3-none-any.whl", hash = "sha256:3bc435d171720150b81b15d27e05e54b8abbde7b4242cddd81ef160d283108b6"}, + {file = "piexif-1.1.3.zip", hash = "sha256:83cb35c606bf3a1ea1a8f0a25cb42cf17e24353fd82e87ae3884e74a302a5f1b"}, +] pillow = [ {file = "Pillow-7.2.0-cp35-cp35m-macosx_10_10_intel.whl", hash = "sha256:1ca594126d3c4def54babee699c055a913efb01e106c309fa6b04405d474d5ae"}, {file = "Pillow-7.2.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c92302a33138409e8f1ad16731568c55c9053eee71bb05b6b744067e1b62380f"}, @@ -1115,6 +1292,10 @@ pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] +poethepoet = [ + {file = "poethepoet-0.9.0-py3-none-any.whl", hash = "sha256:6b1df9a755c297d5b10749cd4713924055b41edfa62055770c8bd6b5da8e2c69"}, + {file = "poethepoet-0.9.0.tar.gz", hash = "sha256:ab2263fd7be81d16d38a4b4fe42a055d992d04421e61cad36498b1e4bd8ee2a6"}, +] psutil = [ {file = "psutil-5.7.3-cp27-none-win32.whl", hash = "sha256:1cd6a0c9fb35ece2ccf2d1dd733c1e165b342604c67454fd56a4c12e0a106787"}, {file = "psutil-5.7.3-cp27-none-win_amd64.whl", hash = "sha256:e02c31b2990dcd2431f4524b93491941df39f99619b0d312dfe1d4d530b08b4b"}, @@ -1156,6 +1337,10 @@ pytest = [ {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"}, ] +pytest-cov = [ + {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, + {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, +] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, @@ -1260,6 +1445,10 @@ toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +tomlkit = [ + {file = "tomlkit-0.7.0-py2.py3-none-any.whl", hash = "sha256:6babbd33b17d5c9691896b0e68159215a9387ebfa938aa3ac42f4a4beeb2b831"}, + {file = "tomlkit-0.7.0.tar.gz", hash = "sha256:ac57f29693fab3e309ea789252fcce3061e19110085aa31af5446ca749325618"}, +] typed-ast = [ {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, @@ -1292,6 +1481,11 @@ typed-ast = [ {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +] urllib3 = [ {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, diff --git a/pyproject.toml b/pyproject.toml index ca5af852..68b9d60d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ packages = [ ] [tool.poetry.dependencies] -python = "^3.6.1" +python = "^3.7.3" Flask = "^1.0" numpy = "1.19.2" # Exact version so we can guarantee a wheel Pillow = "^7.2.0" @@ -43,7 +43,10 @@ expiringdict = "^1.2.1" camera-stage-mapping = "0.1.4" picamerax = ">=20.9.1" pyyaml = "^5.3.1" -labthings = "^1.1.4" +labthings = {git = "https://github.com/labthings/python-labthings", develop = true} +typing-extensions = "^3.7.4" # Needed for some type-hints in Python < 3.8 (e.g. Literal) +pytest-cov = "^2.10.1" +piexif = "^1.1.3" [tool.poetry.extras] rpi = ["RPi.GPIO"] @@ -58,6 +61,9 @@ rope = "^0.14.0" pylint = "^2.3" black = {version = "^18.3-alpha.0",allow-prereleases = true} pytest = "^6.1.2" +mypy = "^0.790" +poethepoet = "^0.9.0" +freezegun = "^1.0.0" [tool.black] exclude = '(\.eggs|\.git|\.venv|node_modules/)' @@ -73,3 +79,16 @@ line_length = 88 [tool.pylint.'MESSAGES CONTROL'] disable = "fixme,C,R" max-line-length = 88 + +[tool.poe.tasks] +black = "black ." +black_check = "black --check ." +isort = "isort ." +pylint = "pylint openflexure_microscope" +mypy = "mypy openflexure_microscope" +test = "pytest ." + +format = ["black", "isort"] +lint = ["pylint", "mypy"] + +check = ["format", "lint", "test"] \ No newline at end of file diff --git a/tests/images/capture_v280.jpg b/tests/images/capture_v280.jpg new file mode 100644 index 00000000..a2d4d1a1 Binary files /dev/null and b/tests/images/capture_v280.jpg differ diff --git a/tests/test_api_utilities.py b/tests/test_api_utilities.py new file mode 100644 index 00000000..2ac58760 --- /dev/null +++ b/tests/test_api_utilities.py @@ -0,0 +1,19 @@ +import pytest + +from openflexure_microscope.api import utilities + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ("true", True), + ("True", True), + ("1", True), + (True, True), + ("false", False), + ("somestring", False), + (False, False), + ], +) +def test_get_bool(test_input, expected): + assert utilities.get_bool(test_input) == expected diff --git a/tests/test_camera_framestream.py b/tests/test_camera_framestream.py new file mode 100644 index 00000000..899253e0 --- /dev/null +++ b/tests/test_camera_framestream.py @@ -0,0 +1,74 @@ +import os +import threading +import time + +from openflexure_microscope.camera.base import FrameStream + + +def test_framestream_write(): + d = b"openflexure" + + with FrameStream() as f: + f.write(d) + assert f.getvalue() == d + + +def test_framestream_overwrite(): + d1 = b"openflexure" + d2 = b"microscope" + + with FrameStream() as f: + f.write(d1) + assert f.getvalue() == d1 + f.write(d2) + assert f.getvalue() == d2 + # d1 should be removed, so f.getbuffer() should only contain d2 + assert f.getbuffer().nbytes == len(d2) + + +def test_tracker(): + sizes = range(1, 100) + + with FrameStream() as f: + for length in sizes: + time.sleep(0.01) + d = b"\x00" + os.urandom(length) + b"\x00" + f.write(d) + assert f.getbuffer().nbytes == len(d) + + # Make sure we have one TrackerFrame for each f.write() call + assert len(f.frames) == len(sizes) + # For each tested write size + for i, frame in enumerate(f.frames): + # Make sure the recorded size is what we expect + # Should be size +2 for our padding bytes \x00 + assert frame.size == sizes[i] + 2 + # Make sure we have a valid time value + assert isinstance(frame.time, float) + # Make sure the recorded time increases for each frame + if i > 0: + assert f.frames[i].time > f.frames[i - 1].time + + # Test FrameStream.last + assert f.last.size == sizes[-1] + 2 + + +def test_new_frame_event(): + d = b"openflexure" + + def target(framestream): + time.sleep(0.01) + framestream.write(d) + + with FrameStream() as f: + # Start a thread to write some data + thread = threading.Thread(target=target, args=(f,)) + thread.daemon = True + thread.start() + # Make sure the stream is empty before we wait for a frame + assert not f.getvalue() + # Wait for a frame to be written + frame = f.getframe() + assert frame == d + # Make sure getframe() cleared the event + assert not f.new_frame.events[threading.get_ident()][0].is_set() diff --git a/tests/test_capture_manager.py b/tests/test_capture_manager.py new file mode 100644 index 00000000..8a1260d3 --- /dev/null +++ b/tests/test_capture_manager.py @@ -0,0 +1,151 @@ +import os +from typing import Type + +import pytest +from freezegun import freeze_time + +from openflexure_microscope.captures.capture_manager import ( + BASE_CAPTURE_PATH, + TEMP_CAPTURE_PATH, + CaptureManager, +) + + +def test_new_manager(): + m = CaptureManager() + assert BASE_CAPTURE_PATH + assert TEMP_CAPTURE_PATH + assert m.paths["default"] == BASE_CAPTURE_PATH + assert m.paths["temp"] == TEMP_CAPTURE_PATH + + +def test_update_settings(): + m = CaptureManager() + assert m.paths["default"] == BASE_CAPTURE_PATH + assert m.paths["temp"] == TEMP_CAPTURE_PATH + + new_settings = { + "paths": {"default": "BASE_CAPTURE_PATH", "temp": "TEMP_CAPTURE_PATH"} + } + m.update_settings(new_settings) + assert m.paths["default"] == "BASE_CAPTURE_PATH" + assert m.paths["temp"] == "TEMP_CAPTURE_PATH" + assert m.read_settings() == new_settings + + +def test__new_output(): + m = CaptureManager() + + out = m._new_output(True, "filename", "subfolder", "fmt") + assert out.file == os.path.join(TEMP_CAPTURE_PATH, "subfolder", "filename.fmt") + + +def test__new_output_generate_filename(): + m = CaptureManager() + + out = m._new_output(True, None, "subfolder", "fmt") + # Assert a filename was actually generated + assert out.name != ".fmt" + + +def test_new_image(): + m = CaptureManager() + out = m.new_image() + capture_key = str(out.id) + assert capture_key in m.images.keys() + assert m.images[capture_key] is out + + +def test_new_video(): + m = CaptureManager() + out = m.new_video() + capture_key = str(out.id) + assert capture_key in m.videos.keys() + assert m.videos[capture_key] is out + + +def test_image_from_id_str(): + m = CaptureManager() + out = m.new_image() + capture_key = str(out.id) + + assert m.image_from_id(capture_key) is out + + +def test_image_from_id_int(): + m = CaptureManager() + out = m.new_image() + capture_key = int(out.id) + + assert m.image_from_id(capture_key) is out + + +def test_image_from_id_uuid(): + m = CaptureManager() + out = m.new_image() + capture_key = out.id + + assert m.image_from_id(capture_key) is out + + +def test_image_from_id_invalid(): + m = CaptureManager() + out = m.new_image() + capture_key = object() + + with pytest.raises(TypeError): + m.image_from_id(capture_key) is out + + +def test_video_from_id_str(): + m = CaptureManager() + out = m.new_video() + capture_key = str(out.id) + + assert m.video_from_id(capture_key) is out + + +def test_video_from_id_int(): + m = CaptureManager() + out = m.new_video() + capture_key = int(out.id) + + assert m.video_from_id(capture_key) is out + + +def test_video_from_id_uuid(): + m = CaptureManager() + out = m.new_video() + capture_key = out.id + + assert m.video_from_id(capture_key) is out + + +def test_video_from_id_invalid(): + m = CaptureManager() + out = m.new_video() + capture_key = object() + + with pytest.raises(TypeError): + m.video_from_id(capture_key) is out + + +@freeze_time("2020-11-27 12:00:01") +def test_generate_numbered_basename(): + m = CaptureManager() + for _ in range(10): + m.new_image() + generated_filenames = [obj.basename for obj in m.images.values()] + # Assert enough names were generated + assert len(generated_filenames) == 10 + # Assert all names are unique + len(generated_filenames) > len(set(generated_filenames)) + # Assert all filenames are in the expected form + for fname in generated_filenames: + assert fname.startswith("2020-11-27_12-00-01") + + +def test_context_manager(): + with CaptureManager() as m: + m.new_image() + m.new_video() diff --git a/tests/test_capture_object.py b/tests/test_capture_object.py new file mode 100644 index 00000000..12bcb99d --- /dev/null +++ b/tests/test_capture_object.py @@ -0,0 +1,232 @@ +import datetime +import io +import os +import uuid +from collections import OrderedDict + +import piexif +from PIL import Image, ImageChops + +from openflexure_microscope.captures import THUMBNAIL_SIZE, CaptureObject +from openflexure_microscope.captures.capture import ( + build_captures_from_exif, + capture_from_path, +) + +IN_DIR = "tests/images/" +OUT_DIR = "tests/images/out/" + + +def generate_small_image(): + # Create a dummy image to serve in the stream + image = Image.new("RGB", (20, 20), color=(0, 0, 0)) + + return image + + +def generate_small_thumbnail(): + # Create a dummy image to serve in the stream + image = Image.new("RGB", (20, 20), color=(0, 0, 0)) + image.thumbnail(THUMBNAIL_SIZE) + + return image + + +def generate_small_capture(filename: str): + out = os.path.join(OUT_DIR, filename) + obj = CaptureObject(out) + generate_small_image().save(obj, format="JPEG") + + return obj + + +def check_valid_capture(obj: CaptureObject): + assert isinstance(obj.time, datetime.datetime) + assert isinstance(obj.id, uuid.UUID) + + assert isinstance(obj.annotations, dict) + assert isinstance(obj.tags, list) + + assert obj.file + assert obj.format + assert obj.basename + assert obj.filefolder + assert obj.name + + +def check_valid_openflexure_metadata(d: dict): + assert "image" in d + assert d["image"].get("id") + assert d["image"].get("name") + assert d["image"].get("time") + assert d["image"].get("format") + + assert isinstance(d["image"].get("tags"), list) + assert isinstance(d["image"].get("annotations"), dict) + + +def test_new_capture_object(): + obj = generate_small_capture("capture0.jpg") + check_valid_capture(obj) + + +def test_initial_metadata(): + obj = generate_small_capture("capture1.jpg") + + data = obj.read_full_metadata() + check_valid_openflexure_metadata(data) + + +def test_tags(): + obj = generate_small_capture("capture2.jpg") + + obj.put_tags(["foo", "bar"]) + assert obj.read_full_metadata()["image"]["tags"] == ["foo", "bar"] + + obj.delete_tag("foo") + assert obj.read_full_metadata()["image"]["tags"] == ["bar"] + + # Delete nonexistent tag, should change nothing + obj.delete_tag("foo") + assert obj.read_full_metadata()["image"]["tags"] == ["bar"] + + +def test_annotations(): + obj = generate_small_capture("capture3.jpg") + + obj.put_annotations({"foo": "zoo", "bar": "zar"}) + assert obj.read_full_metadata()["image"]["annotations"] == { + "foo": "zoo", + "bar": "zar", + } + + obj.delete_annotation("foo") + assert obj.read_full_metadata()["image"]["annotations"] == {"bar": "zar"} + + # Delete nonexistent annotation, should change nothing + obj.delete_annotation("foo") + assert obj.read_full_metadata()["image"]["annotations"] == {"bar": "zar"} + + +def test_put_metadata(): + obj = generate_small_capture("capture4.jpg") + + obj.put_metadata({"foo": {"bar": "zar"}}) + assert obj.read_full_metadata()["foo"] == {"bar": "zar"} + + +def test_put_and_save(): + obj = generate_small_capture("capture_all.jpg") + obj.put_and_save( + tags=["foo", "bar"], + annotations={"foo": "zoo", "bar": "zar"}, + metadata={"foo": {"bar": "zar"}}, + ) + + full_metadata = obj.read_full_metadata() + assert full_metadata["image"]["tags"] == ["foo", "bar"] + assert full_metadata["image"]["annotations"] == {"foo": "zoo", "bar": "zar"} + assert full_metadata["foo"] == {"bar": "zar"} + + +def test_capture_from_path_this_version(): + """Tests reloading a capture object from a file created in the working server version + """ + + def _check_metadata(data: dict): + assert data["image"]["tags"] == ["foo", "bar"] + assert data["image"]["annotations"] == {"foo": "zoo", "bar": "zar"} + assert data["foo"] == {"bar": "zar"} + + # Create the capture file + obj_in = generate_small_capture("capture_reload.jpg") + obj_in.put_and_save( + tags=["foo", "bar"], + annotations={"foo": "zoo", "bar": "zar"}, + metadata={"foo": {"bar": "zar"}}, + ) + + full_metadata_in = obj_in.read_full_metadata() + check_valid_openflexure_metadata(full_metadata_in) + _check_metadata(full_metadata_in) + + # Reload the capture file + obj = capture_from_path(os.path.join(OUT_DIR, "capture_reload.jpg")) + check_valid_capture(obj) + full_metadata = obj.read_full_metadata() + check_valid_openflexure_metadata(full_metadata) + _check_metadata(full_metadata) + + +def test_capture_from_path_v280(): + """Tests reloading a capture object from a file created in server v2.8.0 + """ + obj = capture_from_path(os.path.join(IN_DIR, "capture_v280.jpg")) + check_valid_capture(obj) + full_metadata = obj.read_full_metadata() + check_valid_openflexure_metadata(full_metadata) + assert full_metadata["image"]["tags"] == ["foo", "bar"] + assert full_metadata["image"]["annotations"] == {"foo": "zoo", "bar": "zar"} + assert full_metadata["foo"] == {"bar": "zar"} + + +def test_build_captures_from_exif(): + # Load the whole tests captures directory + objs = build_captures_from_exif(IN_DIR) + # Make sure we have a valid, populated OrderedDict + assert isinstance(objs, OrderedDict) + assert len(objs) > 0 + # Check each reloaded capture + for obj in objs.values(): + check_valid_capture(obj) + + +def test_data(): + # Get a reference PIL image + ref_data = generate_small_image() + # Generate a capture with the same image data as our reference + obj = generate_small_capture("capture5.jpg") + # Pull the capture data out + obj_data = Image.open(obj.data) + # Compare the images + diff = ImageChops.difference(obj_data, ref_data) + assert not diff.getbbox() + + +def test_binary(): + # Get a reference PIL image + ref_data = generate_small_image() + # Generate a capture with the same image data as our reference + obj = generate_small_capture("capture6.jpg") + # Pull the capture data out + obj_data = Image.open(io.BytesIO(obj.binary)) + # Compare the images + diff = ImageChops.difference(obj_data, ref_data) + assert not diff.getbbox() + + +def test_thumbnail(): + # Get a reference PIL image + ref_thumb = generate_small_thumbnail() + # Generate a capture with the same image data as our reference + obj = generate_small_capture("capture7.jpg") + # Pull the generated thumbnail out + obj_thumb = Image.open(obj.thumbnail) + # Compare the images + diff = ImageChops.difference(obj_thumb, ref_thumb) + assert not diff.getbbox() + + # Assert thumbnail was saved to capture file EXIF data + exif_dict = piexif.load(obj.file) + thumbnail = exif_dict.pop("thumbnail") + assert thumbnail + # Compare the images + diff = ImageChops.difference(Image.open(io.BytesIO(thumbnail)), ref_thumb) + + +def test_delete(): + # Generate a capture with the same image data as our reference + obj = generate_small_capture("capture_del.jpg") + assert os.path.isfile(obj.file) + obj.delete() + assert not os.path.isfile(obj.file) diff --git a/tests/test_extension_scan.py b/tests/test_extension_scan.py index c3a86004..924e7ea5 100644 --- a/tests/test_extension_scan.py +++ b/tests/test_extension_scan.py @@ -1,5 +1,3 @@ -from pprint import pprint - from openflexure_microscope.api.default_extensions import scan diff --git a/tests/test_json.py b/tests/test_json.py new file mode 100644 index 00000000..0e365e6a --- /dev/null +++ b/tests/test_json.py @@ -0,0 +1,61 @@ +import json +import re +import uuid +from fractions import Fraction +from uuid import uuid3 + +import numpy as np +import pytest + +from openflexure_microscope.json import JSONEncoder + + +@pytest.mark.parametrize( + "test_input,expected", + [ + (uuid.uuid1(), 1), + (uuid.uuid3(uuid.NAMESPACE_DNS, "openflexure.org"), 3), + (uuid.uuid4(), 4), + (uuid.uuid5(uuid.NAMESPACE_DNS, "openflexure.org"), 5), + ], +) +def test_encode_uuid(test_input, expected): + encoded = json.dumps(test_input, cls=JSONEncoder) + decoded = json.loads(encoded) + assert uuid.UUID(decoded).version == expected + + +@pytest.mark.parametrize( + "test_input", + [ + Fraction(1, 2), + Fraction(1, 3), + Fraction(-27, 4), + Fraction(27, -4), + Fraction(10), + Fraction(18, 63), + Fraction(1, 1000000), + ], +) +def test_encode_fraction(test_input): + encoded = json.dumps(test_input, cls=JSONEncoder) + decoded = json.loads(encoded) + assert Fraction(decoded).limit_denominator() == test_input + + +@pytest.mark.parametrize( + "test_input", [np.random.randint(100, dtype=np.int64) for _ in range(10)] +) +def test_encode_np_int(test_input): + encoded = json.dumps(test_input, cls=JSONEncoder) + decoded = json.loads(encoded) + assert np.int64(decoded) == test_input + + +@pytest.mark.parametrize( + "test_input", [np.float64(np.random.random()) for _ in range(10)] +) +def test_encode_np_float(test_input): + encoded = json.dumps(test_input, cls=JSONEncoder) + decoded = json.loads(encoded) + assert np.float64(decoded) == test_input diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 95ae1fcd..d2cf4f69 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -5,7 +5,7 @@ from openflexure_microscope import utilities def test_serialise_array_b64(): shape_in = (3, 2) - arr_in = np.random.randint(100, size=shape_in, dtype=np.int) + arr_in = np.random.randint(100, size=shape_in, dtype=np.int32) b64_string, dtype, shape = utilities.serialise_array_b64(arr_in) assert b64_string @@ -18,7 +18,7 @@ def test_serialise_array_b64(): def test_ndarray_to_json(): shape_in = (3, 2) - arr_in = np.random.randint(100, size=shape_in, dtype=np.int) + arr_in = np.random.randint(100, size=shape_in, dtype=np.int32) json_out = utilities.ndarray_to_json(arr_in)