Static type analysis
This commit is contained in:
parent
3aebb8bead
commit
7866ec0f47
63 changed files with 1825 additions and 2722 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -60,9 +60,8 @@ docs/_build/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
#Big-o files
|
#Big-o files
|
||||||
capture/
|
|
||||||
record/
|
|
||||||
*.data
|
*.data
|
||||||
|
tests/images/out/
|
||||||
|
|
||||||
#IDE files
|
#IDE files
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
stages:
|
stages:
|
||||||
- analysis
|
- analysis
|
||||||
|
- testing
|
||||||
- build
|
- build
|
||||||
- package
|
- package
|
||||||
- deploy
|
- deploy
|
||||||
|
|
@ -36,7 +37,24 @@ pylint:
|
||||||
<<: *poetry-install
|
<<: *poetry-install
|
||||||
|
|
||||||
script:
|
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:
|
only:
|
||||||
- branches
|
- branches
|
||||||
|
|
@ -49,12 +67,30 @@ black:
|
||||||
stage: analysis
|
stage: analysis
|
||||||
image: python:3.7
|
image: python:3.7
|
||||||
retry: 1
|
retry: 1
|
||||||
|
allow_failure: true
|
||||||
|
|
||||||
<<: *poetry-install
|
<<: *poetry-install
|
||||||
|
|
||||||
script:
|
script:
|
||||||
# Run static build 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:
|
only:
|
||||||
- branches
|
- branches
|
||||||
|
|
|
||||||
|
|
@ -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$
|
|
||||||
|
|
@ -2,6 +2,7 @@ node_modules
|
||||||
.gitlab-ci.yml
|
.gitlab-ci.yml
|
||||||
.gitlab
|
.gitlab
|
||||||
./dist
|
./dist
|
||||||
|
tests/
|
||||||
!openflexure_microscope/api/static/dist
|
!openflexure_microscope/api/static/dist
|
||||||
|
|
||||||
# VCS files
|
# VCS files
|
||||||
|
|
|
||||||
46
README.md
46
README.md
|
|
@ -22,34 +22,48 @@ This includes installing the server in a mode better suited for active developme
|
||||||
* `npm install`
|
* `npm install`
|
||||||
* `npm run build`
|
* `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.
|
Our CI will check each of these automatically, so ensuring they pass locally will save you time.
|
||||||
|
|
||||||
* **Black** - Code formatting with minimal configuration.
|
* **Black** - Code formatting with minimal configuration.
|
||||||
* While sometimes it's not perfect, its fine 90% of the time and prevents arguments about formatting.
|
* While sometimes it's not perfect, its fine 90% of the time and prevents arguments about formatting.
|
||||||
* Automatically formats your code
|
* Automatically formats your code
|
||||||
* `poetry run black .`
|
* `poetry run poe black`
|
||||||
* **Isort** - Import sorting
|
|
||||||
* Automatically organises your imports to stop things getting out of hand
|
|
||||||
* `poetry run isort .`
|
|
||||||
* **Pylint** - Static code analysis
|
* **Pylint** - Static code analysis
|
||||||
* Analyses your code, failing if issues are detected.
|
* 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_
|
* 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.
|
* **Isort** - Import sorting
|
||||||
|
* Automatically organises your imports to stop things getting out of hand
|
||||||
The simplest way to ensure this works is to install pre-commits into your current Python environment:
|
* `poetry run isort .`
|
||||||
|
|
||||||
* `pip3 install pre-commit`
|
|
||||||
* `pre-commit install`
|
|
||||||
|
|
||||||
To run pre-commit analysis manually, you can run `pre-commit run`.
|
|
||||||
|
|
||||||
## Build-system
|
## Build-system
|
||||||
|
|
||||||
|
|
|
||||||
2
mypy.ini
Normal file
2
mypy.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[mypy]
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
@ -39,36 +39,44 @@ from openflexure_microscope.paths import (
|
||||||
logs_file_path,
|
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
|
# Log files
|
||||||
ROOT_LOGFILE = logs_file_path("openflexure_microscope.log")
|
ROOT_LOGFILE = logs_file_path("openflexure_microscope.log")
|
||||||
ACCESS_LOGFILE = logs_file_path("openflexure_microscope.access.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
|
# Our WSGI server uses Werkzeug, so use that for the access log
|
||||||
access_log = logging.getLogger("werkzeug")
|
access_log = logging.getLogger("werkzeug")
|
||||||
# Block the access logs from propagating up to the root logger
|
# Block the access logs from propagating up to the root logger
|
||||||
access_log.propagate = False
|
access_log.propagate = False
|
||||||
|
|
||||||
# Create error log file handler
|
# Create error log file handler
|
||||||
fh = logging.handlers.RotatingFileHandler(
|
fh = CustomRotatingFileHandler(ROOT_LOGFILE, debug=debug_app)
|
||||||
ROOT_LOGFILE, maxBytes=1_000_000, backupCount=5
|
|
||||||
)
|
|
||||||
fh.setFormatter(formatter)
|
|
||||||
fh.setLevel(logging.DEBUG)
|
|
||||||
fh.propagate = False
|
|
||||||
|
|
||||||
# Create access log file handler
|
# Create access log file handler
|
||||||
afh = logging.handlers.RotatingFileHandler(
|
afh = CustomRotatingFileHandler(ACCESS_LOGFILE, debug=debug_app)
|
||||||
ACCESS_LOGFILE, maxBytes=1_000_000, backupCount=5
|
|
||||||
)
|
|
||||||
afh.setFormatter(formatter)
|
|
||||||
afh.setLevel(logging.DEBUG)
|
|
||||||
afh.propagate = False
|
|
||||||
|
|
||||||
# Add file handler to root logger
|
# Add file handler to root logger
|
||||||
root_log.addHandler(fh)
|
root_log.addHandler(fh)
|
||||||
access_log.addHandler(afh)
|
access_log.addHandler(afh)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from typing import Callable, List, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from labthings import current_action, fields, find_component
|
from labthings import current_action, fields, find_component
|
||||||
|
|
@ -8,24 +9,27 @@ from labthings.extensions import BaseExtension
|
||||||
from labthings.views import ActionView, View
|
from labthings.views import ActionView, View
|
||||||
from scipy import ndimage
|
from scipy import ndimage
|
||||||
|
|
||||||
|
from openflexure_microscope.camera.base import BaseCamera
|
||||||
from openflexure_microscope.devel import abort
|
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
|
from openflexure_microscope.utilities import set_properties
|
||||||
|
|
||||||
### Autofocus utilities
|
### Autofocus utilities
|
||||||
|
|
||||||
|
|
||||||
class JPEGSharpnessMonitor:
|
class JPEGSharpnessMonitor:
|
||||||
def __init__(self, microscope):
|
def __init__(self, microscope: Microscope):
|
||||||
self.microscope = microscope
|
self.microscope: Microscope = microscope
|
||||||
self.camera = microscope.camera
|
self.camera: BaseCamera = microscope.camera
|
||||||
self.stage = microscope.stage
|
self.stage: BaseStage = microscope.stage
|
||||||
|
|
||||||
self.recording_start_time = None
|
self.recording_start_time: Optional[float] = None
|
||||||
|
|
||||||
self.stage_positions = []
|
self.stage_positions: List[Tuple[int, int, int]] = []
|
||||||
self.stage_times = []
|
self.stage_times: List[float] = []
|
||||||
self.jpeg_times = []
|
self.jpeg_times: List[float] = []
|
||||||
self.jpeg_sizes = []
|
self.jpeg_sizes: List[int] = []
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
# Log the recording start time
|
# Log the recording start time
|
||||||
|
|
@ -35,14 +39,14 @@ class JPEGSharpnessMonitor:
|
||||||
self.camera.stream.stop_tracking()
|
self.camera.stream.stop_tracking()
|
||||||
self.camera.stream.reset_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
|
# Store the start time and position
|
||||||
self.camera.stream.start_tracking()
|
self.camera.stream.start_tracking()
|
||||||
self.stage_times.append(time.time())
|
self.stage_times.append(time.time())
|
||||||
self.stage_positions.append(self.stage.position)
|
self.stage_positions.append(self.stage.position)
|
||||||
|
|
||||||
# Main move
|
# 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
|
# Store the end time and position
|
||||||
self.camera.stream.stop_tracking()
|
self.camera.stream.stop_tracking()
|
||||||
|
|
@ -63,7 +67,7 @@ class JPEGSharpnessMonitor:
|
||||||
final_z_position = self.stage_positions[-1][2]
|
final_z_position = self.stage_positions[-1][2]
|
||||||
return data_index, final_z_position
|
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"""
|
"""Extract sharpness as a function of (interpolated) z"""
|
||||||
if istop is None:
|
if istop is None:
|
||||||
istop = istart + 2
|
istop = istart + 2
|
||||||
|
|
@ -88,7 +92,7 @@ class JPEGSharpnessMonitor:
|
||||||
jpeg_zs = np.interp(jpeg_times, stage_times, stage_zs)
|
jpeg_zs = np.interp(jpeg_times, stage_times, stage_zs)
|
||||||
return jpeg_times, jpeg_zs, jpeg_sizes[start:stop]
|
return jpeg_times, jpeg_zs, jpeg_sizes[start:stop]
|
||||||
|
|
||||||
def sharpest_z_on_move(self, index):
|
def sharpest_z_on_move(self, index: int):
|
||||||
"""Return the z position of the sharpest image on a given move"""
|
"""Return the z position of the sharpest image on a given move"""
|
||||||
_, jz, js = self.move_data(index)
|
_, jz, js = self.move_data(index)
|
||||||
if len(js) == 0:
|
if len(js) == 0:
|
||||||
|
|
@ -105,23 +109,14 @@ class JPEGSharpnessMonitor:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def decimate_to(shape, image):
|
def sharpness_sum_lap2(rgb_image: np.ndarray):
|
||||||
"""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):
|
|
||||||
"""Return an image sharpness metric: sum(laplacian(image)**")"""
|
"""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_bw = np.mean(rgb_image, 2)
|
||||||
image_lap = ndimage.filters.laplace(image_bw)
|
image_lap = ndimage.filters.laplace(image_bw)
|
||||||
return np.mean(image_lap.astype(np.float) ** 4)
|
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"""
|
"""Return a sharpness metric optimised for vertical lines"""
|
||||||
gray = np.mean(image.astype(float), 2)
|
gray = np.mean(image.astype(float), 2)
|
||||||
n = 20
|
n = 20
|
||||||
|
|
@ -134,13 +129,23 @@ def sharpness_edge(image):
|
||||||
### Autofocus extension
|
### 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."""
|
"""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.
|
"""Perform a simple autofocus routine.
|
||||||
The stage is moved to z positions (relative to current position) in dz,
|
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
|
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:
|
with set_properties(stage, backlash=256), stage.lock, camera.lock:
|
||||||
sharpnesses = []
|
sharpnesses = []
|
||||||
positions = []
|
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):
|
for _ in stage.scan_z(dz, return_to_start=False):
|
||||||
if current_action() and current_action().stopped:
|
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))
|
sharpnesses.append(measure_sharpness(microscope, metric_fn))
|
||||||
|
|
||||||
newposition = positions[np.argmax(sharpnesses)]
|
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
|
return positions, sharpnesses
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def monitor_sharpness(microscope):
|
def monitor_sharpness(microscope: Microscope):
|
||||||
m = JPEGSharpnessMonitor(microscope)
|
m = JPEGSharpnessMonitor(microscope)
|
||||||
m.start()
|
m.start()
|
||||||
try:
|
try:
|
||||||
|
|
@ -179,21 +187,21 @@ def monitor_sharpness(microscope):
|
||||||
m.stop()
|
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"""
|
"""Make a relative Z move and return the peak sharpness position"""
|
||||||
with monitor_sharpness(microscope) as m:
|
with monitor_sharpness(microscope) as m:
|
||||||
m.focus_rel(dz)
|
m.focus_rel(dz)
|
||||||
return m.sharpest_z_on_move(0)
|
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"""
|
"""Make a relative Z move and return the sharpness data"""
|
||||||
with monitor_sharpness(microscope) as m:
|
with monitor_sharpness(microscope) as m:
|
||||||
m.focus_rel(dz)
|
m.focus_rel(dz)
|
||||||
return m.data_dict()
|
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"""
|
"""Perform a down-up-down-up autofocus"""
|
||||||
with microscope.camera.lock, microscope.stage.lock:
|
with microscope.camera.lock, microscope.stage.lock:
|
||||||
with monitor_sharpness(microscope) as m:
|
with monitor_sharpness(microscope) as m:
|
||||||
|
|
@ -216,7 +224,11 @@ def fast_autofocus(microscope, dz=2000):
|
||||||
|
|
||||||
|
|
||||||
def fast_up_down_up_autofocus(
|
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.
|
"""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 microscope.camera.lock, microscope.stage.lock:
|
||||||
with monitor_sharpness(microscope) as m:
|
with monitor_sharpness(microscope) as m:
|
||||||
# Ensure the MJPEG stream has started
|
# Ensure the MJPEG stream has started
|
||||||
microscope.camera.start_stream_recording()
|
microscope.camera.start_stream()
|
||||||
|
|
||||||
logging.debug("Initial move")
|
logging.debug("Initial move")
|
||||||
if initial_move_up:
|
if initial_move_up:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from flask import abort
|
from flask import abort
|
||||||
|
|
@ -9,32 +10,43 @@ from labthings.marshalling import use_args
|
||||||
from labthings.views import PropertyView, View
|
from labthings.views import PropertyView, View
|
||||||
|
|
||||||
from openflexure_microscope.api.utilities.gui import build_gui
|
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
|
from openflexure_microscope.paths import check_rw, settings_file_path
|
||||||
|
|
||||||
AS_SETTINGS_PATH = settings_file_path("autostorage_settings.json")
|
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]
|
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)]
|
return [partition for partition in get_partitions() if check_rw(partition)]
|
||||||
|
|
||||||
|
|
||||||
def get_permissive_locations():
|
def get_permissive_locations() -> List[Tuple[str, str]]:
|
||||||
return [
|
return [
|
||||||
(partition, os.path.join(partition, "openflexure", "data", "micrographs"))
|
(partition, os.path.join(partition, "openflexure", "data", "micrographs"))
|
||||||
for partition in get_permissive_partitions()
|
for partition in get_permissive_partitions()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_current_location(capture_manager):
|
def get_current_location(capture_manager: Optional[CaptureManager]) -> str:
|
||||||
return capture_manager.paths.get("default")
|
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):
|
if not os.path.isdir(location):
|
||||||
os.makedirs(location)
|
os.makedirs(location)
|
||||||
logging.debug("Updating location...")
|
logging.debug("Updating location...")
|
||||||
|
|
@ -44,12 +56,12 @@ def set_current_location(capture_manager, location: str):
|
||||||
logging.debug("Capture location changed successfully.")
|
logging.debug("Capture location changed successfully.")
|
||||||
|
|
||||||
|
|
||||||
def get_default_location():
|
def get_default_location() -> str:
|
||||||
return BASE_CAPTURE_PATH
|
return BASE_CAPTURE_PATH
|
||||||
|
|
||||||
|
|
||||||
def get_all_locations():
|
def get_all_locations() -> Dict[str, str]:
|
||||||
locations = {}
|
locations: Dict[str, str] = {}
|
||||||
# If default is not already listed (e.g. if it's currently set)
|
# If default is not already listed (e.g. if it's currently set)
|
||||||
if get_default_location() not in locations.values():
|
if get_default_location() not in locations.values():
|
||||||
locations["Default"] = get_default_location()
|
locations["Default"] = get_default_location()
|
||||||
|
|
@ -71,29 +83,23 @@ def get_all_locations():
|
||||||
return {k: v for k, v in locations.items() if v}
|
return {k: v for k, v in locations.items() if v}
|
||||||
|
|
||||||
|
|
||||||
class CaptureStorageLocation:
|
|
||||||
def __init__(self, mountpoint):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AutostorageExtension(BaseExtension):
|
class AutostorageExtension(BaseExtension):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
BaseExtension.__init__(
|
super().__init__(
|
||||||
self,
|
|
||||||
"org.openflexure.autostorage",
|
"org.openflexure.autostorage",
|
||||||
version="2.0.0",
|
version="2.0.0",
|
||||||
description="Handle switching capture storage devices",
|
description="Handle switching capture storage devices",
|
||||||
)
|
)
|
||||||
|
|
||||||
# We'll store a reference to a CaptureManager object, who's capture paths will be modified
|
# 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
|
# Register the on_microscope function to run when the microscope is attached
|
||||||
self.on_component("org.openflexure.microscope", self.on_microscope)
|
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."""
|
"""Function to automatically call when the parent LabThing has a microscope attached."""
|
||||||
logging.debug("Autostorage extension found microscope %s", microscope_obj)
|
logging.debug("Autostorage extension found microscope %s", microscope_obj)
|
||||||
if hasattr(microscope_obj, "captures"):
|
if hasattr(microscope_obj, "captures"):
|
||||||
|
|
@ -110,10 +116,15 @@ class AutostorageExtension(BaseExtension):
|
||||||
self.check_location(self.initial_location)
|
self.check_location(self.initial_location)
|
||||||
|
|
||||||
logging.debug(self.get_locations())
|
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:
|
if not location:
|
||||||
location = get_current_location(self.capture_manager)
|
return False
|
||||||
# If preferred path does not exist, or cannot be written to
|
# If preferred path does not exist, or cannot be written to
|
||||||
if not (os.path.isdir(location) and check_rw(location)):
|
if not (os.path.isdir(location) and check_rw(location)):
|
||||||
logging.error(
|
logging.error(
|
||||||
|
|
@ -122,8 +133,9 @@ class AutostorageExtension(BaseExtension):
|
||||||
)
|
)
|
||||||
# Reset the storage location to default
|
# Reset the storage location to default
|
||||||
set_current_location(self.capture_manager, get_default_location())
|
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:
|
if self.capture_manager:
|
||||||
locations = get_all_locations()
|
locations = get_all_locations()
|
||||||
|
|
||||||
|
|
@ -135,7 +147,7 @@ class AutostorageExtension(BaseExtension):
|
||||||
else:
|
else:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def get_preferred_key(self):
|
def get_preferred_key(self) -> Optional[str]:
|
||||||
current = get_current_location(self.capture_manager)
|
current = get_current_location(self.capture_manager)
|
||||||
locations = self.get_locations()
|
locations = self.get_locations()
|
||||||
|
|
||||||
|
|
@ -146,22 +158,28 @@ class AutostorageExtension(BaseExtension):
|
||||||
"Multiple path matches found. Weird, but carrying on using zeroth."
|
"Multiple path matches found. Weird, but carrying on using zeroth."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if matches:
|
||||||
return matches[0]
|
return matches[0]
|
||||||
|
else:
|
||||||
|
logging.warning("No matches found. Skipping.")
|
||||||
|
return None
|
||||||
|
|
||||||
def set_preferred_key(self, new_path_key: str):
|
def set_preferred_key(self, new_path_key: str):
|
||||||
if not new_path_key in self.get_locations().keys():
|
if not new_path_key in self.get_locations().keys():
|
||||||
raise KeyError(f"No location named {new_path_key}")
|
raise KeyError(f"No location named {new_path_key}")
|
||||||
|
|
||||||
location = self.get_locations().get(new_path_key)
|
location = self.get_locations().get(new_path_key)
|
||||||
|
if location:
|
||||||
set_current_location(self.capture_manager, 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():
|
if not path_key in self.get_locations().keys():
|
||||||
raise KeyError(f"No location named {path_key}")
|
raise KeyError(f"No location named {path_key}")
|
||||||
|
|
||||||
return f"{path_key} ({self.get_locations().get(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 = []
|
matches = []
|
||||||
for loc_key in self.get_locations().keys():
|
for loc_key in self.get_locations().keys():
|
||||||
if path_title.startswith(loc_key):
|
if path_title.startswith(loc_key):
|
||||||
|
|
@ -174,10 +192,15 @@ class AutostorageExtension(BaseExtension):
|
||||||
|
|
||||||
return matches[0]
|
return matches[0]
|
||||||
|
|
||||||
def get_titles(self):
|
def get_titles(self) -> List[str]:
|
||||||
return [self.key_to_title(key) for key in self.get_locations().keys()]
|
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())
|
return self.key_to_title(self.get_preferred_key())
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import logging
|
import logging
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
# Type hinting
|
import picamerax
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from flask import abort
|
from flask import abort
|
||||||
from labthings import find_component
|
from labthings import find_component
|
||||||
from labthings.extensions import BaseExtension
|
from labthings.extensions import BaseExtension
|
||||||
from labthings.views import ActionView
|
from labthings.views import ActionView
|
||||||
|
|
||||||
|
from openflexure_microscope.camera.base import BaseCamera
|
||||||
|
from openflexure_microscope.microscope import Microscope
|
||||||
|
|
||||||
from .recalibrate_utils import (
|
from .recalibrate_utils import (
|
||||||
auto_expose_and_freeze_settings,
|
auto_expose_and_freeze_settings,
|
||||||
flat_lens_shading_table,
|
flat_lens_shading_table,
|
||||||
|
|
@ -17,7 +18,7 @@ from .recalibrate_utils import (
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@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.
|
"""This context manager locks a streaming camera, and pauses the stream.
|
||||||
|
|
||||||
The stream is re-enabled, with the original resolution, once the with
|
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
|
not scamera.record_active
|
||||||
), "We can't pause the camera's video stream while a recording is in progress."
|
), "We can't pause the camera's video stream while a recording is in progress."
|
||||||
streaming = scamera.stream_active
|
streaming = scamera.stream_active
|
||||||
old_resolution = scamera.camera.resolution
|
old_resolution = scamera.stream_resolution
|
||||||
if streaming:
|
if streaming:
|
||||||
logging.info("Stopping stream in pause_stream context manager")
|
logging.info("Stopping stream in pause_stream context manager")
|
||||||
scamera.stop_stream_recording(resolution=resolution)
|
scamera.stop_stream()
|
||||||
try:
|
try:
|
||||||
yield scamera
|
yield scamera
|
||||||
finally:
|
finally:
|
||||||
scamera.camera.resolution = old_resolution
|
scamera.stream_resolution = old_resolution
|
||||||
if streaming:
|
if streaming:
|
||||||
logging.info("Restarting stream in pause_stream context manager")
|
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.
|
"""Reset the camera's settings.
|
||||||
|
|
||||||
This generates new gains, exposure time, and lens shading
|
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 a gray level of 230. It takes a little while to run.
|
||||||
"""
|
"""
|
||||||
with pause_stream(microscope.camera) as scamera:
|
with pause_stream(microscope.camera) as scamera:
|
||||||
auto_expose_and_freeze_settings(
|
if hasattr(scamera, "picamera"):
|
||||||
scamera.camera
|
picamera_obj: picamerax.PiCamera = getattr(scamera, "picamera")
|
||||||
) # scamera.camera is the PiCamera object
|
auto_expose_and_freeze_settings(picamera_obj)
|
||||||
recalibrate_camera(scamera.camera)
|
recalibrate_camera(picamera_obj)
|
||||||
microscope.save_settings()
|
microscope.save_settings()
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Recalibrate can only be used with a Raspberry Pi camera"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RecalibrateView(ActionView):
|
class RecalibrateView(ActionView):
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from fractions import Fraction
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from picamerax import PiCamera
|
from picamerax import PiCamera
|
||||||
from picamerax.array import PiBayerArray, PiRGBArray
|
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"""
|
"""Capture an image and return an RGB numpy array"""
|
||||||
with PiRGBArray(camera, size=resize) as output:
|
with PiRGBArray(camera, size=resize) as output:
|
||||||
camera.capture(output, format="rgb", resize=resize, **kwargs)
|
camera.capture(output, format="rgb", resize=resize, **kwargs)
|
||||||
return output.array
|
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.
|
"""Return a flat (i.e. unity gain) lens shading table.
|
||||||
|
|
||||||
This is mostly useful because it makes it easy to get the size
|
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(
|
raise ImportError(
|
||||||
"This program requires the forked picamera library with lens shading support"
|
"This program requires the forked picamera library with lens shading support"
|
||||||
)
|
)
|
||||||
return (
|
# pylint: disable=protected-access
|
||||||
# pylint: disable=W0212
|
return np.zeros(camera._lens_shading_table_shape(), dtype=np.uint8) + 32
|
||||||
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>."""
|
"""Adjust the camera's exposure time until the maximum pixel value is <setpoint>."""
|
||||||
print("Adjusting shutter speed to hit setpoint {}".format(setpoint), end="")
|
print("Adjusting shutter speed to hit setpoint {}".format(setpoint), end="")
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
|
|
@ -43,7 +42,7 @@ def adjust_exposure_to_setpoint(camera, setpoint):
|
||||||
print("done")
|
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"""
|
"""Freeze the settings after auto-exposing to white illumination"""
|
||||||
logging.info("Allowing the camera to auto-expose")
|
logging.info("Allowing the camera to auto-expose")
|
||||||
if "greyworld" in camera.AWB_MODES:
|
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))
|
logging.info("Shutter speed = %s", (camera.shutter_speed))
|
||||||
camera.exposure_mode = "off"
|
camera.exposure_mode = "off"
|
||||||
logging.info("Auto exposure disabled")
|
logging.info("Auto exposure disabled")
|
||||||
g = camera.awb_gains
|
g: Tuple[Fraction, Fraction] = camera.awb_gains
|
||||||
camera.awb_mode = "off"
|
camera.awb_mode = "off"
|
||||||
camera.awb_gains = g
|
camera.awb_gains = g
|
||||||
logging.info("Auto white balance disabled, gains are %s", (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)
|
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."""
|
"""Given the 'array' from a PiBayerArray, return the 4 channels."""
|
||||||
bayer_pattern = [(i // 2, i % 2) for i in range(4)]
|
bayer_pattern: List[Tuple[int, int]] = [(0, 0), (0, 1), (1, 0), (1, 1)]
|
||||||
channels = np.zeros(
|
channels: np.ndarray = np.zeros(
|
||||||
(4, bayer_array.shape[0] // 2, bayer_array.shape[1] // 2),
|
(4, bayer_array.shape[0] // 2, bayer_array.shape[1] // 2),
|
||||||
dtype=bayer_array.dtype,
|
dtype=bayer_array.dtype,
|
||||||
)
|
)
|
||||||
|
|
@ -92,14 +91,19 @@ def channels_from_bayer_array(bayer_array):
|
||||||
return channels
|
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."""
|
"""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
|
full_resolution: np.ndarray = np.array(
|
||||||
# lst_resolution = list(np.ceil(full_resolution / 64.0).astype(int))
|
channels.shape[1:]
|
||||||
lst_resolution = [(r // 64) + 1 for r in full_resolution]
|
) * 2 # channels have been binned
|
||||||
# NB the size of the LST is 1/64th of the image, but rounded UP.
|
|
||||||
|
# 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)
|
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]):
|
for i in range(lens_shading.shape[0]):
|
||||||
image_channel = channels[i, :, :]
|
image_channel = channels[i, :, :]
|
||||||
iw, ih = image_channel.shape
|
iw, ih = image_channel.shape
|
||||||
|
|
@ -153,7 +157,7 @@ def lst_from_channels(channels):
|
||||||
return lens_shading_table[::-1, :, :].copy()
|
return lens_shading_table[::-1, :, :].copy()
|
||||||
|
|
||||||
|
|
||||||
def recalibrate_camera(camera):
|
def recalibrate_camera(camera: PiCamera):
|
||||||
"""Reset the lens shading table and exposure settings.
|
"""Reset the lens shading table and exposure settings.
|
||||||
|
|
||||||
This method first resets to a flat lens shading table, then auto-exposes,
|
This method first resets to a flat lens shading table, then auto-exposes,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from typing import Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from labthings import (
|
from labthings import (
|
||||||
current_action,
|
current_action,
|
||||||
|
|
@ -14,20 +14,32 @@ from labthings import (
|
||||||
)
|
)
|
||||||
from labthings.extensions import BaseExtension
|
from labthings.extensions import BaseExtension
|
||||||
from labthings.views import ActionView
|
from labthings.views import ActionView
|
||||||
|
from typing_extensions import Literal
|
||||||
|
|
||||||
from openflexure_microscope.api.v2.views.actions.camera import FullCaptureArgs
|
from openflexure_microscope.api.v2.views.actions.camera import FullCaptureArgs
|
||||||
from openflexure_microscope.captures.capture_manager import generate_basename
|
from openflexure_microscope.captures.capture_manager import generate_basename
|
||||||
from openflexure_microscope.devel import abort
|
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
|
### 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,
|
Given an initial position, step sizes, and number of steps,
|
||||||
construct a 2-dimensional list of scan x-y positions.
|
construct a 2-dimensional list of scan x-y positions.
|
||||||
"""
|
"""
|
||||||
arr = []
|
arr: List[List[XyCoordinate]] = [] # 2D array of coordinates
|
||||||
|
|
||||||
if style == "spiral":
|
if style == "spiral":
|
||||||
# deal with the centre image immediately
|
# deal with the centre image immediately
|
||||||
|
|
@ -35,31 +47,48 @@ def construct_grid(initial, step_sizes, n_steps, style="raster"):
|
||||||
arr.append([initial])
|
arr.append([initial])
|
||||||
# for spiral, n_steps is the number of shells, and so only requires n_steps[0]
|
# 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):
|
for i in range(2, n_steps[0] + 1):
|
||||||
arr.append([])
|
arr.append([]) # Append new shell holder
|
||||||
side_length = (2 * i) - 1
|
side_length = (2 * i) - 1
|
||||||
|
|
||||||
# iteratively generate the next location to append
|
# Iteratively generate the next location to append
|
||||||
coord = [coord[ax] + [-1, 1][ax] * step_sizes[ax] for ax in range(2)]
|
|
||||||
|
# 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 direction in ([1, 0], [0, -1], [-1, 0], [0, 1]):
|
||||||
for _ in range(side_length - 1):
|
for _ in range(side_length - 1):
|
||||||
coord = [
|
last_coordinate = coord
|
||||||
coord[ax] + direction[ax] * step_sizes[ax] for ax in range(2)
|
coord = (
|
||||||
]
|
last_coordinate[0] + direction[0] * step_sizes[0],
|
||||||
arr[i - 1].append(tuple(coord))
|
last_coordinate[1] + direction[1] * step_sizes[1],
|
||||||
|
)
|
||||||
|
arr[i - 1].append(coord)
|
||||||
|
|
||||||
|
# If raster or snake
|
||||||
else:
|
else:
|
||||||
for i in range(n_steps[0]): # x axis
|
for i in range(n_steps[0]): # x axis
|
||||||
arr.append([])
|
arr.append([])
|
||||||
for j in range(n_steps[1]): # y axis
|
for j in range(n_steps[1]): # y axis
|
||||||
# Create a coordinate array
|
# Create a coordinate tuple
|
||||||
coord = [initial[ax] + [i, j][ax] * step_sizes[ax] for ax in range(2)]
|
coord = (
|
||||||
|
initial[0] + [i, j][0] * step_sizes[0],
|
||||||
|
initial[1] + [i, j][1] * step_sizes[1],
|
||||||
|
)
|
||||||
# Append coordinate array to position grid
|
# Append coordinate array to position grid
|
||||||
arr[i].append(tuple(coord))
|
arr[i].append(coord)
|
||||||
|
|
||||||
# Style modifiers
|
# Style modifiers
|
||||||
if style == "snake":
|
if style == "snake":
|
||||||
|
# For each line (row) in the coordinate array
|
||||||
for i, line in enumerate(arr):
|
for i, line in enumerate(arr):
|
||||||
|
# If it's an odd row
|
||||||
if i % 2 != 0:
|
if i % 2 != 0:
|
||||||
|
# Reverse the list of coordinates
|
||||||
line.reverse()
|
line.reverse()
|
||||||
|
|
||||||
return arr
|
return arr
|
||||||
|
|
@ -76,16 +105,17 @@ class ScanExtension(BaseExtension):
|
||||||
|
|
||||||
def capture(
|
def capture(
|
||||||
self,
|
self,
|
||||||
microscope,
|
microscope: Microscope,
|
||||||
basename,
|
basename: Optional[str],
|
||||||
namemode: str = "coordinates",
|
namemode: str = "coordinates",
|
||||||
temporary: bool = False,
|
temporary: bool = False,
|
||||||
use_video_port: bool = False,
|
use_video_port: bool = False,
|
||||||
resize: Tuple[int, int] = None,
|
resize: Optional[Tuple[int, int]] = None,
|
||||||
bayer: bool = False,
|
bayer: bool = False,
|
||||||
metadata: dict = None,
|
metadata: Optional[dict] = None,
|
||||||
annotations: dict = None,
|
annotations: Optional[Dict[str, str]] = None,
|
||||||
tags: list = None,
|
tags: Optional[List[str]] = None,
|
||||||
|
dataset: Optional[Dict[str, str]] = None,
|
||||||
):
|
):
|
||||||
metadata = metadata or {}
|
metadata = metadata or {}
|
||||||
annotations = annotations or {}
|
annotations = annotations or {}
|
||||||
|
|
@ -113,6 +143,7 @@ class ScanExtension(BaseExtension):
|
||||||
bayer=bayer,
|
bayer=bayer,
|
||||||
annotations=annotations,
|
annotations=annotations,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
dataset=dataset,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
cache_key=folder,
|
cache_key=folder,
|
||||||
)
|
)
|
||||||
|
|
@ -125,21 +156,21 @@ class ScanExtension(BaseExtension):
|
||||||
### Scanning
|
### Scanning
|
||||||
def tile(
|
def tile(
|
||||||
self,
|
self,
|
||||||
microscope,
|
microscope: Microscope,
|
||||||
basename: str = None,
|
basename: Optional[str] = None,
|
||||||
namemode: str = "coordinates",
|
namemode: str = "coordinates",
|
||||||
temporary: bool = False,
|
temporary: bool = False,
|
||||||
stride_size: list = (2000, 1500, 100),
|
stride_size: XyzCoordinate = (2000, 1500, 100),
|
||||||
grid: list = (3, 3, 5),
|
grid: XyzCoordinate = (3, 3, 5),
|
||||||
style="raster",
|
style="raster",
|
||||||
autofocus_dz: int = 50,
|
autofocus_dz: int = 50,
|
||||||
use_video_port: bool = False,
|
use_video_port: bool = False,
|
||||||
resize: Tuple[int, int] = None,
|
resize: Optional[Tuple[int, int]] = None,
|
||||||
bayer: bool = False,
|
bayer: bool = False,
|
||||||
fast_autofocus=False,
|
fast_autofocus: bool = False,
|
||||||
metadata: dict = None,
|
metadata: Optional[dict] = None,
|
||||||
annotations: dict = None,
|
annotations: Optional[Dict[str, str]] = None,
|
||||||
tags: list = None,
|
tags: Optional[List[str]] = None,
|
||||||
):
|
):
|
||||||
metadata = metadata or {}
|
metadata = metadata or {}
|
||||||
annotations = annotations or {}
|
annotations = annotations or {}
|
||||||
|
|
@ -160,7 +191,6 @@ class ScanExtension(BaseExtension):
|
||||||
|
|
||||||
# Add dataset metadata
|
# Add dataset metadata
|
||||||
dataset_d = {
|
dataset_d = {
|
||||||
"dataset": {
|
|
||||||
"id": uuid.uuid4(),
|
"id": uuid.uuid4(),
|
||||||
"type": "xyzScan",
|
"type": "xyzScan",
|
||||||
"name": basename,
|
"name": basename,
|
||||||
|
|
@ -170,7 +200,6 @@ class ScanExtension(BaseExtension):
|
||||||
"style": style,
|
"style": style,
|
||||||
"autofocusDz": autofocus_dz,
|
"autofocusDz": autofocus_dz,
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
# Check if autofocus is enabled
|
# Check if autofocus is enabled
|
||||||
autofocus_extension = find_extension("org.openflexure.autofocus")
|
autofocus_extension = find_extension("org.openflexure.autofocus")
|
||||||
|
|
@ -186,7 +215,7 @@ class ScanExtension(BaseExtension):
|
||||||
|
|
||||||
# Construct an x-y grid (worry about z later)
|
# Construct an x-y grid (worry about z later)
|
||||||
x_y_grid = construct_grid(
|
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
|
# 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
|
next_z = initial_z # Reset z position at start of each new row
|
||||||
logging.debug("Returning to initial z position")
|
logging.debug("Returning to initial z position")
|
||||||
microscope.stage.move_abs(
|
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
|
) # RWB: I think this line is redundant
|
||||||
|
|
||||||
for x_y in line:
|
for x_y in line:
|
||||||
# Move to new grid position without changing z
|
# Move to new grid position without changing z
|
||||||
logging.debug("Moving to step %s", ([x_y[0], x_y[1], next_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
|
# Refocus
|
||||||
if autofocus_enabled:
|
if autofocus_enabled:
|
||||||
if fast_autofocus:
|
if fast_autofocus:
|
||||||
|
|
@ -232,7 +261,7 @@ class ScanExtension(BaseExtension):
|
||||||
use_video_port=use_video_port,
|
use_video_port=use_video_port,
|
||||||
resize=resize,
|
resize=resize,
|
||||||
bayer=bayer,
|
bayer=bayer,
|
||||||
metadata=dataset_d,
|
dataset=dataset_d,
|
||||||
annotations=annotations,
|
annotations=annotations,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
)
|
)
|
||||||
|
|
@ -251,7 +280,7 @@ class ScanExtension(BaseExtension):
|
||||||
use_video_port=use_video_port,
|
use_video_port=use_video_port,
|
||||||
resize=resize,
|
resize=resize,
|
||||||
bayer=bayer,
|
bayer=bayer,
|
||||||
metadata=dataset_d,
|
dataset=dataset_d,
|
||||||
annotations=annotations,
|
annotations=annotations,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
)
|
)
|
||||||
|
|
@ -268,19 +297,20 @@ class ScanExtension(BaseExtension):
|
||||||
|
|
||||||
def stack(
|
def stack(
|
||||||
self,
|
self,
|
||||||
microscope,
|
microscope: Microscope,
|
||||||
basename: str = None,
|
basename: Optional[str] = None,
|
||||||
namemode: str = "coordinates",
|
namemode: str = "coordinates",
|
||||||
temporary: bool = False,
|
temporary: bool = False,
|
||||||
step_size: int = 100,
|
step_size: int = 100,
|
||||||
steps: int = 5,
|
steps: int = 5,
|
||||||
return_to_start: bool = True,
|
return_to_start: bool = True,
|
||||||
use_video_port: bool = False,
|
use_video_port: bool = False,
|
||||||
resize: Tuple[int, int] = None,
|
resize: Optional[Tuple[int, int]] = None,
|
||||||
bayer: bool = False,
|
bayer: bool = False,
|
||||||
metadata: dict = None,
|
metadata: Optional[dict] = None,
|
||||||
annotations: dict = None,
|
annotations: Optional[Dict[str, str]] = None,
|
||||||
tags: list = None,
|
dataset: Optional[Dict[str, str]] = None,
|
||||||
|
tags: Optional[List[str]] = None,
|
||||||
):
|
):
|
||||||
metadata = metadata or {}
|
metadata = metadata or {}
|
||||||
annotations = annotations or {}
|
annotations = annotations or {}
|
||||||
|
|
@ -293,7 +323,7 @@ class ScanExtension(BaseExtension):
|
||||||
with microscope.lock:
|
with microscope.lock:
|
||||||
# Move to center scan
|
# Move to center scan
|
||||||
logging.debug("Moving to z-stack starting position")
|
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)
|
logging.debug("Starting scan from position %s", microscope.stage.position)
|
||||||
|
|
||||||
for i in range(steps):
|
for i in range(steps):
|
||||||
|
|
@ -309,6 +339,7 @@ class ScanExtension(BaseExtension):
|
||||||
bayer=bayer,
|
bayer=bayer,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
annotations=annotations,
|
annotations=annotations,
|
||||||
|
dataset=dataset,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
)
|
)
|
||||||
# Update task progress
|
# Update task progress
|
||||||
|
|
@ -319,7 +350,7 @@ class ScanExtension(BaseExtension):
|
||||||
|
|
||||||
if i != steps - 1:
|
if i != steps - 1:
|
||||||
logging.debug("Moving z by %s", (step_size))
|
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:
|
if return_to_start:
|
||||||
logging.debug("Returning to %s", (initial_position))
|
logging.debug("Returning to %s", (initial_position))
|
||||||
microscope.stage.move_abs(initial_position)
|
microscope.stage.move_abs(initial_position)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from typing import IO, List, Optional
|
||||||
|
|
||||||
from flask import abort, send_file, url_for
|
from flask import abort, send_file, url_for
|
||||||
from labthings import fields, find_component, update_action_progress
|
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.utilities import description_from_view
|
||||||
from labthings.views import ActionView, PropertyView, View
|
from labthings.views import ActionView, PropertyView, View
|
||||||
|
|
||||||
|
from openflexure_microscope.captures import CaptureObject
|
||||||
|
from openflexure_microscope.microscope import Microscope
|
||||||
|
|
||||||
|
|
||||||
class ZipObjectSchema(Schema):
|
class ZipObjectSchema(Schema):
|
||||||
id = fields.String()
|
id = fields.String()
|
||||||
|
|
@ -32,11 +36,13 @@ class ZipObjectSchema(Schema):
|
||||||
|
|
||||||
|
|
||||||
class ZipObjectDescription:
|
class ZipObjectDescription:
|
||||||
def __init__(self, id_, file_pointer, data_size=None):
|
def __init__(
|
||||||
self.id = id_
|
self, id_: str, file_pointer: IO[bytes], data_size: Optional[float] = None
|
||||||
self.fp = file_pointer
|
):
|
||||||
self.data_size = data_size
|
self.id: str = id_
|
||||||
self.zip_size = os.path.getsize(self.fp.name) * 1e-6
|
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):
|
def close(self):
|
||||||
logging.debug(self.fp.name)
|
logging.debug(self.fp.name)
|
||||||
|
|
@ -60,22 +66,26 @@ class ZipManager:
|
||||||
|
|
||||||
self.session_zips = {}
|
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)
|
logging.debug(capture_id_list)
|
||||||
|
|
||||||
# Get array of captures from IDs
|
# 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
|
microscope.captures.images.get(capture_id) for capture_id in capture_id_list
|
||||||
]
|
]
|
||||||
# Remove Nones from list (missing/invalid captures)
|
# 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
|
# 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
|
os.path.getsize(capture_obj.file) for capture_obj in capture_list
|
||||||
]
|
]
|
||||||
# Calculate size of input data in megabytes
|
# 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 more than 1GB
|
||||||
if data_size_megabytes > 1000:
|
if data_size_megabytes > 1000:
|
||||||
|
|
@ -85,10 +95,10 @@ class ZipManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Number of files to add (used for task progress)
|
# 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
|
# Create temporary file
|
||||||
fp = tempfile.NamedTemporaryFile(delete=False)
|
fp: IO[bytes] = tempfile.NamedTemporaryFile(delete=False)
|
||||||
|
|
||||||
# Open temp file as a ZIP file
|
# Open temp file as a ZIP file
|
||||||
with zipfile.ZipFile(fp, "w") as zipObj:
|
with zipfile.ZipFile(fp, "w") as zipObj:
|
||||||
|
|
@ -102,8 +112,8 @@ class ZipManager:
|
||||||
# Update task progress
|
# Update task progress
|
||||||
update_action_progress(int((index / n_files) * 100))
|
update_action_progress(int((index / n_files) * 100))
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
session_id: str = str(uuid.uuid4())
|
||||||
session_description = ZipObjectDescription(
|
session_description: ZipObjectDescription = ZipObjectDescription(
|
||||||
session_id, fp, data_size=data_size_megabytes
|
session_id, fp, data_size=data_size_megabytes
|
||||||
)
|
)
|
||||||
self.session_zips[session_id] = session_description
|
self.session_zips[session_id] = session_description
|
||||||
|
|
@ -113,7 +123,7 @@ class ZipManager:
|
||||||
def marshaled_build_zip_from_capture_ids(self, *args, **kwargs):
|
def marshaled_build_zip_from_capture_ids(self, *args, **kwargs):
|
||||||
return ZipObjectSchema().dump(self.build_zip_from_capture_ids(*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
|
return self.session_zips[session_id].fp
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ class SleepFor(ActionView):
|
||||||
args = {"time": fields.Float(description="Time to sleep, in seconds", example=0.5)}
|
args = {"time": fields.Float(description="Time to sleep, in seconds", example=0.5)}
|
||||||
|
|
||||||
def post(self, args):
|
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)
|
logging.info("Going to sleep for %s...", sleep_time)
|
||||||
start = time.time()
|
start = time.time()
|
||||||
time.sleep(sleep_time)
|
time.sleep(sleep_time)
|
||||||
|
|
|
||||||
196
openflexure_microscope/api/static/package-lock.json
generated
196
openflexure_microscope/api/static/package-lock.json
generated
|
|
@ -2917,6 +2917,16 @@
|
||||||
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
|
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
|
||||||
"dev": true
|
"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": {
|
"browserslist": {
|
||||||
"version": "4.14.7",
|
"version": "4.14.7",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.14.7.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.14.7.tgz",
|
||||||
|
|
@ -2962,12 +2972,36 @@
|
||||||
"integrity": "sha512-gOerH9Wz2IRZ2ZPdMfBvyOi3cjaz4O4dgNwPGzx8EhqAs4+2IL/O+fJsbt+znSigujoZG8bVcIAUM/I/E5K3MA==",
|
"integrity": "sha512-gOerH9Wz2IRZ2ZPdMfBvyOi3cjaz4O4dgNwPGzx8EhqAs4+2IL/O+fJsbt+znSigujoZG8bVcIAUM/I/E5K3MA==",
|
||||||
"dev": true
|
"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": {
|
"electron-to-chromium": {
|
||||||
"version": "1.3.592",
|
"version": "1.3.592",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.592.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.592.tgz",
|
||||||
"integrity": "sha512-kGNowksvqQiPb1pUSQKpd8JFoGPLxYOwduNRCqCxGh/2Q1qE2JdmwouCW41lUzDxOb/2RIV4lR0tVIfboWlO9A==",
|
"integrity": "sha512-kGNowksvqQiPb1pUSQKpd8JFoGPLxYOwduNRCqCxGh/2Q1qE2JdmwouCW41lUzDxOb/2RIV4lR0tVIfboWlO9A==",
|
||||||
"dev": true
|
"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": {
|
"fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"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==",
|
"integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==",
|
||||||
"dev": true
|
"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": {
|
"locate-path": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
|
@ -3134,6 +3197,16 @@
|
||||||
"ansi-regex": "^5.0.0"
|
"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": {
|
"terser-webpack-plugin": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.8.tgz",
|
||||||
|
|
@ -3160,6 +3233,31 @@
|
||||||
"psl": "^1.1.28",
|
"psl": "^1.1.28",
|
||||||
"punycode": "^2.1.1"
|
"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": {
|
"vue-observe-visibility": {
|
||||||
"version": "0.4.6",
|
"version": "0.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-0.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-0.4.6.tgz",
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,10 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
captures: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
tags: {
|
tags: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
|
|
@ -92,7 +96,7 @@ export default {
|
||||||
computed: {
|
computed: {
|
||||||
allURLs: function() {
|
allURLs: function() {
|
||||||
var urls = [];
|
var urls = [];
|
||||||
for (var capture of this.scanState.captures) {
|
for (var capture of this.captures) {
|
||||||
urls.push(capture.links.self.href);
|
urls.push(capture.links.self.href);
|
||||||
}
|
}
|
||||||
return urls;
|
return urls;
|
||||||
|
|
@ -115,6 +119,7 @@ export default {
|
||||||
|
|
||||||
deleteAll: function() {
|
deleteAll: function() {
|
||||||
axios.all(this.allURLs.map(l => axios.delete(l))).then(() => {
|
axios.all(this.allURLs.map(l => axios.delete(l))).then(() => {
|
||||||
|
console.log("Delete finished")
|
||||||
// Emit signal to update capture list
|
// Emit signal to update capture list
|
||||||
this.$root.$emit("globalUpdateCaptures");
|
this.$root.$emit("globalUpdateCaptures");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@
|
||||||
:time="item.time"
|
:time="item.time"
|
||||||
:tags="item.tags"
|
:tags="item.tags"
|
||||||
:type="item.type"
|
:type="item.type"
|
||||||
|
:captures="item.captures"
|
||||||
:thumbnail="item.thumbnail"
|
:thumbnail="item.thumbnail"
|
||||||
@selectFolder="selectFolder"
|
@selectFolder="selectFolder"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,51 @@
|
||||||
import errno
|
import errno
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
from flask import Blueprint, current_app, url_for
|
from flask import Blueprint, Flask, current_app, url_for
|
||||||
from werkzeug.exceptions import BadRequest
|
from flask.views import View
|
||||||
|
|
||||||
from . import gui
|
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
|
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(
|
return Blueprint(
|
||||||
blueprint_name_for_module(module_name, api_ver=api_ver, suffix=suffix),
|
blueprint_name_for_module(module_name, api_ver=api_ver, suffix=suffix),
|
||||||
module_name,
|
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]
|
bp_name = module_name.split(".")[-1]
|
||||||
return f"v{api_ver}_{bp_name}_blueprint{suffix}"
|
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"""
|
"""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
|
return True
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def list_routes(app):
|
def list_routes(app: Flask):
|
||||||
output = {}
|
output = {}
|
||||||
for rule in app.url_map.iter_rules():
|
for rule in app.url_map.iter_rules():
|
||||||
|
|
||||||
|
|
@ -49,7 +62,7 @@ def list_routes(app):
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
def create_file(config_path):
|
def create_file(config_path: str):
|
||||||
if not os.path.exists(os.path.dirname(config_path)):
|
if not os.path.exists(os.path.dirname(config_path)):
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(config_path))
|
os.makedirs(os.path.dirname(config_path))
|
||||||
|
|
@ -58,7 +71,7 @@ def create_file(config_path):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def init_default_extensions(extension_dir):
|
def init_default_extensions(extension_dir: str):
|
||||||
os.makedirs(extension_dir, exist_ok=True)
|
os.makedirs(extension_dir, exist_ok=True)
|
||||||
|
|
||||||
default_ext_path = os.path.join(extension_dir, "defaults.py")
|
default_ext_path = os.path.join(extension_dir, "defaults.py")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from typing import Callable, Union
|
||||||
|
|
||||||
|
from labthings.extensions import BaseExtension
|
||||||
|
|
||||||
|
|
||||||
def clean_rule(rule: str):
|
def clean_rule(rule: str):
|
||||||
|
|
@ -9,13 +12,12 @@ def clean_rule(rule: str):
|
||||||
return f"{rule}"
|
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
|
# Make a working copy of GUI description
|
||||||
api_gui = copy.deepcopy(gui_description)
|
api_gui = copy.deepcopy(gui_description)
|
||||||
# Grab the extensions rules dictionary
|
# Grab the extensions rules dictionary
|
||||||
# NOTE: The LabThings extension object should probably have a non-private
|
# TODO: Public property should be added to LabThings
|
||||||
# way to get the rules dictionary. For now we need to ignore pylint W0212
|
ext_rules = extension_object._rules # pylint: disable=protected-access
|
||||||
ext_rules = extension_object._rules # pylint: disable=W0212
|
|
||||||
|
|
||||||
# Expand shorthand routes into full relative URLs
|
# Expand shorthand routes into full relative URLs
|
||||||
if "forms" in gui_description and isinstance(api_gui["forms"], list):
|
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
|
return api_gui
|
||||||
|
|
||||||
|
|
||||||
def build_gui_from_func(func, extension_object):
|
def build_gui_from_func(func: Callable, extension_object: BaseExtension):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapped(*args, **kwargs):
|
def wrapped(*args, **kwargs):
|
||||||
return build_gui_from_dict(func(*args, **kwargs), extension_object)
|
return build_gui_from_dict(func(*args, **kwargs), extension_object)
|
||||||
|
|
@ -43,7 +45,7 @@ def build_gui_from_func(func, extension_object):
|
||||||
return wrapped
|
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 given a function that generates a GUI dictionary
|
||||||
if callable(gui_description):
|
if callable(gui_description):
|
||||||
# Wrap in the route expander
|
# Wrap in the route expander
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
from flask import send_file
|
from flask import send_file
|
||||||
from labthings import Schema, fields, find_component
|
from labthings import Schema, fields, find_component
|
||||||
|
|
@ -42,12 +43,14 @@ class CaptureAPI(ActionView):
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
resize = args.get("resize", None)
|
resize_dict: Optional[Dict[str, int]] = args.get("resize", None)
|
||||||
if resize:
|
if resize_dict:
|
||||||
resize = (
|
resize: Optional[Tuple[int, int]] = (
|
||||||
int(resize["width"]),
|
int(resize_dict["width"]),
|
||||||
int(resize["height"]),
|
int(resize_dict["height"]),
|
||||||
) # Convert dict to tuple
|
) # Convert dict to tuple
|
||||||
|
else:
|
||||||
|
resize = None
|
||||||
|
|
||||||
# Explicitally acquire lock (prevents empty files being created if lock is unavailable)
|
# Explicitally acquire lock (prevents empty files being created if lock is unavailable)
|
||||||
with microscope.camera.lock:
|
with microscope.camera.lock:
|
||||||
|
|
@ -76,12 +79,14 @@ class RAMCaptureAPI(ActionView):
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
resize = args.get("resize", None)
|
resize_dict: Optional[Dict[str, int]] = args.get("resize", None)
|
||||||
if resize:
|
if resize_dict:
|
||||||
resize = (
|
resize: Optional[Tuple[int, int]] = (
|
||||||
int(resize["width"]),
|
int(resize_dict["width"]),
|
||||||
int(resize["height"]),
|
int(resize_dict["height"]),
|
||||||
) # Convert dict to tuple
|
) # Convert dict to tuple
|
||||||
|
else:
|
||||||
|
resize = None
|
||||||
|
|
||||||
# Open a BytesIO stream to be destroyed once request has returned
|
# Open a BytesIO stream to be destroyed once request has returned
|
||||||
with microscope.camera.lock, io.BytesIO() as stream:
|
with microscope.camera.lock, io.BytesIO() as stream:
|
||||||
|
|
@ -113,15 +118,18 @@ class GPUPreviewStartAPI(ActionView):
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
microscope = find_component("org.openflexure.microscope")
|
||||||
|
|
||||||
window = args.get("window")
|
# Get window argument from request
|
||||||
logging.debug(window)
|
window_arg = args.get("window")
|
||||||
|
logging.debug(window_arg)
|
||||||
|
|
||||||
if len(window) != 4:
|
# Default to no window
|
||||||
fullscreen = True
|
fullscreen: bool = True
|
||||||
window = None
|
window: Optional[Tuple[int, int, int, int]] = None
|
||||||
else:
|
|
||||||
|
# If request argument is well formed, use that
|
||||||
|
if len(window_arg) == 4:
|
||||||
fullscreen = False
|
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)
|
microscope.camera.start_preview(fullscreen=fullscreen, window=window)
|
||||||
return microscope.state
|
return microscope.state
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
from labthings import fields, find_component
|
from labthings import fields, find_component
|
||||||
from labthings.views import ActionView
|
from labthings.views import ActionView
|
||||||
|
|
@ -24,11 +25,11 @@ class MoveStageAPI(ActionView):
|
||||||
|
|
||||||
# Handle absolute positioning (calculate a relative move from current position and target)
|
# Handle absolute positioning (calculate a relative move from current position and target)
|
||||||
if (args.get("absolute")) and (microscope.stage): # Only if stage exists
|
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))
|
logging.debug("TARGET: %s", (target_position))
|
||||||
position = [
|
position: Tuple[int, int, int] = (
|
||||||
target_position[i] - microscope.stage.position[i] for i in range(3)
|
target_position[i] - microscope.stage.position[i] for i in range(3)
|
||||||
]
|
)
|
||||||
logging.debug("DELTA: %s", (position))
|
logging.debug("DELTA: %s", (position))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import subprocess
|
||||||
from labthings.views import ActionView
|
from labthings.views import ActionView
|
||||||
|
|
||||||
|
|
||||||
def is_raspberrypi():
|
def is_raspberrypi() -> bool:
|
||||||
"""
|
"""
|
||||||
Checks if Raspberry Pi.
|
Checks if Raspberry Pi.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,18 @@ class LSTImageProperty(PropertyView):
|
||||||
logging.warning("PiCamera.lens_shading_table returned as None")
|
logging.warning("PiCamera.lens_shading_table returned as None")
|
||||||
return ("", 204)
|
return ("", 204)
|
||||||
else:
|
else:
|
||||||
|
# Get the LST Numpy array from the camera's memoryview
|
||||||
lst = np.asarray(microscope.camera.camera.lens_shading_table)
|
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)
|
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)
|
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)
|
all_channels = np.concatenate((top, bottom), axis=0)
|
||||||
# Create a PNG
|
# Create a PNG
|
||||||
img_bytes = BytesIO()
|
img_bytes: BytesIO = BytesIO()
|
||||||
Image.fromarray(np.uint8(all_channels), "L").save(img_bytes, "PNG")
|
Image.fromarray(np.uint8(all_channels), "L").save(img_bytes, "PNG")
|
||||||
img_bytes.seek(0)
|
img_bytes.seek(0)
|
||||||
# Return the image
|
# 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)
|
return send_file(img_bytes, as_attachment=True, attachment_filename=fname)
|
||||||
|
|
|
||||||
|
|
@ -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 flask import abort, redirect, request, send_file, url_for
|
||||||
from labthings import Schema, fields, find_component
|
from labthings import Schema, fields, find_component
|
||||||
from labthings.marshalling import marshal_with, use_args
|
from labthings.marshalling import marshal_with, use_args
|
||||||
|
|
@ -6,6 +10,7 @@ from labthings.views import PropertyView, View
|
||||||
from marshmallow import pre_dump
|
from marshmallow import pre_dump
|
||||||
|
|
||||||
from openflexure_microscope.api.utilities import get_bool
|
from openflexure_microscope.api.utilities import get_bool
|
||||||
|
from openflexure_microscope.captures import CaptureObject
|
||||||
|
|
||||||
# SCHEMAS
|
# SCHEMAS
|
||||||
|
|
||||||
|
|
@ -61,10 +66,10 @@ class CaptureSchema(ImageSchema):
|
||||||
links = fields.Dict()
|
links = fields.Dict()
|
||||||
|
|
||||||
@pre_dump
|
@pre_dump
|
||||||
def generate_links(self, data, **_):
|
def generate_links(self, data: Union[dict, CaptureObject], **_):
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
capture_id = data.get("id")
|
capture_id: Optional[Union[str, UUID]] = data.get("id")
|
||||||
capture_name = data.get("name")
|
capture_name: Optional[str] = data.get("name")
|
||||||
else:
|
else:
|
||||||
capture_id = data.id
|
capture_id = data.id
|
||||||
capture_name = data.name
|
capture_name = data.name
|
||||||
|
|
@ -102,7 +107,7 @@ class CaptureSchema(ImageSchema):
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
data["links"] = links
|
data["links"] = links
|
||||||
else:
|
else:
|
||||||
data.links = links
|
setattr(data, "links", links)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
@ -129,7 +134,7 @@ class CaptureList(PropertyView):
|
||||||
List all image captures
|
List all image captures
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
microscope = find_component("org.openflexure.microscope")
|
||||||
image_list = microscope.captures.images.values()
|
image_list: List[CaptureObject] = microscope.captures.images.values()
|
||||||
return image_list
|
return image_list
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -142,7 +147,7 @@ class CaptureView(View):
|
||||||
Description of a single image capture
|
Description of a single image capture
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
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:
|
if not capture_obj:
|
||||||
return abort(404) # 404 Not Found
|
return abort(404) # 404 Not Found
|
||||||
|
|
@ -154,7 +159,7 @@ class CaptureView(View):
|
||||||
Delete a single image capture
|
Delete a single image capture
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
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:
|
if not capture_obj:
|
||||||
return abort(404) # 404 Not Found
|
return abort(404) # 404 Not Found
|
||||||
|
|
@ -171,17 +176,17 @@ class CaptureDownload(View):
|
||||||
tags = ["captures"]
|
tags = ["captures"]
|
||||||
responses = {200: {"content_type": "image/jpeg"}}
|
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
|
Image data for a single image capture
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
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:
|
if not capture_obj:
|
||||||
return abort(404) # 404 Not Found
|
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 no filename is specified, redirect to the capture's currently set filename
|
||||||
if not filename:
|
if not filename:
|
||||||
|
|
@ -189,7 +194,7 @@ class CaptureDownload(View):
|
||||||
url_for(
|
url_for(
|
||||||
"DownloadAPI",
|
"DownloadAPI",
|
||||||
id=id_,
|
id=id_,
|
||||||
filename=capture_obj.filename,
|
filename=capture_obj.name,
|
||||||
thumbnail=thumbnail,
|
thumbnail=thumbnail,
|
||||||
),
|
),
|
||||||
code=307,
|
code=307,
|
||||||
|
|
@ -197,10 +202,14 @@ class CaptureDownload(View):
|
||||||
|
|
||||||
# Download the image data using the requested filename
|
# Download the image data using the requested filename
|
||||||
if thumbnail:
|
if thumbnail:
|
||||||
img = capture_obj.thumbnail
|
img: Optional[BytesIO] = capture_obj.thumbnail
|
||||||
else:
|
else:
|
||||||
img = capture_obj.data
|
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")
|
return send_file(img, mimetype="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -212,7 +221,7 @@ class CaptureTags(View):
|
||||||
Get tags associated with a single image capture
|
Get tags associated with a single image capture
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
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:
|
if not capture_obj:
|
||||||
return abort(404) # 404 Not Found
|
return abort(404) # 404 Not Found
|
||||||
|
|
@ -225,7 +234,7 @@ class CaptureTags(View):
|
||||||
Add tags to a single image capture
|
Add tags to a single image capture
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
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:
|
if not capture_obj:
|
||||||
return abort(404) # 404 Not Found
|
return abort(404) # 404 Not Found
|
||||||
|
|
@ -240,7 +249,7 @@ class CaptureTags(View):
|
||||||
Delete tags from a single image capture
|
Delete tags from a single image capture
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
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:
|
if not capture_obj:
|
||||||
return abort(404) # 404 Not Found
|
return abort(404) # 404 Not Found
|
||||||
|
|
@ -259,7 +268,7 @@ class CaptureAnnotations(View):
|
||||||
Get annotations associated with a single image capture
|
Get annotations associated with a single image capture
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
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:
|
if not capture_obj:
|
||||||
return abort(404) # 404 Not Found
|
return abort(404) # 404 Not Found
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
from flask import abort
|
from flask import abort
|
||||||
from labthings import fields, find_component
|
from labthings import fields, find_component
|
||||||
|
|
@ -35,29 +36,29 @@ class NestedSettingsProperty(View):
|
||||||
tags = ["properties"]
|
tags = ["properties"]
|
||||||
responses = {404: {"description": "Settings key cannot be found"}}
|
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
|
Show a nested section of the current microscope settings
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
microscope = find_component("org.openflexure.microscope")
|
||||||
keys = route.split("/")
|
keys: List[str] = route.split("/")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = get_by_path(microscope.read_settings(), keys)
|
value: Any = get_by_path(microscope.read_settings(), keys)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@use_args(fields.Dict())
|
@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
|
Update a nested section of the current microscope settings
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
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)
|
set_by_path(dictionary, keys, args)
|
||||||
|
|
||||||
microscope.update_settings(dictionary)
|
microscope.update_settings(dictionary)
|
||||||
|
|
@ -84,10 +85,10 @@ class NestedStateProperty(View):
|
||||||
Show a nested section of the current microscope state
|
Show a nested section of the current microscope state
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
microscope = find_component("org.openflexure.microscope")
|
||||||
keys = route.split("/")
|
keys: List[str] = route.split("/")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = get_by_path(microscope.state, keys)
|
value: Any = get_by_path(microscope.state, keys)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
|
|
||||||
|
|
@ -112,10 +113,10 @@ class NestedConfigurationProperty(View):
|
||||||
Show a nested section of the current microscope state
|
Show a nested section of the current microscope state
|
||||||
"""
|
"""
|
||||||
microscope = find_component("org.openflexure.microscope")
|
microscope = find_component("org.openflexure.microscope")
|
||||||
keys = route.split("/")
|
keys: List[str] = route.split("/")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = get_by_path(microscope.configuration, keys)
|
value: Any = get_by_path(microscope.configuration, keys)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ def gen(camera):
|
||||||
"""Video streaming generator function."""
|
"""Video streaming generator function."""
|
||||||
while True:
|
while True:
|
||||||
# the obtained frame is a jpeg
|
# 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")
|
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import logging
|
||||||
import time
|
import time
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import BinaryIO, List, Optional, Tuple, Type, Union
|
||||||
|
|
||||||
from labthings import ClientEvent, StrictLock
|
from labthings import ClientEvent, StrictLock
|
||||||
|
|
||||||
|
|
@ -27,16 +29,29 @@ class FrameStream(io.BytesIO):
|
||||||
# Array of TrackerFrame objects
|
# Array of TrackerFrame objects
|
||||||
io.BytesIO.__init__(self, *args, **kwargs)
|
io.BytesIO.__init__(self, *args, **kwargs)
|
||||||
# Array of TrackerFramer objects
|
# Array of TrackerFramer objects
|
||||||
self.frames = []
|
self.frames: List[TrackerFrame] = []
|
||||||
# Last acquired TrackerFramer object
|
# Last acquired TrackerFramer object
|
||||||
self.last = None
|
self.last: Optional[TrackerFrame] = None
|
||||||
|
|
||||||
# Are we currently tracking frame sizes?
|
# 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
|
# 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
|
# 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):
|
def start_tracking(self):
|
||||||
"""Start tracking frame sizes"""
|
"""Start tracking frame sizes"""
|
||||||
|
|
@ -92,13 +107,16 @@ class BaseCamera(metaclass=ABCMeta):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
#: :py:class:`labthings.StrictLock`: Access lock for the camera
|
#: :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
|
#: :py:class:`FrameStream`: Streaming and analysis frame buffer
|
||||||
self.stream = FrameStream()
|
self.stream: FrameStream = FrameStream()
|
||||||
|
|
||||||
self.stream_active = False
|
self.stream_active: bool = False
|
||||||
self.record_active = False
|
self.record_active: bool = False
|
||||||
self.preview_active = False
|
self.preview_active: bool = False
|
||||||
|
|
||||||
|
self.image_resolution: Tuple[int, int] = (1312, 976)
|
||||||
|
self.stream_resolution: Tuple[int, int] = (640, 480)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|
@ -114,6 +132,14 @@ class BaseCamera(metaclass=ABCMeta):
|
||||||
def settings(self):
|
def settings(self):
|
||||||
return self.read_settings()
|
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
|
@abstractmethod
|
||||||
def update_settings(self, config: dict):
|
def update_settings(self, config: dict):
|
||||||
"""Update settings from a config dictionary"""
|
"""Update settings from a config dictionary"""
|
||||||
|
|
@ -122,6 +148,28 @@ class BaseCamera(metaclass=ABCMeta):
|
||||||
def read_settings(self) -> dict:
|
def read_settings(self) -> dict:
|
||||||
"""Return the current settings as a dictionary"""
|
"""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):
|
def __enter__(self):
|
||||||
"""Create camera on context enter."""
|
"""Create camera on context enter."""
|
||||||
return self
|
return self
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Type hinting
|
# Type hinting
|
||||||
|
from typing import BinaryIO, Optional, Tuple, Union
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
from openflexure_microscope.camera.base import BaseCamera
|
from openflexure_microscope.camera.base import BaseCamera
|
||||||
|
|
@ -26,17 +28,17 @@ class MissingCamera(BaseCamera):
|
||||||
BaseCamera.__init__(self)
|
BaseCamera.__init__(self)
|
||||||
|
|
||||||
# Update config properties
|
# Update config properties
|
||||||
self.image_resolution = (1312, 976)
|
self.image_resolution: Tuple[int, int] = (1312, 976)
|
||||||
self.stream_resolution = (640, 480)
|
self.stream_resolution: Tuple[int, int] = (640, 480)
|
||||||
self.numpy_resolution = (1312, 976)
|
self.numpy_resolution: Tuple[int, int] = (1312, 976)
|
||||||
self.jpeg_quality = 75
|
self.jpeg_quality: int = 75
|
||||||
self.framerate = 10
|
self.framerate: int = 10
|
||||||
|
|
||||||
# Generate an initial dummy image
|
# Generate an initial dummy image
|
||||||
self.generate_new_dummy_image()
|
self.generate_new_dummy_image()
|
||||||
|
|
||||||
# Start streaming
|
# 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()
|
self.start_worker()
|
||||||
# Wait until frames are available
|
# Wait until frames are available
|
||||||
logging.info("Waiting for frames")
|
logging.info("Waiting for frames")
|
||||||
|
|
@ -178,7 +180,11 @@ class MissingCamera(BaseCamera):
|
||||||
"""
|
"""
|
||||||
logging.warning("Zoom not implemented in mock camera")
|
logging.warning("Zoom not implemented in mock camera")
|
||||||
|
|
||||||
# LAUNCH ACTIONS
|
def start_stream(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop_stream(self):
|
||||||
|
pass
|
||||||
|
|
||||||
def start_preview(self, *_, **__):
|
def start_preview(self, *_, **__):
|
||||||
"""Start the on board GPU camera preview."""
|
"""Start the on board GPU camera preview."""
|
||||||
|
|
@ -211,7 +217,15 @@ class MissingCamera(BaseCamera):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
logging.warning("Recording not implemented in mock camera")
|
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.
|
Capture a still image to a StreamObject.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# Type hinting
|
# Type hinting
|
||||||
from typing import Tuple
|
from typing import BinaryIO, Tuple, Union
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
@ -77,57 +77,64 @@ class PiCameraStreamer(BaseCamera):
|
||||||
BaseCamera.__init__(self)
|
BaseCamera.__init__(self)
|
||||||
|
|
||||||
#: :py:class:`picamerax.PiCamera`: Attached Picamera object
|
#: :py:class:`picamerax.PiCamera`: Attached Picamera object
|
||||||
self.camera = picamerax.PiCamera()
|
self.picamera: picamerax.PiCamera = picamerax.PiCamera()
|
||||||
|
|
||||||
# Store state of PiCameraStreamer
|
# Store state of PiCameraStreamer
|
||||||
self.preview_active = False
|
self.preview_active: bool = False
|
||||||
|
|
||||||
# Reset variable states
|
# Reset variable states
|
||||||
self.set_zoom(1.0)
|
self.set_zoom(1.0)
|
||||||
|
|
||||||
#: tuple: Resolution for image captures
|
#: 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
|
#: 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
|
#: 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.jpeg_quality: int = 100 #: int: JPEG quality
|
||||||
self.mjpeg_quality = 75 #: int: MJPEG quality
|
self.mjpeg_quality: int = 75 #: int: MJPEG quality
|
||||||
|
|
||||||
# Start stream recording (and set resolution)
|
# Start stream recording (and set resolution)
|
||||||
self.start_stream_recording()
|
self.start_stream()
|
||||||
# Wait until frames are available
|
# Wait until frames are available
|
||||||
logging.debug("Waiting for frames...")
|
logging.debug("Waiting for frames...")
|
||||||
self.stream.new_frame.wait()
|
self.stream.new_frame.wait()
|
||||||
logging.debug("Camera initialised")
|
logging.debug("Camera initialised")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configuration(self):
|
def camera(self):
|
||||||
"""The current camera configuration."""
|
logging.warning(
|
||||||
return {"board": self.camera.revision}
|
"PiCameraStreamer.camera is deprecated. Replace with PiCameraStreamer.picamera"
|
||||||
|
)
|
||||||
|
return self.picamera
|
||||||
|
|
||||||
@property
|
@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."""
|
"""The current read-only camera state."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close the Raspberry Pi PiCameraStreamer."""
|
"""Close the Raspberry Pi PiCameraStreamer."""
|
||||||
# Stop stream recording
|
# Stop stream recording
|
||||||
self.stop_stream_recording()
|
self.stop_stream()
|
||||||
# Run BaseCamera close method
|
# Run BaseCamera close method
|
||||||
super().close()
|
super().close()
|
||||||
# Detach Pi camera
|
# Detach Pi camera
|
||||||
if self.camera:
|
if self.picamera:
|
||||||
self.camera.close()
|
self.picamera.close()
|
||||||
|
|
||||||
# HANDLE SETTINGS
|
# HANDLE SETTINGS
|
||||||
def read_settings(self) -> dict:
|
def read_settings(self) -> dict:
|
||||||
"""
|
"""
|
||||||
Return config dictionary of the PiCameraStreamer.
|
Return config dictionary of the PiCameraStreamer.
|
||||||
"""
|
"""
|
||||||
conf_dict = {
|
conf_dict: dict = {
|
||||||
"stream_resolution": self.stream_resolution,
|
"stream_resolution": self.stream_resolution,
|
||||||
"image_resolution": self.image_resolution,
|
"image_resolution": self.image_resolution,
|
||||||
"numpy_resolution": self.numpy_resolution,
|
"numpy_resolution": self.numpy_resolution,
|
||||||
|
|
@ -139,7 +146,7 @@ class PiCameraStreamer(BaseCamera):
|
||||||
# Include a subset of picamera properties. Excludes lens shading table
|
# Include a subset of picamera properties. Excludes lens shading table
|
||||||
for key in PiCameraStreamer.picamera_settings_keys:
|
for key in PiCameraStreamer.picamera_settings_keys:
|
||||||
try:
|
try:
|
||||||
value = getattr(self.camera, key)
|
value = getattr(self.picamera, key)
|
||||||
logging.debug("Reading PiCamera().%s: %s", key, value)
|
logging.debug("Reading PiCamera().%s: %s", key, value)
|
||||||
conf_dict["picamera"][key] = value
|
conf_dict["picamera"][key] = value
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
|
|
@ -147,11 +154,11 @@ class PiCameraStreamer(BaseCamera):
|
||||||
|
|
||||||
# Include a serialised lens shading table
|
# Include a serialised lens shading table
|
||||||
if (
|
if (
|
||||||
hasattr(self.camera, "lens_shading_table")
|
hasattr(self.picamera, "lens_shading_table")
|
||||||
and getattr(self.camera, "lens_shading_table") is not None
|
and getattr(self.picamera, "lens_shading_table") is not None
|
||||||
):
|
):
|
||||||
conf_dict["picamera"]["lens_shading_table"] = ndarray_to_json(
|
conf_dict["picamera"]["lens_shading_table"] = ndarray_to_json(
|
||||||
getattr(self.camera, "lens_shading_table")
|
getattr(self.picamera, "lens_shading_table")
|
||||||
)
|
)
|
||||||
|
|
||||||
return conf_dict
|
return conf_dict
|
||||||
|
|
@ -179,7 +186,7 @@ class PiCameraStreamer(BaseCamera):
|
||||||
# Pause stream while changing settings
|
# Pause stream while changing settings
|
||||||
if self.stream_active: # If stream is active
|
if self.stream_active: # If stream is active
|
||||||
logging.info("Pausing stream to update config.")
|
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
|
paused_stream = True # Remember to unpause stream when done
|
||||||
|
|
||||||
# PiCamera parameters
|
# PiCamera parameters
|
||||||
|
|
@ -190,11 +197,11 @@ class PiCameraStreamer(BaseCamera):
|
||||||
|
|
||||||
# Handle lens shading if camera supports it
|
# Handle lens shading if camera supports it
|
||||||
if (
|
if (
|
||||||
hasattr(self.camera, "lens_shading_table")
|
hasattr(self.picamera, "lens_shading_table")
|
||||||
and "lens_shading_table" in config["picamera"]
|
and "lens_shading_table" in config["picamera"]
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
self.camera.lens_shading_table = json_to_ndarray(
|
self.picamera.lens_shading_table = json_to_ndarray(
|
||||||
config["picamera"].get("lens_shading_table")
|
config["picamera"].get("lens_shading_table")
|
||||||
)
|
)
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
|
|
@ -208,7 +215,7 @@ class PiCameraStreamer(BaseCamera):
|
||||||
# If stream was paused to update config, unpause
|
# If stream was paused to update config, unpause
|
||||||
if paused_stream:
|
if paused_stream:
|
||||||
logging.info("Resuming stream.")
|
logging.info("Resuming stream.")
|
||||||
self.start_stream_recording()
|
self.start_stream()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
|
|
@ -229,47 +236,47 @@ class PiCameraStreamer(BaseCamera):
|
||||||
logging.debug(
|
logging.debug(
|
||||||
"Applying exposure_mode: %s", (settings_dict["exposure_mode"])
|
"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
|
# Apply gains and let them settle
|
||||||
if "analog_gain" in settings_dict:
|
if "analog_gain" in settings_dict:
|
||||||
logging.debug("Applying analog_gain: %s", (settings_dict["analog_gain"]))
|
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:
|
if "digital_gain" in settings_dict:
|
||||||
logging.debug("Applying digital_gain: %s", (settings_dict["digital_gain"]))
|
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
|
# Apply shutter speed
|
||||||
if "shutter_speed" in settings_dict:
|
if "shutter_speed" in settings_dict:
|
||||||
logging.debug(
|
logging.debug(
|
||||||
"Applying shutter_speed: %s", (settings_dict["shutter_speed"])
|
"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
|
time.sleep(0.2) # Let gains settle
|
||||||
|
|
||||||
# Handle AWB in a half-smart way
|
# Handle AWB in a half-smart way
|
||||||
if "awb_gains" in settings_dict:
|
if "awb_gains" in settings_dict:
|
||||||
logging.debug("Applying awb_mode: off")
|
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"]))
|
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:
|
elif "awb_mode" in settings_dict:
|
||||||
logging.debug("Applying awb_mode: %s", (settings_dict["awb_mode"]))
|
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
|
# Handle some properties that can be quickly applied
|
||||||
batched_keys = ["framerate", "saturation"]
|
batched_keys = ["framerate", "saturation"]
|
||||||
for key in batched_keys:
|
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])
|
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
|
# Final optional pause to settle
|
||||||
if pause_for_effect:
|
if pause_for_effect:
|
||||||
time.sleep(0.2)
|
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.
|
Change the camera zoom, handling re-centering and scaling.
|
||||||
"""
|
"""
|
||||||
|
|
@ -278,7 +285,7 @@ class PiCameraStreamer(BaseCamera):
|
||||||
if self.zoom_value < 1:
|
if self.zoom_value < 1:
|
||||||
self.zoom_value = 1
|
self.zoom_value = 1
|
||||||
# Richard's code for zooming !
|
# 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])
|
centre = np.array([fov[0] + fov[2] / 2.0, fov[1] + fov[3] / 2.0])
|
||||||
size = 1.0 / self.zoom_value
|
size = 1.0 / self.zoom_value
|
||||||
# If the new zoom value would be invalid, move the centre to
|
# 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)
|
centre[i] = 0.5 + (1.0 - size) / 2 * np.sign(centre[i] - 0.5)
|
||||||
logging.info("setting zoom, centre %s, size %s", centre, size)
|
logging.info("setting zoom, centre %s, size %s", centre, size)
|
||||||
new_fov = (centre[0] - size / 2, centre[1] - size / 2, size, 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: bool = True, window: Tuple[int, int, int, int] = None
|
||||||
def start_preview(self, fullscreen=True, window=None):
|
):
|
||||||
"""Start the on board GPU camera preview."""
|
"""Start the on board GPU camera preview."""
|
||||||
with self.lock(timeout=1):
|
with self.lock(timeout=1):
|
||||||
try:
|
try:
|
||||||
if not self.camera.preview:
|
if not self.picamera.preview:
|
||||||
logging.debug("Starting preview")
|
logging.debug("Starting preview")
|
||||||
self.camera.start_preview(fullscreen=fullscreen, window=window)
|
self.picamera.start_preview(fullscreen=fullscreen, window=window)
|
||||||
else:
|
else:
|
||||||
logging.debug("Resizing preview")
|
logging.debug("Resizing preview")
|
||||||
if window:
|
if window:
|
||||||
self.camera.preview.window = window
|
self.picamera.preview.window = window
|
||||||
if fullscreen:
|
if fullscreen:
|
||||||
self.camera.preview.fullscreen = fullscreen
|
self.picamera.preview.fullscreen = fullscreen
|
||||||
self.preview_active = True
|
self.preview_active = True
|
||||||
except picamerax.exc.PiCameraMMALError as e:
|
except picamerax.exc.PiCameraMMALError as e:
|
||||||
logging.error(
|
logging.error(
|
||||||
|
|
@ -320,11 +327,13 @@ class PiCameraStreamer(BaseCamera):
|
||||||
def stop_preview(self):
|
def stop_preview(self):
|
||||||
"""Stop the on board GPU camera preview."""
|
"""Stop the on board GPU camera preview."""
|
||||||
with self.lock(timeout=1):
|
with self.lock(timeout=1):
|
||||||
if self.camera.preview:
|
if self.picamera.preview:
|
||||||
self.camera.stop_preview()
|
self.picamera.stop_preview()
|
||||||
self.preview_active = False
|
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 recording.
|
||||||
|
|
||||||
Start a new video recording, writing to a output object.
|
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
|
# Start the camera video recording on port 2
|
||||||
logging.info("Recording to %s", (output))
|
logging.info("Recording to %s", (output))
|
||||||
|
|
||||||
self.camera.start_recording(
|
self.picamera.start_recording(
|
||||||
output,
|
output,
|
||||||
format=fmt,
|
format=fmt,
|
||||||
splitter_port=2,
|
splitter_port=2,
|
||||||
|
|
@ -370,80 +379,67 @@ class PiCameraStreamer(BaseCamera):
|
||||||
with self.lock(timeout=5):
|
with self.lock(timeout=5):
|
||||||
# Stop the camera video recording on port 2
|
# Stop the camera video recording on port 2
|
||||||
logging.info("Stopping recording")
|
logging.info("Stopping recording")
|
||||||
self.camera.stop_recording(splitter_port=2)
|
self.picamera.stop_recording(splitter_port=2)
|
||||||
logging.info("Recording stopped")
|
logging.info("Recording stopped")
|
||||||
|
|
||||||
# Update state
|
# Update state
|
||||||
self.record_active = False
|
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.
|
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):
|
with self.lock(timeout=None):
|
||||||
# Reduce the resolution for video streaming
|
# Reduce the resolution for video streaming
|
||||||
try:
|
try:
|
||||||
self.camera._check_recording_stopped() # pylint: disable=W0212
|
self.picamera._check_recording_stopped() # pylint: disable=W0212
|
||||||
except picamerax.exc.PiCameraRuntimeError:
|
except picamerax.exc.PiCameraRuntimeError:
|
||||||
logging.info(
|
logging.info(
|
||||||
"Error while changing resolution: Recording already running."
|
"Error while changing resolution: Recording already running."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.camera.resolution = self.stream_resolution
|
self.picamera.resolution = self.stream_resolution
|
||||||
# Sprinkled a sleep to prevent camera getting confused by rapid commands
|
# Sprinkled a sleep to prevent camera getting confused by rapid commands
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
# If the stream should be active
|
# If the stream should be active
|
||||||
try:
|
try:
|
||||||
# Start recording on stream port
|
# Start recording on stream port
|
||||||
self.camera.start_recording(
|
self.picamera.start_recording(
|
||||||
self.stream,
|
self.stream,
|
||||||
format="mjpeg",
|
format="mjpeg",
|
||||||
quality=self.mjpeg_quality,
|
quality=self.mjpeg_quality,
|
||||||
bitrate=-1, # RWB: disable bitrate control
|
bitrate=-1, # RWB: disable bitrate control
|
||||||
# (bitrate control makes JPEG size less good as a focus
|
# (bitrate control makes JPEG size less good as a focus
|
||||||
# metric)
|
# metric)
|
||||||
splitter_port=splitter_port,
|
splitter_port=1,
|
||||||
)
|
)
|
||||||
except picamerax.exc.PiCameraAlreadyRecording:
|
except picamerax.exc.PiCameraAlreadyRecording:
|
||||||
logging.info("Error while starting preview: Recording already running.")
|
logging.info("Error while starting preview: Recording already running.")
|
||||||
else:
|
else:
|
||||||
self.stream_active = True
|
self.stream_active = True
|
||||||
logging.debug(
|
logging.debug(
|
||||||
"Started MJPEG stream at %s on port %s",
|
"Started MJPEG stream at %s on port %s", self.stream_resolution, 1
|
||||||
self.stream_resolution,
|
|
||||||
splitter_port,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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.
|
Sets the camera resolution to the still-image resolution, and stops recording if the stream is active.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
splitter_port (int): Splitter port to stop recording on
|
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:
|
with self.lock:
|
||||||
# Stop the camera video recording on port 1
|
# Stop the camera video recording on port 1
|
||||||
try:
|
try:
|
||||||
self.camera.stop_recording(splitter_port=splitter_port)
|
self.picamera.stop_recording(splitter_port=1)
|
||||||
except picamerax.exc.PiCameraNotRecording:
|
except picamerax.exc.PiCameraNotRecording:
|
||||||
logging.info("Not recording on splitter_port %s", (splitter_port))
|
logging.info("Not recording on splitter_port %s", (1))
|
||||||
else:
|
else:
|
||||||
self.stream_active = False
|
self.stream_active = False
|
||||||
logging.info(
|
logging.info(
|
||||||
"Stopped MJPEG stream on port %s. Switching to %s.",
|
"Stopped MJPEG stream on port %s. Switching to %s.",
|
||||||
splitter_port,
|
1,
|
||||||
self.image_resolution,
|
self.image_resolution,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -451,16 +447,16 @@ class PiCameraStreamer(BaseCamera):
|
||||||
time.sleep(
|
time.sleep(
|
||||||
0.2
|
0.2
|
||||||
) # Sprinkled a sleep to prevent camera getting confused by rapid commands
|
) # 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(
|
def capture(
|
||||||
self,
|
self,
|
||||||
output,
|
output: Union[str, BinaryIO],
|
||||||
fmt: str = "jpeg",
|
fmt: str = "jpeg",
|
||||||
use_video_port: bool = False,
|
use_video_port: bool = False,
|
||||||
resize: Tuple[int, int] = None,
|
resize: Tuple[int, int] = None,
|
||||||
bayer: bool = True,
|
bayer: bool = True,
|
||||||
thumbnail: tuple = None,
|
thumbnail: Tuple[int, int, int] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Capture a still image to a StreamObject.
|
Capture a still image to a StreamObject.
|
||||||
|
|
@ -470,10 +466,11 @@ class PiCameraStreamer(BaseCamera):
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
output: String or file-like object to write capture data to
|
output: String or file-like object to write capture data to
|
||||||
fmt (str): Format of the capture.
|
fmt: Format of the capture.
|
||||||
use_video_port (bool): Capture from the video port used for streaming. Lower resolution, faster.
|
use_video_port: Capture from the video port used for streaming. Lower resolution, faster.
|
||||||
resize ((int, int)): Resize the captured image.
|
resize: Resize the captured image.
|
||||||
bayer (bool): Store raw bayer data in capture
|
bayer: Store raw bayer data in capture
|
||||||
|
thumbnail: Dimensions and quality (x, y, quality) of a thumbnail to generate, if supported
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
output_object (str/BytesIO): Target object.
|
output_object (str/BytesIO): Target object.
|
||||||
|
|
@ -483,9 +480,9 @@ class PiCameraStreamer(BaseCamera):
|
||||||
|
|
||||||
# Set resolution and stop stream recording if necessary
|
# Set resolution and stop stream recording if necessary
|
||||||
if not use_video_port:
|
if not use_video_port:
|
||||||
self.stop_stream_recording()
|
self.stop_stream()
|
||||||
|
|
||||||
self.camera.capture(
|
self.picamera.capture(
|
||||||
output,
|
output,
|
||||||
format=fmt,
|
format=fmt,
|
||||||
quality=self.jpeg_quality,
|
quality=self.jpeg_quality,
|
||||||
|
|
@ -497,11 +494,11 @@ class PiCameraStreamer(BaseCamera):
|
||||||
|
|
||||||
# Set resolution and start stream recording if necessary
|
# Set resolution and start stream recording if necessary
|
||||||
if not use_video_port:
|
if not use_video_port:
|
||||||
self.start_stream_recording()
|
self.start_stream()
|
||||||
|
|
||||||
return output
|
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.
|
"""Capture an uncompressed still RGB image to a Numpy array.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -513,7 +510,9 @@ class PiCameraStreamer(BaseCamera):
|
||||||
"""
|
"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
logging.debug("Creating PiRGBArray")
|
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))
|
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
|
return output.array
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,17 @@ from __future__ import print_function
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
import picamerax
|
import picamerax
|
||||||
from picamerax import exc, mmal
|
from picamerax import exc, mmal
|
||||||
from picamerax.mmalobj import to_rational
|
from picamerax.mmalobj import to_rational
|
||||||
|
|
||||||
MMAL_PARAMETER_ANALOG_GAIN = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x59
|
MMAL_PARAMETER_ANALOG_GAIN: int = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x59
|
||||||
MMAL_PARAMETER_DIGITAL_GAIN = mmal.MMAL_PARAMETER_GROUP_CAMERA + 0x5A
|
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.
|
"""Set the analog gain of a PiCamera.
|
||||||
|
|
||||||
camera: the picamerax.PiCamera() instance you are configuring
|
camera: the picamerax.PiCamera() instance you are configuring
|
||||||
|
|
@ -32,12 +33,12 @@ def set_gain(camera, gain, value):
|
||||||
raise exc.PiCameraMMALError(ret)
|
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 the gain of a PiCamera object to a given value."""
|
||||||
set_gain(camera, MMAL_PARAMETER_ANALOG_GAIN, 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 the digital gain of a PiCamera object to a given value."""
|
||||||
set_gain(camera, MMAL_PARAMETER_DIGITAL_GAIN, value)
|
set_gain(camera, MMAL_PARAMETER_DIGITAL_GAIN, value)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
from . import capture, capture_manager
|
from . import capture, capture_manager
|
||||||
from .capture import THUMBNAIL_SIZE, CaptureObject
|
from .capture import THUMBNAIL_SIZE, CaptureObject
|
||||||
from .capture_manager import CaptureManager
|
from .capture_manager import CaptureManager
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"capture",
|
||||||
|
"capture_manager",
|
||||||
|
"THUMBNAIL_SIZE",
|
||||||
|
"CaptureObject",
|
||||||
|
"CaptureManager",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@ import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from typing import Callable, Dict, List, Optional
|
||||||
|
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
import piexif
|
||||||
|
from piexif import InvalidImageDataError
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from openflexure_microscope.captures import piexif
|
|
||||||
from openflexure_microscope.captures.piexif import InvalidImageDataError
|
|
||||||
from openflexure_microscope.json import JSONEncoder
|
from openflexure_microscope.json import JSONEncoder
|
||||||
|
|
||||||
EXIF_FORMATS = ["JPG", "JPEG", "TIF", "TIFF"]
|
EXIF_FORMATS = ["JPG", "JPEG", "TIF", "TIFF"]
|
||||||
|
|
@ -39,7 +40,7 @@ def build_captures_from_exif(capture_path):
|
||||||
logging.debug("Reloading capture %s...", (f))
|
logging.debug("Reloading capture %s...", (f))
|
||||||
capture = capture_from_path(f)
|
capture = capture_from_path(f)
|
||||||
if capture:
|
if capture:
|
||||||
captures[capture.id] = capture
|
captures[str(capture.id)] = capture
|
||||||
|
|
||||||
logging.info("%s capture files successfully reloaded", (len(captures)))
|
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.
|
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."""
|
"""Create a new StreamObject, to manage capture data."""
|
||||||
# Stream for buffering capture data
|
# Stream for buffering capture data
|
||||||
self.stream = io.BytesIO()
|
self.stream: io.BytesIO = io.BytesIO()
|
||||||
|
|
||||||
# Store a nice ID
|
# 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))
|
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
|
# Create file name. Default to UUID
|
||||||
self.format = None
|
self.file: str = filepath
|
||||||
self.file = 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)
|
self.split_file_path(self.file)
|
||||||
|
|
||||||
if not os.path.exists(self.filefolder):
|
if not os.path.exists(self.filefolder):
|
||||||
os.makedirs(self.filefolder)
|
os.makedirs(self.filefolder)
|
||||||
|
|
||||||
# Dataset information
|
# Dataset information
|
||||||
self._dataset = None
|
self.dataset: Dict[str, str] = {}
|
||||||
|
|
||||||
# Dictionary for storing custom annotations
|
# Dictionary for storing custom annotations
|
||||||
# Can be modified via the web API
|
# Can be modified via the web API
|
||||||
self.annotations = {}
|
self.annotations: Dict[str, str] = {}
|
||||||
# List for storing tags
|
# List for storing tags
|
||||||
# Can be modified via the web API
|
# 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):
|
def write(self, s):
|
||||||
logging.debug("Writing to %s", self)
|
logging.debug("Writing to %s", self)
|
||||||
|
|
@ -112,7 +122,7 @@ class CaptureObject(object):
|
||||||
def open(self, mode):
|
def open(self, mode):
|
||||||
return open(self.file, 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.
|
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.basename = os.path.splitext(self.name)[0]
|
||||||
self.format = self.name.split(".")[-1]
|
self.format = self.name.split(".")[-1]
|
||||||
|
|
||||||
def _read_exif(self):
|
def _read_exif(self) -> dict:
|
||||||
return piexif.load(self.file)
|
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:
|
if "Exif" not in exif_dict:
|
||||||
raise InvalidImageDataError
|
raise InvalidImageDataError
|
||||||
if piexif.ExifIFD.UserComment not in exif_dict["Exif"]:
|
if piexif.ExifIFD.UserComment not in exif_dict["Exif"]:
|
||||||
|
|
@ -150,15 +160,17 @@ class CaptureObject(object):
|
||||||
)
|
)
|
||||||
|
|
||||||
def sync_basic_metadata(self):
|
def sync_basic_metadata(self):
|
||||||
exif_dict = self._read_exif()
|
exif_dict: dict = self._read_exif()
|
||||||
|
|
||||||
metadata_dict = self._decode_usercomment(exif_dict) or {}
|
metadata_dict: dict = self._decode_usercomment(exif_dict) or {}
|
||||||
image_metadata = metadata_dict.get("image")
|
self.dataset = metadata_dict.get("dataset")
|
||||||
|
|
||||||
|
image_metadata: Optional[dict] = metadata_dict.get("image")
|
||||||
|
|
||||||
if not image_metadata:
|
if not image_metadata:
|
||||||
raise InvalidImageDataError("No capture metadata found")
|
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.format = image_metadata.get("format")
|
||||||
self.time = dateutil.parser.isoparse(
|
self.time = dateutil.parser.isoparse(
|
||||||
image_metadata.get("time") or image_metadata.get("acquisitionDate")
|
image_metadata.get("time") or image_metadata.get("acquisitionDate")
|
||||||
|
|
@ -166,24 +178,11 @@ class CaptureObject(object):
|
||||||
self.tags = image_metadata.get("tags")
|
self.tags = image_metadata.get("tags")
|
||||||
self.annotations = image_metadata.get("annotations")
|
self.annotations = image_metadata.get("annotations")
|
||||||
|
|
||||||
dataset = metadata_dict.get("dataset")
|
def read_full_metadata(self) -> dict:
|
||||||
if dataset:
|
|
||||||
self._dataset = {
|
|
||||||
"id": dataset.get("id"),
|
|
||||||
"name": dataset.get("name"),
|
|
||||||
"type": dataset.get("type"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def read_full_metadata(self):
|
|
||||||
logging.info("Reading full capture metadata from %s...", self.file)
|
logging.info("Reading full capture metadata from %s...", self.file)
|
||||||
exif_dict = self._read_exif()
|
exif_dict = self._read_exif()
|
||||||
return self._decode_usercomment(exif_dict)
|
return self._decode_usercomment(exif_dict)
|
||||||
|
|
||||||
@property
|
|
||||||
def dataset(self):
|
|
||||||
"""Read-only dataset property"""
|
|
||||||
return self._dataset
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def exists(self) -> bool:
|
def exists(self) -> bool:
|
||||||
"""Check if capture data file exists on disk."""
|
"""Check if capture data file exists on disk."""
|
||||||
|
|
@ -193,7 +192,7 @@ class CaptureObject(object):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# HANDLE TAGS
|
# HANDLE TAGS
|
||||||
def put_tags(self, tags: list):
|
def put_tags(self, tags: List[str]):
|
||||||
"""
|
"""
|
||||||
Add a new tag to the ``tags`` list attribute.
|
Add a new tag to the ``tags`` list attribute.
|
||||||
|
|
||||||
|
|
@ -218,7 +217,7 @@ class CaptureObject(object):
|
||||||
|
|
||||||
# HANDLE ANNOTATIONS
|
# 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.
|
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)
|
self.put_and_save(annotations=data)
|
||||||
|
|
||||||
def delete_annotation(self, key: str) -> None:
|
def delete_annotation(self, key: str):
|
||||||
# Update in-memory annotations list
|
# Update in-memory annotations list
|
||||||
if key in self.annotations:
|
if key in self.annotations:
|
||||||
del self.annotations[key]
|
del self.annotations[key]
|
||||||
|
|
@ -237,7 +236,7 @@ class CaptureObject(object):
|
||||||
|
|
||||||
# HANDLE METADATA
|
# 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.
|
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)
|
self.put_and_save(metadata=data)
|
||||||
|
|
||||||
|
# HANDLE DATASET
|
||||||
|
|
||||||
|
def put_dataset(self, data: dict):
|
||||||
|
self.put_and_save(dataset=data)
|
||||||
|
|
||||||
# BULK OPERATIONS
|
# BULK OPERATIONS
|
||||||
|
|
||||||
def put_and_save(
|
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
|
Batch-write tags, metadata, and annotations in a single disk operation
|
||||||
|
|
@ -258,6 +266,8 @@ class CaptureObject(object):
|
||||||
tags = []
|
tags = []
|
||||||
if not annotations:
|
if not annotations:
|
||||||
annotations = {}
|
annotations = {}
|
||||||
|
if not dataset:
|
||||||
|
dataset = {}
|
||||||
if not metadata:
|
if not metadata:
|
||||||
metadata = {}
|
metadata = {}
|
||||||
|
|
||||||
|
|
@ -269,6 +279,9 @@ class CaptureObject(object):
|
||||||
# Update in-memory annotations dictionary
|
# Update in-memory annotations dictionary
|
||||||
self.annotations.update(annotations)
|
self.annotations.update(annotations)
|
||||||
|
|
||||||
|
# Update in-memory dataset dictionary
|
||||||
|
self.dataset.update(dataset)
|
||||||
|
|
||||||
# Write new data to file EXIF, if supported
|
# Write new data to file EXIF, if supported
|
||||||
if self.format.upper() in EXIF_FORMATS and self.exists:
|
if self.format.upper() in EXIF_FORMATS and self.exists:
|
||||||
logging.info("Writing Exif data to %s", self.file)
|
logging.info("Writing Exif data to %s", self.file)
|
||||||
|
|
@ -281,6 +294,8 @@ class CaptureObject(object):
|
||||||
metadata_dict.get("image", {})["tags"] = self.tags
|
metadata_dict.get("image", {})["tags"] = self.tags
|
||||||
# Add new annotations to exif dictionary
|
# Add new annotations to exif dictionary
|
||||||
metadata_dict.get("image", {})["annotations"] = self.annotations
|
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
|
# Add new custom metadata to exif dictionary
|
||||||
metadata_dict.update(metadata)
|
metadata_dict.update(metadata)
|
||||||
|
|
||||||
|
|
@ -310,7 +325,7 @@ class CaptureObject(object):
|
||||||
return self.read_full_metadata()
|
return self.read_full_metadata()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data(self) -> io.BytesIO:
|
def data(self) -> Optional[io.BytesIO]:
|
||||||
"""
|
"""
|
||||||
Return a BytesIO object of the capture data.
|
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 = io.BytesIO(f.read()) # Load bytes from file
|
||||||
d.seek(0) # Rewind loaded bytestream
|
d.seek(0) # Rewind loaded bytestream
|
||||||
# Create a copy of the bytestream bytes
|
# Create a copy of the bytestream bytes
|
||||||
data = io.BytesIO(d.getbuffer())
|
return io.BytesIO(d.getbuffer())
|
||||||
else:
|
else:
|
||||||
data = None
|
return None
|
||||||
|
|
||||||
return data # Read and return bytes data
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def binary(self) -> bytes:
|
def binary(self) -> Optional[bytes]:
|
||||||
"""Return a byte string of the capture data."""
|
"""Return a byte string of the capture data."""
|
||||||
|
if self.data:
|
||||||
return self.data.getvalue()
|
return self.data.getvalue()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def thumbnail(self) -> io.BytesIO:
|
def thumbnail(self) -> io.BytesIO:
|
||||||
|
|
@ -354,7 +370,7 @@ class CaptureObject(object):
|
||||||
|
|
||||||
# FILE MANAGEMENT
|
# FILE MANAGEMENT
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self):
|
||||||
"""Write stream to file, and save/update metadata file"""
|
"""Write stream to file, and save/update metadata file"""
|
||||||
# If a stream OR file exists, save the metadata file
|
# If a stream OR file exists, save the metadata file
|
||||||
if self.exists:
|
if self.exists:
|
||||||
|
|
@ -367,6 +383,9 @@ class CaptureObject(object):
|
||||||
logging.info("Deleting file %s", (self.file))
|
logging.info("Deleting file %s", (self.file))
|
||||||
os.remove(self.file)
|
os.remove(self.file)
|
||||||
|
|
||||||
|
if self.on_delete:
|
||||||
|
self.on_delete(self, self.id)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from typing import Dict, List, MutableMapping, Optional, Union, ValuesView
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from labthings import StrictLock
|
from labthings import StrictLock
|
||||||
|
|
||||||
from openflexure_microscope.paths import data_file_path
|
from openflexure_microscope.paths import data_file_path
|
||||||
from openflexure_microscope.utilities import entry_by_uuid
|
|
||||||
|
|
||||||
from .capture import CaptureObject, build_captures_from_exif
|
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")
|
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:
|
class CaptureManager:
|
||||||
def __init__(self):
|
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
|
# Capture data
|
||||||
self.images = OrderedDict()
|
self.images: MutableMapping[str, CaptureObject] = OrderedDict()
|
||||||
self.videos = OrderedDict()
|
self.videos: MutableMapping[str, CaptureObject] = OrderedDict()
|
||||||
|
|
||||||
# FILE MANAGEMENT
|
# FILE MANAGEMENT
|
||||||
|
|
||||||
|
|
@ -96,41 +75,28 @@ class CaptureManager:
|
||||||
|
|
||||||
# RETURNING CAPTURES
|
# RETURNING CAPTURES
|
||||||
|
|
||||||
@property
|
def image_from_id(self, image_id: Union[str, int, UUID]) -> Optional[CaptureObject]:
|
||||||
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):
|
|
||||||
"""Return an image StreamObject with a matching ID."""
|
"""Return an image StreamObject with a matching ID."""
|
||||||
logging.warning("image_from_id is deprecated. Access captures as a dictionary.")
|
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."""
|
"""Return a video StreamObject with a matching ID."""
|
||||||
logging.warning("video_from_id is deprecated. Access captures as a dictionary.")
|
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
|
# CREATING NEW CAPTURES
|
||||||
|
|
||||||
def _new_output(self, temporary, filename, folder, fmt):
|
def _new_output(self, temporary: bool, filename: str, folder: str, fmt: str):
|
||||||
# Generate file name
|
|
||||||
if not filename:
|
|
||||||
filename = generate_numbered_basename(self.images.values())
|
|
||||||
logging.debug(filename)
|
|
||||||
filename = "{}.{}".format(filename, fmt)
|
filename = "{}.{}".format(filename, fmt)
|
||||||
|
|
||||||
# Generate folder
|
# 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)
|
folder = os.path.join(base_folder, folder)
|
||||||
|
|
||||||
# Generate file path
|
# Generate file path
|
||||||
filepath = os.path.join(folder, filename)
|
filepath: str = os.path.join(folder, filename)
|
||||||
|
|
||||||
# Create capture object
|
# Create capture object
|
||||||
output = CaptureObject(filepath=filepath)
|
output = CaptureObject(filepath=filepath)
|
||||||
|
|
@ -143,7 +109,7 @@ class CaptureManager:
|
||||||
def new_image(
|
def new_image(
|
||||||
self,
|
self,
|
||||||
temporary: bool = True,
|
temporary: bool = True,
|
||||||
filename: str = None,
|
filename: Optional[str] = None,
|
||||||
folder: str = "",
|
folder: str = "",
|
||||||
fmt: str = "jpeg",
|
fmt: str = "jpeg",
|
||||||
):
|
):
|
||||||
|
|
@ -158,23 +124,28 @@ class CaptureManager:
|
||||||
folder (str): Name of the folder in which to store the capture.
|
folder (str): Name of the folder in which to store the capture.
|
||||||
fmt (str): Format of 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
|
# Create a new output object
|
||||||
output = self._new_output(temporary, filename, folder, fmt)
|
output = self._new_output(temporary, filename, folder, fmt)
|
||||||
|
# Add an on-delete callback
|
||||||
|
output.on_delete = self.remove_image
|
||||||
|
|
||||||
# Update capture list
|
# Update capture list
|
||||||
capture_key = str(output.id)
|
logging.debug("Adding image %s with key %s", output, output.id)
|
||||||
logging.debug("Adding image %s with key %s", output, capture_key)
|
self.images[str(output.id)] = output
|
||||||
self.images[capture_key] = output
|
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
def new_video(
|
def new_video(
|
||||||
self,
|
self,
|
||||||
temporary: bool = False,
|
temporary: bool = False,
|
||||||
filename: str = None,
|
filename: Optional[str] = None,
|
||||||
folder: str = "",
|
folder: str = "",
|
||||||
fmt: str = "h264",
|
fmt: str = "h264",
|
||||||
):
|
) -> CaptureObject:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Create a new video capture object.
|
Create a new video capture object.
|
||||||
|
|
@ -186,12 +157,72 @@ class CaptureManager:
|
||||||
folder (str): Name of the folder in which to store the capture.
|
folder (str): Name of the folder in which to store the capture.
|
||||||
fmt (str): Format of 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
|
# Create a new output object
|
||||||
output = self._new_output(temporary, filename, folder, fmt)
|
output = self._new_output(temporary, filename, folder, fmt)
|
||||||
|
# Add an on-delete callback
|
||||||
|
output.on_delete = self.remove_video
|
||||||
|
|
||||||
# Update capture list
|
# Update capture list
|
||||||
capture_key = str(output.id)
|
logging.debug("Adding video %s with key %s", output, output.id)
|
||||||
logging.debug("Adding video %s with key %s", output, capture_key)
|
self.videos[str(output.id)] = output
|
||||||
self.videos[capture_key] = output
|
|
||||||
|
|
||||||
return 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
class InvalidImageDataError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -4,6 +4,8 @@ from uuid import UUID
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from labthings.json import LabThingsJSONEncoder
|
from labthings.json import LabThingsJSONEncoder
|
||||||
|
|
||||||
|
__all__ = ["JSONEncoder", "LabThingsJSONEncoder"]
|
||||||
|
|
||||||
|
|
||||||
class JSONEncoder(LabThingsJSONEncoder):
|
class JSONEncoder(LabThingsJSONEncoder):
|
||||||
"""
|
"""
|
||||||
|
|
@ -19,12 +21,12 @@ class JSONEncoder(LabThingsJSONEncoder):
|
||||||
# Numpy integers
|
# Numpy integers
|
||||||
elif isinstance(o, np.integer):
|
elif isinstance(o, np.integer):
|
||||||
return int(o)
|
return int(o)
|
||||||
|
# Numpy floats
|
||||||
|
elif isinstance(o, np.float):
|
||||||
|
return float(o)
|
||||||
# Numpy arrays
|
# Numpy arrays
|
||||||
elif isinstance(o, np.ndarray):
|
elif isinstance(o, np.ndarray):
|
||||||
return o.tolist()
|
return o.tolist()
|
||||||
# UUIDs
|
|
||||||
elif isinstance(o, UUID):
|
|
||||||
return str(o)
|
|
||||||
else:
|
else:
|
||||||
# call base class implementation which takes care of
|
# call base class implementation which takes care of
|
||||||
# raising exceptions for unsupported types
|
# raising exceptions for unsupported types
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,16 @@ Defines a microscope object, binding a camera and stage with basic functionality
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Tuple
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
from expiringdict import ExpiringDict
|
from expiringdict import ExpiringDict
|
||||||
|
|
||||||
|
from openflexure_microscope.camera.base import BaseCamera
|
||||||
from openflexure_microscope.camera.mock import MissingCamera
|
from openflexure_microscope.camera.mock import MissingCamera
|
||||||
from openflexure_microscope.captures import THUMBNAIL_SIZE, CaptureManager
|
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.mock import MissingStage
|
||||||
from openflexure_microscope.stage.sanga import SangaDeltaStage, SangaStage
|
from openflexure_microscope.stage.sanga import SangaDeltaStage, SangaStage
|
||||||
|
|
||||||
|
|
@ -19,7 +22,7 @@ try:
|
||||||
except Exception as e: # pylint: disable=W0703
|
except Exception as e: # pylint: disable=W0703
|
||||||
logging.error(e)
|
logging.error(e)
|
||||||
logging.warning("Unable to import PiCameraStreamer")
|
logging.warning("Unable to import PiCameraStreamer")
|
||||||
from labthings import CompositeLock
|
from labthings import CompositeLock, StrictLock
|
||||||
|
|
||||||
from openflexure_microscope.config import user_configuration, user_settings
|
from openflexure_microscope.config import user_configuration, user_settings
|
||||||
|
|
||||||
|
|
@ -32,23 +35,23 @@ class Microscope:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, settings=user_settings, configuration=user_configuration):
|
def __init__(self, settings=user_settings, configuration=user_configuration):
|
||||||
self.id = f"openflexure:microscope:{uuid.uuid4()}"
|
self.id: str = f"openflexure:microscope:{uuid.uuid4()}"
|
||||||
self.name = self.id
|
self.name: str = self.id
|
||||||
|
|
||||||
self.captures = CaptureManager()
|
self.captures: CaptureManager = CaptureManager()
|
||||||
|
|
||||||
# Store settings and configuration files
|
# Store settings and configuration files
|
||||||
self.settings_file = settings
|
self.settings_file: OpenflexureSettingsFile = settings
|
||||||
self.configuration_file = configuration
|
self.configuration_file: OpenflexureSettingsFile = configuration
|
||||||
|
|
||||||
self.extension_settings = {}
|
self.extension_settings: dict = {}
|
||||||
|
|
||||||
# Initialise with an empty composite lock
|
# Initialise with an empty composite lock
|
||||||
#: :py:class:`labthings.CompositeLock`: Composite lock for locking both camera and stage
|
#: :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.camera: BaseCamera = None #: Currently connected camera object
|
||||||
self.stage = None #: Currently connected stage object
|
self.stage: BaseStage = None #: Currently connected stage object
|
||||||
|
|
||||||
self.setup(self.configuration_file.load()) # Attach components
|
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
|
# 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,
|
# 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.
|
# 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.configuration_cache: Union[dict, ExpiringDict] = ExpiringDict(
|
||||||
self.metadata_cache = ExpiringDict(max_len=100, max_age_seconds=3600)
|
max_len=100, max_age_seconds=3600
|
||||||
|
)
|
||||||
|
self.metadata_cache: Union[dict, ExpiringDict] = ExpiringDict(
|
||||||
|
max_len=100, max_age_seconds=3600
|
||||||
|
)
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""Create microscope on context enter."""
|
"""Create microscope on context enter."""
|
||||||
|
|
@ -86,7 +93,7 @@ class Microscope:
|
||||||
self.captures.close()
|
self.captures.close()
|
||||||
logging.info("Closed %s", (self))
|
logging.info("Closed %s", (self))
|
||||||
|
|
||||||
def setup(self, configuration):
|
def setup(self, configuration: dict):
|
||||||
"""
|
"""
|
||||||
Attach microscope components based on initially passed configuration file
|
Attach microscope components based on initially passed configuration file
|
||||||
"""
|
"""
|
||||||
|
|
@ -119,12 +126,13 @@ class Microscope:
|
||||||
if hasattr(self.stage, "lock"):
|
if hasattr(self.stage, "lock"):
|
||||||
self.lock.locks.append(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
|
Set or change the stage geometry
|
||||||
"""
|
"""
|
||||||
if not configuration:
|
configuration = configuration or self.configuration
|
||||||
configuration = self.configuration
|
|
||||||
|
|
||||||
if stage_type:
|
if stage_type:
|
||||||
if stage_type == configuration["stage"].get("type"):
|
if stage_type == configuration["stage"].get("type"):
|
||||||
|
|
@ -135,7 +143,7 @@ class Microscope:
|
||||||
|
|
||||||
### Close any existing stages
|
### Close any existing stages
|
||||||
if self.stage:
|
if self.stage:
|
||||||
stage_port = self.stage.port
|
stage_port = getattr(self.stage, "port")
|
||||||
self.stage.close()
|
self.stage.close()
|
||||||
|
|
||||||
logging.info("Setting stage")
|
logging.info("Setting stage")
|
||||||
|
|
@ -151,7 +159,7 @@ class Microscope:
|
||||||
except Exception as e: # pylint: disable=W0703
|
except Exception as e: # pylint: disable=W0703
|
||||||
logging.error(e)
|
logging.error(e)
|
||||||
logging.warning("No compatible Sangaboard hardware found.")
|
logging.warning("No compatible Sangaboard hardware found.")
|
||||||
elif stage_type in ("SangaDeltaStage"):
|
elif stage_type in ("SangaDeltaStage",):
|
||||||
try:
|
try:
|
||||||
logging.info("Trying SangaDeltaStage")
|
logging.info("Trying SangaDeltaStage")
|
||||||
self.stage = SangaDeltaStage(port=stage_port)
|
self.stage = SangaDeltaStage(port=stage_port)
|
||||||
|
|
@ -173,7 +181,7 @@ class Microscope:
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def has_real_camera(self):
|
def has_real_camera(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a real (non-mock) camera is currently attached.
|
Check if a real (non-mock) camera is currently attached.
|
||||||
"""
|
"""
|
||||||
|
|
@ -184,7 +192,7 @@ class Microscope:
|
||||||
|
|
||||||
# Create unified state
|
# Create unified state
|
||||||
@property
|
@property
|
||||||
def state(self):
|
def state(self) -> dict:
|
||||||
"""Dictionary of the basic microscope state.
|
"""Dictionary of the basic microscope state.
|
||||||
|
|
||||||
Return:
|
Return:
|
||||||
|
|
@ -224,7 +232,7 @@ class Microscope:
|
||||||
for key in settings.keys():
|
for key in settings.keys():
|
||||||
logging.warning("Key %s is unused and was ignored", key)
|
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.
|
Get an updated settings dictionary.
|
||||||
|
|
||||||
|
|
@ -270,7 +278,7 @@ class Microscope:
|
||||||
# Save config to file
|
# Save config to file
|
||||||
self.settings_file.save(current_config, backup=True)
|
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()
|
initial_configuration = self.configuration_file.load()
|
||||||
|
|
||||||
current_configuration = {
|
current_configuration = {
|
||||||
|
|
@ -293,7 +301,7 @@ class Microscope:
|
||||||
initial_configuration.update(current_configuration)
|
initial_configuration.update(current_configuration)
|
||||||
return initial_configuration
|
return initial_configuration
|
||||||
|
|
||||||
def get_configuration(self, cache_key=None):
|
def get_configuration(self, cache_key: Optional[str] = None) -> dict:
|
||||||
if cache_key:
|
if cache_key:
|
||||||
cached_config = self.configuration_cache.get(cache_key, None)
|
cached_config = self.configuration_cache.get(cache_key, None)
|
||||||
if cached_config:
|
if cached_config:
|
||||||
|
|
@ -308,7 +316,7 @@ class Microscope:
|
||||||
def configuration(self):
|
def configuration(self):
|
||||||
return self.get_configuration()
|
return self.get_configuration()
|
||||||
|
|
||||||
def force_get_metadata(self):
|
def force_get_metadata(self) -> dict:
|
||||||
"""
|
"""
|
||||||
Read cachable bits of microscope metadata.
|
Read cachable bits of microscope metadata.
|
||||||
Currently ID, settings, and configuration can be cached
|
Currently ID, settings, and configuration can be cached
|
||||||
|
|
@ -321,7 +329,7 @@ class Microscope:
|
||||||
|
|
||||||
return system_metadata
|
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
|
Read microscope metadata, with partial caching
|
||||||
"""
|
"""
|
||||||
|
|
@ -345,22 +353,23 @@ class Microscope:
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def metadata(self):
|
def metadata(self) -> dict:
|
||||||
return self.get_metadata()
|
return self.get_metadata()
|
||||||
|
|
||||||
def capture(
|
def capture(
|
||||||
self,
|
self,
|
||||||
filename: str = None,
|
filename: Optional[str] = None,
|
||||||
folder: str = "",
|
folder: str = "",
|
||||||
temporary: bool = False,
|
temporary: bool = False,
|
||||||
use_video_port: bool = False,
|
use_video_port: bool = False,
|
||||||
resize: Tuple[int, int] = None,
|
resize: Tuple[int, int] = None,
|
||||||
bayer: bool = True,
|
bayer: bool = True,
|
||||||
fmt: str = "jpeg",
|
fmt: str = "jpeg",
|
||||||
annotations: dict = None,
|
annotations: Optional[Dict[str, str]] = None,
|
||||||
tags: list = None,
|
tags: Optional[List[str]] = None,
|
||||||
metadata: dict = None,
|
dataset: Optional[Dict[str, str]] = None,
|
||||||
cache_key: str = None,
|
metadata: Optional[dict] = None,
|
||||||
|
cache_key: Optional[str] = None,
|
||||||
):
|
):
|
||||||
logging.debug("Microscope capturing to %s", filename)
|
logging.debug("Microscope capturing to %s", filename)
|
||||||
if not annotations:
|
if not annotations:
|
||||||
|
|
@ -381,9 +390,6 @@ class Microscope:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Capture to output object
|
# Capture to output object
|
||||||
extras = {}
|
|
||||||
if fmt == "jpeg":
|
|
||||||
extras["thumbnail"] = (*THUMBNAIL_SIZE, 85)
|
|
||||||
logging.info("Starting microscope capture %s", output.file)
|
logging.info("Starting microscope capture %s", output.file)
|
||||||
self.camera.capture(
|
self.camera.capture(
|
||||||
output,
|
output,
|
||||||
|
|
@ -391,10 +397,10 @@ class Microscope:
|
||||||
resize=resize,
|
resize=resize,
|
||||||
bayer=bayer,
|
bayer=bayer,
|
||||||
fmt=fmt,
|
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)
|
logging.debug("Finished capture to %s", output.file)
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import os
|
||||||
# UTILITIES
|
# 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)
|
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"""
|
"""Generate a full file path for a filename to be stored in server settings folder"""
|
||||||
settings_dir = os.path.join(OPENFLEXURE_VAR_PATH, "settings")
|
settings_dir = os.path.join(OPENFLEXURE_VAR_PATH, "settings")
|
||||||
if not os.path.exists(settings_dir):
|
if not os.path.exists(settings_dir):
|
||||||
|
|
@ -15,7 +15,7 @@ def settings_file_path(filename: str):
|
||||||
return os.path.join(settings_dir, filename)
|
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"""
|
"""Generate a full file path for a filename to be stored in server data folder"""
|
||||||
data_dir = os.path.join(OPENFLEXURE_VAR_PATH, "data")
|
data_dir = os.path.join(OPENFLEXURE_VAR_PATH, "data")
|
||||||
if not os.path.exists(data_dir):
|
if not os.path.exists(data_dir):
|
||||||
|
|
@ -23,7 +23,7 @@ def data_file_path(filename: str):
|
||||||
return os.path.join(data_dir, filename)
|
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"""
|
"""Generate a full file path for a folder to be stored in server extensions"""
|
||||||
ext_dir = os.path.join(OPENFLEXURE_VAR_PATH, "extensions")
|
ext_dir = os.path.join(OPENFLEXURE_VAR_PATH, "extensions")
|
||||||
if not os.path.exists(ext_dir):
|
if not os.path.exists(ext_dir):
|
||||||
|
|
@ -31,7 +31,7 @@ def extensions_file_path(filename: str):
|
||||||
return os.path.join(ext_dir, filename)
|
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"""
|
"""Generate a full file path for a filename to be stored in server logs"""
|
||||||
logs_dir = os.path.join(OPENFLEXURE_VAR_PATH, "logs")
|
logs_dir = os.path.join(OPENFLEXURE_VAR_PATH, "logs")
|
||||||
if not os.path.exists(logs_dir):
|
if not os.path.exists(logs_dir):
|
||||||
|
|
@ -42,14 +42,14 @@ def logs_file_path(filename: str):
|
||||||
# BASE PATHS
|
# BASE PATHS
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
PREFERRED_VAR_PATH = os.getenv("PROGRAMDATA") or "C:\\ProgramData"
|
PREFERRED_VAR_PATH: str = os.getenv("PROGRAMDATA") or "C:\\ProgramData"
|
||||||
FALLBACK_VAR_PATH = os.path.expanduser("~")
|
FALLBACK_VAR_PATH: str = os.path.expanduser("~")
|
||||||
else:
|
else:
|
||||||
PREFERRED_VAR_PATH = "/var"
|
PREFERRED_VAR_PATH = "/var"
|
||||||
FALLBACK_VAR_PATH = os.path.expanduser("~")
|
FALLBACK_VAR_PATH = os.path.expanduser("~")
|
||||||
|
|
||||||
PREFERRED_OPENFLEXURE_VAR_PATH = os.path.join(PREFERRED_VAR_PATH, "openflexure")
|
PREFERRED_OPENFLEXURE_VAR_PATH: str = os.path.join(PREFERRED_VAR_PATH, "openflexure")
|
||||||
FALLBACK_OPENFLEXURE_VAR_PATH = os.path.join(FALLBACK_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):
|
if not os.path.exists(PREFERRED_OPENFLEXURE_VAR_PATH) and check_rw(PREFERRED_VAR_PATH):
|
||||||
os.makedirs(PREFERRED_OPENFLEXURE_VAR_PATH)
|
os.makedirs(PREFERRED_OPENFLEXURE_VAR_PATH)
|
||||||
|
|
@ -65,8 +65,8 @@ else:
|
||||||
# SERVER PATHS
|
# SERVER PATHS
|
||||||
|
|
||||||
#: Path of microscope settings file
|
#: 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
|
#: 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
|
#: Path of microscope extensions directory
|
||||||
OPENFLEXURE_EXTENSIONS_PATH = extensions_file_path("microscope_extensions")
|
OPENFLEXURE_EXTENSIONS_PATH: str = extensions_file_path("microscope_extensions")
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from .error_sources import ErrorSource
|
from .error_sources import ErrorSource
|
||||||
|
|
||||||
ERROR_SOURCES = []
|
ERROR_SOURCES: List[ErrorSource] = []
|
||||||
|
|
||||||
|
|
||||||
def trace_config_exceptions():
|
def trace_config_exceptions():
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from labthings import StrictLock
|
from labthings import StrictLock
|
||||||
|
from typing_extensions import Literal
|
||||||
|
|
||||||
|
|
||||||
class BaseStage(metaclass=ABCMeta):
|
class BaseStage(metaclass=ABCMeta):
|
||||||
|
|
@ -39,11 +41,11 @@ class BaseStage(metaclass=ABCMeta):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def position(self):
|
def position(self) -> Tuple[int, int, int]:
|
||||||
"""The current position, as a list"""
|
"""The current position, as a list"""
|
||||||
|
|
||||||
@property
|
@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]}
|
return {"x": self.position[0], "y": self.position[1], "z": self.position[2]}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -52,19 +54,27 @@ class BaseStage(metaclass=ABCMeta):
|
||||||
"""Get the distance used for backlash compensation."""
|
"""Get the distance used for backlash compensation."""
|
||||||
|
|
||||||
@backlash.setter
|
@backlash.setter
|
||||||
@abstractmethod
|
|
||||||
def backlash(self):
|
def backlash(self):
|
||||||
"""Set the distance used for backlash compensation."""
|
"""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
|
@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.
|
"""Make a relative move, optionally correcting for backlash.
|
||||||
displacement: integer or array/list of 3 integers
|
displacement: integer or array/list of 3 integers
|
||||||
backlash: (default: True) whether to correct for backlash.
|
backlash: (default: True) whether to correct for backlash.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def move_abs(self, final, **kwargs):
|
def move_abs(self, final: Tuple[int, int, int], **kwargs):
|
||||||
"""Make an absolute move to a position"""
|
"""Make an absolute move to a position"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|
@ -75,7 +85,12 @@ class BaseStage(metaclass=ABCMeta):
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Cleanly close communication with the stage"""
|
"""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)
|
Scan through a list of (relative) positions (generator fn)
|
||||||
rel_positions should be an nx3-element array (or list of 3 element arrays).
|
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..
|
exception occurs during the scan..
|
||||||
"""
|
"""
|
||||||
starting_position = self.position
|
starting_position = self.position
|
||||||
rel_positions = np.array(rel_positions)
|
rel_positions_array: np.ndarray = np.array(rel_positions)
|
||||||
assert rel_positions.shape[1] == 3, ValueError(
|
assert rel_positions_array.shape[1] == 3, ValueError(
|
||||||
"Positions should be 3 elements long."
|
"Positions should be 3 elements long."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.move_rel(rel_positions[0], backlash=backlash)
|
self.move_rel(rel_positions_array[0], backlash=backlash)
|
||||||
yield 0
|
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)
|
self.move_rel(step, backlash=backlash)
|
||||||
yield i + 1
|
yield i + 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -104,11 +119,11 @@ class BaseStage(metaclass=ABCMeta):
|
||||||
if return_to_start:
|
if return_to_start:
|
||||||
self.move_abs(starting_position, backlash=backlash)
|
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)
|
"""Scan through a list of (relative) z positions (generator fn)
|
||||||
This function takes a 1D numpy array of Z positions, relative to
|
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
|
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
|
array of 3D positions with x=y=0. This, along with all the
|
||||||
keyword arguments, is then passed to ``scan_linear``.
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from typing import Optional, Tuple, Union
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from typing_extensions import Literal
|
||||||
|
|
||||||
from openflexure_microscope.stage.base import BaseStage
|
from openflexure_microscope.stage.base import BaseStage
|
||||||
from openflexure_microscope.utilities import axes_to_array
|
from openflexure_microscope.utilities import axes_to_array
|
||||||
|
|
@ -15,8 +17,6 @@ class MissingStage(BaseStage):
|
||||||
self._n_axis = 3
|
self._n_axis = 3
|
||||||
self._backlash = None
|
self._backlash = None
|
||||||
|
|
||||||
self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self):
|
def state(self):
|
||||||
"""The general state dictionary of the board."""
|
"""The general state dictionary of the board."""
|
||||||
|
|
@ -66,14 +66,27 @@ class MissingStage(BaseStage):
|
||||||
else:
|
else:
|
||||||
self._backlash = np.array([int(blsh)] * self.n_axes, dtype=np.int)
|
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)
|
time.sleep(0.5)
|
||||||
if axis is not None:
|
if axis:
|
||||||
assert axis in self.axis_names, "axis must be one of {}".format(
|
# Displacement MUST be an integer if axis name is specified
|
||||||
self.axis_names
|
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
|
displacement = move
|
||||||
|
|
||||||
initial_move = np.array(displacement, dtype=np.int)
|
initial_move = np.array(displacement, dtype=np.int)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from typing import Optional, Tuple, Union
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from sangaboard import Sangaboard
|
from sangaboard import Sangaboard
|
||||||
|
from typing_extensions import Literal
|
||||||
|
|
||||||
from openflexure_microscope.stage.base import BaseStage
|
from openflexure_microscope.stage.base import BaseStage
|
||||||
from openflexure_microscope.utilities import axes_to_array
|
from openflexure_microscope.utilities import axes_to_array
|
||||||
|
|
@ -32,7 +34,6 @@ class SangaStage(BaseStage):
|
||||||
None # Initialise backlash storage, used by property setter/getter
|
None # Initialise backlash storage, used by property setter/getter
|
||||||
)
|
)
|
||||||
self.settle_time = 0.2 # Default move settle time
|
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
|
self._position_on_enter = None
|
||||||
|
|
||||||
|
|
@ -52,7 +53,7 @@ class SangaStage(BaseStage):
|
||||||
@property
|
@property
|
||||||
def n_axes(self):
|
def n_axes(self):
|
||||||
"""The number of axes this stage has."""
|
"""The number of axes this stage has."""
|
||||||
return len(self.board.axis_names)
|
return 3
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def position(self):
|
def position(self):
|
||||||
|
|
@ -111,7 +112,12 @@ class SangaStage(BaseStage):
|
||||||
|
|
||||||
return config
|
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.
|
"""Make a relative move, optionally correcting for backlash.
|
||||||
displacement: integer or array/list of 3 integers
|
displacement: integer or array/list of 3 integers
|
||||||
axis: None (for 3-axis moves) or one of 'x','y','z'
|
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)
|
logging.debug("Moving sangaboard by %s", displacement)
|
||||||
if not backlash or self.backlash is None:
|
if not backlash or self.backlash is None:
|
||||||
return self.board.move_rel(displacement, axis=axis)
|
return self.board.move_rel(displacement, axis=axis)
|
||||||
if axis is not None:
|
# If we specify an axis name and a displacement int, convert to a displacement tuple
|
||||||
# backlash correction is easier if we're always in 3D
|
if axis:
|
||||||
# so this code just converts single-axis moves into all-axis moves.
|
# Displacement MUST be an integer if axis name is specified
|
||||||
assert axis in self.axis_names, "axis must be one of {}".format(
|
if not isinstance(displacement, int):
|
||||||
self.axis_names
|
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
|
displacement = move
|
||||||
|
|
||||||
initial_move = np.array(displacement, dtype=np.int)
|
initial_move = np.array(displacement, dtype=np.int)
|
||||||
|
|
@ -154,7 +167,7 @@ class SangaStage(BaseStage):
|
||||||
# can just take over before settling
|
# can just take over before settling
|
||||||
time.sleep(self.settle_time)
|
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
|
"""Make an absolute move to a position
|
||||||
"""
|
"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import copy
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from uuid import UUID
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
@ -22,12 +22,12 @@ class Timer(object):
|
||||||
logging.debug("%s time: %s", self.name, self.end - self.start)
|
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)
|
flat_arr = np.frombuffer(base64.b64decode(b64_string), dtype)
|
||||||
return flat_arr.reshape(shape)
|
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")
|
b64_string = base64.b64encode(npy_arr).decode("ascii")
|
||||||
dtype = str(npy_arr.dtype)
|
dtype = str(npy_arr.dtype)
|
||||||
shape = npy_arr.shape
|
shape = npy_arr.shape
|
||||||
|
|
@ -50,9 +50,14 @@ def json_to_ndarray(json_dict: dict):
|
||||||
if not json_dict.get(required_param):
|
if not json_dict.get(required_param):
|
||||||
raise KeyError(f"Missing required key {required_param}")
|
raise KeyError(f"Missing required key {required_param}")
|
||||||
|
|
||||||
return deserialise_array_b64(
|
b64_string: Optional[str] = json_dict.get("base64")
|
||||||
json_dict.get("base64"), json_dict.get("dtype"), json_dict.get("shape")
|
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
|
@contextmanager
|
||||||
|
|
@ -82,8 +87,11 @@ def set_properties(obj, **kwargs):
|
||||||
|
|
||||||
|
|
||||||
def axes_to_array(
|
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"""
|
"""Takes key-value pairs of a JSON value, and maps onto an array"""
|
||||||
# If no base array is given
|
# If no base array is given
|
||||||
if not base_array:
|
if not base_array:
|
||||||
|
|
@ -101,21 +109,3 @@ def axes_to_array(
|
||||||
)
|
)
|
||||||
|
|
||||||
return base_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
246
poetry.lock
generated
|
|
@ -67,7 +67,7 @@ wrapt = ">=1.11,<2.0"
|
||||||
name = "atomicwrites"
|
name = "atomicwrites"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
description = "Atomic file writes."
|
description = "Atomic file writes."
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
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"
|
name = "attrs"
|
||||||
version = "20.3.0"
|
version = "20.3.0"
|
||||||
description = "Classes Without Boilerplate"
|
description = "Classes Without Boilerplate"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
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"
|
name = "colorama"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
description = "Cross-platform colored terminal text."
|
description = "Cross-platform colored terminal text."
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||||
|
|
||||||
|
|
@ -172,6 +172,17 @@ python-versions = "*"
|
||||||
doc = ["sphinx"]
|
doc = ["sphinx"]
|
||||||
test = ["pytest", "coverage", "mock"]
|
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]]
|
[[package]]
|
||||||
name = "docutils"
|
name = "docutils"
|
||||||
version = "0.16"
|
version = "0.16"
|
||||||
|
|
@ -222,6 +233,17 @@ python-versions = "*"
|
||||||
Flask = ">=0.9"
|
Flask = ">=0.9"
|
||||||
Six = "*"
|
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]]
|
[[package]]
|
||||||
name = "future"
|
name = "future"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
|
|
@ -258,7 +280,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||||
name = "importlib-metadata"
|
name = "importlib-metadata"
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
description = "Read metadata from Python packages"
|
description = "Read metadata from Python packages"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
|
|
@ -273,7 +295,7 @@ testing = ["packaging", "pep517", "unittest2", "importlib-resources (>=1.3)"]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
description = "iniconfig: brain-dead simple config-ini parsing"
|
description = "iniconfig: brain-dead simple config-ini parsing"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
|
||||||
|
|
@ -314,21 +336,28 @@ i18n = ["Babel (>=0.8)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "labthings"
|
name = "labthings"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
description = "Python implementation of LabThings, based on the Flask microframework"
|
description = "Python implementation of LabThings, based on the Flask microframework"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6,<4.0"
|
python-versions = "^3.6"
|
||||||
|
develop = true
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
apispec = ">=3.2.0,<4.0.0"
|
apispec = "^3.2.0"
|
||||||
apispec_webframeworks = ">=0.5.2,<0.6.0"
|
apispec_webframeworks = "^0.5.2"
|
||||||
Flask = ">=1.1.1,<2.0.0"
|
Flask = "^1.1.1"
|
||||||
flask-cors = ">=3.0.8,<4.0.0"
|
flask-cors = "^3.0.8"
|
||||||
marshmallow = ">=3.4.0,<4.0.0"
|
marshmallow = "^3.4.0"
|
||||||
webargs = ">=6.0.0,<7.0.0"
|
webargs = "^6.0.0"
|
||||||
zeroconf = ">=0.24.5,<0.29.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]]
|
[[package]]
|
||||||
name = "lazy-object-proxy"
|
name = "lazy-object-proxy"
|
||||||
version = "1.4.3"
|
version = "1.4.3"
|
||||||
|
|
@ -367,6 +396,30 @@ category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
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]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "1.19.2"
|
version = "1.19.2"
|
||||||
|
|
@ -390,7 +443,7 @@ numpy = ">=1.17.3"
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "20.4"
|
version = "20.4"
|
||||||
description = "Core utilities for Python packages"
|
description = "Core utilities for Python packages"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
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"
|
pyparsing = ">=2.0.2"
|
||||||
six = "*"
|
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]]
|
[[package]]
|
||||||
name = "picamerax"
|
name = "picamerax"
|
||||||
version = "20.9.1"
|
version = "20.9.1"
|
||||||
|
|
@ -414,6 +475,14 @@ array = ["numpy"]
|
||||||
doc = ["sphinx"]
|
doc = ["sphinx"]
|
||||||
test = ["coverage", "pytest", "mock", "pillow", "numpy"]
|
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]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "7.2.0"
|
version = "7.2.0"
|
||||||
|
|
@ -426,7 +495,7 @@ python-versions = ">=3.5"
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
description = "plugin and hook calling mechanisms for python"
|
description = "plugin and hook calling mechanisms for python"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
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]
|
[package.extras]
|
||||||
dev = ["pre-commit", "tox"]
|
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]]
|
[[package]]
|
||||||
name = "psutil"
|
name = "psutil"
|
||||||
version = "5.7.3"
|
version = "5.7.3"
|
||||||
|
|
@ -451,7 +532,7 @@ test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]
|
||||||
name = "py"
|
name = "py"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
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"
|
name = "pyparsing"
|
||||||
version = "2.4.7"
|
version = "2.4.7"
|
||||||
description = "Python parsing module"
|
description = "Python parsing module"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||||
|
|
||||||
|
|
@ -514,7 +595,7 @@ cp2110 = ["hidapi"]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "6.1.2"
|
version = "6.1.2"
|
||||||
description = "pytest: simple powerful testing with Python"
|
description = "pytest: simple powerful testing with Python"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.5"
|
python-versions = ">=3.5"
|
||||||
|
|
||||||
|
|
@ -533,6 +614,21 @@ toml = "*"
|
||||||
checkqa_mypy = ["mypy (==0.780)"]
|
checkqa_mypy = ["mypy (==0.780)"]
|
||||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
|
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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.8.1"
|
version = "2.8.1"
|
||||||
|
|
@ -752,10 +848,18 @@ test = ["pytest"]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
description = "Python Library for Tom's Obvious, Minimal Language"
|
description = "Python Library for Tom's Obvious, Minimal Language"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
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]]
|
[[package]]
|
||||||
name = "typed-ast"
|
name = "typed-ast"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
|
|
@ -764,6 +868,14 @@ category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
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]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "1.26.2"
|
version = "1.26.2"
|
||||||
|
|
@ -830,7 +942,7 @@ ifaddr = ">=0.1.7"
|
||||||
name = "zipp"
|
name = "zipp"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
description = "Backport of pathlib-compatible object wrapper for zip files"
|
description = "Backport of pathlib-compatible object wrapper for zip files"
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
|
|
@ -843,8 +955,8 @@ rpi = ["RPi.GPIO"]
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.6.1"
|
python-versions = "^3.7.3"
|
||||||
content-hash = "394961d14d2bd06da0a05b6c34db8db97d88ff2b3d12e0e149da4e2706169cab"
|
content-hash = "e2f1011751b49fc5837f475f5304822216724a89f877ee5daf9c281cd8830673"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
alabaster = [
|
alabaster = [
|
||||||
|
|
@ -907,6 +1019,42 @@ colorzero = [
|
||||||
{file = "colorzero-1.1-py2.py3-none-any.whl", hash = "sha256:e3c36d15b293de2b2f77ff54a5bd243fffac941ed0a5332d0697a6612a26a0a3"},
|
{file = "colorzero-1.1-py2.py3-none-any.whl", hash = "sha256:e3c36d15b293de2b2f77ff54a5bd243fffac941ed0a5332d0697a6612a26a0a3"},
|
||||||
{file = "colorzero-1.1.tar.gz", hash = "sha256:acba47119b5d8555680d3cda9afe6ccc5481385ccc3c00084dd973f7aa184599"},
|
{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 = [
|
docutils = [
|
||||||
{file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"},
|
{file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"},
|
||||||
{file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"},
|
{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.tar.gz", hash = "sha256:6bcfc100288c5d1bcb1dbb854babd59beee622ffd321e444b05f24d6d58466b8"},
|
||||||
{file = "Flask_Cors-3.0.9-py2.py3-none-any.whl", hash = "sha256:cee4480aaee421ed029eaa788f4049e3e26d15b5affb6a880dade6bafad38324"},
|
{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 = [
|
future = [
|
||||||
{file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"},
|
{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-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"},
|
||||||
{file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"},
|
{file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"},
|
||||||
]
|
]
|
||||||
labthings = [
|
labthings = []
|
||||||
{file = "labthings-1.1.4-py3-none-any.whl", hash = "sha256:18febba20cb552af9e5c0f8f47b06acd9649dce7de7205734b9e1fd3334e2d79"},
|
|
||||||
{file = "labthings-1.1.4.tar.gz", hash = "sha256:69ca83d28720597327296d3e145bf57e68af17d245c0051c274db928e0a13e76"},
|
|
||||||
]
|
|
||||||
lazy-object-proxy = [
|
lazy-object-proxy = [
|
||||||
{file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"},
|
{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"},
|
{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-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
|
||||||
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
|
{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 = [
|
numpy = [
|
||||||
{file = "numpy-1.19.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b594f76771bc7fc8a044c5ba303427ee67c17a09b36e1fa32bde82f5c419d17a"},
|
{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"},
|
{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-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"},
|
||||||
{file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"},
|
{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 = [
|
picamerax = [
|
||||||
{file = "picamerax-20.9.1-py3-none-any.whl", hash = "sha256:8dc45542644ed9c67e3b331e90bcffa04098e8b056a42d0fedb25769b0b1f17e"},
|
{file = "picamerax-20.9.1-py3-none-any.whl", hash = "sha256:8dc45542644ed9c67e3b331e90bcffa04098e8b056a42d0fedb25769b0b1f17e"},
|
||||||
{file = "picamerax-20.9.1.tar.gz", hash = "sha256:fccb201ad9e2ab67946111d6bf875289c141ef900fd5875de6d435d219c59440"},
|
{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 = [
|
pillow = [
|
||||||
{file = "Pillow-7.2.0-cp35-cp35m-macosx_10_10_intel.whl", hash = "sha256:1ca594126d3c4def54babee699c055a913efb01e106c309fa6b04405d474d5ae"},
|
{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"},
|
{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-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
|
||||||
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
|
{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 = [
|
psutil = [
|
||||||
{file = "psutil-5.7.3-cp27-none-win32.whl", hash = "sha256:1cd6a0c9fb35ece2ccf2d1dd733c1e165b342604c67454fd56a4c12e0a106787"},
|
{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"},
|
{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-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"},
|
||||||
{file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"},
|
{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 = [
|
python-dateutil = [
|
||||||
{file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
|
{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"},
|
{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-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
|
||||||
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
|
{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 = [
|
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_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
|
||||||
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
|
{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-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"},
|
||||||
{file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
|
{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 = [
|
urllib3 = [
|
||||||
{file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"},
|
{file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"},
|
||||||
{file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"},
|
{file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"},
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ packages = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.6.1"
|
python = "^3.7.3"
|
||||||
Flask = "^1.0"
|
Flask = "^1.0"
|
||||||
numpy = "1.19.2" # Exact version so we can guarantee a wheel
|
numpy = "1.19.2" # Exact version so we can guarantee a wheel
|
||||||
Pillow = "^7.2.0"
|
Pillow = "^7.2.0"
|
||||||
|
|
@ -43,7 +43,10 @@ expiringdict = "^1.2.1"
|
||||||
camera-stage-mapping = "0.1.4"
|
camera-stage-mapping = "0.1.4"
|
||||||
picamerax = ">=20.9.1"
|
picamerax = ">=20.9.1"
|
||||||
pyyaml = "^5.3.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]
|
[tool.poetry.extras]
|
||||||
rpi = ["RPi.GPIO"]
|
rpi = ["RPi.GPIO"]
|
||||||
|
|
@ -58,6 +61,9 @@ rope = "^0.14.0"
|
||||||
pylint = "^2.3"
|
pylint = "^2.3"
|
||||||
black = {version = "^18.3-alpha.0",allow-prereleases = true}
|
black = {version = "^18.3-alpha.0",allow-prereleases = true}
|
||||||
pytest = "^6.1.2"
|
pytest = "^6.1.2"
|
||||||
|
mypy = "^0.790"
|
||||||
|
poethepoet = "^0.9.0"
|
||||||
|
freezegun = "^1.0.0"
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
exclude = '(\.eggs|\.git|\.venv|node_modules/)'
|
exclude = '(\.eggs|\.git|\.venv|node_modules/)'
|
||||||
|
|
@ -73,3 +79,16 @@ line_length = 88
|
||||||
[tool.pylint.'MESSAGES CONTROL']
|
[tool.pylint.'MESSAGES CONTROL']
|
||||||
disable = "fixme,C,R"
|
disable = "fixme,C,R"
|
||||||
max-line-length = 88
|
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"]
|
||||||
BIN
tests/images/capture_v280.jpg
Normal file
BIN
tests/images/capture_v280.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 906 B |
19
tests/test_api_utilities.py
Normal file
19
tests/test_api_utilities.py
Normal 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
|
||||||
74
tests/test_camera_framestream.py
Normal file
74
tests/test_camera_framestream.py
Normal 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()
|
||||||
151
tests/test_capture_manager.py
Normal file
151
tests/test_capture_manager.py
Normal 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()
|
||||||
232
tests/test_capture_object.py
Normal file
232
tests/test_capture_object.py
Normal 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)
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from pprint import pprint
|
|
||||||
|
|
||||||
from openflexure_microscope.api.default_extensions import scan
|
from openflexure_microscope.api.default_extensions import scan
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
61
tests/test_json.py
Normal file
61
tests/test_json.py
Normal 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
|
||||||
|
|
@ -5,7 +5,7 @@ from openflexure_microscope import utilities
|
||||||
|
|
||||||
def test_serialise_array_b64():
|
def test_serialise_array_b64():
|
||||||
shape_in = (3, 2)
|
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)
|
b64_string, dtype, shape = utilities.serialise_array_b64(arr_in)
|
||||||
assert b64_string
|
assert b64_string
|
||||||
|
|
@ -18,7 +18,7 @@ def test_serialise_array_b64():
|
||||||
|
|
||||||
def test_ndarray_to_json():
|
def test_ndarray_to_json():
|
||||||
shape_in = (3, 2)
|
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)
|
json_out = utilities.ndarray_to_json(arr_in)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue