diff --git a/docs/source/capture.rst b/docs/source/capture.rst index 1ed90b8c..4b0477cc 100644 --- a/docs/source/capture.rst +++ b/docs/source/capture.rst @@ -3,7 +3,9 @@ Capture Object By default, all image and video capture data are stored to instances of :py:class:`openflexure_microscope.camera.capture.CaptureObject`. This class mostly wraps up complexity associated with moving data between disk and memory. -The class also includes some convenience features such as automatically cleaning up captures marked as temporary, handling metadata files and file names, and generating image thumbnails. Often, the most recent capture wants to be stored in memory, for applications that involve immediately processing the capture data in some way. Rather than manually keep track of the location of the capture data, you can use the CaptureObject ``data`` attribute, which automatically returns capture data from the fastest available location. Below are details of available methods and attributes. +The class also includes some convenience features such as automatically cleaning up captures marked as temporary, handling metadata tags and file names, and generating image thumbnails. Often, the most recent capture wants to be stored in memory, for applications that involve immediately processing the capture data in some way. Rather than manually keep track of the location of the capture data, you can use the CaptureObject ``data`` attribute, which automatically returns capture data from the fastest available location. Additionally, the class handles storing capture metadata to Exif tags in supported formats. + +Below are details of available methods and attributes. .. automodule:: openflexure_microscope.camera.capture :members: \ No newline at end of file diff --git a/openflexure_microscope/api/v1/blueprints/camera/capture.py b/openflexure_microscope/api/v1/blueprints/camera/capture.py index 56557f07..861737a3 100644 --- a/openflexure_microscope/api/v1/blueprints/camera/capture.py +++ b/openflexure_microscope/api/v1/blueprints/camera/capture.py @@ -127,10 +127,10 @@ class ListAPI(MicroscopeView): resize=resize, bayer=bayer) - output.put_metadata(metadata) + metadata.update({'Position': self.microscope.state['stage']['position']}) - for tag in tags: - output.put_tag(str(tag)) + output.put_metadata(metadata) + output.put_tags(tags) return jsonify(output.state) diff --git a/openflexure_microscope/api/v1/blueprints/camera/preview.py b/openflexure_microscope/api/v1/blueprints/camera/preview.py index 2150712f..11ea28f8 100644 --- a/openflexure_microscope/api/v1/blueprints/camera/preview.py +++ b/openflexure_microscope/api/v1/blueprints/camera/preview.py @@ -1,13 +1,16 @@ +from openflexure_microscope.api.utilities import JsonPayload from openflexure_microscope.api.v1.views import MicroscopeView -from flask import jsonify +from flask import jsonify, request class GPUPreviewAPI(MicroscopeView): def post(self, operation): """ - Create a new image capture. + Start or stop the onboard GPU preview. + Optional "window" parameter can be passed to control the position and size of the preview window, + in the format ``[x, y, width, height]``. .. :quickref: GPU Preview; Start/stop preview @@ -18,6 +21,10 @@ class GPUPreviewAPI(MicroscopeView): POST /camera/preview/start HTTP/1.1 Accept: application/json + { + "window": [0, 0, 480, 320], + } + .. sourcecode:: http POST /camera/preview/stop HTTP/1.1 @@ -29,7 +36,16 @@ class GPUPreviewAPI(MicroscopeView): :status 200: preview started/stopped """ if operation == "start": - self.microscope.camera.start_preview() + payload = JsonPayload(request) + + window = payload.param('window', default=[]) + fullscreen = False + + if len(window) != 4 or not all(isinstance(n, int) for n in window): + fullscreen = True + window = None + + self.microscope.camera.start_preview(fullscreen=fullscreen, window=window) elif operation == "stop": self.microscope.camera.stop_preview() return jsonify(self.microscope.state) diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index 16869005..266dcd2b 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -242,26 +242,6 @@ class BaseCamera(object): with open(db_path, 'w') as outfile: yaml.safe_dump(capture_state, outfile) - def validate_captures(self, capture_list): - # Purge captures with missing data files - valid_captures = [] - for capture in capture_list: - if capture.file_exists: - valid_captures.append(capture) - else: - if os.path.isfile(capture.metadata_file): - logging.warning("Capture data missing. Deleting metadata file {}.".format(capture.metadata_file)) - os.remove(capture.metadata_file) - - # Validate and repair captures - for capture in valid_captures: - # If the captures metadata file is missing - if not os.path.isfile(capture.metadata_file): - # Recreate the metadata file - capture.save_metadata() - - return valid_captures - def load_capture_db(self, db_path): if os.path.isfile(db_path): # Load list of capture dictionary representations from db_path @@ -269,9 +249,11 @@ class BaseCamera(object): capture_dict_list = yaml.load(infile) # Create capture object list, and validate captures - capture_list = self.validate_captures( - [capture_from_dict(capture_dict) for capture_dict in capture_dict_list] - ) + capture_list = [] + + for capture_dict in capture_dict_list: + if os.path.isfile(capture_dict['path']): + capture_list.append(capture_from_dict(capture_dict)) return capture_list diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index 0ee440cb..fb8cce2d 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -3,14 +3,16 @@ import io import os import glob import datetime -import copy import yaml import logging from PIL import Image import atexit -pil_formats = ['JPG', 'JPEG', 'PNG', 'TIF', 'TIFF'] -thumbnail_size = (200, 150) +from openflexure_microscope.camera import piexif + +PIL_FORMATS = ['JPG', 'JPEG', 'PNG', 'TIF', 'TIFF'] +EXIF_FORMATS = ['JPG', 'JPEG', 'TIF', 'TIFF'] +THUMBNAIL_SIZE = (200, 150) BASE_CAPTURE_PATH = os.path.join(os.path.expanduser('~'), 'micrographs') #: str: Base path to store all captures TEMP_CAPTURE_PATH = os.path.join(BASE_CAPTURE_PATH, 'tmp') #: str: Base path to store all temporary captures (automatically emptied) @@ -30,6 +32,40 @@ def clear_tmp(): logging.debug("Cleared {}.".format(TEMP_CAPTURE_PATH)) +def pull_usercomment_dict(filepath): + """ + Reads UserComment Exif data from a file, and returns the contained bytes as a dictionary. + Args: + filepath: Path to the Exif-containing file + """ + exif_dict = piexif.load(filepath) + if 'Exif' in exif_dict and 37510 in exif_dict['Exif']: + return yaml.load(exif_dict['Exif'][37510].decode()) + else: + return {} + + +def extract_with_priority(key, best_dict, backup_dict): + """ + Extracts a value from one of two dictionaries, prioritising one over the other. + Second dictionary is used only if the key doesn't exist in the first. + + Args: + key: Key to search for + best_dict: Ideal dictionary to use + backup_dict: Fallback dictionary + + """ + if key in best_dict: + return best_dict[key] + elif key in backup_dict: + logging.warning("Key {} not found in primary dictionary. Falling back to backup.".format(key)) + return backup_dict[key] + else: + logging.error("Key {} not found in either dictionary!".format(key)) + return None + + def capture_from_dict(capture_dict): """ Creates an instance of CaptureObject from a dictionary of capture information. @@ -39,22 +75,29 @@ def capture_from_dict(capture_dict): Args: capture_dict (dict): Dictionary containing capture information """ - capture = CaptureObject(create_metadata_file=False) # Create a placeholder capture + global EXIF_FORMATS + + capture = CaptureObject() # Create a placeholder capture + + # Get inherent capture information from database + capture.file = capture_dict['path'] + capture.split_file_path(capture.file) + capture.temporary = capture_dict['temporary'] + + if capture.format.upper() in EXIF_FORMATS: + md_exif = pull_usercomment_dict(capture.file) + else: + md_exif = {} + + md_database = capture_dict['metadata'] # Populate capture parameters - capture.temporary = capture_dict['temporary'] - capture.id = capture_dict['metadata']['id'] - capture.timestring = capture_dict['metadata']['time'] - capture.format = capture_dict['metadata']['format'] + capture.id = extract_with_priority('id', md_exif, md_database) + capture.timestring = extract_with_priority('time', md_exif, md_database) + capture.format = extract_with_priority('format', md_exif, md_database) - if 'custom' in capture_dict['metadata']: - capture._metadata = capture_dict['metadata']['custom'] - - if 'tags' in capture_dict['metadata']: - capture.tags = capture_dict['metadata']['tags'] - - capture.file = capture_dict['metadata']['path'] - capture.split_file_path(capture.file) + capture._metadata = extract_with_priority('custom', md_exif, md_database) + capture.tags = extract_with_priority('tags', md_exif, md_database) return capture @@ -72,8 +115,7 @@ class CaptureObject(object): temporary: bool = False, filename: str = '', folder: str = '', - fmt: str = '', - create_metadata_file: bool = True) -> None: + fmt: str = '') -> None: """Create a new StreamObject, to manage capture data.""" # Store a nice ID @@ -107,7 +149,7 @@ class CaptureObject(object): self.tags = [] #: list: List of tags. Essentially just as extra custom metadata field, but useful for quick organisation # Initialise the capture stream - self.initialise_capture(create_metadata_file=create_metadata_file) + self.initialise_capture() # Log if created by context manager self.context_manager = False @@ -136,14 +178,12 @@ class CaptureObject(object): logging.info("Cleaning up {}".format(self.id)) self.close() - def initialise_capture(self, create_metadata_file=True): + def initialise_capture(self): """ Initialise the capture object. Creates a file name and builds a file path, creates an in-memory byte stream for capture data, and creates a metadata file on disk. - Args: - create_metadata_file (bool): Skips creating a metadata file if False (useful for captures being processed but not stored) """ self.build_file_path(self.filename, self.folder) @@ -159,8 +199,7 @@ class CaptureObject(object): self.stream = self.file # Save initial metadata file - if create_metadata_file: - self.save_metadata() + self.save_metadata() def build_file_path( self, @@ -206,10 +245,11 @@ class CaptureObject(object): Args: filepath (str): String of the full file path, including file format extension """ - self.filefolder, self.filename = os.path.split( - filepath) # Split the full file path into a folder and a filename + self.filefolder, self.filename = os.path.split(filepath) # Split the full file path into a folder and a filename self.basename = os.path.splitext(self.filename)[0] # Split the filename out from it's file extension - self.metadataname = "{}.yaml".format(self.basename) + + if not self.format: + self.format = self.filename.split('.')[-1] def lock(self): """Set locked flag to True.""" @@ -240,16 +280,18 @@ class CaptureObject(object): return False # HANDLE TAGS - def put_tag(self, tag: str): + def put_tags(self, tags: list): """ Add a new tag to the ``tags`` list attribute. Args: - tag (str): Tag to be added + tags (list): List of tags to be added """ - if not tag in self.tags: - self.tags.append(tag) - self.save_metadata() + for tag in tags: + if tag not in self.tags: + self.tags.append(tag) + + self.save_metadata() def delete_tag(self, tag: str): """ @@ -261,14 +303,9 @@ class CaptureObject(object): if tag in self.tags: self.tags = [new_tag for new_tag in self.tags if new_tag != tag] - # HANDLE METADATA + self.save_metadata() - @property - def metadata_file(self) -> str: - """ - Path to the associated on-disk metadata YAML file. - """ - return "{}.yaml".format(os.path.splitext(self.file)[0]) + # HANDLE METADATA def put_metadata(self, data: dict) -> None: """ @@ -282,19 +319,25 @@ class CaptureObject(object): def save_metadata(self) -> None: """ - Save metadata to on-disk metadata YAML file + Save metadata to exif, if supported """ - logging.debug("Writing metadata to file {}".format(self.metadata_file)) - with open(self.metadata_file, 'w') as outfile: - yaml.dump(self.metadata, outfile, default_flow_style=False) + global EXIF_FORMATS - def delete_metadata(self) -> None: - """ - Delete on-disk metadata YAML file, if it exists - """ - if os.path.isfile(self.metadata_file): - logging.info("Deleting file {}".format(self.metadata_file)) - os.remove(self.metadata_file) + print(self.format) + print(EXIF_FORMATS) + + if self.format.upper() in EXIF_FORMATS and self.file_exists: + logging.debug("Writing exif data to capture file") + # Extract current Exif data + exif_dict = piexif.load(self.file) + # Serialize metadata + metadata_string = yaml.safe_dump(self.metadata) + # Insert metadata into exif_dict + exif_dict['Exif'][piexif.ExifIFD.UserComment] = metadata_string.encode() + # Convert new exif dict to exif bytes + exif_bytes = piexif.dump(exif_dict) + # Insert exif into file + piexif.insert(exif_bytes, self.file) @property def metadata(self) -> dict: @@ -331,8 +374,7 @@ class CaptureObject(object): """ # Create basic state dictionary - d = {'locked': self.locked, 'temporary': self.temporary, 'metadata': self.metadata, - 'metadata_path': self.metadata_file} + d = {'path': self.file, 'locked': self.locked, 'temporary': self.temporary, 'metadata': self.metadata} # Check bytestream if self.stream_exists: @@ -385,14 +427,14 @@ class CaptureObject(object): """ Returns a thumbnail of the capture data, for supported image formats. """ - global thumbnail_size + global THUMBNAIL_SIZE # If no thumbnail exists, try and make one if not self.thumb_bytes: logging.info("Building thumbnail") self.thumb_bytes = io.BytesIO() - if self.format.upper() in pil_formats: + if self.format.upper() in PIL_FORMATS: im = Image.open(self.data) - im.thumbnail(thumbnail_size) + im.thumbnail(THUMBNAIL_SIZE) im.save(self.thumb_bytes, self.format) self.thumb_bytes.seek(0) else: @@ -445,9 +487,6 @@ class CaptureObject(object): def delete_file(self) -> bool: """If the StreamObject has been saved, delete the file.""" - if os.path.isfile(self.metadata_file): - logging.info("Deleting file {}".format(self.metadata_file)) - os.remove(self.metadata_file) if os.path.isfile(self.file): logging.info("Deleting file {}".format(self.file)) @@ -462,7 +501,6 @@ class CaptureObject(object): logging.info("Deleting {}".format(self.id)) self.delete_stream() self.delete_file() - self.delete_metadata() def shunt(self): """Demote the StreamObject from being stored in memory.""" diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index f39034e6..df49fc40 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -225,9 +225,9 @@ class StreamingCamera(BaseCamera): # LAUNCH ACTIONS - def start_preview(self) -> bool: + def start_preview(self, fullscreen=True, window=None) -> bool: """Start the on board GPU camera preview.""" - self.camera.start_preview() + self.camera.start_preview(fullscreen=fullscreen, window=window) self.state['preview_active'] = True return True diff --git a/openflexure_microscope/camera/piexif/LICENSE.txt b/openflexure_microscope/camera/piexif/LICENSE.txt new file mode 100644 index 00000000..be0bbced --- /dev/null +++ b/openflexure_microscope/camera/piexif/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014, 2015 hMatoba + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openflexure_microscope/camera/piexif/__init__.py b/openflexure_microscope/camera/piexif/__init__.py new file mode 100644 index 00000000..95cc5de1 --- /dev/null +++ b/openflexure_microscope/camera/piexif/__init__.py @@ -0,0 +1,11 @@ +from ._remove import remove +from ._load import load +from ._dump import dump +from ._transplant import transplant +from ._insert import insert +from ._exif import * +from ._exceptions import * + + + +VERSION = '1.1.2' diff --git a/openflexure_microscope/camera/piexif/_common.py b/openflexure_microscope/camera/piexif/_common.py new file mode 100644 index 00000000..28e45871 --- /dev/null +++ b/openflexure_microscope/camera/piexif/_common.py @@ -0,0 +1,94 @@ +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 1: + length = struct.unpack(">H", head[2: 4])[0] + + if head[:2] == b"\xff\xe1": + segment_data = f.read(length - 2) + if segment_data[:4] != b'Exif': + head = f.read(HEAD_LENGTH) + continue + exif = head + segment_data + break + elif head[0:1] == b"\xff": + f.read(length - 2) + head = f.read(HEAD_LENGTH) + else: + break + + f.close() + return exif + +def get_exif_seg(segments): + """Returns Exif from JPEG meta data list + """ + for seg in segments: + if seg[0:2] == b"\xff\xe1" and seg[4:10] == b"Exif\x00\x00": + return seg + return None + + +def merge_segments(segments, exif=b""): + """Merges Exif with APP0 and APP1 manipulations. + """ + if segments[1][0:2] == b"\xff\xe0" and \ + segments[2][0:2] == b"\xff\xe1" and \ + segments[2][4:10] == b"Exif\x00\x00": + if exif: + segments[2] = exif + segments.pop(1) + elif exif is None: + segments.pop(2) + else: + segments.pop(1) + elif segments[1][0:2] == b"\xff\xe0": + if exif: + segments[1] = exif + elif segments[1][0:2] == b"\xff\xe1" and \ + segments[1][4:10] == b"Exif\x00\x00": + if exif: + segments[1] = exif + elif exif is None: + segments.pop(1) + else: + if exif: + segments.insert(1, exif) + return b"".join(segments) diff --git a/openflexure_microscope/camera/piexif/_dump.py b/openflexure_microscope/camera/piexif/_dump.py new file mode 100644 index 00000000..4617a69a --- /dev/null +++ b/openflexure_microscope/camera/piexif/_dump.py @@ -0,0 +1,346 @@ +import copy +import numbers +import struct + +from ._common import * +from ._exif import * + + +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: + try: + new_value = raw_value + b"\x00" + except TypeError: + raise ValueError("Got invalid type to convert.") + 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 n, 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 n, 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: + raise ValueError("Got invalid type to convert.") + else: + try: + value_str = raw_value + b"\x00" * (4 - length) + except TypeError: + raise ValueError("Got invalid type to convert.") + 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 n, 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: + raise ValueError( + '"dump" got wrong type of exif value.\n' + + '{0} in {1} IFD. Got as {2}.'.format(key, ifd, type(ifd_dict[key])) + ) + + entries += key_str + type_str + length_str + value_str + values += four_bytes_over + return (entry_header + entries, values) diff --git a/openflexure_microscope/camera/piexif/_exceptions.py b/openflexure_microscope/camera/piexif/_exceptions.py new file mode 100644 index 00000000..bde09086 --- /dev/null +++ b/openflexure_microscope/camera/piexif/_exceptions.py @@ -0,0 +1,2 @@ +class InvalidImageDataError(ValueError): + pass diff --git a/openflexure_microscope/camera/piexif/_exif.py b/openflexure_microscope/camera/piexif/_exif.py new file mode 100644 index 00000000..abdf0bc4 --- /dev/null +++ b/openflexure_microscope/camera/piexif/_exif.py @@ -0,0 +1,638 @@ +class TYPES: + Byte = 1 + Ascii = 2 + Short = 3 + Long = 4 + Rational = 5 + SByte = 6 + Undefined = 7 + SShort = 8 + SLong = 9 + SRational = 10 + Float = 11 + DFloat = 12 + + +TAGS = { + 'Image': {11: {'name': 'ProcessingSoftware', 'type': TYPES.Ascii}, + 254: {'name': 'NewSubfileType', 'type': TYPES.Long}, + 255: {'name': 'SubfileType', 'type': TYPES.Short}, + 256: {'name': 'ImageWidth', 'type': TYPES.Long}, + 257: {'name': 'ImageLength', 'type': TYPES.Long}, + 258: {'name': 'BitsPerSample', 'type': TYPES.Short}, + 259: {'name': 'Compression', 'type': TYPES.Short}, + 262: {'name': 'PhotometricInterpretation', 'type': TYPES.Short}, + 263: {'name': 'Threshholding', 'type': TYPES.Short}, + 264: {'name': 'CellWidth', 'type': TYPES.Short}, + 265: {'name': 'CellLength', 'type': TYPES.Short}, + 266: {'name': 'FillOrder', 'type': TYPES.Short}, + 269: {'name': 'DocumentName', 'type': TYPES.Ascii}, + 270: {'name': 'ImageDescription', 'type': TYPES.Ascii}, + 271: {'name': 'Make', 'type': TYPES.Ascii}, + 272: {'name': 'Model', 'type': TYPES.Ascii}, + 273: {'name': 'StripOffsets', 'type': TYPES.Long}, + 274: {'name': 'Orientation', 'type': TYPES.Short}, + 277: {'name': 'SamplesPerPixel', 'type': TYPES.Short}, + 278: {'name': 'RowsPerStrip', 'type': TYPES.Long}, + 279: {'name': 'StripByteCounts', 'type': TYPES.Long}, + 282: {'name': 'XResolution', 'type': TYPES.Rational}, + 283: {'name': 'YResolution', 'type': TYPES.Rational}, + 284: {'name': 'PlanarConfiguration', 'type': TYPES.Short}, + 290: {'name': 'GrayResponseUnit', 'type': TYPES.Short}, + 291: {'name': 'GrayResponseCurve', 'type': TYPES.Short}, + 292: {'name': 'T4Options', 'type': TYPES.Long}, + 293: {'name': 'T6Options', 'type': TYPES.Long}, + 296: {'name': 'ResolutionUnit', 'type': TYPES.Short}, + 301: {'name': 'TransferFunction', 'type': TYPES.Short}, + 305: {'name': 'Software', 'type': TYPES.Ascii}, + 306: {'name': 'DateTime', 'type': TYPES.Ascii}, + 315: {'name': 'Artist', 'type': TYPES.Ascii}, + 316: {'name': 'HostComputer', 'type': TYPES.Ascii}, + 317: {'name': 'Predictor', 'type': TYPES.Short}, + 318: {'name': 'WhitePoint', 'type': TYPES.Rational}, + 319: {'name': 'PrimaryChromaticities', 'type': TYPES.Rational}, + 320: {'name': 'ColorMap', 'type': TYPES.Short}, + 321: {'name': 'HalftoneHints', 'type': TYPES.Short}, + 322: {'name': 'TileWidth', 'type': TYPES.Short}, + 323: {'name': 'TileLength', 'type': TYPES.Short}, + 324: {'name': 'TileOffsets', 'type': TYPES.Short}, + 325: {'name': 'TileByteCounts', 'type': TYPES.Short}, + 330: {'name': 'SubIFDs', 'type': TYPES.Long}, + 332: {'name': 'InkSet', 'type': TYPES.Short}, + 333: {'name': 'InkNames', 'type': TYPES.Ascii}, + 334: {'name': 'NumberOfInks', 'type': TYPES.Short}, + 336: {'name': 'DotRange', 'type': TYPES.Byte}, + 337: {'name': 'TargetPrinter', 'type': TYPES.Ascii}, + 338: {'name': 'ExtraSamples', 'type': TYPES.Short}, + 339: {'name': 'SampleFormat', 'type': TYPES.Short}, + 340: {'name': 'SMinSampleValue', 'type': TYPES.Short}, + 341: {'name': 'SMaxSampleValue', 'type': TYPES.Short}, + 342: {'name': 'TransferRange', 'type': TYPES.Short}, + 343: {'name': 'ClipPath', 'type': TYPES.Byte}, + 344: {'name': 'XClipPathUnits', 'type': TYPES.Long}, + 345: {'name': 'YClipPathUnits', 'type': TYPES.Long}, + 346: {'name': 'Indexed', 'type': TYPES.Short}, + 347: {'name': 'JPEGTables', 'type': TYPES.Undefined}, + 351: {'name': 'OPIProxy', 'type': TYPES.Short}, + 512: {'name': 'JPEGProc', 'type': TYPES.Long}, + 513: {'name': 'JPEGInterchangeFormat', 'type': TYPES.Long}, + 514: {'name': 'JPEGInterchangeFormatLength', 'type': TYPES.Long}, + 515: {'name': 'JPEGRestartInterval', 'type': TYPES.Short}, + 517: {'name': 'JPEGLosslessPredictors', 'type': TYPES.Short}, + 518: {'name': 'JPEGPointTransforms', 'type': TYPES.Short}, + 519: {'name': 'JPEGQTables', 'type': TYPES.Long}, + 520: {'name': 'JPEGDCTables', 'type': TYPES.Long}, + 521: {'name': 'JPEGACTables', 'type': TYPES.Long}, + 529: {'name': 'YCbCrCoefficients', 'type': TYPES.Rational}, + 530: {'name': 'YCbCrSubSampling', 'type': TYPES.Short}, + 531: {'name': 'YCbCrPositioning', 'type': TYPES.Short}, + 532: {'name': 'ReferenceBlackWhite', 'type': TYPES.Rational}, + 700: {'name': 'XMLPacket', 'type': TYPES.Byte}, + 18246: {'name': 'Rating', 'type': TYPES.Short}, + 18249: {'name': 'RatingPercent', 'type': TYPES.Short}, + 32781: {'name': 'ImageID', 'type': TYPES.Ascii}, + 33421: {'name': 'CFARepeatPatternDim', 'type': TYPES.Short}, + 33422: {'name': 'CFAPattern', 'type': TYPES.Byte}, + 33423: {'name': 'BatteryLevel', 'type': TYPES.Rational}, + 33432: {'name': 'Copyright', 'type': TYPES.Ascii}, + 33434: {'name': 'ExposureTime', 'type': TYPES.Rational}, + 34377: {'name': 'ImageResources', 'type': TYPES.Byte}, + 34665: {'name': 'ExifTag', 'type': TYPES.Long}, + 34675: {'name': 'InterColorProfile', 'type': TYPES.Undefined}, + 34853: {'name': 'GPSTag', 'type': TYPES.Long}, + 34857: {'name': 'Interlace', 'type': TYPES.Short}, + 34858: {'name': 'TimeZoneOffset', 'type': TYPES.Long}, + 34859: {'name': 'SelfTimerMode', 'type': TYPES.Short}, + 37387: {'name': 'FlashEnergy', 'type': TYPES.Rational}, + 37388: {'name': 'SpatialFrequencyResponse', 'type': TYPES.Undefined}, + 37389: {'name': 'Noise', 'type': TYPES.Undefined}, + 37390: {'name': 'FocalPlaneXResolution', 'type': TYPES.Rational}, + 37391: {'name': 'FocalPlaneYResolution', 'type': TYPES.Rational}, + 37392: {'name': 'FocalPlaneResolutionUnit', 'type': TYPES.Short}, + 37393: {'name': 'ImageNumber', 'type': TYPES.Long}, + 37394: {'name': 'SecurityClassification', 'type': TYPES.Ascii}, + 37395: {'name': 'ImageHistory', 'type': TYPES.Ascii}, + 37397: {'name': 'ExposureIndex', 'type': TYPES.Rational}, + 37398: {'name': 'TIFFEPStandardID', 'type': TYPES.Byte}, + 37399: {'name': 'SensingMethod', 'type': TYPES.Short}, + 40091: {'name': 'XPTitle', 'type': TYPES.Byte}, + 40092: {'name': 'XPComment', 'type': TYPES.Byte}, + 40093: {'name': 'XPAuthor', 'type': TYPES.Byte}, + 40094: {'name': 'XPKeywords', 'type': TYPES.Byte}, + 40095: {'name': 'XPSubject', 'type': TYPES.Byte}, + 50341: {'name': 'PrintImageMatching', 'type': TYPES.Undefined}, + 50706: {'name': 'DNGVersion', 'type': TYPES.Byte}, + 50707: {'name': 'DNGBackwardVersion', 'type': TYPES.Byte}, + 50708: {'name': 'UniqueCameraModel', 'type': TYPES.Ascii}, + 50709: {'name': 'LocalizedCameraModel', 'type': TYPES.Byte}, + 50710: {'name': 'CFAPlaneColor', 'type': TYPES.Byte}, + 50711: {'name': 'CFALayout', 'type': TYPES.Short}, + 50712: {'name': 'LinearizationTable', 'type': TYPES.Short}, + 50713: {'name': 'BlackLevelRepeatDim', 'type': TYPES.Short}, + 50714: {'name': 'BlackLevel', 'type': TYPES.Rational}, + 50715: {'name': 'BlackLevelDeltaH', 'type': TYPES.SRational}, + 50716: {'name': 'BlackLevelDeltaV', 'type': TYPES.SRational}, + 50717: {'name': 'WhiteLevel', 'type': TYPES.Short}, + 50718: {'name': 'DefaultScale', 'type': TYPES.Rational}, + 50719: {'name': 'DefaultCropOrigin', 'type': TYPES.Short}, + 50720: {'name': 'DefaultCropSize', 'type': TYPES.Short}, + 50721: {'name': 'ColorMatrix1', 'type': TYPES.SRational}, + 50722: {'name': 'ColorMatrix2', 'type': TYPES.SRational}, + 50723: {'name': 'CameraCalibration1', 'type': TYPES.SRational}, + 50724: {'name': 'CameraCalibration2', 'type': TYPES.SRational}, + 50725: {'name': 'ReductionMatrix1', 'type': TYPES.SRational}, + 50726: {'name': 'ReductionMatrix2', 'type': TYPES.SRational}, + 50727: {'name': 'AnalogBalance', 'type': TYPES.Rational}, + 50728: {'name': 'AsShotNeutral', 'type': TYPES.Short}, + 50729: {'name': 'AsShotWhiteXY', 'type': TYPES.Rational}, + 50730: {'name': 'BaselineExposure', 'type': TYPES.SRational}, + 50731: {'name': 'BaselineNoise', 'type': TYPES.Rational}, + 50732: {'name': 'BaselineSharpness', 'type': TYPES.Rational}, + 50733: {'name': 'BayerGreenSplit', 'type': TYPES.Long}, + 50734: {'name': 'LinearResponseLimit', 'type': TYPES.Rational}, + 50735: {'name': 'CameraSerialNumber', 'type': TYPES.Ascii}, + 50736: {'name': 'LensInfo', 'type': TYPES.Rational}, + 50737: {'name': 'ChromaBlurRadius', 'type': TYPES.Rational}, + 50738: {'name': 'AntiAliasStrength', 'type': TYPES.Rational}, + 50739: {'name': 'ShadowScale', 'type': TYPES.SRational}, + 50740: {'name': 'DNGPrivateData', 'type': TYPES.Byte}, + 50741: {'name': 'MakerNoteSafety', 'type': TYPES.Short}, + 50778: {'name': 'CalibrationIlluminant1', 'type': TYPES.Short}, + 50779: {'name': 'CalibrationIlluminant2', 'type': TYPES.Short}, + 50780: {'name': 'BestQualityScale', 'type': TYPES.Rational}, + 50781: {'name': 'RawDataUniqueID', 'type': TYPES.Byte}, + 50827: {'name': 'OriginalRawFileName', 'type': TYPES.Byte}, + 50828: {'name': 'OriginalRawFileData', 'type': TYPES.Undefined}, + 50829: {'name': 'ActiveArea', 'type': TYPES.Short}, + 50830: {'name': 'MaskedAreas', 'type': TYPES.Short}, + 50831: {'name': 'AsShotICCProfile', 'type': TYPES.Undefined}, + 50832: {'name': 'AsShotPreProfileMatrix', 'type': TYPES.SRational}, + 50833: {'name': 'CurrentICCProfile', 'type': TYPES.Undefined}, + 50834: {'name': 'CurrentPreProfileMatrix', 'type': TYPES.SRational}, + 50879: {'name': 'ColorimetricReference', 'type': TYPES.Short}, + 50931: {'name': 'CameraCalibrationSignature', 'type': TYPES.Byte}, + 50932: {'name': 'ProfileCalibrationSignature', 'type': TYPES.Byte}, + 50934: {'name': 'AsShotProfileName', 'type': TYPES.Byte}, + 50935: {'name': 'NoiseReductionApplied', 'type': TYPES.Rational}, + 50936: {'name': 'ProfileName', 'type': TYPES.Byte}, + 50937: {'name': 'ProfileHueSatMapDims', 'type': TYPES.Long}, + 50938: {'name': 'ProfileHueSatMapData1', 'type': TYPES.Float}, + 50939: {'name': 'ProfileHueSatMapData2', 'type': TYPES.Float}, + 50940: {'name': 'ProfileToneCurve', 'type': TYPES.Float}, + 50941: {'name': 'ProfileEmbedPolicy', 'type': TYPES.Long}, + 50942: {'name': 'ProfileCopyright', 'type': TYPES.Byte}, + 50964: {'name': 'ForwardMatrix1', 'type': TYPES.SRational}, + 50965: {'name': 'ForwardMatrix2', 'type': TYPES.SRational}, + 50966: {'name': 'PreviewApplicationName', 'type': TYPES.Byte}, + 50967: {'name': 'PreviewApplicationVersion', 'type': TYPES.Byte}, + 50968: {'name': 'PreviewSettingsName', 'type': TYPES.Byte}, + 50969: {'name': 'PreviewSettingsDigest', 'type': TYPES.Byte}, + 50970: {'name': 'PreviewColorSpace', 'type': TYPES.Long}, + 50971: {'name': 'PreviewDateTime', 'type': TYPES.Ascii}, + 50972: {'name': 'RawImageDigest', 'type': TYPES.Undefined}, + 50973: {'name': 'OriginalRawFileDigest', 'type': TYPES.Undefined}, + 50974: {'name': 'SubTileBlockSize', 'type': TYPES.Long}, + 50975: {'name': 'RowInterleaveFactor', 'type': TYPES.Long}, + 50981: {'name': 'ProfileLookTableDims', 'type': TYPES.Long}, + 50982: {'name': 'ProfileLookTableData', 'type': TYPES.Float}, + 51008: {'name': 'OpcodeList1', 'type': TYPES.Undefined}, + 51009: {'name': 'OpcodeList2', 'type': TYPES.Undefined}, + 51022: {'name': 'OpcodeList3', 'type': TYPES.Undefined}, + 60606: {'name': 'ZZZTestSlong1', 'type': TYPES.SLong}, + 60607: {'name': 'ZZZTestSlong2', 'type': TYPES.SLong}, + 60608: {'name': 'ZZZTestSByte', 'type': TYPES.SByte}, + 60609: {'name': 'ZZZTestSShort', 'type': TYPES.SShort}, + 60610: {'name': 'ZZZTestDFloat', 'type': TYPES.DFloat},}, + 'Exif': {33434: {'name': 'ExposureTime', 'type': TYPES.Rational}, + 33437: {'name': 'FNumber', 'type': TYPES.Rational}, + 34850: {'name': 'ExposureProgram', 'type': TYPES.Short}, + 34852: {'name': 'SpectralSensitivity', 'type': TYPES.Ascii}, + 34855: {'name': 'ISOSpeedRatings', 'type': TYPES.Short}, + 34856: {'name': 'OECF', 'type': TYPES.Undefined}, + 34864: {'name': 'SensitivityType', 'type': TYPES.Short}, + 34865: {'name': 'StandardOutputSensitivity', 'type': TYPES.Long}, + 34866: {'name': 'RecommendedExposureIndex', 'type': TYPES.Long}, + 34867: {'name': 'ISOSpeed', 'type': TYPES.Long}, + 34868: {'name': 'ISOSpeedLatitudeyyy', 'type': TYPES.Long}, + 34869: {'name': 'ISOSpeedLatitudezzz', 'type': TYPES.Long}, + 36864: {'name': 'ExifVersion', 'type': TYPES.Undefined}, + 36867: {'name': 'DateTimeOriginal', 'type': TYPES.Ascii}, + 36868: {'name': 'DateTimeDigitized', 'type': TYPES.Ascii}, + 36880: {'name': 'OffsetTime', 'type': TYPES.Ascii}, + 36881: {'name': 'OffsetTimeOriginal', 'type': TYPES.Ascii}, + 36882: {'name': 'OffsetTimeDigitized', 'type': TYPES.Ascii}, + 37121: {'name': 'ComponentsConfiguration', 'type': TYPES.Undefined}, + 37122: {'name': 'CompressedBitsPerPixel', 'type': TYPES.Rational}, + 37377: {'name': 'ShutterSpeedValue', 'type': TYPES.SRational}, + 37378: {'name': 'ApertureValue', 'type': TYPES.Rational}, + 37379: {'name': 'BrightnessValue', 'type': TYPES.SRational}, + 37380: {'name': 'ExposureBiasValue', 'type': TYPES.SRational}, + 37381: {'name': 'MaxApertureValue', 'type': TYPES.Rational}, + 37382: {'name': 'SubjectDistance', 'type': TYPES.Rational}, + 37383: {'name': 'MeteringMode', 'type': TYPES.Short}, + 37384: {'name': 'LightSource', 'type': TYPES.Short}, + 37385: {'name': 'Flash', 'type': TYPES.Short}, + 37386: {'name': 'FocalLength', 'type': TYPES.Rational}, + 37396: {'name': 'SubjectArea', 'type': TYPES.Short}, + 37500: {'name': 'MakerNote', 'type': TYPES.Undefined}, + 37510: {'name': 'UserComment', 'type': TYPES.Undefined}, + 37520: {'name': 'SubSecTime', 'type': TYPES.Ascii}, + 37521: {'name': 'SubSecTimeOriginal', 'type': TYPES.Ascii}, + 37522: {'name': 'SubSecTimeDigitized', 'type': TYPES.Ascii}, + 37888: {'name': 'Temperature', 'type': TYPES.SRational}, + 37889: {'name': 'Humidity', 'type': TYPES.Rational}, + 37890: {'name': 'Pressure', 'type': TYPES.Rational}, + 37891: {'name': 'WaterDepth', 'type': TYPES.SRational}, + 37892: {'name': 'Acceleration', 'type': TYPES.Rational}, + 37893: {'name': 'CameraElevationAngle', 'type': TYPES.SRational}, + 40960: {'name': 'FlashpixVersion', 'type': TYPES.Undefined}, + 40961: {'name': 'ColorSpace', 'type': TYPES.Short}, + 40962: {'name': 'PixelXDimension', 'type': TYPES.Long}, + 40963: {'name': 'PixelYDimension', 'type': TYPES.Long}, + 40964: {'name': 'RelatedSoundFile', 'type': TYPES.Ascii}, + 40965: {'name': 'InteroperabilityTag', 'type': TYPES.Long}, + 41483: {'name': 'FlashEnergy', 'type': TYPES.Rational}, + 41484: {'name': 'SpatialFrequencyResponse', 'type': TYPES.Undefined}, + 41486: {'name': 'FocalPlaneXResolution', 'type': TYPES.Rational}, + 41487: {'name': 'FocalPlaneYResolution', 'type': TYPES.Rational}, + 41488: {'name': 'FocalPlaneResolutionUnit', 'type': TYPES.Short}, + 41492: {'name': 'SubjectLocation', 'type': TYPES.Short}, + 41493: {'name': 'ExposureIndex', 'type': TYPES.Rational}, + 41495: {'name': 'SensingMethod', 'type': TYPES.Short}, + 41728: {'name': 'FileSource', 'type': TYPES.Undefined}, + 41729: {'name': 'SceneType', 'type': TYPES.Undefined}, + 41730: {'name': 'CFAPattern', 'type': TYPES.Undefined}, + 41985: {'name': 'CustomRendered', 'type': TYPES.Short}, + 41986: {'name': 'ExposureMode', 'type': TYPES.Short}, + 41987: {'name': 'WhiteBalance', 'type': TYPES.Short}, + 41988: {'name': 'DigitalZoomRatio', 'type': TYPES.Rational}, + 41989: {'name': 'FocalLengthIn35mmFilm', 'type': TYPES.Short}, + 41990: {'name': 'SceneCaptureType', 'type': TYPES.Short}, + 41991: {'name': 'GainControl', 'type': TYPES.Short}, + 41992: {'name': 'Contrast', 'type': TYPES.Short}, + 41993: {'name': 'Saturation', 'type': TYPES.Short}, + 41994: {'name': 'Sharpness', 'type': TYPES.Short}, + 41995: {'name': 'DeviceSettingDescription', 'type': TYPES.Undefined}, + 41996: {'name': 'SubjectDistanceRange', 'type': TYPES.Short}, + 42016: {'name': 'ImageUniqueID', 'type': TYPES.Ascii}, + 42032: {'name': 'CameraOwnerName', 'type': TYPES.Ascii}, + 42033: {'name': 'BodySerialNumber', 'type': TYPES.Ascii}, + 42034: {'name': 'LensSpecification', 'type': TYPES.Rational}, + 42035: {'name': 'LensMake', 'type': TYPES.Ascii}, + 42036: {'name': 'LensModel', 'type': TYPES.Ascii}, + 42037: {'name': 'LensSerialNumber', 'type': TYPES.Ascii}, + 42240: {'name': 'Gamma', 'type': TYPES.Rational}}, + 'GPS': {0: {'name': 'GPSVersionID', 'type': TYPES.Byte}, + 1: {'name': 'GPSLatitudeRef', 'type': TYPES.Ascii}, + 2: {'name': 'GPSLatitude', 'type': TYPES.Rational}, + 3: {'name': 'GPSLongitudeRef', 'type': TYPES.Ascii}, + 4: {'name': 'GPSLongitude', 'type': TYPES.Rational}, + 5: {'name': 'GPSAltitudeRef', 'type': TYPES.Byte}, + 6: {'name': 'GPSAltitude', 'type': TYPES.Rational}, + 7: {'name': 'GPSTimeStamp', 'type': TYPES.Rational}, + 8: {'name': 'GPSSatellites', 'type': TYPES.Ascii}, + 9: {'name': 'GPSStatus', 'type': TYPES.Ascii}, + 10: {'name': 'GPSMeasureMode', 'type': TYPES.Ascii}, + 11: {'name': 'GPSDOP', 'type': TYPES.Rational}, + 12: {'name': 'GPSSpeedRef', 'type': TYPES.Ascii}, + 13: {'name': 'GPSSpeed', 'type': TYPES.Rational}, + 14: {'name': 'GPSTrackRef', 'type': TYPES.Ascii}, + 15: {'name': 'GPSTrack', 'type': TYPES.Rational}, + 16: {'name': 'GPSImgDirectionRef', 'type': TYPES.Ascii}, + 17: {'name': 'GPSImgDirection', 'type': TYPES.Rational}, + 18: {'name': 'GPSMapDatum', 'type': TYPES.Ascii}, + 19: {'name': 'GPSDestLatitudeRef', 'type': TYPES.Ascii}, + 20: {'name': 'GPSDestLatitude', 'type': TYPES.Rational}, + 21: {'name': 'GPSDestLongitudeRef', 'type': TYPES.Ascii}, + 22: {'name': 'GPSDestLongitude', 'type': TYPES.Rational}, + 23: {'name': 'GPSDestBearingRef', 'type': TYPES.Ascii}, + 24: {'name': 'GPSDestBearing', 'type': TYPES.Rational}, + 25: {'name': 'GPSDestDistanceRef', 'type': TYPES.Ascii}, + 26: {'name': 'GPSDestDistance', 'type': TYPES.Rational}, + 27: {'name': 'GPSProcessingMethod', 'type': TYPES.Undefined}, + 28: {'name': 'GPSAreaInformation', 'type': TYPES.Undefined}, + 29: {'name': 'GPSDateStamp', 'type': TYPES.Ascii}, + 30: {'name': 'GPSDifferential', 'type': TYPES.Short}, + 31: {'name': 'GPSHPositioningError', 'type': TYPES.Rational}}, + 'Interop': {1: {'name': 'InteroperabilityIndex', 'type': TYPES.Ascii}}, +} + +TAGS["0th"] = TAGS["Image"] +TAGS["1st"] = TAGS["Image"] + +class ImageIFD: + """Exif tag number reference - 0th IFD""" + ProcessingSoftware = 11 + NewSubfileType = 254 + SubfileType = 255 + ImageWidth = 256 + ImageLength = 257 + BitsPerSample = 258 + Compression = 259 + PhotometricInterpretation = 262 + Threshholding = 263 + CellWidth = 264 + CellLength = 265 + FillOrder = 266 + DocumentName = 269 + ImageDescription = 270 + Make = 271 + Model = 272 + StripOffsets = 273 + Orientation = 274 + SamplesPerPixel = 277 + RowsPerStrip = 278 + StripByteCounts = 279 + XResolution = 282 + YResolution = 283 + PlanarConfiguration = 284 + GrayResponseUnit = 290 + GrayResponseCurve = 291 + T4Options = 292 + T6Options = 293 + ResolutionUnit = 296 + TransferFunction = 301 + Software = 305 + DateTime = 306 + Artist = 315 + HostComputer = 316 + Predictor = 317 + WhitePoint = 318 + PrimaryChromaticities = 319 + ColorMap = 320 + HalftoneHints = 321 + TileWidth = 322 + TileLength = 323 + TileOffsets = 324 + TileByteCounts = 325 + SubIFDs = 330 + InkSet = 332 + InkNames = 333 + NumberOfInks = 334 + DotRange = 336 + TargetPrinter = 337 + ExtraSamples = 338 + SampleFormat = 339 + SMinSampleValue = 340 + SMaxSampleValue = 341 + TransferRange = 342 + ClipPath = 343 + XClipPathUnits = 344 + YClipPathUnits = 345 + Indexed = 346 + JPEGTables = 347 + OPIProxy = 351 + JPEGProc = 512 + JPEGInterchangeFormat = 513 + JPEGInterchangeFormatLength = 514 + JPEGRestartInterval = 515 + JPEGLosslessPredictors = 517 + JPEGPointTransforms = 518 + JPEGQTables = 519 + JPEGDCTables = 520 + JPEGACTables = 521 + YCbCrCoefficients = 529 + YCbCrSubSampling = 530 + YCbCrPositioning = 531 + ReferenceBlackWhite = 532 + XMLPacket = 700 + Rating = 18246 + RatingPercent = 18249 + ImageID = 32781 + CFARepeatPatternDim = 33421 + CFAPattern = 33422 + BatteryLevel = 33423 + Copyright = 33432 + ExposureTime = 33434 + ImageResources = 34377 + ExifTag = 34665 + InterColorProfile = 34675 + GPSTag = 34853 + Interlace = 34857 + TimeZoneOffset = 34858 + SelfTimerMode = 34859 + FlashEnergy = 37387 + SpatialFrequencyResponse = 37388 + Noise = 37389 + FocalPlaneXResolution = 37390 + FocalPlaneYResolution = 37391 + FocalPlaneResolutionUnit = 37392 + ImageNumber = 37393 + SecurityClassification = 37394 + ImageHistory = 37395 + ExposureIndex = 37397 + TIFFEPStandardID = 37398 + SensingMethod = 37399 + XPTitle = 40091 + XPComment = 40092 + XPAuthor = 40093 + XPKeywords = 40094 + XPSubject = 40095 + PrintImageMatching = 50341 + DNGVersion = 50706 + DNGBackwardVersion = 50707 + UniqueCameraModel = 50708 + LocalizedCameraModel = 50709 + CFAPlaneColor = 50710 + CFALayout = 50711 + LinearizationTable = 50712 + BlackLevelRepeatDim = 50713 + BlackLevel = 50714 + BlackLevelDeltaH = 50715 + BlackLevelDeltaV = 50716 + WhiteLevel = 50717 + DefaultScale = 50718 + DefaultCropOrigin = 50719 + DefaultCropSize = 50720 + ColorMatrix1 = 50721 + ColorMatrix2 = 50722 + CameraCalibration1 = 50723 + CameraCalibration2 = 50724 + ReductionMatrix1 = 50725 + ReductionMatrix2 = 50726 + AnalogBalance = 50727 + AsShotNeutral = 50728 + AsShotWhiteXY = 50729 + BaselineExposure = 50730 + BaselineNoise = 50731 + BaselineSharpness = 50732 + BayerGreenSplit = 50733 + LinearResponseLimit = 50734 + CameraSerialNumber = 50735 + LensInfo = 50736 + ChromaBlurRadius = 50737 + AntiAliasStrength = 50738 + ShadowScale = 50739 + DNGPrivateData = 50740 + MakerNoteSafety = 50741 + CalibrationIlluminant1 = 50778 + CalibrationIlluminant2 = 50779 + BestQualityScale = 50780 + RawDataUniqueID = 50781 + OriginalRawFileName = 50827 + OriginalRawFileData = 50828 + ActiveArea = 50829 + MaskedAreas = 50830 + AsShotICCProfile = 50831 + AsShotPreProfileMatrix = 50832 + CurrentICCProfile = 50833 + CurrentPreProfileMatrix = 50834 + ColorimetricReference = 50879 + CameraCalibrationSignature = 50931 + ProfileCalibrationSignature = 50932 + AsShotProfileName = 50934 + NoiseReductionApplied = 50935 + ProfileName = 50936 + ProfileHueSatMapDims = 50937 + ProfileHueSatMapData1 = 50938 + ProfileHueSatMapData2 = 50939 + ProfileToneCurve = 50940 + ProfileEmbedPolicy = 50941 + ProfileCopyright = 50942 + ForwardMatrix1 = 50964 + ForwardMatrix2 = 50965 + PreviewApplicationName = 50966 + PreviewApplicationVersion = 50967 + PreviewSettingsName = 50968 + PreviewSettingsDigest = 50969 + PreviewColorSpace = 50970 + PreviewDateTime = 50971 + RawImageDigest = 50972 + OriginalRawFileDigest = 50973 + SubTileBlockSize = 50974 + RowInterleaveFactor = 50975 + ProfileLookTableDims = 50981 + ProfileLookTableData = 50982 + OpcodeList1 = 51008 + OpcodeList2 = 51009 + OpcodeList3 = 51022 + NoiseProfile = 51041 + ZZZTestSlong1 = 60606 + ZZZTestSlong2 = 60607 + ZZZTestSByte = 60608 + ZZZTestSShort = 60609 + ZZZTestDFloat = 60610 + + +class ExifIFD: + """Exif tag number reference - Exif IFD""" + ExposureTime = 33434 + FNumber = 33437 + ExposureProgram = 34850 + SpectralSensitivity = 34852 + ISOSpeedRatings = 34855 + OECF = 34856 + SensitivityType = 34864 + StandardOutputSensitivity = 34865 + RecommendedExposureIndex = 34866 + ISOSpeed = 34867 + ISOSpeedLatitudeyyy = 34868 + ISOSpeedLatitudezzz = 34869 + ExifVersion = 36864 + DateTimeOriginal = 36867 + DateTimeDigitized = 36868 + OffsetTime = 36880 + OffsetTimeOriginal = 36881 + OffsetTimeDigitized = 36882 + ComponentsConfiguration = 37121 + CompressedBitsPerPixel = 37122 + ShutterSpeedValue = 37377 + ApertureValue = 37378 + BrightnessValue = 37379 + ExposureBiasValue = 37380 + MaxApertureValue = 37381 + SubjectDistance = 37382 + MeteringMode = 37383 + LightSource = 37384 + Flash = 37385 + FocalLength = 37386 + Temperature = 37888 + Humidity = 37889 + Pressure = 37890 + WaterDepth = 37891 + Acceleration = 37892 + CameraElevationAngle = 37893 + SubjectArea = 37396 + MakerNote = 37500 + UserComment = 37510 + SubSecTime = 37520 + SubSecTimeOriginal = 37521 + SubSecTimeDigitized = 37522 + FlashpixVersion = 40960 + ColorSpace = 40961 + PixelXDimension = 40962 + PixelYDimension = 40963 + RelatedSoundFile = 40964 + InteroperabilityTag = 40965 + FlashEnergy = 41483 + SpatialFrequencyResponse = 41484 + FocalPlaneXResolution = 41486 + FocalPlaneYResolution = 41487 + FocalPlaneResolutionUnit = 41488 + SubjectLocation = 41492 + ExposureIndex = 41493 + SensingMethod = 41495 + FileSource = 41728 + SceneType = 41729 + CFAPattern = 41730 + CustomRendered = 41985 + ExposureMode = 41986 + WhiteBalance = 41987 + DigitalZoomRatio = 41988 + FocalLengthIn35mmFilm = 41989 + SceneCaptureType = 41990 + GainControl = 41991 + Contrast = 41992 + Saturation = 41993 + Sharpness = 41994 + DeviceSettingDescription = 41995 + SubjectDistanceRange = 41996 + ImageUniqueID = 42016 + CameraOwnerName = 42032 + BodySerialNumber = 42033 + LensSpecification = 42034 + LensMake = 42035 + LensModel = 42036 + LensSerialNumber = 42037 + Gamma = 42240 + + +class GPSIFD: + """Exif tag number reference - GPS IFD""" + GPSVersionID = 0 + GPSLatitudeRef = 1 + GPSLatitude = 2 + GPSLongitudeRef = 3 + GPSLongitude = 4 + GPSAltitudeRef = 5 + GPSAltitude = 6 + GPSTimeStamp = 7 + GPSSatellites = 8 + GPSStatus = 9 + GPSMeasureMode = 10 + GPSDOP = 11 + GPSSpeedRef = 12 + GPSSpeed = 13 + GPSTrackRef = 14 + GPSTrack = 15 + GPSImgDirectionRef = 16 + GPSImgDirection = 17 + GPSMapDatum = 18 + GPSDestLatitudeRef = 19 + GPSDestLatitude = 20 + GPSDestLongitudeRef = 21 + GPSDestLongitude = 22 + GPSDestBearingRef = 23 + GPSDestBearing = 24 + GPSDestDistanceRef = 25 + GPSDestDistance = 26 + GPSProcessingMethod = 27 + GPSAreaInformation = 28 + GPSDateStamp = 29 + GPSDifferential = 30 + GPSHPositioningError = 31 + + +class InteropIFD: + """Exif tag number reference - Interoperability IFD""" + InteroperabilityIndex = 1 diff --git a/openflexure_microscope/camera/piexif/_insert.py b/openflexure_microscope/camera/piexif/_insert.py new file mode 100644 index 00000000..6d74836a --- /dev/null +++ b/openflexure_microscope/camera/piexif/_insert.py @@ -0,0 +1,60 @@ +import io +import struct +import sys + +from ._common import * +from ._exceptions import InvalidImageDataError +from piexif import _webp + +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") \ No newline at end of file diff --git a/openflexure_microscope/camera/piexif/_load.py b/openflexure_microscope/camera/piexif/_load.py new file mode 100644 index 00000000..61760289 --- /dev/null +++ b/openflexure_microscope/camera/piexif/_load.py @@ -0,0 +1,270 @@ +import struct +import sys + +from ._common import * +from ._exceptions import InvalidImageDataError +from ._exif import * +from piexif import _webp + +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.") + + 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 \ No newline at end of file diff --git a/openflexure_microscope/camera/piexif/_remove.py b/openflexure_microscope/camera/piexif/_remove.py new file mode 100644 index 00000000..6ba49116 --- /dev/null +++ b/openflexure_microscope/camera/piexif/_remove.py @@ -0,0 +1,56 @@ +import io + +from ._common import * +from piexif import _webp + +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 e: + print(e.args) + raise ValueError("Error occurred.") + + 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") \ No newline at end of file diff --git a/openflexure_microscope/camera/piexif/_transplant.py b/openflexure_microscope/camera/piexif/_transplant.py new file mode 100644 index 00000000..0abf7f11 --- /dev/null +++ b/openflexure_microscope/camera/piexif/_transplant.py @@ -0,0 +1,45 @@ +import io + +from ._common import * + + +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") \ No newline at end of file diff --git a/openflexure_microscope/camera/piexif/_webp.py b/openflexure_microscope/camera/piexif/_webp.py new file mode 100644 index 00000000..f9cc42ea --- /dev/null +++ b/openflexure_microscope/camera/piexif/_webp.py @@ -0,0 +1,240 @@ + +import struct + + +def split(data): + if data[0:4] != b"RIFF" or data[8:12] != b"WEBP": + raise ValueError("Not WebP") + + webp_length_bytes = data[4:8] + webp_length = struct.unpack("> 5-1 & ord(b"\x01") + contains = 1 * flag + return contains + +def _get_size_from_vp8L(chunk): + b1 = chunk["data"][1:2] + b2 = chunk["data"][2:3] + b3 = chunk["data"][3:4] + b4 = chunk["data"][4:5] + + width_minus_one = (ord(b2) & ord(b"\x3F")) << 8 | ord(b1) + width = width_minus_one + 1 + + height_minus_one = (ord(b4) & ord(b"\x0F")) << 10 | ord(b3) << 2 | (ord(b2) & ord(b"\xC0")) >> 6 + height = height_minus_one + 1 + + return (width, height) + +def _get_size_from_anmf(chunk): + width_minus_one_bytes = chunk["data"][6:9] + b"\x00" + width_minus_one = struct.unpack("