Merge branch 'typing' into 'master'

Static type analysis

See merge request openflexure/openflexure-microscope-server!99
This commit is contained in:
Joel Collins 2020-11-30 13:36:45 +00:00
commit 9be4b32bf8
63 changed files with 1825 additions and 2722 deletions

3
.gitignore vendored
View file

@ -60,9 +60,8 @@ docs/_build/
target/
#Big-o files
capture/
record/
*.data
tests/images/out/
#IDE files
.vscode/

View file

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

View file

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

View file

@ -2,6 +2,7 @@ node_modules
.gitlab-ci.yml
.gitlab
./dist
tests/
!openflexure_microscope/api/static/dist
# VCS files

View file

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

2
mypy.ini Normal file
View file

@ -0,0 +1,2 @@
[mypy]
ignore_missing_imports = True

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <setpoint>."""
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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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");
});

View file

@ -105,6 +105,7 @@
:time="item.time"
:tags="item.tags"
:type="item.type"
:captures="item.captures"
:thumbnail="item.thumbnail"
@selectFolder="selectFolder"
/>

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import subprocess
from labthings.views import ActionView
def is_raspberrypi():
def is_raspberrypi() -> bool:
"""
Checks if Raspberry Pi.
"""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
class InvalidImageDataError(ValueError):
pass

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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("<L", webp_length_bytes)[0]
RIFF_HEADER_SIZE = 8
file_size = RIFF_HEADER_SIZE + webp_length
start = 12
pointer = start
CHUNK_FOURCC_LENGTH = 4
LENGTH_BYTES_LENGTH = 4
chunks = []
while pointer + CHUNK_FOURCC_LENGTH + LENGTH_BYTES_LENGTH < file_size:
fourcc = data[pointer : pointer + CHUNK_FOURCC_LENGTH]
pointer += CHUNK_FOURCC_LENGTH
chunk_length_bytes = data[pointer : pointer + LENGTH_BYTES_LENGTH]
chunk_length = struct.unpack("<L", chunk_length_bytes)[0]
pointer += LENGTH_BYTES_LENGTH
chunk_data = data[pointer : pointer + chunk_length]
chunks.append(
{"fourcc": fourcc, "length_bytes": chunk_length_bytes, "data": chunk_data}
)
padding = 1 if chunk_length % 2 else 0
pointer += chunk_length + padding
return chunks
def merge_chunks(chunks):
merged = b"".join(
[
chunk["fourcc"]
+ chunk["length_bytes"]
+ chunk["data"]
+ (len(chunk["data"]) % 2) * b"\x00"
for chunk in chunks
]
)
return merged
def _get_size_from_vp8x(chunk):
width_minus_one_bytes = chunk["data"][-6:-3] + b"\x00"
width_minus_one = struct.unpack("<L", width_minus_one_bytes)[0]
width = width_minus_one + 1
height_minus_one_bytes = chunk["data"][-3:] + b"\x00"
height_minus_one = struct.unpack("<L", height_minus_one_bytes)[0]
height = height_minus_one + 1
return (width, height)
def _get_size_from_vp8(chunk):
BEGIN_CODE = b"\x9d\x01\x2a"
begin_index = chunk["data"].find(BEGIN_CODE)
if begin_index == -1:
ValueError("wrong VP8")
else:
BEGIN_CODE_LENGTH = len(BEGIN_CODE)
LENGTH_BYTES_LENGTH = 2
length_start = begin_index + BEGIN_CODE_LENGTH
width_bytes = chunk["data"][length_start : length_start + LENGTH_BYTES_LENGTH]
width = struct.unpack("<H", width_bytes)[0]
height_bytes = chunk["data"][
length_start + LENGTH_BYTES_LENGTH : length_start + 2 * LENGTH_BYTES_LENGTH
]
height = struct.unpack("<H", height_bytes)[0]
return (width, height)
def _vp8L_contains_alpha(chunk_data):
flag = ord(chunk_data[4:5]) >> 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("<L", width_minus_one_bytes)[0]
width = width_minus_one + 1
height_minus_one_bytes = chunk["data"][9:12] + b"\x00"
height_minus_one = struct.unpack("<L", height_minus_one_bytes)[0]
height = height_minus_one + 1
return (width, height)
def set_vp8x(chunks):
width = None
height = None
flags = [
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
b"0",
] # [0, 0, ICC, Alpha, EXIF, XMP, Anim, 0]
for chunk in chunks:
if chunk["fourcc"] == b"VP8X":
width, height = _get_size_from_vp8x(chunk)
elif chunk["fourcc"] == b"VP8 ":
width, height = _get_size_from_vp8(chunk)
elif chunk["fourcc"] == b"VP8L":
is_rgba = _vp8L_contains_alpha(chunk["data"])
if is_rgba:
flags[3] = b"1"
width, height = _get_size_from_vp8L(chunk)
elif chunk["fourcc"] == b"ANMF":
width, height = _get_size_from_anmf(chunk)
elif chunk["fourcc"] == b"ICCP":
flags[2] = b"1"
elif chunk["fourcc"] == b"ALPH":
flags[3] = b"1"
elif chunk["fourcc"] == b"EXIF":
flags[4] = b"1"
elif chunk["fourcc"] == b"XMP ":
flags[5] = b"1"
elif chunk["fourcc"] == b"ANIM":
flags[6] = b"1"
width_minus_one = width - 1
height_minus_one = height - 1
if chunks[0]["fourcc"] == b"VP8X":
chunks.pop(0)
header_bytes = b"VP8X"
length_bytes = b"\x0a\x00\x00\x00"
flags_bytes = struct.pack("B", int(b"".join(flags), 2))
padding_bytes = b"\x00\x00\x00"
width_bytes = struct.pack("<L", width_minus_one)[:3]
height_bytes = struct.pack("<L", height_minus_one)[:3]
data_bytes = flags_bytes + padding_bytes + width_bytes + height_bytes
vp8x_chunk = {
"fourcc": header_bytes,
"length_bytes": length_bytes,
"data": data_bytes,
}
chunks.insert(0, vp8x_chunk)
return chunks
def get_file_header(chunks):
WEBP_HEADER_LENGTH = 4
FOURCC_LENGTH = 4
LENGTH_BYTES_LENGTH = 4
length = WEBP_HEADER_LENGTH
for chunk in chunks:
data_length = struct.unpack("<L", chunk["length_bytes"])[0]
data_length += 1 if data_length % 2 else 0
length += FOURCC_LENGTH + LENGTH_BYTES_LENGTH + data_length
length_bytes = struct.pack("<L", length)
riff = b"RIFF"
webp_header = b"WEBP"
file_header = riff + length_bytes + webp_header
return file_header
def get_exif(data):
if data[0:4] != b"RIFF" or data[8:12] != b"WEBP":
raise ValueError("Not WebP")
if data[12:16] != b"VP8X":
raise ValueError("doesnot have exif")
webp_length_bytes = data[4:8]
webp_length = struct.unpack("<L", webp_length_bytes)[0]
RIFF_HEADER_SIZE = 8
file_size = RIFF_HEADER_SIZE + webp_length
start = 12
pointer = start
CHUNK_FOURCC_LENGTH = 4
LENGTH_BYTES_LENGTH = 4
while pointer < file_size:
fourcc = data[pointer : pointer + CHUNK_FOURCC_LENGTH]
pointer += CHUNK_FOURCC_LENGTH
chunk_length_bytes = data[pointer : pointer + LENGTH_BYTES_LENGTH]
chunk_length = struct.unpack("<L", chunk_length_bytes)[0]
if chunk_length % 2:
chunk_length += 1
pointer += LENGTH_BYTES_LENGTH
if fourcc == b"EXIF":
return data[pointer : pointer + chunk_length]
pointer += chunk_length
return None # if there isn't exif, return None.
def insert_exif_into_chunks(chunks, exif_bytes):
EXIF_HEADER = b"EXIF"
exif_length_bytes = struct.pack("<L", len(exif_bytes))
exif_chunk = {
"fourcc": EXIF_HEADER,
"length_bytes": exif_length_bytes,
"data": exif_bytes,
}
xmp_index = None
animation_index = None
for index, chunk in enumerate(chunks):
if chunk["fourcc"] == b"EXIF":
chunks.pop(index)
for index, chunk in enumerate(chunks):
if chunk["fourcc"] == b"XMP ":
xmp_index = index
elif chunk["fourcc"] == b"ANIM":
animation_index = index
if xmp_index is not None:
chunks.insert(xmp_index, exif_chunk)
elif animation_index is not None:
chunks.insert(animation_index, exif_chunk)
else:
chunks.append(exif_chunk)
return chunks
def insert(webp_bytes, exif_bytes):
chunks = split(webp_bytes)
chunks = insert_exif_into_chunks(chunks, exif_bytes)
chunks = set_vp8x(chunks)
file_header = get_file_header(chunks)
merged = merge_chunks(chunks)
new_webp_bytes = file_header + merged
return new_webp_bytes
def remove(webp_bytes):
chunks = split(webp_bytes)
for index, chunk in enumerate(chunks):
if chunk["fourcc"] == b"EXIF":
chunks.pop(index)
chunks = set_vp8x(chunks)
file_header = get_file_header(chunks)
merged = merge_chunks(chunks)
new_webp_bytes = file_header + merged
return new_webp_bytes

View file

@ -1,76 +0,0 @@
class UserComment:
#
# Names of encodings that we publicly support.
#
ASCII = "ascii"
JIS = "jis"
UNICODE = "unicode"
ENCODINGS = (ASCII, JIS, UNICODE)
#
# The actual encodings accepted by the standard library differ slightly from
# the above.
#
_JIS = "shift_jis"
_UNICODE = "utf_16_be"
_PREFIX_SIZE = 8
#
# From Table 9: Character Codes and their Designation
#
_ASCII_PREFIX = b"\x41\x53\x43\x49\x49\x00\x00\x00"
_JIS_PREFIX = b"\x4a\x49\x53\x00\x00\x00\x00\x00"
_UNICODE_PREFIX = b"\x55\x4e\x49\x43\x4f\x44\x45\x00"
_UNDEFINED_PREFIX = b"\x00\x00\x00\x00\x00\x00\x00\x00"
@classmethod
def load(cls, data):
"""
Convert "UserComment" value in exif format to str.
:param bytes data: "UserComment" value from exif
:return: u"foobar"
:rtype: str(Unicode)
:raises: ValueError if the data does not conform to the EXIF specification,
or the encoding is unsupported.
"""
if len(data) < cls._PREFIX_SIZE:
raise ValueError("not enough data to decode UserComment")
prefix = data[: cls._PREFIX_SIZE]
body = data[cls._PREFIX_SIZE :]
if prefix == cls._UNDEFINED_PREFIX:
raise ValueError("prefix is UNDEFINED, unable to decode UserComment")
try:
encoding = {
cls._ASCII_PREFIX: cls.ASCII,
cls._JIS_PREFIX: cls._JIS,
cls._UNICODE_PREFIX: cls._UNICODE,
}[prefix]
except KeyError as e:
raise ValueError("unable to determine appropriate encoding") from e
return body.decode(encoding, errors="replace")
@classmethod
def dump(cls, data, encoding="ascii"):
"""
Convert str to appropriate format for "UserComment".
:param data: Like u"foobar"
:param str encoding: "ascii", "jis", or "unicode"
:return: b"ASCII\x00\x00\x00foobar"
:rtype: bytes
:raises: ValueError if the encoding is unsupported.
"""
if encoding not in cls.ENCODINGS:
raise ValueError(
"encoding %r must be one of %r" % (encoding, cls.ENCODINGS)
)
prefix = {
cls.ASCII: cls._ASCII_PREFIX,
cls.JIS: cls._JIS_PREFIX,
cls.UNICODE: cls._UNICODE_PREFIX,
}[encoding]
internal_encoding = {cls.UNICODE: cls._UNICODE, cls.JIS: cls._JIS}.get(
encoding, encoding
)
return prefix + data.encode(internal_encoding, errors="replace")

View file

@ -4,6 +4,8 @@ from uuid import UUID
import numpy as np
from labthings.json import LabThingsJSONEncoder
__all__ = ["JSONEncoder", "LabThingsJSONEncoder"]
class JSONEncoder(LabThingsJSONEncoder):
"""
@ -19,12 +21,12 @@ class JSONEncoder(LabThingsJSONEncoder):
# Numpy integers
elif isinstance(o, np.integer):
return int(o)
# Numpy floats
elif isinstance(o, np.float):
return float(o)
# Numpy arrays
elif isinstance(o, np.ndarray):
return o.tolist()
# UUIDs
elif isinstance(o, UUID):
return str(o)
else:
# call base class implementation which takes care of
# raising exceptions for unsupported types

