diff --git a/CHANGES.txt b/CHANGES.txt index bc823bd..f70550e 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,5 @@ -Feb 08, 2014 - Removed support for Python 2.6. Added write support for - JP2 DataEntryURL, Palette and Component Mapping boxes, JPX +Feb 08, 2014 - Removed support for Python 2.6. Added write support for JP2 + UUID, DataEntryURL, Palette and Component Mapping boxes, JPX Association, NumberList and DataReference boxes. Added read support for JPX free, number list, data reference, fragment table, and fragment list boxes. Palette box now a 2D numpy diff --git a/docs/source/how_do_i.rst b/docs/source/how_do_i.rst index f34264e..01dd24c 100644 --- a/docs/source/how_do_i.rst +++ b/docs/source/how_do_i.rst @@ -71,7 +71,7 @@ The **append** method can add an XML box as shown below:: An existing raw codestream (or JP2 file) can be wrapped (re-wrapped) in a user-defined set of JP2 boxes. To get just a minimal JP2 jacket on the codestream provided by `goodstuff.j2k` (a file consisting of a raw codestream), -you can use the **wrap** method with no box argument: :: +you can use the :py:meth:`wrap` method with no box argument: :: >>> import glymur >>> jfile = glymur.data.goodstuff() @@ -108,8 +108,9 @@ JP2 header superbox. XML boxes are not in the minimal set of box requirements for the JP2 format, so in order to add an XML box into the mix before the codestream box, we'll need to -re-specify all of the boxes. If you already have a JP2 jacket in place, you can just reuse that, -though. Take the following example content in an XML file `favorites.xml` : :: +re-specify all of the boxes. If you already have a JP2 jacket in place, +you can just reuse that, though. Take the following example content in +an XML file `favorites.xml` : :: @@ -152,10 +153,10 @@ the following will work. :: . (truncated) . -As to the question of which method you should use, **append** or **wrap**, -to add metadata, you should keep in mind that **wrap** produces a new JP2 file, -while **append** modifies an existing file and is currently limited to XML -boxes. +As to the question of which method you should use, :py:meth:`append` or +:py:meth:`wrap`, to add metadata, you should keep in mind that :py:meth:`wrap` +produces a new JP2 file, while :py:meth:`append` modifies an existing file and +is currently limited to XML and UUID boxes. ... create an image with an alpha layer? ======================================== @@ -219,32 +220,142 @@ Here's how the Preview application on the mac shows the RGBA image. .. image:: goodstuff_alpha.png -work with XMP UUIDs? -==================== +... work with XMP UUIDs? +======================== The example JP2 file shipped with glymur has an XMP UUID. :: >>> import glymur >>> j = glymur.Jp2k(glymur.data.nemo()) - >>> print(j.box[4]) - UUID Box (uuid) @ (715, 2412) - UUID: be7acfcb-97a9-42e8-9c71-999491e3afac (XMP) - UUID Data: - + >>> print(j.box[3]) # formatting added to the XML below + - - - + + Google + 2013-02-09T14:47:53 + -Since the UUID data in this case is returned as an ElementTree instance, one can -use ElementTree to access the data. For example, to extract the -**CreatorTool** attribute value, the following would work:: + . + . + . + - >>> xmp = j.box[4].data - >>> ns0 = '{http://www.w3.org/1999/02/22-rdf-syntax-ns#}' - >>> ns1 = '{http://ns.adobe.com/xap/1.0/}' - >>> name = '{0}RDF/{0}Description'.format(ns0) +Since the UUID data in this case is returned as an ElementTree instance, +one can use ElementTree from the standard library to access the data. +For example, to extract the **CreatorTool** attribute value, one could do the +following + + >>> xmp = j.box[3].data.packet + >>> rdf = '{http://www.w3.org/1999/02/22-rdf-syntax-ns#}' + >>> ns2 = '{http://ns.adobe.com/xap/1.0/}' + >>> name = '{0}RDF/{0}Description/{1}CreatorTool'.format(rdf, ns2) >>> elt = xmp.find(name) >>> elt - - >>> elt.attrib['{0}CreatorTool'.format(ns1)] - 'glymur' + + >>> elt.text + 'Google' + +But that would be painful. A better solution is to install the Python XMP +Toolkit (make sure it is version 2.0):: + + >>> from libxmp import XMPMeta + >>> from libxmp.consts import XMP_NS_XMP as NS_XAP + >>> meta = XMPMeta() + >>> meta.parse_from_str(j.box[3].raw_data.decode('utf-8')) + >>> meta.get_property(NS_XAP, 'CreatorTool') + 'Google' + +Where the Python XMP Toolkit can really shine, though, is when you are +converting an image from another format such as TIFF or JPEG into JPEG 2000. +For example, if you were to be converting the TIFF image found at +http://photojournal.jpl.nasa.gov/tiff/PIA17145.tif info JPEG 2000:: + + >>> import skimage.io + >>> image = skimage.io.imread('PIA17145.tif') + >>> from glymur import Jp2k + >>> jp2 = Jp2k('PIA17145.jp2', 'wb') + >>> jp2.write(image) + +Next you can extract the XMP metadata. + + >>> from libxmp import XMPFiles + >>> xf = XMPFiles() + >>> xf.open_file('PIA17145.tif') + >>> xmp = xf.get_xmp() + >>> print(xmp) + + + + + 1016 + 1016 + + + 8 + + + 1 + 1 + 1 + 1 + 2 + + + + + converted PNM file + + + + + + + +If you are familiar with TIFF, you can verify that there's no XMP tag in the +TIFF file, but the Python XMP Toolkit takes advantage of the TIFF header +structure to populate an XMP packet for you. If you were working with a JPEG +file with Exif metadata, that information would be included in the XMP packet +as well. Now you can append the XMP packet in a UUIDBox. In order to do this, +though, you have to know the UUID that signifies XMP data.:: + + >>> import uuid + >>> xmp_uuid = uuid.UUID('be7acfcb-97a9-42e8-9c71-999491e3afac') + >>> box = glymur.jp2box.UUIDBox(xmp_uuid, str(xmp).encode()) + >>> jp2.append(box) + >>> print(jp2.box[-1]) + UUID Box (uuid) @ (592316, 1053) + UUID: be7acfcb-97a9-42e8-9c71-999491e3afac (XMP) + UUID Data: + + + + 1016 + 1016 + + + 8 + + + 1 + 1 + 1 + 1 + 2 + + + + + converted PNM file + + + + + diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 75cdbfa..23698e3 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -7,11 +7,9 @@ which allows one to read and write JPEG 2000 files from within Python. Glymur supports both reading and writing of JPEG 2000 images, but writing JPEG 2000 images is currently limited to images that can fit in memory -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. +In regards to metadata, most JP2 boxes are properly interpreted. +Certain optional JP2 boxes can also be written, including XML boxes and +XMP UUIDs. There is some very limited support for reading JPX metadata. Glymur 0.6 works on Python versions 2.7, 3.3 and 3.4. If you have 2.6, you should use the 0.5 series. @@ -21,8 +19,8 @@ OpenJPEG Installation Glymur will read JPEG 2000 images with versions 1.3, 1.4, 1.5, 2.0, and the trunk/development version of OpenJPEG. Writing images is only supported with the 1.5 or better, however, and the trunk/development -version is strongly recommended. For more information about OpenJPEG, -please consult http://www.openjpeg.org. +version of OpenJPEG is strongly recommended. For more information about +OpenJPEG, please consult http://www.openjpeg.org. If you use MacPorts or if you have a sufficiently recent version of Linux, your package manager should already provide you with a version of diff --git a/glymur/_uuid_io/Exif.py b/glymur/_uuid_io/Exif.py new file mode 100644 index 0000000..b07cd42 --- /dev/null +++ b/glymur/_uuid_io/Exif.py @@ -0,0 +1,506 @@ +# -*- coding: utf-8 -*- +""" +Handlers for Exif UUIDs. Be nice if we would find a standard for this. +""" +import pprint +import re +import struct +import sys +import warnings +import xml.etree.cElementTree as ET + +if sys.hexversion < 0x02070000: + # pylint: disable=F0401,E0611 + from ordereddict import OrderedDict +else: + from collections import OrderedDict + +def xml(raw_data): + """ + XMP data to be parsed as XML. + """ + if sys.hexversion < 0x03000000: + elt = ET.fromstring(raw_data) + else: + text = raw_data.decode('utf-8') + elt = ET.fromstring(text) + + return ET.ElementTree(elt) + +def tiff_header(read_buffer): + """ + Interpret the uuid raw data as a tiff header. + """ + # Ignore the first six bytes. + # Next 8 should be (73, 73, 42, 8) or (77, 77, 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. + processed_ifd : dictionary + Maps tag name 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, read_buffer, offset): + self.endian = endian + self.read_buffer = read_buffer + self.processed_ifd = OrderedDict() + + self.num_tags, = struct.unpack(endian + 'H', + read_buffer[offset:offset + 2]) + + fmt = self.endian + 'HHII' * self.num_tags + ifd_buffer = read_buffer[offset + 2:offset + 2 + self.num_tags * 12] + data = struct.unpack(fmt, ifd_buffer) + self.raw_ifd = OrderedDict() + for j, tag in enumerate(data[0::4]): + # 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 = read_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. + offset, = struct.unpack(self.endian + 'I', offset_buf) + target_buffer = self.read_buffer[offset:offset + payload_size] + + if dtype == 2: + # ASCII + 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: + # 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 + + def post_process(self, tagnum2name): + """Map the tag name instead of tag number to the tag value. + """ + for tag, value in self.raw_ifd.items(): + try: + tag_name = tagnum2name[tag] + except KeyError: + # Ok, we don't recognize this tag. Just use the numeric id. + msg = 'Unrecognized Exif tag "{0}".'.format(tag) + warnings.warn(msg, UserWarning) + tag_name = tag + self.processed_ifd[tag_name] = value + + +class _ExifImageIfd(_Ifd): + """ + 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', + 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', + 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, read_buffer, offset): + _Ifd.__init__(self, endian, read_buffer, offset) + self.post_process(self.tagnum2name) + + +class _ExifPhotoIfd(_Ifd): + """Represents tags found in the Exif sub ifd. + """ + 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', + 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, read_buffer, offset): + _Ifd.__init__(self, endian, read_buffer, offset) + self.post_process(self.tagnum2name) + + +class _ExifGPSInfoIfd(_Ifd): + """Represents information found in the GPSInfo sub 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, read_buffer, offset): + _Ifd.__init__(self, endian, read_buffer, offset) + self.post_process(self.tagnum2name) + + +class _ExifInteroperabilityIfd(_Ifd): + """Represents tags found in the Interoperability sub IFD. + """ + tagnum2name = {1: 'InteroperabilityIndex', + 2: 'InteroperabilityVersion', + 4096: 'RelatedImageFileFormat', + 4097: 'RelatedImageWidth', + 4098: 'RelatedImageLength'} + + def __init__(self, endian, read_buffer, offset): + _Ifd.__init__(self, endian, read_buffer, offset) + self.post_process(self.tagnum2name) + + + diff --git a/glymur/_uuid_io/__init__.py b/glymur/_uuid_io/__init__.py new file mode 100644 index 0000000..778f912 --- /dev/null +++ b/glymur/_uuid_io/__init__.py @@ -0,0 +1,4 @@ +""" +Sub package for handling various types of UUIDs. +""" +from .Exif import tiff_header, xml diff --git a/glymur/codestream.py b/glymur/codestream.py index fc94d32..4259788 100644 --- a/glymur/codestream.py +++ b/glymur/codestream.py @@ -663,7 +663,6 @@ class Codestream(object): bitdepth = tuple(((x & 0x7f) + 1) for x in data[0::3]) signed = tuple(((x & 0x80) > 0) for x in data[0::3]) - xrsiz = data[1::3] yrsiz = data[2::3] @@ -1538,7 +1537,7 @@ class SIZsegment(Segment): signed=self.signed, xyrsiz=(self.xrsiz, self.yrsiz)) return msg - + def __str__(self): msg = Segment.__str__(self) msg += '\n ' diff --git a/glymur/core.py b/glymur/core.py index 22b5a19..620d0e3 100644 --- a/glymur/core.py +++ b/glymur/core.py @@ -1,5 +1,8 @@ """Core definitions to be shared amongst the modules. """ +import copy +import xml.etree.cElementTree as ET + # Progression order LRCP = 0 RLCP = 1 @@ -73,3 +76,45 @@ _CAPABILITIES_DISPLAY = { 1: '0', 2: '1', 3: '3'} + + +def _pretty_print_xml(xml, level=0): + """Pretty print XML data. + """ + xml = copy.deepcopy(xml) + _indent(xml.getroot(), level=level) + xmltext = ET.tostring(xml.getroot(), encoding='utf-8').decode('utf-8') + + # Indent it a bit. + lst = [(' ' + x) for x in xmltext.split('\n')] + try: + xml = '\n'.join(lst) + return '\n{0}'.format(xml) + except UnicodeEncodeError: + # This can happen on python 2.x if the character set contains certain + # non-ascii characters. Just print out the corresponding xml char + # entities instead. + xml = u'\n'.join(lst) + text = u'\n{0}'.format(xml) + text = text.encode('ascii', 'xmlcharrefreplace') + return text + + +def _indent(elem, level=0): + """Recipe for pretty printing XML. Please see + + http://effbot.org/zone/element-lib.htm#prettyprint + """ + i = "\n" + level * " " + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = i + " " + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + _indent(elem, level + 1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i diff --git a/glymur/data/nemo.jp2 b/glymur/data/nemo.jp2 index 55d199c..838583d 100644 Binary files a/glymur/data/nemo.jp2 and b/glymur/data/nemo.jp2 differ diff --git a/glymur/jp2box.py b/glymur/jp2box.py index d535af9..f8a0dc8 100644 --- a/glymur/jp2box.py +++ b/glymur/jp2box.py @@ -34,6 +34,9 @@ from .core import _COLORSPACE_MAP_DISPLAY from .core import _COLOR_TYPE_MAP_DISPLAY from .core import ENUMERATED_COLORSPACE, RESTRICTED_ICC_PROFILE from .core import ANY_ICC_PROFILE, VENDOR_COLOR_METHOD +from .core import _pretty_print_xml + +from . import _uuid_io _METHOD_DISPLAY = { ENUMERATED_COLORSPACE: 'enumerated colorspace', @@ -576,11 +579,11 @@ class CodestreamHeaderBox(Jp2kBox): box : list List of boxes contained in this superbox. """ - def __init__(self, box=[], length=0, offset=-1): + def __init__(self, box=None, length=0, offset=-1): Jp2kBox.__init__(self, box_id='jpch', longname='Codestream Header') self.length = length self.offset = offset - self.box = box + self.box = box if box is not None else [] def __repr__(self): msg = "glymur.jp2box.CodestreamHeaderBox(box={0})".format(self.box) @@ -638,12 +641,12 @@ class CompositingLayerHeaderBox(Jp2kBox): box : list List of boxes contained in this superbox. """ - def __init__(self, box=[], length=0, offset=-1): + def __init__(self, box=None, length=0, offset=-1): Jp2kBox.__init__(self, box_id='jplh', longname='Compositing Layer Header') self.length = length self.offset = offset - self.box = [] + self.box = box if box is not None else [] def __repr__(self): msg = "glymur.jp2box.CompositingLayerHeaderBox(box={0})" @@ -1365,11 +1368,11 @@ class AssociationBox(Jp2kBox): box : list List of boxes contained in this superbox. """ - def __init__(self, box=[], length=0, offset=-1): + def __init__(self, box=None, length=0, offset=-1): Jp2kBox.__init__(self, box_id='asoc', longname='Association') self.length = length self.offset = offset - self.box = box + self.box = box if box is not None else [] def __repr__(self): msg = "glymur.jp2box.AssociationBox(box={0})".format(self.box) @@ -1432,11 +1435,11 @@ class JP2HeaderBox(Jp2kBox): box : list List of boxes contained in this superbox. """ - def __init__(self, box=[], length=0, offset=-1): + def __init__(self, box=None, length=0, offset=-1): Jp2kBox.__init__(self, box_id='jp2h', longname='JP2 Header') self.length = length self.offset = offset - self.box = box + self.box = box if box is not None else [] def __repr__(self): msg = "glymur.jp2box.JP2HeaderBox(box={0})".format(self.box) @@ -1496,7 +1499,7 @@ class JPEG2000SignatureBox(Jp2kBox): offset of the box from the start of the file. longname : str more verbose description of the box. - signature : byte + signature : tuple Four-byte tuple identifying the file as JPEG 2000. """ def __init__(self, signature=(13, 10, 135, 10), length=0, offset=-1): @@ -1963,11 +1966,11 @@ class ResolutionBox(Jp2kBox): box : list List of boxes contained in this superbox. """ - def __init__(self, box=[], length=0, offset=-1): + def __init__(self, box=None, length=0, offset=-1): Jp2kBox.__init__(self, box_id='res ', longname='Resolution') self.length = length self.offset = offset - self.box = box + self.box = box if box is not None else [] def __repr__(self): msg = "glymur.jp2box.ResolutionBox(box={0})" @@ -2149,7 +2152,7 @@ class LabelBox(Jp2kBox): longname : str more verbose description of the box. label : str - Label + Textual label. """ def __init__(self, label, length=0, offset=-1): Jp2kBox.__init__(self, box_id='lbl ', longname='Label') @@ -2462,11 +2465,11 @@ class UUIDInfoBox(Jp2kBox): box : list List of boxes contained in this superbox. """ - def __init__(self, box=[], length=0, offset=-1): + def __init__(self, box=None, length=0, offset=-1): Jp2kBox.__init__(self, box_id='uinf', longname='UUIDInfo') self.length = length self.offset = offset - self.box = box + self.box = box if box is not None else [] def __repr__(self): msg = "glymur.jp2box.UUIDInfoBox(box={0})".format(self.box) @@ -2613,9 +2616,12 @@ class UUIDBox(Jp2kBox): more verbose description of the box. uuid : uuid.UUID 16-byte UUID - data : bytes or dict or ElementTree.Element - Vendor-specific data. Exif UUIDs are interpreted as dictionaries. - XMP UUIDs are interpreted as standard XML. + raw_data : byte array + Sequence of uninterpreted bytes as read from the file. + data : object + Specific to each type of UUID. There are handlers for XMP, Exif, and + generic (unknown) UUIDs. In the case of XMP and Exif UUIDs, this is + the interpreted version of raw_data. References ---------- @@ -2630,7 +2636,7 @@ class UUIDBox(Jp2kBox): the_uuid : uuid.UUID Identifies the type of UUID box. raw_data : byte array - This is the "payload" of data for the specified UUID. + Sequence of uninterpreted bytes as read from the UUID box. length : int length of the box in bytes. offset : int @@ -2639,64 +2645,53 @@ class UUIDBox(Jp2kBox): Jp2kBox.__init__(self, box_id='uuid', longname='UUID') self.uuid = the_uuid self.raw_data = raw_data - - if the_uuid == uuid.UUID('be7acfcb-97a9-42e8-9c71-999491e3afac'): - # XMP data. Parse as XML. Seems to be a difference between - # ElementTree in version 2.7 and 3.3. - if sys.hexversion < 0x03000000: - elt = ET.fromstring(raw_data) - else: - text = raw_data.decode('utf-8') - elt = ET.fromstring(text) - self.data = ET.ElementTree(elt) - elif the_uuid.bytes == b'JpgTiffExif->JP2': - exif_obj = Exif(raw_data) - ifds = OrderedDict() - ifds['Image'] = exif_obj.exif_image - ifds['Photo'] = exif_obj.exif_photo - ifds['GPSInfo'] = exif_obj.exif_gpsinfo - ifds['Iop'] = exif_obj.exif_iop - self.data = ifds - else: - self.data = raw_data - self.length = length self.offset = offset + self.data = None + + try: + self._parse_raw_data() + except RuntimeError as error: + warnings.warn(str(error)) + + def _parse_raw_data(self): + """ + Private function for parsing UUID payloads if possible. + """ + if self.uuid == uuid.UUID('be7acfcb-97a9-42e8-9c71-999491e3afac'): + self.data = _uuid_io.xml(self.raw_data) + elif self.uuid.bytes == b'JpgTiffExif->JP2': + self.data = _uuid_io.tiff_header(self.raw_data) + else: + self.data = self.raw_data def __repr__(self): msg = "glymur.jp2box.UUIDBox(the_uuid={0}, " msg += "raw_data=)" return msg.format(repr(self.uuid), len(self.raw_data)) - def __str__(self): - msg = '{0}\n' - msg += ' UUID: {1}{2}\n' - msg += ' UUID Data: {3}' + msg = '{0}\n UUID: {1}'.format(Jp2kBox.__str__(self), self.uuid) if self.uuid == uuid.UUID('be7acfcb-97a9-42e8-9c71-999491e3afac'): - uuid_type = ' (XMP)' - uuid_data = _pretty_print_xml(self.data) + line = ' (XMP)\n UUID Data: {0}' + msg += line.format(_pretty_print_xml(self.data)) elif self.uuid.bytes == b'JpgTiffExif->JP2': - uuid_type = ' (Exif)' - # 2.7 has trouble pretty-printing ordered dicts, so print them - # as regular dicts. Not ideal, but at least it's good on 3.3+. - if sys.hexversion < 0x03000000: - data = dict(self.data) - else: - data = self.data - uuid_data = '\n' + pprint.pformat(data) + msg += ' (EXIF)\n UUID Data: {0}'.format(str(self.data)) else: - uuid_type = '' - uuid_data = '{0} bytes'.format(len(self.data)) - - msg = msg.format(Jp2kBox.__str__(self), - self.uuid, - uuid_type, - uuid_data) + line = ' (unknown)\n UUID Data: {0} bytes' + msg += line.format(len(self.raw_data)) return msg + def write(self, fptr): + """Write a UUID box to file. + """ + write_buffer = struct.pack('>I4s', self.length, b'uuid') + fptr.write(write_buffer) + fptr.write(self.uuid.bytes) + fptr.write(self.raw_data) + @staticmethod def parse(fptr, offset, length): """Parse UUID box. @@ -2724,511 +2719,6 @@ class UUIDBox(Jp2kBox): return box -class Exif(object): - """ - Attributes - ---------- - read_buffer : bytes - Raw byte stream consisting of the UUID data. - endian : str - Either '<' for big-endian, or '>' for little-endian. - """ - - def __init__(self, read_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.read_buffer = read_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. - processed_ifd : dictionary - Maps tag name 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, read_buffer, offset): - self.endian = endian - self.read_buffer = read_buffer - self.processed_ifd = OrderedDict() - - self.num_tags, = struct.unpack(endian + 'H', - read_buffer[offset:offset + 2]) - - fmt = self.endian + 'HHII' * self.num_tags - ifd_buffer = read_buffer[offset + 2:offset + 2 + self.num_tags * 12] - data = struct.unpack(fmt, ifd_buffer) - self.raw_ifd = OrderedDict() - for j, tag in enumerate(data[0::4]): - # 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 = read_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. - offset, = struct.unpack(self.endian + 'I', offset_buf) - target_buffer = self.read_buffer[offset:offset + payload_size] - - if dtype == 2: - # ASCII - 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: - # 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 - - def post_process(self, tagnum2name): - """Map the tag name instead of tag number to the tag value. - """ - for tag, value in self.raw_ifd.items(): - try: - tag_name = tagnum2name[tag] - except KeyError: - # Ok, we don't recognize this tag. Just use the numeric id. - msg = 'Unrecognized Exif tag "{0}".'.format(tag) - warnings.warn(msg, UserWarning) - tag_name = tag - self.processed_ifd[tag_name] = value - - -class _ExifImageIfd(_Ifd): - """ - 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', - 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', - 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, read_buffer, offset): - _Ifd.__init__(self, endian, read_buffer, offset) - self.post_process(self.tagnum2name) - - -class _ExifPhotoIfd(_Ifd): - """Represents tags found in the Exif sub ifd. - """ - 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', - 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, read_buffer, offset): - _Ifd.__init__(self, endian, read_buffer, offset) - self.post_process(self.tagnum2name) - - -class _ExifGPSInfoIfd(_Ifd): - """Represents information found in the GPSInfo sub 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, read_buffer, offset): - _Ifd.__init__(self, endian, read_buffer, offset) - self.post_process(self.tagnum2name) - - -class _ExifInteroperabilityIfd(_Ifd): - """Represents tags found in the Interoperability sub IFD. - """ - tagnum2name = {1: 'InteroperabilityIndex', - 2: 'InteroperabilityVersion', - 4096: 'RelatedImageFileFormat', - 4097: 'RelatedImageWidth', - 4098: 'RelatedImageLength'} - - def __init__(self, endian, read_buffer, offset): - _Ifd.__init__(self, endian, read_buffer, offset) - self.post_process(self.tagnum2name) - - # Map each box ID to the corresponding class. _BOX_WITH_ID = { 'asoc': AssociationBox, @@ -3258,45 +2748,3 @@ _BOX_WITH_ID = { 'url ': DataEntryURLBox, 'uuid': UUIDBox, 'xml ': XMLBox} - - -def _indent(elem, level=0): - """Recipe for pretty printing XML. Please see - - http://effbot.org/zone/element-lib.htm#prettyprint - """ - i = "\n" + level * " " - if len(elem): - if not elem.text or not elem.text.strip(): - elem.text = i + " " - if not elem.tail or not elem.tail.strip(): - elem.tail = i - for elem in elem: - _indent(elem, level + 1) - if not elem.tail or not elem.tail.strip(): - elem.tail = i - else: - 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. - """ - xml = copy.deepcopy(xml) - _indent(xml.getroot(), level=level) - xmltext = ET.tostring(xml.getroot(), encoding='utf-8').decode('utf-8') - - # Indent it a bit. - lst = [(' ' + x) for x in xmltext.split('\n')] - try: - xml = '\n'.join(lst) - return '\n{0}'.format(xml) - except UnicodeEncodeError: - # This can happen on python 2.x if the character set contains certain - # non-ascii characters. Just print out the corresponding xml char - # entities instead. - xml = u'\n'.join(lst) - text = u'\n{0}'.format(xml) - text = text.encode('ascii', 'xmlcharrefreplace') - return text diff --git a/glymur/jp2k.py b/glymur/jp2k.py index f9e1030..3ecc96f 100644 --- a/glymur/jp2k.py +++ b/glymur/jp2k.py @@ -21,6 +21,7 @@ import ctypes import math import os import struct +from uuid import UUID import warnings import numpy as np @@ -488,17 +489,17 @@ class Jp2k(Jp2kBox): stack.callback(opj2.image_destroy, image) _populate_image_struct(cparams, image, img_array) - + codec = opj2.create_compress(cparams.codec_fmt) stack.callback(opj2.destroy_codec, codec) - + info_handler = _INFO_CALLBACK if verbose else None opj2.set_info_handler(codec, info_handler) opj2.set_warning_handler(codec, _WARNING_CALLBACK) opj2.set_error_handler(codec, _ERROR_CALLBACK) - + opj2.setup_encoder(codec, cparams, image) - + if _OPENJP2_IS_OFFICIAL_V2: fptr = libc.fopen(self.filename, 'wb') strm = opj2.stream_create_default_file_stream(fptr, False) @@ -509,11 +510,11 @@ class Jp2k(Jp2kBox): strm = opj2.stream_create_default_file_stream_v3(self.filename, False) stack.callback(opj2.stream_destroy_v3, strm) - + opj2.start_compress(codec, image, strm) opj2.encode(codec, strm) opj2.end_compress(codec, strm) - + # Refresh the metadata. self.parse() @@ -523,14 +524,18 @@ class Jp2k(Jp2kBox): Parameters ---------- box : Jp2Box - Instance of a JP2 box. Currently only XML boxes are allowed. + Instance of a JP2 box. Only UUID and XML boxes can currently be + appended. """ if self._codec_format == opj2.CODEC_J2K: msg = "Only JP2 files can currently have boxes appended to them." raise IOError(msg) - if box.box_id != 'xml ': - raise IOError("Only XML boxes can currently be appended.") + if not ((box.box_id == 'xml ') or + (box.box_id == 'uuid' and + box.uuid == UUID('be7acfcb-97a9-42e8-9c71-999491e3afac'))): + msg = "Only XML boxes and XMP UUID boxes can currently be appended." + raise IOError(msg) # Check the last box. If the length field is zero, then rewrite # the length field to reflect the true length of the box. @@ -1019,7 +1024,7 @@ class Jp2k(Jp2kBox): >>> jp2 = glymur.Jp2k(jfile) >>> codestream = jp2.get_codestream() >>> print(codestream.segment[1]) - SIZ marker segment @ (3137, 47) + SIZ marker segment @ (3233, 47) Profile: 2 Reference Grid Height, Width: (1456 x 2592) Vertical, Horizontal Reference Grid Offset: (0 x 0) diff --git a/glymur/lib/openjpeg.py b/glymur/lib/openjpeg.py index 5b1183f..418e9df 100644 --- a/glymur/lib/openjpeg.py +++ b/glymur/lib/openjpeg.py @@ -11,8 +11,8 @@ import numpy as np from .config import glymur_config _, OPENJPEG = glymur_config() -# Maximum number of tile parts expected by JPWL: increase at your will -JPWL_MAX_NO_TILESPECS = 16 +# Maximum number of tile parts expected by JPWL: increase at your will +JPWL_MAX_NO_TILESPECS = 16 J2K_MAXRLVLS = 33 # Number of maximum resolution level authorized PATH_LEN = 4096 # maximum allowed size for filenames @@ -58,7 +58,7 @@ class CommonStructType(ctypes.Structure): ("mj2_handle", ctypes.c_void_p)] -STREAM_READ = 0x0001 # The stream was opened for reading. +STREAM_READ = 0x0001 # The stream was opened for reading. STREAM_WRITE = 0x0002 # The stream was opened for writing. class CioType(ctypes.Structure): """Byte input-output stream (CIO) @@ -81,7 +81,7 @@ class CioType(ctypes.Structure): class CompressionInfoType(CommonStructType): - """Common fields between JPEG-2000 compression and decompression contexts. + """Common fields between JPEG-2000 compression and decompression contexts. This is for compression contexts. Corresponds to common_struct_t. """ pass @@ -91,68 +91,68 @@ class PocType(ctypes.Structure): """Progression order changes.""" _fields_ = [("resno", ctypes.c_int), # Resolution num start, Component num start, given by POC - ("compno0", ctypes.c_int), + ("compno0", ctypes.c_int), # Layer num end,Resolution num end, Component num end, given by POC - ("layno1", ctypes.c_int), - ("resno1", ctypes.c_int), - ("compno1", ctypes.c_int), + ("layno1", ctypes.c_int), + ("resno1", ctypes.c_int), + ("compno1", ctypes.c_int), - # Layer num start,Precinct num start, Precinct num end - ("layno0", ctypes.c_int), - ("precno0", ctypes.c_int), - ("precno1", ctypes.c_int), + # Layer num start,Precinct num start, Precinct num end + ("layno0", ctypes.c_int), + ("precno0", ctypes.c_int), + ("precno1", ctypes.c_int), # Progression order enum # OPJ_PROG_ORDER prg1,prg; - ("prg1", ctypes.c_int), - ("prg", ctypes.c_int), + ("prg1", ctypes.c_int), + ("prg", ctypes.c_int), - # Progression order string + # Progression order string # char progorder[5]; ("progorder", ctypes.c_char * 5), - # Tile number + # Tile number # int tile; - ("tile", ctypes.c_int), + ("tile", ctypes.c_int), # /** Start and end values for Tile width and height*/ # int tx0,tx1,ty0,ty1; - ("tx0", ctypes.c_int), - ("tx1", ctypes.c_int), - ("ty0", ctypes.c_int), - ("ty1", ctypes.c_int), + ("tx0", ctypes.c_int), + ("tx1", ctypes.c_int), + ("ty0", ctypes.c_int), + ("ty1", ctypes.c_int), # /** Start value, initialised in pi_initialise_encode*/ # int layS, resS, compS, prcS; - ("layS", ctypes.c_int), - ("resS", ctypes.c_int), - ("compS", ctypes.c_int), + ("layS", ctypes.c_int), + ("resS", ctypes.c_int), + ("compS", ctypes.c_int), ("prcS", ctypes.c_int), # /** End value, initialised in pi_initialise_encode */ # int layE, resE, compE, prcE; - ("layE", ctypes.c_int), - ("resE", ctypes.c_int), - ("compE", ctypes.c_int), - ("prcE", ctypes.c_int), + ("layE", ctypes.c_int), + ("resE", ctypes.c_int), + ("compE", ctypes.c_int), + ("prcE", ctypes.c_int), # Start and end values of Tile width and height, initialised in # pi_initialise_encode int txS,txE,tyS,tyE,dx,dy; - ("txS", ctypes.c_int), - ("txE", ctypes.c_int), - ("tyS", ctypes.c_int), - ("tyE", ctypes.c_int), - ("dx", ctypes.c_int), - ("dy", ctypes.c_int), + ("txS", ctypes.c_int), + ("txE", ctypes.c_int), + ("tyS", ctypes.c_int), + ("tyE", ctypes.c_int), + ("dx", ctypes.c_int), + ("dy", ctypes.c_int), - # Temporary values for Tile parts, initialised in pi_create_encode + # Temporary values for Tile parts, initialised in pi_create_encode # int lay_t, res_t, comp_t, prc_t,tx0_t,ty0_t; - ("lay_t", ctypes.c_int), - ("res_t", ctypes.c_int), - ("comp_t", ctypes.c_int), - ("prc_t", ctypes.c_int), - ("tx0_t", ctypes.c_int), + ("lay_t", ctypes.c_int), + ("res_t", ctypes.c_int), + ("comp_t", ctypes.c_int), + ("prc_t", ctypes.c_int), + ("tx0_t", ctypes.c_int), ("ty0_t", ctypes.c_int)] @@ -374,23 +374,23 @@ class DecompressionParametersType(ctypes.Structure): class ImageComptParmType(ctypes.Structure): """Component parameters structure used by the opj_image_create function. """ - _fields_ = [ - # XRsiz: horizontal separation of a sample of ith component with - # respect to the reference grid - ("dx", ctypes.c_int), + _fields_ = [ + # XRsiz: horizontal separation of a sample of ith component with + # respect to the reference grid + ("dx", ctypes.c_int), - # YRsiz: vertical separation of a sample of ith component with + # YRsiz: vertical separation of a sample of ith component with # respect to the reference grid */ - ("dy", ctypes.c_int), - - # data width, height - ("w", ctypes.c_int), - ("h", ctypes.c_int), + ("dy", ctypes.c_int), - # x component offset compared to the whole image - # y component offset compared to the whole image - ("x0", ctypes.c_int), - ("y0", ctypes.c_int), + # data width, height + ("w", ctypes.c_int), + ("h", ctypes.c_int), + + # x component offset compared to the whole image + # y component offset compared to the whole image + ("x0", ctypes.c_int), + ("y0", ctypes.c_int), # precision ('prec', ctypes.c_int), @@ -398,7 +398,7 @@ class ImageComptParmType(ctypes.Structure): # image depth in bits ('bpp', ctypes.c_int), - # signed (1) / unsigned (0) + # signed (1) / unsigned (0) ('sgnd', ctypes.c_int)] @@ -511,7 +511,7 @@ def destroy_compress(cinfo): def encode(cinfo, cio, image): """Wrapper for openjpeg library function opj_encode. - Encodes an image into a JPEG-2000 codestream. + Encodes an image into a JPEG-2000 codestream. Parameters ---------- @@ -540,7 +540,7 @@ def destroy_decompress(dinfo): def image_cmptparm_t_from_np(np_image): """Return appropriate image_cmptparm_t based on given numpy array. """ - try: + try: num_comps = np_image.shape[2] except IndexError: num_comps = 1 @@ -557,17 +557,17 @@ def image_cmptparm_t_from_np(np_image): bpp = 8 sgnd = 1 elif np_image.dtype == np.uint16: - prec = 16 - bpp = 16 + prec = 16 + bpp = 16 sgnd = 0 elif np_image.dtype == np.int16: - prec = 16 - bpp = 16 + prec = 16 + bpp = 16 sgnd = 1 else: raise(TypeError("unhandled")) - for j in range(0, num_comps): + for j in range(0, num_comps): tarr[j].dx = 1 tarr[j].dy = 1 tarr[j].w = np_image.shape[1] diff --git a/glymur/lib/test/test_openjp2.py b/glymur/lib/test/test_openjp2.py index 3ca6f55..0abac84 100644 --- a/glymur/lib/test/test_openjp2.py +++ b/glymur/lib/test/test_openjp2.py @@ -1,5 +1,5 @@ """ -Tests for libopenjp2 wrapping functions. +Tests for libopenjp2 wrapping functions. """ # R0904: Seems like pylint is fooled in this situation # W0142: using kwargs is ok in this context @@ -205,7 +205,7 @@ class TestOpenJP2(unittest.TestCase): """Runs test designated tte3 in OpenJPEG test suite.""" with tempfile.NamedTemporaryFile(suffix=".j2k") as tfile: xtx3_setup(tfile.name) - self.assertTrue(True) + self.assertTrue(True) def test_rta3(self): """Runs test designated rta3 in OpenJPEG test suite.""" @@ -214,13 +214,13 @@ class TestOpenJP2(unittest.TestCase): codec_format = openjp2.CODEC_J2K self.j2k_random_tile_access(tfile.name, codec_format) - self.assertTrue(True) + self.assertTrue(True) def test_tte4(self): """Runs test designated tte4 in OpenJPEG test suite.""" with tempfile.NamedTemporaryFile(suffix=".j2k") as tfile: xtx4_setup(tfile.name) - self.assertTrue(True) + self.assertTrue(True) def test_rta4(self): """Runs test designated rta4 in OpenJPEG test suite.""" @@ -234,7 +234,7 @@ class TestOpenJP2(unittest.TestCase): """Runs test designated tte5 in OpenJPEG test suite.""" with tempfile.NamedTemporaryFile(suffix=".j2k") as tfile: xtx5_setup(tfile.name) - self.assertTrue(True) + self.assertTrue(True) def test_rta5(self): """Runs test designated rta5 in OpenJPEG test suite.""" @@ -325,8 +325,8 @@ def tile_encoder(**kwargs): def tile_decoder(**kwargs): """Fixture called with various configurations by many tests. - - Reads a tile. That's all it does. + + Reads a tile. That's all it does. """ stream = openjp2.stream_create_default_file_stream_v3(kwargs['filename'], True) @@ -348,7 +348,7 @@ def tile_decoder(**kwargs): openjp2.setup_decoder(codec, dparam) image = openjp2.read_header(stream, codec) - openjp2.set_decode_area(codec, image, + openjp2.set_decode_area(codec, image, kwargs['x0'], kwargs['y0'], kwargs['x1'], kwargs['y1']) diff --git a/glymur/test/fixtures.py b/glymur/test/fixtures.py index b872f1d..ff33237 100644 --- a/glymur/test/fixtures.py +++ b/glymur/test/fixtures.py @@ -11,6 +11,19 @@ import numpy as np import glymur +# The Python XMP Toolkit may be used for XMP UUIDs, but only if available and +# if the version is at least 2.0.0. +try: + import libxmp + if hasattr(libxmp, 'version') and re.match(r'''[2-9].\d*.\d*''', + libxmp.version.VERSION): + from libxmp import XMPMeta + HAS_PYTHON_XMP_TOOLKIT = True + else: + HAS_PYTHON_XMP_TOOLKIT = False +except ImportError: + HAS_PYTHON_XMP_TOOLKIT = False + # Need to know of the libopenjp2 version is the official 2.0.0 release and NOT # the 2.0+ development version. OPENJP2_IS_V2_OFFICIAL = False @@ -167,3 +180,173 @@ def read_pgx_header(pgx_file): header = header.rstrip() return header, pos + +nemo_xmp_box = """UUID Box (uuid) @ (77, 3146) + UUID: be7acfcb-97a9-42e8-9c71-999491e3afac (XMP) + UUID Data: + + + + Google + 2013-02-09T14:47:53 + + + 1 + 72/1 + 72/1 + 2 + HTC + HTC Glacier + 2592 + 1456 + + + 8 + 8 + 8 + + + 2 + 3 + + + 1343036288/4294967295 + 1413044224/4294967295 + + + + + 2748779008/4294967295 + 1417339264/4294967295 + 1288490240/4294967295 + 2576980480/4294967295 + 644245120/4294967295 + 257698032/4294967295 + + + + + 1 + 2528 + 1424 + 353/100 + 0 + 0/1 + WGS-84 + 2013-02-09T14:47:53 + + + 76 + + + 0220 + 0100 + + + 1 + 2 + 3 + 0 + + + 42,20.56N + 71,5.29W + 2013-02-09T19:47:53Z + NETWORK + + + 2013-02-09T14:47:53 + + + + + Glymur + Python XMP Toolkit + + + + + """ + +SimpleRDF = """ + + + Simple value + + + + Suse + Fedora + + + + +""" + +text_gbr_27 = """Colour Specification Box (colr) @ (179, 1339) + Method: any ICC profile + Precedence: 2 + Approximation: accurately represents correct colorspace definition + ICC Profile: + {'Color Space': 'RGB', + 'Connection Space': 'XYZ', + 'Creator': u'appl', + 'Datetime': datetime.datetime(2009, 2, 25, 11, 26, 11), + 'Device Attributes': 'reflective, glossy, positive media polarity, color media', + 'Device Class': 'display device profile', + 'Device Manufacturer': u'appl', + 'Device Model': '', + 'File Signature': u'acsp', + 'Flags': 'not embedded, can be used independently', + 'Illuminant': array([ 0.96420288, 1. , 0.8249054 ]), + 'Platform': u'APPL', + 'Preferred CMM Type': 1634758764, + 'Rendering Intent': 'perceptual', + 'Size': 1328, + 'Version': '2.2.0'}""" + +text_gbr_33 = """Colour Specification Box (colr) @ (179, 1339) + Method: any ICC profile + Precedence: 2 + Approximation: accurately represents correct colorspace definition + ICC Profile: + {'Size': 1328, + 'Preferred CMM Type': 1634758764, + 'Version': '2.2.0', + 'Device Class': 'display device profile', + 'Color Space': 'RGB', + 'Connection Space': 'XYZ', + 'Datetime': datetime.datetime(2009, 2, 25, 11, 26, 11), + 'File Signature': 'acsp', + 'Platform': 'APPL', + 'Flags': 'not embedded, can be used independently', + 'Device Manufacturer': 'appl', + 'Device Model': '', + 'Device Attributes': 'reflective, glossy, positive media polarity, color media', + 'Rendering Intent': 'perceptual', + 'Illuminant': array([ 0.96420288, 1. , 0.8249054 ]), + 'Creator': 'appl'}""" + +text_gbr_34 = """Colour Specification Box (colr) @ (179, 1339) + Method: any ICC profile + Precedence: 2 + Approximation: accurately represents correct colorspace definition + ICC Profile: + {'Size': 1328, + 'Preferred CMM Type': 1634758764, + 'Version': '2.2.0', + 'Device Class': 'display device profile', + 'Color Space': 'RGB', + 'Connection Space': 'XYZ', + 'Datetime': datetime.datetime(2009, 2, 25, 11, 26, 11), + 'File Signature': 'acsp', + 'Platform': 'APPL', + 'Flags': 'not embedded, can be used independently', + 'Device Manufacturer': 'appl', + 'Device Model': '', + 'Device Attributes': 'reflective, glossy, positive media polarity, color ' + 'media', + 'Rendering Intent': 'perceptual', + 'Illuminant': array([ 0.96420288, 1. , 0.8249054 ]), + 'Creator': 'appl'}""" diff --git a/glymur/test/test_jp2box.py b/glymur/test/test_jp2box.py index 831c4c6..23c8e93 100644 --- a/glymur/test/test_jp2box.py +++ b/glymur/test/test_jp2box.py @@ -441,7 +441,7 @@ class TestAppend(unittest.TestCase): # The sequence of box IDs should be the same as before, but with an # xml box at the end. box_ids = [box.box_id for box in jp2.box] - expected = ['jP ', 'ftyp', 'jp2h', 'uuid', 'uuid', 'jp2c', 'xml '] + expected = ['jP ', 'ftyp', 'jp2h', 'uuid', 'jp2c', 'xml '] self.assertEqual(box_ids, expected) self.assertEqual(ET.tostring(jp2.box[-1].xml.getroot()), b'0') @@ -451,14 +451,14 @@ class TestAppend(unittest.TestCase): with tempfile.NamedTemporaryFile(suffix=".j2k") as tfile: shutil.copyfile(self.j2kfile, tfile.name) - jp2 = Jp2k(tfile.name) + j2k = Jp2k(tfile.name) - # Make a UUID box. - uuid_instance = uuid.UUID('00000000-0000-0000-0000-000000000000') - data = b'0123456789' - uuidbox = glymur.jp2box.UUIDBox(uuid_instance, data) + # Make an XML box. XML boxes should always be appendable to jp2 + # files. + the_xml = ET.fromstring('0') + xmlbox = glymur.jp2box.XMLBox(xml=the_xml) with self.assertRaises(IOError): - jp2.append(uuidbox) + j2k.append(xmlbox) def test_length_field_is_zero(self): """L=0 (length field in box header) is handled. @@ -490,19 +490,19 @@ class TestAppend(unittest.TestCase): # The sequence of box IDs should be the same as before, but with an # xml box at the end. box_ids = [box.box_id for box in jp2.box] - expected = ['jP ', 'ftyp', 'jp2h', 'uuid', 'uuid', 'jp2c', 'xml '] + expected = ['jP ', 'ftyp', 'jp2h', 'uuid', 'jp2c', 'xml '] self.assertEqual(box_ids, expected) self.assertEqual(ET.tostring(jp2.box[-1].xml.getroot()), b'0') - def test_only_xml_allowed_to_append(self): + def test_append_allowable_boxes(self): """Only XML boxes are allowed to be appended.""" with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: shutil.copyfile(self.jp2file, tfile.name) jp2 = Jp2k(tfile.name) - # Make a UUID box. + # Make a UUID box. Only XMP UUID boxes can currently be appended. uuid_instance = uuid.UUID('00000000-0000-0000-0000-000000000000') data = b'0123456789' uuidbox = glymur.jp2box.UUIDBox(uuid_instance, data) @@ -946,9 +946,9 @@ class TestRepr(unittest.TestCase): tree = ET.ElementTree(elt) box = glymur.jp2box.XMLBox(xml=tree) - regexp = "glymur.jp2box.XMLBox" - regexp += "\(xml=<(xml.etree.ElementTree.){0,1}ElementTree object " - regexp += "at 0x([a-f0-9]*)>\)" + regexp = r"""glymur.jp2box.XMLBox""" + regexp += r"""\(xml=<(xml.etree.ElementTree.){0,1}ElementTree object """ + regexp += """at 0x([a-f0-9]*)>\)""" if sys.hexversion < 0x03000000: self.assertRegexpMatches(repr(box), regexp) @@ -970,7 +970,7 @@ class TestRepr(unittest.TestCase): self.assertEqual(box.vendor_feature, newbox.vendor_feature) self.assertEqual(box.vendor_mask, newbox.vendor_mask) - def test_uuid_box(self): + def test_uuid_box_generic(self): """Verify uuid repr method.""" uuid_instance = uuid.UUID('00000000-0000-0000-0000-000000000000') data = b'0123456789' @@ -978,9 +978,27 @@ class TestRepr(unittest.TestCase): # Since the raw_data parameter is a sequence of bytes which could be # quite long, don't bother trying to make it conform to eval(repr()). - regexp = "glymur.jp2box.UUIDBox\(" - regexp += "the_uuid=UUID\('00000000-0000-0000-0000-000000000000'\),\s" - regexp += "raw_data=\)" + regexp = r"""glymur.jp2box.UUIDBox\(""" + regexp += """the_uuid=UUID\('00000000-0000-0000-0000-000000000000'\),\s""" + regexp += """raw_data=\)""" + + if sys.hexversion < 0x03000000: + self.assertRegexpMatches(repr(box), regexp) + else: + self.assertRegex(repr(box), regexp) + + @unittest.skipIf(sys.hexversion < 0x02070000, "Requires 2.7+") + def test_uuid_box_xmp(self): + """Verify uuid repr method for XMP UUID box.""" + jp2file = glymur.data.nemo() + j = Jp2k(jp2file) + box = j.box[3] + + # Since the raw_data parameter is a sequence of bytes which could be + # quite long, don't bother trying to make it conform to eval(repr()). + regexp = r"""glymur.jp2box.UUIDBox\(""" + regexp += """the_uuid=UUID\('be7acfcb-97a9-42e8-9c71-999491e3afac'\),\s""" + regexp += """raw_data=\)""" if sys.hexversion < 0x03000000: self.assertRegexpMatches(repr(box), regexp) diff --git a/glymur/test/test_jp2box_jpx.py b/glymur/test/test_jp2box_jpx.py index 39151ba..571afbc 100644 --- a/glymur/test/test_jp2box_jpx.py +++ b/glymur/test/test_jp2box_jpx.py @@ -43,7 +43,7 @@ class TestJPXWrap(unittest.TestCase): def test_association_box(self): """Wrap JP2 to JPX with asoc(nlst, xml)""" jp2 = Jp2k(self.jp2file) - boxes = [jp2.box[idx] for idx in [0, 1, 2, 5]] + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] # The ftyp box must be modified to jpx with jp2 compatibility. boxes[1].brand = 'jpx ' @@ -70,7 +70,7 @@ class TestJPXWrap(unittest.TestCase): def test_only_one_data_reference(self): """Data reference boxes cannot be inside a superbox .""" jp2 = Jp2k(self.jp2file) - boxes = [jp2.box[idx] for idx in [0, 1, 2, 5]] + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] flag = 0 version = (0, 0, 0) @@ -87,7 +87,7 @@ class TestJPXWrap(unittest.TestCase): def test_data_reference_not_at_top_level(self): """Data reference boxes cannot be inside a superbox .""" jp2 = Jp2k(self.jp2file) - boxes = [jp2.box[idx] for idx in [0, 1, 2, 5]] + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] flag = 0 version = (0, 0, 0) @@ -105,7 +105,7 @@ class TestJPXWrap(unittest.TestCase): def test_jp2_to_jpx_sans_jp2_compatibility(self): """jp2 wrapped to jpx not including jp2 compatibility is wrong.""" jp2 = Jp2k(self.jp2file) - boxes = [jp2.box[idx] for idx in [0, 1, 2, 5]] + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] boxes[1].compatibility_list.append('jp2 ') numbers = [0, 1] nlst = glymur.jp2box.NumberListBox(numbers) @@ -121,7 +121,7 @@ class TestJPXWrap(unittest.TestCase): def test_jp2_to_jpx_sans_jpx_brand(self): """Verify error when jp2 wrapped to jpx does not include jpx brand.""" jp2 = Jp2k(self.jp2file) - boxes = [jp2.box[idx] for idx in [0, 1, 2, 5]] + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] boxes[1].brand = 'jpx ' numbers = [0, 1] nlst = glymur.jp2box.NumberListBox(numbers) diff --git a/glymur/test/test_jp2box_uuid.py b/glymur/test/test_jp2box_uuid.py new file mode 100644 index 0000000..82dc253 --- /dev/null +++ b/glymur/test/test_jp2box_uuid.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +"""Test suite for printing. +""" +# C0302: don't care too much about having too many lines in a test module +# pylint: disable=C0302 + +# E061: unittest.mock introduced in 3.3 (python-2.7/pylint issue) +# pylint: disable=E0611,F0401 + +# R0904: Not too many methods in unittest. +# pylint: disable=R0904 + +import os +import re +import shutil +import struct +import sys +import tempfile +import uuid +import warnings +import xml.etree + +if sys.hexversion < 0x02070000: + import unittest2 as unittest +else: + import unittest + +if sys.hexversion < 0x03000000: + from StringIO import StringIO +else: + from io import StringIO + +if sys.hexversion <= 0x03030000: + from mock import patch +else: + from unittest.mock import patch + +from .fixtures import HAS_PYTHON_XMP_TOOLKIT, OPJ_DATA_ROOT +if HAS_PYTHON_XMP_TOOLKIT: + from libxmp import XMPMeta + +import glymur +from glymur import Jp2k +from .fixtures import OPJ_DATA_ROOT, opj_data_file, SimpleRDF + + +class TestUUIDXMP(unittest.TestCase): + """Tests for UUIDs of XMP type.""" + + def setUp(self): + self.jp2file = glymur.data.nemo() + + def tearDown(self): + pass + + def test_append(self): + """Should be able to append an XMP UUID box.""" + the_uuid = uuid.UUID('be7acfcb-97a9-42e8-9c71-999491e3afac') + raw_data = SimpleRDF.encode('utf-8') + with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile: + shutil.copyfile(self.jp2file, tfile.name) + jp2 = Jp2k(tfile.name) + ubox = glymur.jp2box.UUIDBox(the_uuid=the_uuid, raw_data=raw_data) + jp2.append(ubox) + + # Should be two UUID boxes now. + expected_ids = ['jP ', 'ftyp', 'jp2h', 'uuid', 'jp2c', 'uuid'] + actual_ids = [b.box_id for b in jp2.box] + self.assertEqual(actual_ids, expected_ids) + + # The data should be an XMP packet, which gets interpreted as + # an ElementTree. + self.assertTrue(isinstance(jp2.box[-1].data, + xml.etree.ElementTree.ElementTree)) + +class TestUUIDExif(unittest.TestCase): + """Tests for UUIDs of Exif type.""" + + def setUp(self): + self.jp2file = glymur.data.nemo() + + def tearDown(self): + pass + + @unittest.skipIf(sys.hexversion < 0x03000000, "Requires assertWarns, 3.2+") + def test_unrecognized_exif_tag(self): + """Verify warning in case of unrecognized tag.""" + with tempfile.NamedTemporaryFile(suffix='.jp2', mode='wb') as tfile: + + with open(self.jp2file, 'rb') as ifptr: + tfile.write(ifptr.read()) + + # Write L, T, UUID identifier. + tfile.write(struct.pack('>I4s', 52, b'uuid')) + tfile.write(b'JpgTiffExif->JP2') + + tfile.write(b'Exif\x00\x00') + xbuffer = struct.pack('I4s', 52, b'uuid')) + tfile.write(b'JpgTiffExif->JP2') + + tfile.write(b'Exif\x00\x00') + xbuffer = struct.pack('I4s', 52, b'uuid')) + tfile.write(b'JpgTiffExif->JP2') + + tfile.write(b'Exif\x00\x00') + xbuffer = struct.pack('I4s', 52, b'uuid')) + tfile.write(b'JpgTiffExif->JP2') + + tfile.write(b'Exif\x00\x00') + xbuffer = struct.pack('>BBHI', 77, 77, 42, 8) + tfile.write(xbuffer) + + # We will write just a single tag. + tfile.write(struct.pack('>H', 1)) + + # The "Make" tag is tag no. 271. + tfile.write(struct.pack('>HHI4s', 271, 2, 3, b'HTC\x00')) + tfile.flush() + + jp2 = glymur.Jp2k(tfile.name) + self.assertEqual(jp2.box[-1].data['Make'], "HTC") + +if __name__ == "__main__": + unittest.main() diff --git a/glymur/test/test_jp2k.py b/glymur/test/test_jp2k.py index b3704ed..a36d42c 100644 --- a/glymur/test/test_jp2k.py +++ b/glymur/test/test_jp2k.py @@ -29,7 +29,11 @@ import pkg_resources import glymur from glymur import Jp2k -from .fixtures import OPENJP2_IS_V2_OFFICIAL +from .fixtures import HAS_PYTHON_XMP_TOOLKIT, OPENJP2_IS_V2_OFFICIAL +if HAS_PYTHON_XMP_TOOLKIT: + import libxmp + from libxmp import XMPMeta + from .fixtures import OPJ_DATA_ROOT, opj_data_file @@ -103,7 +107,7 @@ class TestJp2k(unittest.TestCase): jp2k = Jp2k(self.jp2file) # top-level boxes - self.assertEqual(len(jp2k.box), 6) + self.assertEqual(len(jp2k.box), 5) self.assertEqual(jp2k.box[0].box_id, 'jP ') self.assertEqual(jp2k.box[0].offset, 0) @@ -122,15 +126,11 @@ class TestJp2k(unittest.TestCase): self.assertEqual(jp2k.box[3].box_id, 'uuid') self.assertEqual(jp2k.box[3].offset, 77) - self.assertEqual(jp2k.box[3].length, 638) + self.assertEqual(jp2k.box[3].length, 3146) - self.assertEqual(jp2k.box[4].box_id, 'uuid') - self.assertEqual(jp2k.box[4].offset, 715) - self.assertEqual(jp2k.box[4].length, 2412) - - self.assertEqual(jp2k.box[5].box_id, 'jp2c') - self.assertEqual(jp2k.box[5].offset, 3127) - self.assertEqual(jp2k.box[5].length, 1132296) + self.assertEqual(jp2k.box[4].box_id, 'jp2c') + self.assertEqual(jp2k.box[4].offset, 3223) + self.assertEqual(jp2k.box[4].length, 1132296) # jp2h super box self.assertEqual(len(jp2k.box[2].box), 2) @@ -172,7 +172,7 @@ class TestJp2k(unittest.TestCase): with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile: with open(self.jp2file, 'rb') as ifile: # Everything up until the jp2c box. - write_buffer = ifile.read(3127) + write_buffer = ifile.read(3223) tfile.write(write_buffer) # The L field must be 1 in order to signal the presence of the @@ -193,9 +193,9 @@ class TestJp2k(unittest.TestCase): jp2k = Jp2k(tfile.name) - self.assertEqual(jp2k.box[5].box_id, 'jp2c') - self.assertEqual(jp2k.box[5].offset, 3127) - self.assertEqual(jp2k.box[5].length, 1133427 + 8) + self.assertEqual(jp2k.box[4].box_id, 'jp2c') + self.assertEqual(jp2k.box[4].offset, 3223) + self.assertEqual(jp2k.box[4].length, 1133427 + 8) @unittest.skipIf(os.name == "nt", "NamedTemporaryFile issue on windows") def test_length_field_is_zero(self): @@ -357,16 +357,24 @@ class TestJp2k(unittest.TestCase): self.assertEqual(ET.tostring(jp2k.box[3].xml.getroot()), b'this is a test') + @unittest.skipIf(not HAS_PYTHON_XMP_TOOLKIT, + "Requires Python XMP Toolkit >= 2.0") def test_xmp_attribute(self): """Verify the XMP packet in the shipping example file can be read.""" j = Jp2k(self.jp2file) - xmp = j.box[4].data + + xmp = j.box[3].data ns0 = '{http://www.w3.org/1999/02/22-rdf-syntax-ns#}' ns1 = '{http://ns.adobe.com/xap/1.0/}' - name = '{0}RDF/{0}Description'.format(ns0) + name = '{0}RDF/{0}Description/{1}CreatorTool'.format(ns0, ns1) elt = xmp.find(name) - attr_value = elt.attrib['{0}CreatorTool'.format(ns1)] - self.assertEqual(attr_value, 'glymur') + self.assertEqual(elt.text, 'Google') + + xmp = XMPMeta() + xmp.parse_from_str(j.box[3].raw_data.decode('utf-8'), + xmpmeta_wrap=False) + creator_tool = xmp.get_property(libxmp.consts.XMP_NS_XMP, 'CreatorTool') + self.assertEqual(creator_tool, 'Google') def test_jpx_mult_codestreams_jp2_brand(self): """Read JPX codestream when jp2-compatible.""" @@ -381,38 +389,6 @@ class TestJp2k(unittest.TestCase): else: self.assertEqual(data.shape, (1024, 1024, 3)) - @unittest.skipIf(os.name == "nt", "NamedTemporaryFile issue on windows") - def test_unrecognized_exif_tag(self): - """An unrecognized exif tag should be handled gracefully.""" - with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile: - shutil.copyfile(self.jp2file, tfile.name) - - # The Exif UUID starts at byte 77. There are 8 bytes for the L and - # T fields, then 16 bytes for the UUID identifier, then 6 exif - # header bytes, then 8 bytes for the TIFF header, then 2 bytes - # the the Image IFD number of tags, where we finally find the first - # tag, "Make" (271). We'll corrupt it by changing it into 171, - # which does not correspond to any known Exif Image tag. - with open(tfile.name, 'r+b') as fptr: - fptr.seek(117) - write_buffer = struct.pack('', - ' ', - ' ', - ' ', - ' '] - expected = '\n'.join(lst) + expected = nemo_xmp_box self.assertEqual(actual, expected) def test_codestream(self): @@ -651,8 +571,8 @@ class TestPrinting(unittest.TestCase): print(j.get_codestream()) actual = fake_out.getvalue().strip() lst = ['Codestream:', - ' SOC marker segment @ (3135, 0)', - ' SIZ marker segment @ (3137, 47)', + ' SOC marker segment @ (3231, 0)', + ' SIZ marker segment @ (3233, 47)', ' Profile: 2', ' Reference Grid Height, Width: (1456 x 2592)', ' Vertical, Horizontal Reference Grid Offset: (0 x 0)', @@ -662,7 +582,7 @@ class TestPrinting(unittest.TestCase): ' Signed: (False, False, False)', ' Vertical, Horizontal Subsampling: ' + '((1, 1), (1, 1), (1, 1))', - ' COD marker segment @ (3186, 12)', + ' COD marker segment @ (3282, 12)', ' Coding style:', ' Entropy coder, without partitions', ' SOP marker segments: False', @@ -684,11 +604,11 @@ class TestPrinting(unittest.TestCase): ' Vertically stripe causal context: False', ' Predictable termination: False', ' Segmentation symbols: False', - ' QCD marker segment @ (3200, 7)', + ' QCD marker segment @ (3296, 7)', ' Quantization style: no quantization, ' + '2 guard bits', ' Step size: [(0, 8), (0, 9), (0, 9), (0, 10)]', - ' CME marker segment @ (3209, 37)', + ' CME marker segment @ (3305, 37)', ' "Created by OpenJPEG version 2.0.0"'] expected = '\n'.join(lst) self.assertEqual(actual, expected) @@ -967,12 +887,10 @@ class TestPrinting(unittest.TestCase): expected = '\n'.join(lines) self.assertEqual(actual, expected) - @unittest.skipIf(sys.hexversion < 0x03000000, - "Ordered dicts not printing well in 2.7") @unittest.skipIf(OPJ_DATA_ROOT is None, "OPJ_DATA_ROOT environment variable not set") - def test_jpx_approx_icc_profile(self): - """verify jpx with approx field equal to zero""" + def test_icc_profile(self): + """verify icc profile printing with a jpx""" # ICC profiles may be used in JP2, but the approximation field should # be zero unless we have jpx. This file does both. filename = opj_data_file('input/nonregression/text_GBR.jp2') @@ -984,34 +902,13 @@ class TestPrinting(unittest.TestCase): with patch('sys.stdout', new=StringIO()) as fake_out: print(jp2.box[3].box[1]) actual = fake_out.getvalue().strip() - lines = ["Colour Specification Box (colr) @ (179, 1339)", - " Method: any ICC profile", - " Precedence: 2", - " Approximation: accurately represents " - + "correct colorspace definition", - " ICC Profile:", - " {'Size': 1328,", - " 'Preferred CMM Type': 1634758764,", - " 'Version': '2.2.0',", - " 'Device Class': 'display device profile',", - " 'Color Space': 'RGB',", - " 'Connection Space': 'XYZ',", - " 'Datetime': " - + "datetime.datetime(2009, 2, 25, 11, 26, 11),", - " 'File Signature': 'acsp',", - " 'Platform': 'APPL',", - " 'Flags': 'not embedded, " - + "can be used independently',", - " 'Device Manufacturer': 'appl',", - " 'Device Model': '',", - " 'Device Attributes': 'reflective, glossy, " - + "positive media polarity, color media',", - " 'Rendering Intent': 'perceptual',", - " 'Illuminant': array([ 0.96420288, 1. ," - + " 0.8249054 ]),", - " 'Creator': 'appl'}"] + if sys.hexversion < 0x03000000: + expected = text_gbr_27 + elif sys.hexversion < 0x03040000: + expected = text_gbr_33 + else: + expected = text_gbr_34 - expected = '\n'.join(lines) self.assertEqual(actual, expected) @unittest.skipIf(OPJ_DATA_ROOT is None, @@ -1028,7 +925,7 @@ class TestPrinting(unittest.TestCase): print(jp2.box[4]) actual = fake_out.getvalue().strip() lines = ['UUID Box (uuid) @ (1544, 25)', - ' UUID: 3a0d0218-0ae9-4115-b376-4bca41ce0e71', + ' UUID: 3a0d0218-0ae9-4115-b376-4bca41ce0e71 (unknown)', ' UUID Data: 1 bytes'] expected = '\n'.join(lines) @@ -1038,60 +935,37 @@ class TestPrinting(unittest.TestCase): "Ordered dicts not printing well in 2.7") def test_exif_uuid(self): """Verify printing of exif information""" - j = glymur.Jp2k(self.jp2file) + with tempfile.NamedTemporaryFile(suffix='.jp2', mode='wb') as tfile: - with patch('sys.stdout', new=StringIO()) as fake_out: - print(j.box[3]) - actual = fake_out.getvalue().strip() + with open(self.jp2file, 'rb') as ifptr: + tfile.write(ifptr.read()) - lines = ["UUID Box (uuid) @ (77, 638)", - " UUID: 4a706754-6966-6645-7869-662d3e4a5032 (Exif)", - " UUID Data: ", - "{'Image': {'Make': 'HTC',", - " 'Model': 'HTC Glacier',", - " 'XResolution': 72.0,", - " 'YResolution': 72.0,", - " 'ResolutionUnit': 2,", - " 'YCbCrPositioning': 1,", - " 'ExifTag': 138,", - " 'GPSTag': 354},", - " 'Photo': {'ISOSpeedRatings': 76,", - " 'ExifVersion': (48, 50, 50, 48),", - " 'DateTimeOriginal': '2013:02:09 14:47:53',", - " 'DateTimeDigitized': '2013:02:09 14:47:53',", - " 'ComponentsConfiguration': (1, 2, 3, 0),", - " 'FocalLength': 3.53,", - " 'FlashpixVersion': (48, 49, 48, 48),", - " 'ColorSpace': 1,", - " 'PixelXDimension': 2528,", - " 'PixelYDimension': 1424,", - " 'InteroperabilityTag': 324},", - " 'GPSInfo': {'GPSVersionID': (2, 2, 0),", - " 'GPSLatitudeRef': 'N',", - " 'GPSLatitude': [42.0, 20.0, 33.61],", - " 'GPSLongitudeRef': 'W',", - " 'GPSLongitude': [71.0, 5.0, 17.32],", - " 'GPSAltitudeRef': 0,", - " 'GPSAltitude': 0.0,", - " 'GPSTimeStamp': [19.0, 47.0, 53.0],", - " 'GPSMapDatum': 'WGS-84',", - " 'GPSProcessingMethod': (65,", - " 83,", - " 67,", - " 73,", - " 73,", - " 0,", - " 0,", - " 0,", - " 78,", - " 69,", - " 84,", - " 87,", - " 79,", - " 82,", - " 75),", - " 'GPSDateStamp': '2013:02:09'},", - " 'Iop': None}"] + # Write L, T, UUID identifier. + tfile.write(struct.pack('>I4s', 76, b'uuid')) + tfile.write(b'JpgTiffExif->JP2') + + tfile.write(b'Exif\x00\x00') + xbuffer = struct.pack('\d*.\d*.\d*.*)"\n', contents) kwargs['version'] = match.group('version')