From 0f20fc18af7b24bd95d5df1f52ba9c251cf3972d Mon Sep 17 00:00:00 2001 From: jevans Date: Mon, 3 Jun 2013 21:06:57 -0400 Subject: [PATCH 1/5] Starting Exif read support. --- glymur/jp2box.py | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/glymur/jp2box.py b/glymur/jp2box.py index a435f67..f11674e 100644 --- a/glymur/jp2box.py +++ b/glymur/jp2box.py @@ -1618,6 +1618,8 @@ class UUIDBox(Jp2kBox): else: text = buffer.decode('utf-8') kwargs['data'] = ET.fromstring(text) + elif kwargs['uuid'].bytes == b'JpgTiffExif->JP2': + kwargs['data'] = _parse_exif(buffer) else: kwargs['data'] = buffer box = UUIDBox(**kwargs) @@ -1666,6 +1668,73 @@ def _indent(elem, level=0): if level and (not elem.tail or not elem.tail.strip()): elem.tail = i +_tagnum2name = {271: 'Make', 272: 'Model', + 282: 'XResolution', 283: 'YResolution', + 296: 'ResolutionUnit', + 531: 'YCbCrPositioning', + 34665: 'ExifTag', + 34853: 'GPSTag'} + +def _parse_exif(buffer): + """Interpret raw buffer consisting of Exif IFD. + """ + # Ignore the first six bytes. + # Next six should be (73, 73, 42, 8, numtags) + data = struct.unpack(' Date: Tue, 4 Jun 2013 20:52:59 -0400 Subject: [PATCH 2/5] Added Exif classes for preliminary Exif image, photo, iop, gps support. --- glymur/jp2box.py | 295 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 227 insertions(+), 68 deletions(-) diff --git a/glymur/jp2box.py b/glymur/jp2box.py index f11674e..9edf010 100644 --- a/glymur/jp2box.py +++ b/glymur/jp2box.py @@ -1619,12 +1619,238 @@ class UUIDBox(Jp2kBox): text = buffer.decode('utf-8') kwargs['data'] = ET.fromstring(text) elif kwargs['uuid'].bytes == b'JpgTiffExif->JP2': - kwargs['data'] = _parse_exif(buffer) + kwargs['data'] = Exif(buffer).exif_image else: kwargs['data'] = buffer box = UUIDBox(**kwargs) return box +class Exif: + """ + Attributes + ---------- + buffer : bytes + Raw byte stream consisting of the UUID data. + endian : str + Either '<' for big-endian, or '>' for little-endian. + """ + + def __init__(self, buffer): + """Interpret raw buffer consisting of Exif IFD. + """ + self.exif_image = None + self.exif_photo = None + self.exif_gpsinfo = None + self.exif_iop = None + + self.buffer = buffer + + # Ignore the first six bytes. + # Next 8 should be (73, 73, 42, 8) + data = struct.unpack('' for little-endian. + num_tags : int + Number of tags in the IFD. + raw_ifd : dictionary + Maps tag number to "mildly-interpreted" tag value. + """ + datatype2fmt = {1: ('B', 1), + 2: ('B', 1), + 3: ('H', 2), + 4: ('I', 4), + 5: ('II', 8), + 7: ('B', 1), + 9: ('i', 4), + 10: ('ii', 8)} + + def __init__(self, endian, buffer, offset): + self.endian = endian + self.buffer = buffer + + self.num_tags, = struct.unpack(endian + 'H', buffer[offset:offset + 2]) + + fmt = self.endian + 'HHII' * self.num_tags + data = struct.unpack(fmt, buffer[offset + 2:offset + 2 + self.num_tags * 12]) + self.raw_ifd = {} + for j, tag in enumerate(data[0::4]): + tag_entry = buffer[offset + 2 + j * 12 + 8:offset + 2 + j * 12 + 8 + 4] + payload = self.parse_tag(data[j * 4 + 1], + data[j * 4 + 2], + tag_entry) + self.raw_ifd[tag] = payload + + def parse_tag(self, dtype, count, offset_buf): + """Interpret an Exif image tag data payload. + """ + fmt = self.datatype2fmt[dtype][0] * count + payload_size = self.datatype2fmt[dtype][1] * count + + if payload_size <= 4: + # Interpret the payload from the 4 bytes in the tag entry. + target_buffer = offset_buf[:payload_size] + else: + # Interpret the payload at the offset specified by the 4 bytes in the + # tag entry. + offset, = struct.unpack(self.endian + 'I', offset_buf) + target_buffer = self.buffer[offset:offset + payload_size] + + if dtype == 2: + # ASCII + payload = target_buffer.decode('utf-8').rstrip() + else: + payload = struct.unpack(self.endian + fmt, target_buffer) + if dtype == 5 or dtype == 10: + # Rational or Signed Rational. Construct the list of values. + rational_payload = [] + for j in range(count): + value = float(payload[j * 2]) / float(payload[j * 2 + 1]) + rational_payload.append(value) + payload = rational_payload + if count == 1: + # If just a single value, then return a scalar instead of a + # tuple. + payload = payload[0] + + return payload + + +class ExifImageIfd(Ifd): + tagnum2name = {271: 'Make', + 272: 'Model', + 282: 'XResolution', + 283: 'YResolution', + 296: 'ResolutionUnit', + 531: 'YCbCrPositioning', + 34665: 'ExifTag', + 34853: 'GPSTag'} + + def __init__(self, endian, buffer, offset): + Ifd.__init__(self, endian, buffer, offset) + + # Now post process the raw IFD. + self.ifd = {} + for tag, value in self.raw_ifd.items(): + tag_name = self.tagnum2name[tag] + self.ifd[tag_name] = value + +class ExifPhotoIfd(Ifd): + tagnum2name = {34855: 'ISOSpeedRatings', + 36864: 'ExifVersion', + 36867: 'DateTimeOriginal', + 36868: 'DateTimeDigitized', + 37121: 'ComponentsConfiguration', + 37386: 'FocalLength', + 40960: 'FlashpixVersion', + 40961: 'ColorSpace', + 40962: 'PixelXDimension', + 40963: 'PixelYDimension', + 40965: 'InteroperabilityTag'} + + def __init__(self, endian, buffer, offset): + Ifd.__init__(self, endian, buffer, offset) + + # Now post process the raw IFD. + self.ifd = {} + for tag, value in self.raw_ifd.items(): + tag_name = self.tagnum2name[tag] + self.ifd[tag_name] = value + +class ExifGPSInfoIfd(Ifd): + tagnum2name = {0: 'GPSVersionID', + 1: 'GPSLatitudeRef', + 2: 'GPSLatitude', + 3: 'GPSLongitudeRef', + 4: 'GPSLongitude', + 5: 'GPSAltitudeRef', + 6: 'GPSAltitude', + 7: 'GPSTimeStamp', + 8: 'GPSSatellites', + 9: 'GPSStatus', + 10: 'GPSMeasureMode', + 11: 'GPSDOP', + 12: 'GPSSpeedRef', + 13: 'GPSSpeed', + 14: 'GPSTrackRef', + 15: 'GPSTrack', + 16: 'GPSImgDirectionRef', + 17: 'GPSImgDirection', + 18: 'GPSMapDatum', + 19: 'GPSDestLatitudeRef', + 20: 'GPSDestLatitude', + 21: 'GPSDestLongitudeRef', + 22: 'GPSDestLongitude', + 23: 'GPSDestBearingRef', + 24: 'GPSDestBearing', + 25: 'GPSDestDistanceRef', + 26: 'GPSDestDistance', + 27: 'GPSProcessingMethod', + 28: 'GPSAreaInformation', + 29: 'GPSDateStamp', + 30: 'GPSDifferential'} + + def __init__(self, endian, buffer, offset): + Ifd.__init__(self, endian, buffer, offset) + + # Now post process the raw IFD. + self.ifd = {} + for tag, value in self.raw_ifd.items(): + tag_name = self.tagnum2name[tag] + self.ifd[tag_name] = value + +class ExifInteroperabilityIfd(Ifd): + tagnum2name = {1: 'InteroperabilityIndex', + 2: 'InteroperabilityVersion', + 4096: 'RelatedImageFileFormat', + 4097: 'RelatedImageWidth', + 4098: 'RelatedImageLength'} + + def __init__(self, endian, buffer, offset): + Ifd.__init__(self, endian, buffer, offset) + + # Now post process the raw IFD. + self.ifd = {} + for tag, value in self.raw_ifd.items(): + tag_name = self.tagnum2name[tag] + self.ifd[tag_name] = value # Map each box ID to the corresponding class. _box_with_id = { @@ -1668,73 +1894,6 @@ def _indent(elem, level=0): if level and (not elem.tail or not elem.tail.strip()): elem.tail = i -_tagnum2name = {271: 'Make', 272: 'Model', - 282: 'XResolution', 283: 'YResolution', - 296: 'ResolutionUnit', - 531: 'YCbCrPositioning', - 34665: 'ExifTag', - 34853: 'GPSTag'} - -def _parse_exif(buffer): - """Interpret raw buffer consisting of Exif IFD. - """ - # Ignore the first six bytes. - # Next six should be (73, 73, 42, 8, numtags) - data = struct.unpack(' Date: Tue, 4 Jun 2013 20:54:36 -0400 Subject: [PATCH 3/5] Just a comment. --- glymur/test/test_jp2k.py | 1 + 1 file changed, 1 insertion(+) diff --git a/glymur/test/test_jp2k.py b/glymur/test/test_jp2k.py index 41628c2..f04741d 100644 --- a/glymur/test/test_jp2k.py +++ b/glymur/test/test_jp2k.py @@ -52,6 +52,7 @@ class TestJp2k(unittest.TestCase): @classmethod def setUpClass(cls): + # Setup a JP2 file with a bad XML box. jp2file = pkg_resources.resource_filename(glymur.__name__, "data/nemo.jp2") with tempfile.NamedTemporaryFile(suffix='.jp2', delete=False) as tfile: From 6407a272086f1c18884ffe39119e3ae244b61699 Mon Sep 17 00:00:00 2001 From: jevans Date: Tue, 4 Jun 2013 20:54:57 -0400 Subject: [PATCH 4/5] No longer testing nemo directly. --- glymur/test/test_printing.py | 100 ++++++++++++++++++++++------------- 1 file changed, 64 insertions(+), 36 deletions(-) diff --git a/glymur/test/test_printing.py b/glymur/test/test_printing.py index 0278d63..f0d1f7d 100644 --- a/glymur/test/test_printing.py +++ b/glymur/test/test_printing.py @@ -11,6 +11,7 @@ else: from io import StringIO import glymur +from glymur import Jp2k try: data_root = os.environ['OPJ_DATA_ROOT'] @@ -22,6 +23,22 @@ except: class TestPrinting(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Setup a plain JP2 file without the two UUID boxes. + jp2file = pkg_resources.resource_filename(glymur.__name__, + "data/nemo.jp2") + with tempfile.NamedTemporaryFile(suffix='.jp2', delete=False) as tfile: + cls._plain_nemo_file = tfile.name + ijfile = Jp2k(jp2file) + data = ijfile.read(reduce=3) + ojfile = Jp2k(cls._plain_nemo_file, 'wb') + ojfile.write(data) + + @classmethod + def tearDownClass(cls): + os.unlink(cls._plain_nemo_file) + def setUp(self): # Save sys.stdout. self.stdout = sys.stdout @@ -30,15 +47,14 @@ class TestPrinting(unittest.TestCase): "data/nemo.jp2") # Save the output of dumping nemo.jp2 for more than one test. - lines = ['File: nemo.jp2', - 'JPEG 2000 Signature Box (jP ) @ (0, 12)', + lines = ['JPEG 2000 Signature Box (jP ) @ (0, 12)', ' Signature: 0d0a870a', 'File Type Box (ftyp) @ (12, 20)', ' Brand: jp2 ', " Compatibility: ['jp2 ']", 'JP2 Header Box (jp2h) @ (32, 45)', ' Image Header Box (ihdr) @ (40, 22)', - ' Size: [1456 2592 3]', + ' Size: [182 324 3]', ' Bitdepth: 8', ' Signed: False', ' Compression: wavelet', @@ -47,45 +63,29 @@ class TestPrinting(unittest.TestCase): ' Method: enumerated colorspace', ' Precedence: 0', ' Colorspace: sRGB', - 'UUID Box (uuid) @ (77, 638)', - ' UUID: 4a706754-6966-6645-7869-662d3e4a5032', - ' UUID Data: 614 bytes', - 'UUID Box (uuid) @ (715, 2412)', - ' UUID: be7acfcb-97a9-42e8-9c71-999491e3afac (XMP)', - ' UUID Data: ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - 'Contiguous Codestream Box (jp2c) @ (3127, 1133427)', + 'Contiguous Codestream Box (jp2c) @ (77, 112814)', ' Main header:', - ' SOC marker segment @ (3135, 0)', - ' SIZ marker segment @ (3137, 47)', + ' SOC marker segment @ (85, 0)', + ' SIZ marker segment @ (87, 47)', ' Profile: 2', - ' Reference Grid Height, Width: (1456 x 2592)', + ' Reference Grid Height, Width: (182 x 324)', ' Vertical, Horizontal Reference Grid Offset: ' + '(0 x 0)', - ' Reference Tile Height, Width: (512 x 512)', + ' Reference Tile Height, Width: (182 x 324)', ' Vertical, Horizontal Reference Tile Offset: ' + '(0 x 0)', ' Bitdepth: (8, 8, 8)', ' Signed: (False, False, False)', ' Vertical, Horizontal Subsampling: ' + '((1, 1), (1, 1), (1, 1))', - ' COD marker segment @ (3186, 12)', + ' COD marker segment @ (136, 12)', ' Coding style:', ' Entropy coder, without partitions', ' SOP marker segments: False', ' EPH marker segments: False', ' Coding style parameters:', ' Progression order: LRCP', - ' Number of layers: 3', + ' Number of layers: 1', ' Multiple component transformation usage: ' + 'reversible', ' Number of resolutions: 6', @@ -102,26 +102,29 @@ class TestPrinting(unittest.TestCase): + 'False', ' Predictable termination: False', ' Segmentation symbols: False', - ' QCD marker segment @ (3200, 19)', + ' QCD marker segment @ (150, 19)', ' Quantization style: no quantization, ' + '2 guard bits', ' Step size: [(0, 8), (0, 9), (0, 9), ' + '(0, 10), (0, 9), (0, 9), (0, 10), (0, 9), (0, 9), ' + '(0, 10), (0, 9), (0, 9), (0, 10), (0, 9), (0, 9), ' + '(0, 10)]'] - self.expectedNemo = '\n'.join(lines) + self.expectedPlain = '\n'.join(lines) def tearDown(self): # Restore stdout. sys.stdout = self.stdout - #import pdb; pdb.set_trace() def test_jp2dump(self): - glymur.jp2dump(self.jp2file) + glymur.jp2dump(self._plain_nemo_file) actual = sys.stdout.getvalue().strip() - self.actual = actual - self.expected = self.expectedNemo - self.assertEqual(actual, self.expectedNemo) + + # Get rid of the filename line, as it is not set in stone. + lst = actual.split('\n') + lst = lst[1:] + actual = '\n'.join(lst) + + self.assertEqual(actual, self.expectedPlain) def test_COC_segment(self): j = glymur.Jp2k(self.jp2file) @@ -429,17 +432,42 @@ class TestPrinting(unittest.TestCase): expected = '\n'.join(lines) self.assertEqual(actual, expected) - def test_entire_file(self): + def test_xmp(self): + # Verify the printing of a UUID/XMP box. j = glymur.Jp2k(self.jp2file) + print(j.box[4]) + actual = sys.stdout.getvalue().strip() + lst = ['UUID Box (uuid) @ (715, 2412)', + ' UUID: be7acfcb-97a9-42e8-9c71-999491e3afac (XMP)', + ' UUID Data: ', + ' ', + ' ', + ' ', + ' ', + ' '] + expected = '\n'.join(lst) + self.assertEqual(actual, expected) + + def test_entire_file(self): + j = glymur.Jp2k(self._plain_nemo_file) print(j) actual = sys.stdout.getvalue().strip() - self.assertEqual(actual, self.expectedNemo) + + # Get rid of the filename line, as it is not set in stone. + lst = actual.split('\n') + lst = lst[1:] + actual = '\n'.join(lst) + + self.assertEqual(actual, self.expectedPlain) def test_codestream(self): j = glymur.Jp2k(self.jp2file) print(j.get_codestream()) actual = sys.stdout.getvalue().strip() - lst = ['Codestream:', ' SOC marker segment @ (3135, 0)', ' SIZ marker segment @ (3137, 47)', From 949671aa12d72b3c666ec933fe5108fae7f45859 Mon Sep 17 00:00:00 2001 From: jevans Date: Wed, 5 Jun 2013 20:42:42 -0400 Subject: [PATCH 5/5] Added Exif support. Closes #11. --- CHANGES.txt | 2 + docs/source/api.rst | 3 +- docs/source/conf.py | 2 +- docs/source/how_do_i.rst | 12 +- docs/source/index.rst | 7 +- docs/source/introduction.rst | 16 +- docs/source/roadmap.rst | 5 +- glymur/jp2box.py | 340 +++++++++++++++++++++++++++--- glymur/test/test_opj_suite_neg.py | 2 +- glymur/test/test_printing.py | 58 +++++ setup.py | 2 +- 11 files changed, 400 insertions(+), 49 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index bd5acf8..b8c6f05 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,5 @@ +Jun 05, 2013 - v0.1.4 Added Exif UUID read support. + Jun 02, 2013 - v0.1.3p1 Raising IOErrors when code block size and precinct sizes are not in harmony. Added statement to docs about upstream library dependence. Added roadmap to docs. diff --git a/docs/source/api.rst b/docs/source/api.rst index 3ab4997..fb2fd61 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -1,5 +1,6 @@ +--- API -=== +--- Jp2k ---- diff --git a/docs/source/conf.py b/docs/source/conf.py index 0b72b85..1e28b1e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -75,7 +75,7 @@ copyright = u'2013, John Evans' # The short X.Y version. version = '0.1' # The full version, including alpha/beta/rc tags. -release = '0.1.3p1' +release = '0.1.4' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/source/how_do_i.rst b/docs/source/how_do_i.rst index c5a411d..724a3b7 100644 --- a/docs/source/how_do_i.rst +++ b/docs/source/how_do_i.rst @@ -1,6 +1,6 @@ -************ +------------ How do I...? -************ +------------ Get the code? ============= @@ -9,7 +9,7 @@ Go to http://github.com/quintusdias/glymur Display metadata? ================= -There are two ways. From the unix command line, the script **jp2dump** is +There are two ways. From the unix command line, the script *jp2dump* is available. :: $ jp2dump /path/to/glymur/installation/data/nemo.jp2 @@ -22,8 +22,8 @@ From within Python, it is as simple as printing the Jp2k object, i.e. :: >>> j = Jp2k(file) >>> print(j) -The primary emphasis is on JP2 metadata, but it is possible to -display just raw codestream as well. This will display metadata present in the -codestream's main header only. :: +This prints the metadata found in the JP2 boxes, but in the case of the +codestream box, only the main header is printed. It is possible to print +**only** the codestream information as well, i.e. :: >>> print(j.get_codestream()) diff --git a/docs/source/index.rst b/docs/source/index.rst index ffc9fa7..1a7ddda 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,8 +3,9 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. +================================== Welcome to glymur's documentation! -=================================== +================================== Contents: @@ -16,9 +17,9 @@ Contents: how_do_i roadmap - +------------------ Indices and tables -================== +------------------ * :ref:`genindex` * :ref:`modindex` diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index c090d71..1797017 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -1,6 +1,6 @@ -========================================= +---------------------------------------- Glymur: a Python interface for JPEG 2000 -========================================= +---------------------------------------- **Glymur** contains a Python interface to the OpenJPEG library which allows linux and mac users to read and write JPEG 2000 files. For more @@ -13,15 +13,15 @@ Glymur supports both reading and writing of JPEG 2000 images (part 1). Writing JPEG 2000 images is currently limited to images that can fit in memory, however. -Of particular focus is retrieval of metadata. Reading XMP UUID -boxes is supported, as the data block consists of XML. There is +Of particular focus is retrieval of metadata. Reading Exif UUIDs is supported, +as is reading XMP UUIDs as the XMP data packet is just XML. There is some very limited support for reading JPX metadata. For instance, **asoc** and **labl** boxes are recognized, so GMLJP2 metadata can be retrieved from such JPX files. ------------- +'''''''''''' Requirements ------------- +'''''''''''' glymur works on Python 2.7 and 3.3. Python 3.3 is strongly recommended. OpenJPEG @@ -132,9 +132,9 @@ Windows ------- Not currently supported. ------------------------------------- +'''''''''''''''''''''''''''''''''''' Installation, Testing, Configuration ------------------------------------- +'''''''''''''''''''''''''''''''''''' From this point forward, python3 will be referred to as just "python". diff --git a/docs/source/roadmap.rst b/docs/source/roadmap.rst index d010885..63036b3 100644 --- a/docs/source/roadmap.rst +++ b/docs/source/roadmap.rst @@ -1,9 +1,10 @@ +------- Roadmap -======= +------- Here's an incomplete list of what I'd like to focus on in the near future. * continue to monitor upstream changes in the **openjp2** library - * add read support or Exif UUIDs * investigate using CFFI or cython instead of ctypes to wrap **openjp2** + * add read support for ICC profiles diff --git a/glymur/jp2box.py b/glymur/jp2box.py index 9edf010..b8361d3 100644 --- a/glymur/jp2box.py +++ b/glymur/jp2box.py @@ -14,6 +14,7 @@ References import copy import math import os +import pprint import struct import sys import uuid @@ -1570,6 +1571,9 @@ class UUIDBox(Jp2kBox): if self.uuid == uuid.UUID('be7acfcb-97a9-42e8-9c71-999491e3afac'): uuid_type = ' (XMP)' uuid_data = _pretty_print_xml(self.data) + elif self.uuid.bytes == b'JpgTiffExif->JP2': + uuid_type = ' (Exif)' + uuid_data = '\n' + pprint.pformat(self.data) else: uuid_type = '' uuid_data = '{0} bytes'.format(len(self.data)) @@ -1610,7 +1614,7 @@ class UUIDBox(Jp2kBox): n = offset + length - f.tell() buffer = f.read(n) if kwargs['uuid'] == uuid.UUID('be7acfcb-97a9-42e8-9c71-999491e3afac'): - # XMP data. Parse as XML. Seems to be a difference between + # XMP data. Parse as XML. Seems to be a difference between # ElementTree in version 2.7 and 3.3. if sys.hexversion < 0x03000000: parser = ET.XMLParser(encoding='utf-8') @@ -1619,18 +1623,25 @@ class UUIDBox(Jp2kBox): text = buffer.decode('utf-8') kwargs['data'] = ET.fromstring(text) elif kwargs['uuid'].bytes == b'JpgTiffExif->JP2': - kwargs['data'] = Exif(buffer).exif_image + e = Exif(buffer) + d = {} + d['Exif'] = e.exif_image + d['Photo'] = e.exif_photo + d['GPSInfo'] = e.exif_gpsinfo + d['Iop'] = e.exif_iop + kwargs['data'] = d else: kwargs['data'] = buffer box = UUIDBox(**kwargs) return box + class Exif: """ Attributes ---------- buffer : bytes - Raw byte stream consisting of the UUID data. + Raw byte stream consisting of the UUID data. endian : str Either '<' for big-endian, or '>' for little-endian. """ @@ -1657,7 +1668,7 @@ class Exif: offset = data[3] # This is the 'Exif Image' portion. - exif = ExifImageIfd(self.endian, buffer[6:], offset) + exif = ExifImageIfd(self.endian, buffer[6:], offset) self.exif_image = exif.ifd if 'ExifTag' in self.exif_image.keys(): @@ -1677,12 +1688,13 @@ class Exif: gps = ExifGPSInfoIfd(self.endian, buffer[6:], offset) self.exif_gpsinfo = gps.ifd + class Ifd: """ Attributes ---------- buffer : bytes - Raw byte stream consisting of the UUID data. + Raw byte stream consisting of the UUID data. datatype2fmt : dictionary Class attribute, maps the TIFF enumerated datatype to the python datatype and data width. @@ -1706,36 +1718,45 @@ class Ifd: self.endian = endian self.buffer = buffer - self.num_tags, = struct.unpack(endian + 'H', buffer[offset:offset + 2]) - + self.num_tags, = struct.unpack(endian + 'H', + buffer[offset:offset + 2]) + fmt = self.endian + 'HHII' * self.num_tags - data = struct.unpack(fmt, buffer[offset + 2:offset + 2 + self.num_tags * 12]) + ifd_buffer = buffer[offset + 2:offset + 2 + self.num_tags * 12] + data = struct.unpack(fmt, ifd_buffer) self.raw_ifd = {} for j, tag in enumerate(data[0::4]): - tag_entry = buffer[offset + 2 + j * 12 + 8:offset + 2 + j * 12 + 8 + 4] - payload = self.parse_tag(data[j * 4 + 1], - data[j * 4 + 2], - tag_entry) - self.raw_ifd[tag] = payload + # The offset to the tag offset/payload is the offset to the IFD + # plus 2 bytes for the number of tags plus 12 bytes for each + # tag entry plus 8 bytes to the offset/payload itself. + toffp = buffer[offset + 10 + j * 12:offset + 10 + j * 12 + 4] + tag_data = self.parse_tag(data[j * 4 + 1], + data[j * 4 + 2], + toffp) + self.raw_ifd[tag] = tag_data def parse_tag(self, dtype, count, offset_buf): """Interpret an Exif image tag data payload. """ fmt = self.datatype2fmt[dtype][0] * count payload_size = self.datatype2fmt[dtype][1] * count - + if payload_size <= 4: # Interpret the payload from the 4 bytes in the tag entry. target_buffer = offset_buf[:payload_size] else: - # Interpret the payload at the offset specified by the 4 bytes in the - # tag entry. + # Interpret the payload at the offset specified by the 4 bytes in + # the tag entry. offset, = struct.unpack(self.endian + 'I', offset_buf) target_buffer = self.buffer[offset:offset + payload_size] - + if dtype == 2: # ASCII - payload = target_buffer.decode('utf-8').rstrip() + if sys.hexversion < 0x03000000: + payload = target_buffer.rstrip('\x00') + else: + payload = target_buffer.decode('utf-8').rstrip('\x00') + else: payload = struct.unpack(self.endian + fmt, target_buffer) if dtype == 5 or dtype == 10: @@ -1746,22 +1767,226 @@ class Ifd: rational_payload.append(value) payload = rational_payload if count == 1: - # If just a single value, then return a scalar instead of a + # If just a single value, then return a scalar instead of a # tuple. payload = payload[0] - - return payload + + return payload class ExifImageIfd(Ifd): - tagnum2name = {271: 'Make', + """ + Attributes + ---------- + tagnum2name : dict + Maps Exif image tag numbers to the tag names. + ifd : dict + Maps tag names to tag values. + """ + tagnum2name = {11: 'ProcessingSoftware', + 254: 'NewSubfileType', + 255: 'SubfileType', + 256: 'ImageWidth', + 257: 'ImageLength', + 258: 'BitsPerSample', + 259: 'Compression', + 262: 'PhotometricInterpretation', + 263: 'Threshholding', + 264: 'CellWidth', + 265: 'CellLength', + 266: 'FillOrder', + 269: 'DocumentName', + 270: 'ImageDescription', + 271: 'Make', 272: 'Model', + 273: 'StripOffsets', + 274: 'Orientation', + 277: 'SamplesPerPixel', + 278: 'RowsPerStrip', + 279: 'StripByteCounts', 282: 'XResolution', - 283: 'YResolution', + 283: 'YResolution', + 284: 'PlanarConfiguration', + 290: 'GrayResponseUnit', + 291: 'GrayResponseCurve', + 292: 'T4Options', + 293: 'T6Options', 296: 'ResolutionUnit', + 301: 'TransferFunction', + 305: 'Software', + 306: 'DateTime', + 315: 'Artist', + 316: 'HostComputer', + 317: 'Predictor', + 318: 'WhitePoint', + 319: 'PrimaryChromaticities', + 320: 'ColorMap', + 321: 'HalftoneHints', + 322: 'TileWidth', + 323: 'TileLength', + 324: 'TileOffsets', + 325: 'TileByteCounts', + 330: 'SubIFDs', + 332: 'InkSet', + 333: 'InkNames', + 334: 'NumberOfInks', + 336: 'DotRange', + 337: 'TargetPrinter', + 338: 'ExtraSamples', + 339: 'SampleFormat', + 340: 'SMinSampleValue', + 341: 'SMaxSampleValue', + 342: 'TransferRange', + 343: 'ClipPath', + 344: 'XClipPathUnits', + 345: 'YClipPathUnits', + 346: 'Indexed', + 347: 'JPEGTables', + 351: 'OPIProxy', + 512: 'JPEGProc', + 513: 'JPEGInterchangeFormat', + 514: 'JPEGInterchangeFormatLength', + 515: 'JPEGRestartInterval', + 517: 'JPEGLosslessPredictors', + 518: 'JPEGPointTransforms', + 519: 'JPEGQTables', + 520: 'JPEGDCTables', + 521: 'JPEGACTables', + 529: 'YCbCrCoefficients', + 530: 'YCbCrSubSampling', 531: 'YCbCrPositioning', + 532: 'ReferenceBlackWhite', + 700: 'XMLPacket', + 18246: 'Rating', + 18249: 'RatingPercent', + 32781: 'ImageID', + 33421: 'CFARepeatPatternDim', + 33422: 'CFAPattern', + 33423: 'BatteryLevel', + 33432: 'Copyright', + 33434: 'ExposureTime', + 33437: 'FNumber', + 33723: 'IPTCNAA', + 34377: 'ImageResources', 34665: 'ExifTag', - 34853: 'GPSTag'} + 34675: 'InterColorProfile', + 34850: 'ExposureProgram', + 34852: 'SpectralSensitivity', + 34853: 'GPSTag', + 34855: 'ISOSpeedRatings', + 34856: 'OECF', + 34857: 'Interlace', + 34858: 'TimeZoneOffset', + 34859: 'SelfTimerMode', + 36867: 'DateTimeOriginal', + 37122: 'CompressedBitsPerPixel', + 37377: 'ShutterSpeedValue', + 37378: 'ApertureValue', + 37379: 'BrightnessValue', + 37380: 'ExposureBiasValue', + 37381: 'MaxApertureValue', + 37382: 'SubjectDistance', + 37383: 'MeteringMode', + 37384: 'LightSource', + 37385: 'Flash', + 37386: 'FocalLength', + 37387: 'FlashEnergy', + 37388: 'SpatialFrequencyResponse', + 37389: 'Noise', + 37390: 'FocalPlaneXResolution', + 37391: 'FocalPlaneYResolution', + 37392: 'FocalPlaneResolutionUnit', + 37393: 'ImageNumber', + 37394: 'SecurityClassification', + 37395: 'ImageHistory', + 37396: 'SubjectLocation', + 37397: 'ExposureIndex', + 37398: 'TIFFEPStandardID', + 37399: 'SensingMethod', + 40091: 'XPTitle', + 40092: 'XPComment', + 40093: 'XPAuthor', + 40094: 'XPKeywords', + 40095: 'XPSubject', + 50341: 'PrintImageMatching', + 50706: 'DNGVersion', + 50707: 'DNGBackwardVersion', + 50708: 'UniqueCameraModel', + 50709: 'LocalizedCameraModel', + 50710: 'CFAPlaneColor', + 50711: 'CFALayout', + 50712: 'LinearizationTable', + 50713: 'BlackLevelRepeatDim', + 50714: 'BlackLevel', + 50715: 'BlackLevelDeltaH', + 50716: 'BlackLevelDeltaV', + 50717: 'WhiteLevel', + 50718: 'DefaultScale', + 50719: 'DefaultCropOrigin', + 50720: 'DefaultCropSize', + 50721: 'ColorMatrix1', + 50722: 'ColorMatrix2', + 50723: 'CameraCalibration1', + 50724: 'CameraCalibration2', + 50725: 'ReductionMatrix1', + 50726: 'ReductionMatrix2', + 50727: 'AnalogBalance', + 50728: 'AsShotNeutral', + 50729: 'AsShotWhiteXY', + 50730: 'BaselineExposure', + 50731: 'BaselineNoise', + 50732: 'BaselineSharpness', + 50733: 'BayerGreenSplit', + 50734: 'LinearResponseLimit', + 50735: 'CameraSerialNumber', + 50736: 'LensInfo', + 50737: 'ChromaBlurRadius', + 50738: 'AntiAliasStrength', + 50739: 'ShadowScale', + 50740: 'DNGPrivateData', + 50741: 'MakerNoteSafety', + 50778: 'CalibrationIlluminant1', + 50779: 'CalibrationIlluminant2', + 50780: 'BestQualityScale', + 50781: 'RawDataUniqueID', + 50827: 'OriginalRawFileName', + 50828: 'OriginalRawFileData', + 50829: 'ActiveArea', + 50830: 'MaskedAreas', + 50831: 'AsShotICCProfile', + 50832: 'AsShotPreProfileMatrix', + 50833: 'CurrentICCProfile', + 50834: 'CurrentPreProfileMatrix', + 50879: 'ColorimetricReference', + 50931: 'CameraCalibrationSignature', + 50932: 'ProfileCalibrationSignature', + 50934: 'AsShotProfileName', + 50935: 'NoiseReductionApplied', + 50936: 'ProfileName', + 50937: 'ProfileHueSatMapDims', + 50938: 'ProfileHueSatMapData1', + 50939: 'ProfileHueSatMapData2', + 50940: 'ProfileToneCurve', + 50941: 'ProfileEmbedPolicy', + 50942: 'ProfileCopyright', + 50964: 'ForwardMatrix1', + 50965: 'ForwardMatrix2', + 50966: 'PreviewApplicationName', + 50967: 'PreviewApplicationVersion', + 50968: 'PreviewSettingsName', + 50969: 'PreviewSettingsDigest', + 50970: 'PreviewColorSpace', + 50971: 'PreviewDateTime', + 50972: 'RawImageDigest', + 50973: 'OriginalRawFileDigest', + 50974: 'SubTileBlockSize', + 50975: 'RowInterleaveFactor', + 50981: 'ProfileLookTableDims', + 50982: 'ProfileLookTableData', + 51008: 'OpcodeList1', + 51009: 'OpcodeList2', + 51022: 'OpcodeList3', + 51041: 'NoiseProfile'} def __init__(self, endian, buffer, offset): Ifd.__init__(self, endian, buffer, offset) @@ -1772,18 +1997,77 @@ class ExifImageIfd(Ifd): tag_name = self.tagnum2name[tag] self.ifd[tag_name] = value + class ExifPhotoIfd(Ifd): - tagnum2name = {34855: 'ISOSpeedRatings', + tagnum2name = {33434: 'ExposureTime', + 33437: 'FNumber', + 34850: 'ExposureProgram', + 34852: 'SpectralSensitivity', + 34855: 'ISOSpeedRatings', + 34856: 'OECF', + 34864: 'SensitivityType', + 34865: 'StandardOutputSensitivity', + 34866: 'RecommendedExposureIndex', + 34867: 'ISOSpeed', + 34868: 'ISOSpeedLatitudeyyy', + 34869: 'ISOSpeedLatitudezzz', 36864: 'ExifVersion', 36867: 'DateTimeOriginal', 36868: 'DateTimeDigitized', 37121: 'ComponentsConfiguration', + 37122: 'CompressedBitsPerPixel', + 37377: 'ShutterSpeedValue', + 37378: 'ApertureValue', + 37379: 'BrightnessValue', + 37380: 'ExposureBiasValue', + 37381: 'MaxApertureValue', + 37382: 'SubjectDistance', + 37383: 'MeteringMode', + 37384: 'LightSource', + 37385: 'Flash', 37386: 'FocalLength', + 37396: 'SubjectArea', + 37500: 'MakerNote', + 37510: 'UserComment', + 37520: 'SubSecTime', + 37521: 'SubSecTimeOriginal', + 37522: 'SubSecTimeDigitized', 40960: 'FlashpixVersion', 40961: 'ColorSpace', 40962: 'PixelXDimension', 40963: 'PixelYDimension', - 40965: 'InteroperabilityTag'} + 40964: 'RelatedSoundFile', + 40965: 'InteroperabilityTag', + 41483: 'FlashEnergy', + 41484: 'SpatialFrequencyResponse', + 41486: 'FocalPlaneXResolution', + 41487: 'FocalPlaneYResolution', + 41488: 'FocalPlaneResolutionUnit', + 41492: 'SubjectLocation', + 41493: 'ExposureIndex', + 41495: 'SensingMethod', + 41728: 'FileSource', + 41729: 'SceneType', + 41730: 'CFAPattern', + 41985: 'CustomRendered', + 41986: 'ExposureMode', + 41987: 'WhiteBalance', + 41988: 'DigitalZoomRatio', + 41989: 'FocalLengthIn35mmFilm', + 41990: 'SceneCaptureType', + 41991: 'GainControl', + 41992: 'Contrast', + 41993: 'Saturation', + 41994: 'Sharpness', + 41995: 'DeviceSettingDescription', + 41996: 'SubjectDistanceRange', + 42016: 'ImageUniqueID', + 42032: 'CameraOwnerName', + 42033: 'BodySerialNumber', + 42034: 'LensSpecification', + 42035: 'LensMake', + 42036: 'LensModel', + 42037: 'LensSerialNumber'} def __init__(self, endian, buffer, offset): Ifd.__init__(self, endian, buffer, offset) @@ -1794,6 +2078,7 @@ class ExifPhotoIfd(Ifd): tag_name = self.tagnum2name[tag] self.ifd[tag_name] = value + class ExifGPSInfoIfd(Ifd): tagnum2name = {0: 'GPSVersionID', 1: 'GPSLatitudeRef', @@ -1836,6 +2121,7 @@ class ExifGPSInfoIfd(Ifd): tag_name = self.tagnum2name[tag] self.ifd[tag_name] = value + class ExifInteroperabilityIfd(Ifd): tagnum2name = {1: 'InteroperabilityIndex', 2: 'InteroperabilityVersion', @@ -1875,6 +2161,7 @@ _box_with_id = { 'uuid': UUIDBox, 'xml ': XMLBox} + def _indent(elem, level=0): """Recipe for pretty printing XML. Please see @@ -1894,6 +2181,7 @@ def _indent(elem, level=0): if level and (not elem.tail or not elem.tail.strip()): elem.tail = i + def _pretty_print_xml(xml, level=0): """Pretty print XML data. """ diff --git a/glymur/test/test_opj_suite_neg.py b/glymur/test/test_opj_suite_neg.py index 0862c42..75c9c6f 100644 --- a/glymur/test/test_opj_suite_neg.py +++ b/glymur/test/test_opj_suite_neg.py @@ -171,7 +171,7 @@ class TestSuiteNegative(unittest.TestCase): ofile.write(data, cbsize=(13, 12)) def test_codeblock_size_with_precinct_size(self): - # Seems like code block sizes should never exceed half that of + # Seems like code block sizes should never exceed half that of # precinct size. ifile = Jp2k(self.jp2file) data = ifile.read(reduce=3) diff --git a/glymur/test/test_printing.py b/glymur/test/test_printing.py index f0d1f7d..8b7ba34 100644 --- a/glymur/test/test_printing.py +++ b/glymur/test/test_printing.py @@ -810,5 +810,63 @@ class TestPrinting(unittest.TestCase): expected = '\n'.join(lines) self.assertEqual(actual, expected) + def test_exif_uuid(self): + j = glymur.Jp2k(self.jp2file) + + print(j.box[3]) + actual = sys.stdout.getvalue().strip() + + lines = ["UUID Box (uuid) @ (77, 638)", + " UUID: 4a706754-6966-6645-7869-662d3e4a5032 (Exif)", + " UUID Data: ", + "{'Exif': {'ExifTag': 138,", + " 'GPSTag': 354,", + " 'Make': 'HTC',", + " 'Model': 'HTC Glacier',", + " 'ResolutionUnit': 2,", + " 'XResolution': 72.0,", + " 'YCbCrPositioning': 1,", + " 'YResolution': 72.0},", + " 'GPSInfo': {'GPSAltitude': 0.0,", + " 'GPSAltitudeRef': 0,", + " 'GPSDateStamp': '2013:02:09',", + " 'GPSLatitude': [42.0, 20.0, 33.61],", + " 'GPSLatitudeRef': 'N',", + " 'GPSLongitude': [71.0, 5.0, 17.32],", + " 'GPSLongitudeRef': 'W',", + " 'GPSMapDatum': 'WGS-84',", + " 'GPSProcessingMethod': (65,", + " 83,", + " 67,", + " 73,", + " 73,", + " 0,", + " 0,", + " 0,", + " 78,", + " 69,", + " 84,", + " 87,", + " 79,", + " 82,", + " 75),", + " 'GPSTimeStamp': [19.0, 47.0, 53.0],", + " 'GPSVersionID': (2, 2, 0)},", + " 'Iop': None,", + " 'Photo': {'ColorSpace': 1,", + " 'ComponentsConfiguration': (1, 2, 3, 0),", + " 'DateTimeDigitized': '2013:02:09 14:47:53',", + " 'DateTimeOriginal': '2013:02:09 14:47:53',", + " 'ExifVersion': (48, 50, 50, 48),", + " 'FlashpixVersion': (48, 49, 48, 48),", + " 'FocalLength': 3.53,", + " 'ISOSpeedRatings': 76,", + " 'InteroperabilityTag': 324,", + " 'PixelXDimension': 2528,", + " 'PixelYDimension': 1424}}"] + + expected = '\n'.join(lines) + self.assertEqual(actual, expected) + if __name__ == "__main__": unittest.main() diff --git a/setup.py b/setup.py index 449cb96..5febec4 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from distutils.core import setup kwargs = {'name': 'Glymur', - 'version': '0.1.3p1', + 'version': '0.1.4', 'description': 'Tools for accessing JPEG2000 files', 'long_description': open('README.md').read(), 'author': 'John Evans',