View file

@ -4,13 +4,16 @@ Defines a microscope object, binding a camera and stage with basic functionality
"""
import logging
import uuid
from typing import Tuple
from typing import Dict, List, Optional, Tuple, Union
import pkg_resources
from expiringdict import ExpiringDict
from openflexure_microscope.camera.base import BaseCamera
from openflexure_microscope.camera.mock import MissingCamera
from openflexure_microscope.captures import THUMBNAIL_SIZE, CaptureManager
from openflexure_microscope.config import OpenflexureSettingsFile
from openflexure_microscope.stage.base import BaseStage
from openflexure_microscope.stage.mock import MissingStage
from openflexure_microscope.stage.sanga import SangaDeltaStage, SangaStage
@ -19,7 +22,7 @@ try:
except Exception as e: # pylint: disable=W0703
logging.error(e)
logging.warning("Unable to import PiCameraStreamer")
from labthings import CompositeLock
from labthings import CompositeLock, StrictLock
from openflexure_microscope.config import user_configuration, user_settings
@ -32,23 +35,23 @@ class Microscope:
"""
def __init__(self, settings=user_settings, configuration=user_configuration):
self.id = f"openflexure:microscope:{uuid.uuid4()}"
self.name = self.id
self.id: str = f"openflexure:microscope:{uuid.uuid4()}"
self.name: str = self.id
self.captures = CaptureManager()
self.captures: CaptureManager = CaptureManager()
# Store settings and configuration files
self.settings_file = settings
self.configuration_file = configuration
self.settings_file: OpenflexureSettingsFile = settings
self.configuration_file: OpenflexureSettingsFile = configuration
self.extension_settings = {}
self.extension_settings: dict = {}
# Initialise with an empty composite lock
#: :py:class:`labthings.CompositeLock`: Composite lock for locking both camera and stage
self.lock = CompositeLock([])
self.lock: Union[CompositeLock, StrictLock] = CompositeLock([])
self.camera = None #: Currently connected camera object
self.stage = None #: Currently connected stage object
self.camera: BaseCamera = None #: Currently connected camera object
self.stage: BaseStage = None #: Currently connected stage object
self.setup(self.configuration_file.load()) # Attach components
@ -59,8 +62,12 @@ class Microscope:
# Sometimes, like when doing large scans, we don't need to read the hardware
# state for every single capture. We can get the data once at the start,
# cache it, and reuse that to save on IO. Cached data is stored here.
self.configuration_cache = ExpiringDict(max_len=100, max_age_seconds=3600)
self.metadata_cache = ExpiringDict(max_len=100, max_age_seconds=3600)
self.configuration_cache: Union[dict, ExpiringDict] = ExpiringDict(
max_len=100, max_age_seconds=3600
)
self.metadata_cache: Union[dict, ExpiringDict] = ExpiringDict(
max_len=100, max_age_seconds=3600
)
def __enter__(self):
"""Create microscope on context enter."""
@ -86,7 +93,7 @@ class Microscope:
self.captures.close()
logging.info("Closed %s", (self))
def setup(self, configuration):
def setup(self, configuration: dict):
"""
Attach microscope components based on initially passed configuration file
"""
@ -119,12 +126,13 @@ class Microscope:
if hasattr(self.stage, "lock"):
self.lock.locks.append(self.stage.lock)
def set_stage(self, configuration=None, stage_type=None):
def set_stage(
self, configuration: Optional[dict] = None, stage_type: Optional[str] = None
):
"""
Set or change the stage geometry
"""
if not configuration:
configuration = self.configuration
configuration = configuration or self.configuration
if stage_type:
if stage_type == configuration["stage"].get("type"):
@ -135,7 +143,7 @@ class Microscope:
### Close any existing stages
if self.stage:
stage_port = self.stage.port
stage_port = getattr(self.stage, "port")
self.stage.close()
logging.info("Setting stage")
@ -151,7 +159,7 @@ class Microscope:
except Exception as e: # pylint: disable=W0703
logging.error(e)
logging.warning("No compatible Sangaboard hardware found.")
elif stage_type in ("SangaDeltaStage"):
elif stage_type in ("SangaDeltaStage",):
try:
logging.info("Trying SangaDeltaStage")
self.stage = SangaDeltaStage(port=stage_port)
@ -173,7 +181,7 @@ class Microscope:
else:
return False
def has_real_camera(self):
def has_real_camera(self) -> 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

View file

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

View file

@ -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():

View file

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

View file

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

View file

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

View file

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

246
poetry.lock generated
View file

@ -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"},

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 B

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,3 @@
from pprint import pprint
from openflexure_microscope.api.default_extensions import scan

61
tests/test_json.py Normal file
View file

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

View file

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