diff --git a/glymur/_uuid_io.py b/glymur/_uuid_io.py index 399fa99..8cd5bcf 100644 --- a/glymur/_uuid_io.py +++ b/glymur/_uuid_io.py @@ -43,7 +43,7 @@ def tiff_header(read_buffer): endian = '>' else: msg = "Bad byte order indication: {0}".format(read_buffer[6:8]) - raise RuntimeError(msg) + raise IOError(msg) _, offset = struct.unpack(endian + 'HI', read_buffer[8:14]) diff --git a/glymur/codestream.py b/glymur/codestream.py index 35e3fee..2f9d37a 100644 --- a/glymur/codestream.py +++ b/glymur/codestream.py @@ -17,7 +17,6 @@ codestreams. # the base Segment class. # pylint: disable=R0903 -import collections import math import struct import sys @@ -28,33 +27,36 @@ import numpy as np from .core import LRCP, RLCP, RPCL, PCRL, CPRL from .core import WAVELET_XFORM_9X7_IRREVERSIBLE from .core import WAVELET_XFORM_5X3_REVERSIBLE -from .core import _CAPABILITIES_DISPLAY +from .core import _Keydefaultdict from .lib import openjp2 as opj2 -class _keydefaultdict(collections.defaultdict): - """Unlisted keys help form their own error message. - - Normally defaultdict uses a factory function with no input arguments, but - that's not quite the behavior we want. - """ - def __missing__(self, key): - if self.default_factory is None: - raise KeyError(key) - else: - ret = self[key] = self.default_factory(key) - return ret - _factory = lambda x: '{0} (invalid)'.format(x) -_PROGRESSION_ORDER_DISPLAY = _keydefaultdict(_factory, +_PROGRESSION_ORDER_DISPLAY = _Keydefaultdict(_factory, { LRCP: 'LRCP', RLCP: 'RLCP', RPCL: 'RPCL', PCRL: 'PCRL', CPRL: 'CPRL'}) -_WAVELET_TRANSFORM_DISPLAY = { - WAVELET_XFORM_9X7_IRREVERSIBLE: '9-7 irreversible', - WAVELET_XFORM_5X3_REVERSIBLE: '5-3 reversible'} +_WAVELET_TRANSFORM_DISPLAY = _Keydefaultdict(_factory, + { WAVELET_XFORM_9X7_IRREVERSIBLE: '9-7 irreversible', + WAVELET_XFORM_5X3_REVERSIBLE: '5-3 reversible'}) + +_NO_PROFILE = 0 +_PROFILE_0 = 1 +_PROFILE_1 = 2 +_PROFILE_3 = 3 +_PROFILE_4 = 4 + +_KNOWN_PROFILES = [_NO_PROFILE, _PROFILE_0, _PROFILE_1, _PROFILE_3, _PROFILE_4] + +# How to display the codestream profile. +_CAPABILITIES_DISPLAY = _Keydefaultdict(_factory, + { _NO_PROFILE: 'no profile', + _PROFILE_0: '0', + _PROFILE_1: '1', + _PROFILE_3: 'Cinema 2K', + _PROFILE_4: 'Cinema 4K'} ) # Need a catch-all list of valid markers. # See table A-1 in ISO/IEC FCD15444-1. @@ -390,6 +392,11 @@ class Codestream(object): msg = "Invalid progression order in COD segment: {0}." warnings.warn(msg.format(spcod[0])) + if spcod[8] not in [WAVELET_XFORM_9X7_IRREVERSIBLE, + WAVELET_XFORM_5X3_REVERSIBLE]: + msg = "Invalid wavelet transform in COD segment: {0}." + warnings.warn(msg.format(spcod[8])) + sop = (scod & 2) > 0 eph = (scod & 4) > 0 @@ -667,6 +674,9 @@ class Codestream(object): data = struct.unpack('>HIIIIIIIIH', xy_buffer) rsiz = data[0] + if rsiz not in _KNOWN_PROFILES: + warnings.warn("Invalid profile: (Rsiz={0}).".format(rsiz)) + xysiz = (data[1], data[2]) xyosiz = (data[3], data[4]) xytsiz = (data[5], data[6]) diff --git a/glymur/core.py b/glymur/core.py index 95e1cbf..07949f9 100644 --- a/glymur/core.py +++ b/glymur/core.py @@ -1,8 +1,22 @@ """Core definitions to be shared amongst the modules. """ +import collections import copy import lxml.etree as ET +class _Keydefaultdict(collections.defaultdict): + """Unlisted keys help form their own error message. + + Normally defaultdict uses a factory function with no input arguments, but + that's not quite the behavior we want. + """ + def __missing__(self, key): + if self.default_factory is None: + raise KeyError(key) + else: + ret = self[key] = self.default_factory(key) + return ret + # Progression order LRCP = 0 RLCP = 1 @@ -57,24 +71,26 @@ YCC = 18 E_SRGB = 20 ROMM_RGB = 21 -_COLORSPACE_MAP_DISPLAY = { - CMYK: 'CMYK', - SRGB: 'sRGB', - GREYSCALE: 'greyscale', - YCC: 'YCC', - E_SRGB: 'e-sRGB', - ROMM_RGB: 'ROMM-RGB'} +_factory = lambda x: '{0} (unrecognized)'.format(x) +_COLORSPACE_MAP_DISPLAY = _Keydefaultdict(_factory, + { CMYK: 'CMYK', + SRGB: 'sRGB', + GREYSCALE: 'greyscale', + YCC: 'YCC', + E_SRGB: 'e-sRGB', + ROMM_RGB: 'ROMM-RGB'} ) # enumerated color channel types COLOR = 0 OPACITY = 1 PRE_MULTIPLIED_OPACITY = 2 _UNSPECIFIED = 65535 -_COLOR_TYPE_MAP_DISPLAY = { - COLOR: 'color', - OPACITY: 'opacity', - PRE_MULTIPLIED_OPACITY: 'pre-multiplied opacity', - _UNSPECIFIED: 'unspecified'} +_factory = lambda x: '{0} (invalid)'.format(x) +_COLOR_TYPE_MAP_DISPLAY = _Keydefaultdict(_factory, + { COLOR: 'color', + OPACITY: 'opacity', + PRE_MULTIPLIED_OPACITY: 'pre-multiplied opacity', + _UNSPECIFIED: 'unspecified'}) # color channel definitions. RED = 1 @@ -90,9 +106,3 @@ _COLORSPACE = {SRGB: {"R": 1, "G": 2, "B": 3}, E_SRGB: {"R": 1, "G": 2, "B": 3}, ROMM_RGB: {"R": 1, "G": 2, "B": 3}} -# How to display the codestream profile. -_CAPABILITIES_DISPLAY = { - 0: '2', - 1: '0', - 2: '1', - 3: '3'} diff --git a/glymur/data/12-v6.4.jpx b/glymur/data/12-v6.4.jpx deleted file mode 100644 index a3e0c60..0000000 Binary files a/glymur/data/12-v6.4.jpx and /dev/null differ diff --git a/glymur/data/__init__.py b/glymur/data/__init__.py index 2bcd7f8..de1e62a 100644 --- a/glymur/data/__init__.py +++ b/glymur/data/__init__.py @@ -34,13 +34,13 @@ def goodstuff(): def jpxfile(): - """Shortcut for specifying path to 12-v6.4.jpx. + """Shortcut for specifying path to heliov.jpx. Returns ------- file : str Platform-independent path to 12-v6.4.jpx """ - filename = pkg_resources.resource_filename(__name__, "12-v6.4.jpx") + filename = pkg_resources.resource_filename(__name__, "heliov.jpx") return filename diff --git a/glymur/data/heliov.jpx b/glymur/data/heliov.jpx new file mode 100644 index 0000000..c7d5cdb Binary files /dev/null and b/glymur/data/heliov.jpx differ diff --git a/glymur/jp2box.py b/glymur/jp2box.py index 578ef36..311e76f 100644 --- a/glymur/jp2box.py +++ b/glymur/jp2box.py @@ -33,6 +33,7 @@ from .core import _COLOR_TYPE_MAP_DISPLAY from .core import SRGB, GREYSCALE, YCC from .core import ENUMERATED_COLORSPACE, RESTRICTED_ICC_PROFILE from .core import ANY_ICC_PROFILE, VENDOR_COLOR_METHOD +from .core import _Keydefaultdict from . import _uuid_io @@ -42,14 +43,12 @@ _METHOD_DISPLAY = { ANY_ICC_PROFILE: 'any ICC profile', VENDOR_COLOR_METHOD: 'vendor color method'} -_APPROX_DISPLAY = {1: 'accurately represents correct colorspace definition', - 2: 'approximates correct colorspace definition, ' - + 'exceptional quality', - 3: 'approximates correct colorspace definition, ' - + 'reasonable quality', - 4: 'approximates correct colorspace definition, ' - + 'poor quality'} - +_factory = lambda x: '{0} (invalid)'.format(x) +_APPROX_DISPLAY = _Keydefaultdict(_factory, + {1: 'accurately represents correct colorspace definition', + 2: 'approximates correct colorspace definition, exceptional quality', + 3: 'approximates correct colorspace definition, reasonable quality', + 4: 'approximates correct colorspace definition, poor quality'}) class Jp2kBox(object): """Superclass for JPEG 2000 boxes. @@ -86,6 +85,17 @@ class Jp2kBox(object): msg += " @ ({0}, {1})".format(self.offset, self.length) return msg + def _dispatch_validation_error(self, msg, writing=False): + """Issue either a warning or an error depending on circumstance. + + If writing to file, then error out, as we do not wish to create bad + JP2 files. If reading, then we should be more lenient and just warn. + """ + if writing: + raise IOError(msg) + else: + warnings.warn(msg) + def write(self, _): """Must be implemented in a subclass. """ @@ -166,34 +176,19 @@ class Jp2kBox(object): object corresponding to the current box """ try: - box = _BOX_WITH_ID[box_id].parse(fptr, start, num_bytes) + parser = _BOX_WITH_ID[box_id].parse + except KeyError: + # We don't recognize the box ID, so create an UnknownBox and be + # done with it. msg = 'Unrecognized box ({0}) encountered.'.format(box_id) warnings.warn(msg) box = UnknownBox(box_id, offset=start, length=num_bytes, longname='Unknown') - cpos = fptr.tell() - if not ((cpos == start + 8) or (cpos == start + 16)): - # If the file pointer has advanced, then the KeyError - # ocurred during the parsing of the box. - pass - else: - # Could it be a superbox with recognizable child boxes? - # Peek ahead to see. - pos = fptr.tell() - read_buffer = fptr.read(8) - _, sub_id = struct.unpack('>I4s', read_buffer) - sub_id = sub_id.decode('utf-8') - - # Regardless of whether or not we recognize the box, rewind back - # to properly advance to the next box. - fptr.seek(pos) - - # Now process any child boxes if we actually did recognize it. - if sub_id in _BOX_WITH_ID.keys(): - box.box = box.parse_superbox(fptr) + return box + box = parser(fptr, start, num_bytes) return box def parse_superbox(self, fptr): @@ -220,16 +215,12 @@ class Jp2kBox(object): break read_buffer = fptr.read(8) - try: - (box_length, box_id) = struct.unpack('>I4s', read_buffer) - except Exception as err: + if len(read_buffer) < 8: msg = "Extra bytes at end of file ignored." warnings.warn(msg) return superbox - if sys.hexversion >= 0x03000000: - box_id = box_id.decode('utf-8') - + (box_length, box_id) = struct.unpack('>I4s', read_buffer) if box_length == 0: # The length of the box is presumed to last until the end of # the file. Compute the effective length of the box. @@ -301,23 +292,29 @@ class ColourSpecificationBox(Jp2kBox): approximation=0, colorspace=None, icc_profile=None, length=0, offset=-1): Jp2kBox.__init__(self, box_id='colr', longname='Colour Specification') + self.method = method self.precedence = precedence self.approximation = approximation + self.colorspace = colorspace self.icc_profile = icc_profile self.length = length self.offset = offset - self._validate() - def _validate(self): + self._validate(writing=False) + + def _validate(self, writing=False): """Verify that the box obeys the specifications.""" if self.colorspace is not None and self.icc_profile is not None: - raise IOError("colorspace and icc_profile cannot both be set.") + msg = "Colorspace and icc_profile cannot both be set." + self._dispatch_validation_error(msg, writing=writing) if self.method not in (1, 2, 3, 4): - raise IOError("Invalid method.") + msg = "Invalid method.".format(self.method) + self._dispatch_validation_error(msg, writing=writing) if self.approximation not in (0, 1, 2, 3, 4): - raise IOError("Invalid approximation.") + msg = "Invalid approximation: {0}".format(self.approximation) + self._dispatch_validation_error(msg, writing=writing) def _write_validate(self): """In addition to constructor validation steps, run validation steps @@ -325,15 +322,15 @@ class ColourSpecificationBox(Jp2kBox): if self.colorspace is None: msg = "Writing Colour Specification boxes without enumerated " msg += "colorspaces is not supported at this time." - raise IOError(msg) + self._dispatch_validation_error(msg, writing=True) if self.icc_profile is None: if self.colorspace not in [SRGB, GREYSCALE, YCC]: msg = "Colorspace should correspond to one of SRGB, GREYSCALE, " msg += "or YCC." - raise IOError(msg) + self._dispatch_validation_error(msg, writing=True) - self._validate() + self._validate(writing=True) def __repr__(self): @@ -384,7 +381,7 @@ class ColourSpecificationBox(Jp2kBox): self._write_validate() length = 15 if self.icc_profile is None else 11 + len(self.icc_profile) fptr.write(struct.pack('>I', length)) - fptr.write('colr'.encode()) + fptr.write(b'colr') read_buffer = struct.pack('>BBBI', self.method, @@ -393,8 +390,8 @@ class ColourSpecificationBox(Jp2kBox): self.colorspace) fptr.write(read_buffer) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse JPEG 2000 color specification box. Parameters @@ -419,6 +416,9 @@ class ColourSpecificationBox(Jp2kBox): # enumerated colour space read_buffer = fptr.read(4) colorspace, = struct.unpack('>I', read_buffer) + if colorspace not in _COLORSPACE_MAP_DISPLAY.keys(): + msg = "Unrecognized colorspace: {0}".format(colorspace) + warnings.warn(msg) icc_profile = None else: @@ -434,14 +434,13 @@ class ColourSpecificationBox(Jp2kBox): profile = _ICCProfile(fptr.read(numbytes)) icc_profile = profile.header - box = ColourSpecificationBox(method=method, - precedence=precedence, - approximation=approximation, - colorspace=colorspace, - icc_profile=icc_profile, - length=length, - offset=offset) - return box + return cls(method=method, + precedence=precedence, + approximation=approximation, + colorspace=colorspace, + icc_profile=icc_profile, + length=length, + offset=offset) class _ICCProfile(object): @@ -588,15 +587,15 @@ class ChannelDefinitionBox(Jp2kBox): self.channel_type = tuple(channel_type) self.association = tuple(association) self.__dict__.update(**kwargs) - self._validate() + self._validate(writing=False) - def _validate(self): + def _validate(self, writing=False): """Verify that the box obeys the specifications.""" # channel type and association must be specified. if not ((len(self.index) == len(self.channel_type)) and (len(self.channel_type) == len(self.association))): msg = "Length of channel definition box inputs must be the same." - raise IOError(msg) + self._dispatch_validation_error(msg, writing=writing) # channel types must be one of 0, 1, 2, 65535 if any(x not in [0, 1, 2, 65535] for x in self.channel_type): @@ -605,7 +604,7 @@ class ChannelDefinitionBox(Jp2kBox): msg += " 1 - opacity\n" msg += " 2 - premultiplied opacity\n" msg += " 65535 - unspecified" - raise IOError(msg) + self._dispatch_validation_error(msg, writing=writing) def __str__(self): @@ -632,10 +631,10 @@ class ChannelDefinitionBox(Jp2kBox): def write(self, fptr): """Write a channel definition box to file. """ - self._validate() + self._validate(writing=True) num_components = len(self.association) fptr.write(struct.pack('>I', 8 + 2 + num_components * 6)) - fptr.write('cdef'.encode('utf-8')) + fptr.write(b'cdef') fptr.write(struct.pack('>H', num_components)) for j in range(num_components): fptr.write(struct.pack('>' + 'H' * 3, @@ -643,8 +642,8 @@ class ChannelDefinitionBox(Jp2kBox): self.channel_type[j], self.association[j])) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse component definition box. Parameters @@ -670,11 +669,10 @@ class ChannelDefinitionBox(Jp2kBox): channel_type = data[1:num_components * 6:3] association = data[2:num_components * 6:3] - box = ChannelDefinitionBox(index=tuple(index), - channel_type=tuple(channel_type), - association=tuple(association), - length=length, offset=offset) - return box + return cls(index=tuple(index), + channel_type=tuple(channel_type), + association=tuple(association), + length=length, offset=offset) class CodestreamHeaderBox(Jp2kBox): @@ -707,8 +705,13 @@ class CodestreamHeaderBox(Jp2kBox): msg = self._str_superbox() return msg - @staticmethod - def parse(fptr, offset, length): + def write(self, fptr): + """Write a codestream header box to file. + """ + self._write_superbox(fptr) + + @classmethod + def parse(cls, fptr, offset, length): """Parse codestream header box. Parameters @@ -724,7 +727,7 @@ class CodestreamHeaderBox(Jp2kBox): ------- CodestreamHeaderBox instance """ - box = CodestreamHeaderBox(length=length, offset=offset) + box = cls(length=length, offset=offset) # The codestream header box is a superbox, so go ahead and parse its # child boxes. @@ -733,6 +736,75 @@ class CodestreamHeaderBox(Jp2kBox): return box +class ColourGroupBox(Jp2kBox): + """Container for colour group box information. + + Attributes + ---------- + box_id : str + 4-character identifier for the box. + length : int + length of the box in bytes. + offset : int + offset of the box from the start of the file. + longname : str + more verbose description of the box. + box : list + List of boxes contained in this superbox. + """ + def __init__(self, box=None, length=0, offset=-1): + Jp2kBox.__init__(self, box_id='cgrp', longname='Colour Group') + self.length = length + self.offset = offset + self.box = box if box is not None else [] + + def __repr__(self): + msg = "glymur.jp2box.ColourGroupBox(box={0})".format(self.box) + return msg + + def __str__(self): + msg = self._str_superbox() + return msg + + def _validate(self, writing=True): + """Verify that the box obeys the specifications.""" + if any([box.box_id != 'colr' for box in self.box]): + msg = "Colour group boxes can only contain colour specification " + msg += "boxes." + self._dispatch_validation_error(msg, writing=writing) + + def write(self, fptr): + """Write a colour group box to file. + """ + self._validate(writing=True) + self._write_superbox(fptr) + + @classmethod + def parse(cls, fptr, offset, length): + """Parse colour group box. + + Parameters + ---------- + fptr : file + Open file object. + offset : int + Start position of box in bytes. + length : int + Length of the box in bytes. + + Returns + ------- + ColourGroupBox instance + """ + box = cls(length=length, offset=offset) + + # The colour group box is a superbox, so go ahead and parse its + # child boxes. + box.box = box.parse_superbox(fptr) + + return box + + class CompositingLayerHeaderBox(Jp2kBox): """Container for compositing layer header box information. @@ -765,8 +837,13 @@ class CompositingLayerHeaderBox(Jp2kBox): msg = self._str_superbox() return msg - @staticmethod - def parse(fptr, offset, length): + def write(self, fptr): + """Write a compositing layer header box to file. + """ + self._write_superbox(fptr) + + @classmethod + def parse(cls, fptr, offset, length): """Parse compositing layer header box. Parameters @@ -782,7 +859,7 @@ class CompositingLayerHeaderBox(Jp2kBox): ------- CompositingLayerHeaderBox instance """ - box = CompositingLayerHeaderBox(length=length, offset=offset) + box = cls(length=length, offset=offset) # This box is a superbox, so go ahead and parse its # child boxes. box.box = box.parse_superbox(fptr) @@ -846,7 +923,7 @@ class ComponentMappingBox(Jp2kBox): """Write a Component Mapping box to file. """ length = 8 + 4 * len(self.component_index) - write_buffer = struct.pack('>I4s', length, self.box_id.encode()) + write_buffer = struct.pack('>I4s', length, b'cmap') fptr.write(write_buffer) for j in range(len(self.component_index)): @@ -856,8 +933,8 @@ class ComponentMappingBox(Jp2kBox): self.palette_index[j]) fptr.write(write_buffer) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse component mapping box. Parameters @@ -879,13 +956,12 @@ class ComponentMappingBox(Jp2kBox): read_buffer = fptr.read(num_bytes) data = struct.unpack('>' + 'HBB' * num_components, read_buffer) - component_index = data[0:num_bytes:num_components] - mapping_type = data[1:num_bytes:num_components] - palette_index = data[2:num_bytes:num_components] + component_index = data[0:num_bytes:3] + mapping_type = data[1:num_bytes:3] + palette_index = data[2:num_bytes:3] - box = ComponentMappingBox(component_index, mapping_type, palette_index, - length=length, offset=offset) - return box + return cls(component_index, mapping_type, palette_index, + length=length, offset=offset) class ContiguousCodestreamBox(Jp2kBox): @@ -927,8 +1003,8 @@ class ContiguousCodestreamBox(Jp2kBox): return msg - @staticmethod - def parse(fptr, offset=0, length=0): + @classmethod + def parse(cls, fptr, offset=0, length=0): """Parse a codestream box. Parameters @@ -945,9 +1021,7 @@ class ContiguousCodestreamBox(Jp2kBox): ContiguousCodestreamBox instance """ main_header = Codestream(fptr, length, header_only=True) - box = ContiguousCodestreamBox(main_header, length=length, - offset=offset) - return box + return cls(main_header, length=length, offset=offset) class DataReferenceBox(Jp2kBox): @@ -974,23 +1048,23 @@ class DataReferenceBox(Jp2kBox): self.DR = data_entry_url_boxes self.length = length self.offset = offset - self._validate() + self._validate(writing=False) - def _validate(self): + def _validate(self, writing=False): """Verify that the box obeys the specifications.""" for box in self.DR: if box.box_id != 'url ': msg = 'All child boxes of a data reference box must be data ' msg += 'entry URL boxes.' - raise IOError(msg) + self._dispatch_validation_error(msg, writing=writing) def _write_validate(self): """Verify that the box obeys the specifications for writing. """ if len(self.DR) == 0: msg = "A data reference box cannot be empty when written to a file." - raise IOError(msg) - self._validate() + self._dispatch_validation_error(msg, writing=True) + self._validate(writing=True) def write(self, fptr): """Write a Data Reference box to file. @@ -1000,7 +1074,7 @@ class DataReferenceBox(Jp2kBox): # Very similar to the say a superbox is written. orig_pos = fptr.tell() fptr.write(struct.pack('>I', 0)) - fptr.write(self.box_id.encode()) + fptr.write(b'dtbl') # Write the number of data entry url boxes. write_buffer = struct.pack('>H', len(self.DR)) @@ -1027,8 +1101,8 @@ class DataReferenceBox(Jp2kBox): msg = 'glymur.jp2box.DataReferenceBox()' return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse Label box. Parameters @@ -1060,8 +1134,7 @@ class DataReferenceBox(Jp2kBox): box = DataEntryURLBox.parse(fptr, start, box_length) data_entry_url_box_list.append(box) - return DataReferenceBox(data_entry_url_box_list, - length=length, offset=offset) + return cls(data_entry_url_box_list, length=length, offset=offset) class FileTypeBox(Jp2kBox): @@ -1096,6 +1169,7 @@ class FileTypeBox(Jp2kBox): self.compatibility_list = compatibility_list self.length = length self.offset = offset + self._validate(writing=False) def __repr__(self): msg = "glymur.jp2box.FileTypeBox(brand='{0}', minor_version={1}, " @@ -1117,33 +1191,34 @@ class FileTypeBox(Jp2kBox): return msg - def _validate(self): + def _validate(self, writing=False): """Validate the box before writing to file.""" if self.brand not in ['jp2 ', 'jpx ']: msg = "The file type brand must be either 'jp2 ' or 'jpx '." - raise IOError(msg) + self._dispatch_validation_error(msg, writing=writing) valid_cls = ['jp2 ', 'jpx ', 'jpxb'] for item in self.compatibility_list: if item not in valid_cls: msg = "The file type compatibility list item '{0}' is not " msg += "valid: valid entries are {1}" - raise IOError(msg.format(item, valid_cls)) + msg = msg.format(item, valid_cls) + self._dispatch_validation_error(msg, writing=writing) def write(self, fptr): """Write a File Type box to file. """ - self._validate() + self._validate(writing=True) length = 16 + 4*len(self.compatibility_list) fptr.write(struct.pack('>I', length)) - fptr.write('ftyp'.encode()) + fptr.write(b'ftyp') fptr.write(self.brand.encode()) fptr.write(struct.pack('>I', self.minor_version)) for item in self.compatibility_list: fptr.write(item.encode()) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse JPEG 2000 file type box. Parameters @@ -1178,10 +1253,9 @@ class FileTypeBox(Jp2kBox): compatibility_list = compatibility_list - box = FileTypeBox(brand=brand, minor_version=minor_version, - compatibility_list=compatibility_list, - length=length, offset=offset) - return box + return cls(brand=brand, minor_version=minor_version, + compatibility_list=compatibility_list, + length=length, offset=offset) class FragmentListBox(Jp2kBox): @@ -1206,18 +1280,21 @@ class FragmentListBox(Jp2kBox): self.data_reference = data_reference self.length = length self.offset = offset + self._validate(writing=False) - def _validate(self): + def _validate(self, writing=False): """Validate internal correctness.""" if (((len(self.fragment_offset) != len(self.fragment_length)) or (len(self.fragment_length) != len(self.data_reference)))): msg = "The lengths of the fragment offsets, fragment lengths, and " msg += "data reference items must be the same." - raise IOError(msg) + self._dispatch_validation_error(msg, writing=writing) if any([x <= 0 for x in self.fragment_offset]): - raise IOError("Fragment offsets must all be positive.") + msg = "Fragment offsets must all be positive." + self._dispatch_validation_error(msg, writing=writing) if any([x <= 0 for x in self.fragment_length]): - raise IOError("Fragment lengths must all be positive.") + msg = "Fragment lengths must all be positive." + self._dispatch_validation_error(msg, writing=writing) def __repr__(self): msg = "glymur.jp2box.FragmentListBox()" @@ -1241,11 +1318,11 @@ class FragmentListBox(Jp2kBox): def write(self, fptr): """Write a fragment list box to file. """ - self._validate() + self._validate(writing=True) num_items = len(self.fragment_offset) length = 8 + 2 + num_items * 14 fptr.write(struct.pack('>I', length)) - fptr.write(self.box_id.encode()) + fptr.write(b'flst') fptr.write(struct.pack('>H', num_items)) for j in range(num_items): write_buffer = struct.pack('>QIH', @@ -1254,8 +1331,8 @@ class FragmentListBox(Jp2kBox): self.data_reference[j]) fptr.write(write_buffer) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse JPX free box. Parameters @@ -1279,8 +1356,8 @@ class FragmentListBox(Jp2kBox): frag_offset = lst[0::3] frag_len = lst[1::3] data_reference = lst[2::3] - return FragmentListBox(frag_offset, frag_len, data_reference, - length=length, offset=offset) + return cls(frag_offset, frag_len, data_reference, + length=length, offset=offset) class FragmentTableBox(Jp2kBox): @@ -1311,8 +1388,8 @@ class FragmentTableBox(Jp2kBox): msg = self._str_superbox() return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse JPX fragment table superbox box. Parameters @@ -1328,7 +1405,7 @@ class FragmentTableBox(Jp2kBox): ------- FragmentTableBox instance """ - box = FragmentTableBox(length=length, offset=offset) + box = cls(length=length, offset=offset) # The FragmentTable box is a superbox, so go ahead and parse its child # boxes. @@ -1336,18 +1413,18 @@ class FragmentTableBox(Jp2kBox): return box - def _validate(self): + def _validate(self, writing=False): """Self-validate the box before writing.""" box_ids = [box.box_id for box in self.box] if len(box_ids) != 1 or box_ids[0] != 'flst': msg = "Fragment table boxes must have a single fragment list " msg += "box as a child box." - raise IOError(msg) + self._dispatch_validation_error(msg, writing=writing) def write(self, fptr): """Write a fragment table box to file. """ - self._validate() + self._validate(writing=True) self._write_superbox(fptr) @@ -1382,8 +1459,8 @@ class FreeBox(Jp2kBox): return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse JPX free box. Parameters @@ -1399,7 +1476,7 @@ class FreeBox(Jp2kBox): ------- FreeBox instance """ - return FreeBox(length=length, offset=offset) + return cls(length=length, offset=offset) class ImageHeaderBox(Jp2kBox): @@ -1491,7 +1568,7 @@ class ImageHeaderBox(Jp2kBox): """Write an Image Header box to file. """ fptr.write(struct.pack('>I', 22)) - fptr.write('ihdr'.encode()) + fptr.write(b'ihdr') # signedness and bps are stored together in a single byte bit_depth_signedness = 0x80 if self.signed else 0x00 @@ -1506,8 +1583,8 @@ class ImageHeaderBox(Jp2kBox): 1 if self.ip_provided else 0) fptr.write(read_buffer) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse JPEG 2000 image header box. Parameters @@ -1535,14 +1612,13 @@ class ImageHeaderBox(Jp2kBox): colorspace_unknown = True if params[5] else False ip_provided = True if params[6] else False - box = ImageHeaderBox(height, width, num_components=num_components, - bits_per_component=bits_per_component, - signed=signed, - compression=compression, - colorspace_unknown=colorspace_unknown, - ip_provided=ip_provided, - length=length, offset=offset) - return box + return cls(height, width, num_components=num_components, + bits_per_component=bits_per_component, + signed=signed, + compression=compression, + colorspace_unknown=colorspace_unknown, + ip_provided=ip_provided, + length=length, offset=offset) class AssociationBox(Jp2kBox): @@ -1575,8 +1651,8 @@ class AssociationBox(Jp2kBox): msg = self._str_superbox() return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse association box. Parameters @@ -1592,7 +1668,7 @@ class AssociationBox(Jp2kBox): ------- AssociationBox instance """ - box = AssociationBox(length=length, offset=offset) + box = cls(length=length, offset=offset) # The Association box is a superbox, so go ahead and parse its child # boxes. @@ -1641,8 +1717,8 @@ class JP2HeaderBox(Jp2kBox): """ self._write_superbox(fptr) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse JPEG 2000 header box. Parameters @@ -1658,7 +1734,7 @@ class JP2HeaderBox(Jp2kBox): ------- JP2HeaderBox instance """ - box = JP2HeaderBox(length=length, offset=offset) + box = cls(length=length, offset=offset) # The JP2 header box is a superbox, so go ahead and parse its child # boxes. @@ -1706,11 +1782,11 @@ class JPEG2000SignatureBox(Jp2kBox): """Write a JPEG 2000 Signature box to file. """ fptr.write(struct.pack('>I', 12)) - fptr.write(self.box_id.encode()) + fptr.write(b'jP ') fptr.write(struct.pack('>BBBB', *self.signature)) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse JPEG 2000 signature box. Parameters @@ -1729,9 +1805,7 @@ class JPEG2000SignatureBox(Jp2kBox): read_buffer = fptr.read(4) signature = struct.unpack('>BBBB', read_buffer) - box = JPEG2000SignatureBox(signature=signature, length=length, - offset=offset) - return box + return cls(signature=signature, length=length, offset=offset) class PaletteBox(Jp2kBox): @@ -1758,15 +1832,15 @@ class PaletteBox(Jp2kBox): self.signed = signed self.length = length self.offset = offset - self._validate() + self._validate(writing=False) - def _validate(self): + def _validate(self, writing=False): """Verify that the box obeys the specifications.""" if ((len(self.bits_per_component) != len(self.signed)) or (len(self.signed) != self.palette.shape[1])): msg = "The length of the 'bits_per_component' and the 'signed' " msg += "members must equal the number of columns of the palette." - raise IOError(msg) + self._dispatch_validation_error(msg, writing=writing) def __repr__(self): msg = "glymur.jp2box.PaletteBox({0}, bits_per_component={1}, " @@ -1786,14 +1860,13 @@ class PaletteBox(Jp2kBox): def write(self, fptr): """Write a Palette box to file. """ - self._validate() + self._validate(writing=True) bytes_per_row = sum(self.bits_per_component) / 8 bytes_per_palette = bytes_per_row * self.palette.shape[0] box_length = 8 + 3 + self.palette.shape[1] + bytes_per_palette # Write the usual header. - write_buffer = struct.pack('>I4s', - int(box_length), self.box_id.encode()) + write_buffer = struct.pack('>I4s', int(box_length), b'pclr') fptr.write(write_buffer) write_buffer = struct.pack('>HB', self.palette.shape[0], @@ -1808,20 +1881,37 @@ class PaletteBox(Jp2kBox): *bps_signed) fptr.write(write_buffer) - if self.bits_per_component[0] <= 8: - code = 'B' - elif self.bits_per_component[0] <= 16: - code = 'H' - elif self.bits_per_component[0] <= 32: - code = 'I' - - fmt = '>' + code * self.palette.shape[1] - for row in self.palette: - write_buffer = struct.pack(fmt, *row) + bps = self.bits_per_component + if any(b != bps[0] for b in bps): + # All components are the same. Writing is straightforward. + if self.bits_per_component[0] <= 8: + code = 'B' + dtype = np.uint8 + elif self.bits_per_component[0] <= 16: + code = 'H' + dtype = np.uint16 + elif self.bits_per_component[0] <= 32: + code = 'I' + dtype = np.uint32 + nelts = self.palette.shape[0] * self.palette.shape[1] + fmt = '>{0}{1}'.format(nelts, code) + write_buffer = struct.pack(fmt, + self.palette.astype(dtype).flatten()) fptr.write(write_buffer) + else: + # Not all the components are the same. More general, but much rarer + # case. Does this even happen. + code_dict = {8: 'B', 16: 'H', 32: 'I'} + codes = '' + for width in bps: + codes += code_dict[width] + fmt = '>' + codes + for row in self.palette: + write_buffer = struct.pack(fmt, *row) + fptr.write(write_buffer) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse palette box. Parameters @@ -1866,7 +1956,7 @@ class PaletteBox(Jp2kBox): palette[j] = struct.unpack_from(fmt, read_buffer, offset=j * row_nbytes) - return PaletteBox(palette, bps, signed, length=length, offset=offset) + return cls(palette, bps, signed, length=length, offset=offset) # Map rreq codes to display text. @@ -2032,8 +2122,8 @@ class ReaderRequirementsBox(Jp2kBox): return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse reader requirements box. Parameters @@ -2079,10 +2169,9 @@ class ReaderRequirementsBox(Jp2kBox): msg += 'The box contents will not be interpreted.' warnings.warn(msg.format(mask_length), UserWarning) - box = ReaderRequirementsBox(fuam, dcm, standard_flag, standard_mask, - vendor_feature, vendor_mask, - length=length, offset=offset) - return box + return cls(fuam, dcm, standard_flag, standard_mask, + vendor_feature, vendor_mask, + length=length, offset=offset) def _parse_rreq3(fptr, length, offset): @@ -2240,8 +2329,8 @@ class ResolutionBox(Jp2kBox): msg = self._str_superbox() return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse Resolution box. Parameters @@ -2257,7 +2346,7 @@ class ResolutionBox(Jp2kBox): ------- ResolutionBox instance """ - box = ResolutionBox(length=length, offset=offset) + box = cls(length=length, offset=offset) # The JP2 header box is a superbox, so go ahead and parse its child # boxes. @@ -2304,8 +2393,8 @@ class CaptureResolutionBox(Jp2kBox): msg += '\n HCR: {0}'.format(self.horizontal_resolution) return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse CaptureResolutionBox. Parameters @@ -2326,9 +2415,7 @@ class CaptureResolutionBox(Jp2kBox): vres = rn1 / rd1 * math.pow(10, re1) hres = rn2 / rd2 * math.pow(10, re2) - box = CaptureResolutionBox(vres, hres, length=length, offset=offset) - - return box + return cls(vres, hres, length=length, offset=offset) class DisplayResolutionBox(Jp2kBox): @@ -2369,8 +2456,8 @@ class DisplayResolutionBox(Jp2kBox): msg += '\n HDR: {0}'.format(self.horizontal_resolution) return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse display resolution box. Parameters @@ -2392,9 +2479,7 @@ class DisplayResolutionBox(Jp2kBox): vres = rn1 / rd1 * math.pow(10, re1) hres = rn2 / rd2 * math.pow(10, re2) - box = DisplayResolutionBox(vres, hres, length=length, offset=offset) - - return box + return cls(vres, hres, length=length, offset=offset) class LabelBox(Jp2kBox): @@ -2436,11 +2521,11 @@ class LabelBox(Jp2kBox): """ length = 8 + len(self.label.encode()) fptr.write(struct.pack('>I', length)) - fptr.write(self.box_id.encode()) + fptr.write(b'lbl ') fptr.write(self.label.encode()) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse Label box. Parameters @@ -2459,8 +2544,7 @@ class LabelBox(Jp2kBox): num_bytes = offset + length - fptr.tell() read_buffer = fptr.read(num_bytes) label = read_buffer.decode('utf-8') - box = LabelBox(label, length=length, offset=offset) - return box + return cls(label, length=length, offset=offset) class NumberListBox(Jp2kBox): @@ -2502,7 +2586,7 @@ class NumberListBox(Jp2kBox): elif (association >> 24) == 2: idx = association & 0x00FFFFFF msg += 'Compositing Layer {0}' - msg = msg.format(j, idx) + msg = msg.format(idx) else: msg += 'unrecognized' return msg @@ -2511,9 +2595,9 @@ class NumberListBox(Jp2kBox): msg = 'glymur.jp2box.NumberListBox()' return msg - @staticmethod - def parse(fptr, offset, length): - """Parse Label box. + @classmethod + def parse(cls, fptr, offset, length): + """Parse number list box. Parameters ---------- @@ -2532,14 +2616,13 @@ class NumberListBox(Jp2kBox): raw_data = fptr.read(num_bytes) num_associations = int(len(raw_data) / 4) lst = struct.unpack('>' + 'I' * num_associations, raw_data) - box = NumberListBox(lst, length=length, offset=offset) - return box + return cls(lst, length=length, offset=offset) def write(self, fptr): """Write a NumberList box to file. """ fptr.write(struct.pack('>I', len(self.associations) * 4 + 8)) - fptr.write(self.box_id.encode()) + fptr.write(b'nlst') fmt = '>' + 'I' * len(self.associations) write_buffer = struct.pack(fmt, *self.associations) @@ -2613,11 +2696,11 @@ class XMLBox(Jp2kBox): read_buffer = ET.tostring(self.xml.getroot(), encoding='utf-8') fptr.write(struct.pack('>I', len(read_buffer) + 8)) - fptr.write(self.box_id.encode()) + fptr.write(b'xml ') fptr.write(read_buffer) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse XML box. Parameters @@ -2678,8 +2761,7 @@ class XMLBox(Jp2kBox): warnings.warn(msg, UserWarning) xml = None - box = XMLBox(xml=xml, length=length, offset=offset) - return box + return cls(xml=xml, length=length, offset=offset) class UUIDListBox(Jp2kBox): @@ -2717,8 +2799,8 @@ class UUIDListBox(Jp2kBox): msg += '\n UUID[{0}]: {1}'.format(j, uuid_item) return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse UUIDList box. Parameters @@ -2742,8 +2824,7 @@ class UUIDListBox(Jp2kBox): read_buffer = fptr.read(16) ulst.append(uuid.UUID(bytes=read_buffer)) - box = UUIDListBox(ulst, length=length, offset=offset) - return box + return cls(ulst, length=length, offset=offset) class UUIDInfoBox(Jp2kBox): @@ -2776,8 +2857,8 @@ class UUIDInfoBox(Jp2kBox): msg = self._str_superbox() return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse UUIDInfo super box. Parameters @@ -2794,7 +2875,7 @@ class UUIDInfoBox(Jp2kBox): UUIDInfoBox instance """ - box = UUIDInfoBox(length=length, offset=offset) + box = cls(length=length, offset=offset) # The UUIDInfo box is a superbox, so go ahead and parse its child # boxes. @@ -2841,7 +2922,7 @@ class DataEntryURLBox(Jp2kBox): length = 8 + 1 + 3 + len(url.encode()) write_buffer = struct.pack('>I4sBBBB', - length, self.box_id.encode(), + length, b'url ', self.version, self.flag[0], self.flag[1], self.flag[2]) fptr.write(write_buffer) @@ -2869,8 +2950,8 @@ class DataEntryURLBox(Jp2kBox): self.url) return msg - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse data entry URL box. Parameters @@ -2894,8 +2975,7 @@ class DataEntryURLBox(Jp2kBox): numbytes = offset + length - fptr.tell() read_buffer = fptr.read(numbytes) url = read_buffer.decode('utf-8').rstrip(chr(0)) - box = DataEntryURLBox(version, flag, url, length=length, offset=offset) - return box + return cls(version, flag, url, length=length, offset=offset) class UnknownBox(Jp2kBox): @@ -2979,7 +3059,11 @@ class UUIDBox(Jp2kBox): try: self._parse_raw_data() - except RuntimeError as error: + except KeyError as error: + # Such as when an Exif tag is unrecognized. + warnings.warn(str(error)) + except IOError as error: + # Such as when Exif byte order is unrecognized. warnings.warn(str(error)) def _parse_raw_data(self): @@ -3040,8 +3124,8 @@ class UUIDBox(Jp2kBox): fptr.write(self.uuid.bytes) fptr.write(self.raw_data) - @staticmethod - def parse(fptr, offset, length): + @classmethod + def parse(cls, fptr, offset, length): """Parse UUID box. Parameters @@ -3063,39 +3147,39 @@ class UUIDBox(Jp2kBox): numbytes = offset + length - fptr.tell() read_buffer = fptr.read(numbytes) - box = UUIDBox(the_uuid, read_buffer, length=length, offset=offset) - return box + return cls(the_uuid, read_buffer, length=length, offset=offset) # Map each box ID to the corresponding class. _BOX_WITH_ID = { - 'asoc': AssociationBox, - 'cdef': ChannelDefinitionBox, - 'cmap': ComponentMappingBox, - 'colr': ColourSpecificationBox, - 'dtbl': DataReferenceBox, - 'ftyp': FileTypeBox, - 'ihdr': ImageHeaderBox, - 'jP ': JPEG2000SignatureBox, - 'jpch': CodestreamHeaderBox, - 'jplh': CompositingLayerHeaderBox, - 'jp2c': ContiguousCodestreamBox, - 'free': FreeBox, - 'flst': FragmentListBox, - 'ftbl': FragmentTableBox, - 'jp2h': JP2HeaderBox, - 'lbl ': LabelBox, - 'nlst': NumberListBox, - 'pclr': PaletteBox, - 'res ': ResolutionBox, - 'resc': CaptureResolutionBox, - 'resd': DisplayResolutionBox, - 'rreq': ReaderRequirementsBox, - 'uinf': UUIDInfoBox, - 'ulst': UUIDListBox, - 'url ': DataEntryURLBox, - 'uuid': UUIDBox, - 'xml ': XMLBox} + b'asoc': AssociationBox, + b'cdef': ChannelDefinitionBox, + b'cgrp': ColourGroupBox, + b'cmap': ComponentMappingBox, + b'colr': ColourSpecificationBox, + b'dtbl': DataReferenceBox, + b'ftyp': FileTypeBox, + b'ihdr': ImageHeaderBox, + b'jP ': JPEG2000SignatureBox, + b'jpch': CodestreamHeaderBox, + b'jplh': CompositingLayerHeaderBox, + b'jp2c': ContiguousCodestreamBox, + b'free': FreeBox, + b'flst': FragmentListBox, + b'ftbl': FragmentTableBox, + b'jp2h': JP2HeaderBox, + b'lbl ': LabelBox, + b'nlst': NumberListBox, + b'pclr': PaletteBox, + b'res ': ResolutionBox, + b'resc': CaptureResolutionBox, + b'resd': DisplayResolutionBox, + b'rreq': ReaderRequirementsBox, + b'uinf': UUIDInfoBox, + b'ulst': UUIDListBox, + b'url ': DataEntryURLBox, + b'uuid': UUIDBox, + b'xml ': XMLBox} _printoptions = {'short': False, 'xml': True, 'codestream': True} diff --git a/glymur/jp2k.py b/glymur/jp2k.py index 5d39ed5..971c82d 100644 --- a/glymur/jp2k.py +++ b/glymur/jp2k.py @@ -29,7 +29,7 @@ import numpy as np from .codestream import Codestream from .core import SRGB, GREYSCALE -from .core import PROGRESSION_ORDER, RSIZ, CINEMA_MODE +from .core import PROGRESSION_ORDER, CINEMA_MODE from .core import ENUMERATED_COLORSPACE, RESTRICTED_ICC_PROFILE from .jp2box import Jp2kBox from .jp2box import JPEG2000SignatureBox, FileTypeBox, JP2HeaderBox @@ -442,7 +442,7 @@ class Jp2k(Jp2kBox): If glymur is unable to load the openjp2 library. """ if opj2.OPENJP2 is not None: - self._write_openjp2(img_array, verbose=verbose, **kwargs) + self._write_openjp2(img_array, verbose=verbose, **kwargs) elif opj.OPENJPEG is not None: self._write_openjpeg(img_array, verbose=verbose, **kwargs) else: @@ -606,7 +606,12 @@ class Jp2k(Jp2kBox): self.parse() def wrap(self, filename, boxes=None): - """Write the codestream back out to file, wrapped in new JP2 jacket. + """Create a new JP2/JPX file wrapped in a new set of JP2 boxes. + + This method is primarily aimed at wrapping a raw codestream in a set of + of JP2 boxes (turning it into a JP2 file instead of just a raw + codestream), or rewrapping a codestream in a JP2 file in a new "jacket" + of JP2 boxes. Parameters ---------- @@ -616,6 +621,8 @@ class Jp2k(Jp2kBox): JP2 box definitions to define the JP2 file format. If not provided, a default ""jacket" is assumed, consisting of JP2 signature, file type, JP2 header, and contiguous codestream boxes. + A JPX file rewrapped without the boxes argument results in a JP2 + file encompassing the first codestream. Returns ------- @@ -631,19 +638,7 @@ class Jp2k(Jp2kBox): >>> jp2 = j2k.wrap(tfile.name) """ if boxes is None: - # Try to create a reasonable default. - boxes = [JPEG2000SignatureBox(), - FileTypeBox(), - JP2HeaderBox(), - ContiguousCodestreamBox()] - codestream = self.get_codestream() - height = codestream.segment[1].ysiz - width = codestream.segment[1].xsiz - num_components = len(codestream.segment[1].xrsiz) - boxes[2].box = [ImageHeaderBox(height=height, - width=width, - num_components=num_components), - ColourSpecificationBox(colorspace=SRGB)] + boxes = self._get_default_jp2_boxes() _validate_jp2_box_sequence(boxes) @@ -652,34 +647,92 @@ class Jp2k(Jp2kBox): if box.box_id != 'jp2c': box.write(ofile) else: - # The codestream gets written last. - if len(self.box) == 0: - # Am I a raw codestream? If so, then it is pretty - # easy, just write the codestream box header plus all - # of myself out to file. - ofile.write(struct.pack('>I', self.length + 8)) - ofile.write('jp2c'.encode()) - with open(self.filename, 'rb') as ifile: - ofile.write(ifile.read()) - else: - # OK, I'm a jp2 file. Need to find out where the - # raw codestream actually starts. - jp2c = [box for box in self.box - if box.box_id == 'jp2c'] - jp2c = jp2c[0] - ofile.write(struct.pack('>I', jp2c.length)) - ofile.write('jp2c'.encode()) - with open(self.filename, 'rb') as ifile: - # Seek 8 bytes past the L, T fields to get to the - # raw codestream. - ifile.seek(jp2c.offset + 8) - ofile.write(ifile.read(jp2c.length - 8)) - + self._write_wrapped_codestream(ofile, box) ofile.flush() jp2 = Jp2k(filename) return jp2 + def _write_wrapped_codestream(self, ofile, box): + """Write wrapped codestream.""" + # Codestreams require a bit more care. + # Am I a raw codestream? + if len(self.box) == 0: + # Yes, just write the codestream box header plus all + # of myself out to file. + ofile.write(struct.pack('>I', self.length + 8)) + ofile.write(b'jp2c') + with open(self.filename, 'rb') as ifile: + ofile.write(ifile.read()) + return + + # OK, I'm a jp2/jpx file. Need to find out where the raw codestream + # actually starts. + offset = box.offset + if offset == -1: + if self.box[1].brand == 'jpx ': + msg = "The codestream box must have its offset and " + msg += "length attributes fully specified if the file " + msg += "type brand is JPX." + raise IOError(msg) + + # Find the first codestream in the file. + jp2c = [box for box in self.box if box.box_id == 'jp2c'] + offset = jp2c[0].offset + + # Ready to write the codestream. + with open(self.filename, 'rb') as ifile: + ifile.seek(offset) + + # Verify that the specified codestream is right. + read_buffer = ifile.read(8) + L, T = struct.unpack_from('>I4s', read_buffer, 0) + if T != b'jp2c': + msg = "Unable to locate the specified codestream." + raise IOError(msg) + if L == 0: + # The length of the box is presumed to last until the end of + # the file. Compute the effective length of the box. + L = os.path.getsize(ifile.name) - ifile.tell() + 8 + + elif L == 1: + # The length of the box is in the XL field, a 64-bit value. + read_buffer = ifile.read(8) + L, = struct.unpack('>Q', read_buffer) + + ifile.seek(offset) + read_buffer = ifile.read(L) + ofile.write(read_buffer) + + def _get_default_jp2_boxes(self): + """Create a default set of JP2 boxes.""" + # Try to create a reasonable default. + boxes = [JPEG2000SignatureBox(), + FileTypeBox(), + JP2HeaderBox(), + ContiguousCodestreamBox()] + codestream = self.get_codestream() + height = codestream.segment[1].ysiz + width = codestream.segment[1].xsiz + num_components = len(codestream.segment[1].xrsiz) + if num_components < 3: + colorspace = GREYSCALE + else: + if len(self.box) == 0: + # Best guess is SRGB + colorspace = SRGB + else: + # Take whatever the first jp2 header / color specification + # says. + jp2hs = [box for box in self.box if box.box_id == 'jp2h'] + colorspace = jp2hs[0].box[1].colorspace + + boxes[2].box = [ImageHeaderBox(height=height, width=width, + num_components=num_components), + ColourSpecificationBox(colorspace=colorspace)] + + return boxes + def read(self, **kwargs): """Read a JPEG 2000 image. @@ -696,8 +749,9 @@ class Jp2k(Jp2kBox): (first_row, first_col, last_row, last_col) tile : int, optional Number of tile to decode. - no_cxform : bool - Whether or not to apply intended color transforms. + ignore_pclr_cmap_cdef : bool + Whether or not to ignore the pclr, cmap, or cdef boxes during any + color transformation. Defaults to False. verbose : bool, optional Print informational messages produced by the OpenJPEG library. @@ -752,7 +806,8 @@ class Jp2k(Jp2kBox): msg += "the read_bands method instead." raise RuntimeError(msg) - def _read_openjpeg(self, rlevel=0, no_cxform=False, verbose=False): + def _read_openjpeg(self, rlevel=0, ignore_pclr_cmap_cdef=False, + verbose=False): """Read a JPEG 2000 image using libopenjpeg. Parameters @@ -760,8 +815,9 @@ class Jp2k(Jp2kBox): rlevel : int, optional Factor by which to rlevel output resolution. Use -1 to get the lowest resolution thumbnail. - no_cxform : bool - Whether or not to apply intended color transforms. + ignore_pclr_cmap_cdef : bool + Whether or not to ignore the pclr, cmap, or cdef boxes during any + color transformation. Defaults to False. verbose : bool, optional Print informational messages produced by the OpenJPEG library. @@ -786,9 +842,9 @@ class Jp2k(Jp2kBox): # -1 is shorthand for the largest rlevel rlevel = max_rlevel elif rlevel < -1 or rlevel > max_rlevel: - msg = "rlevel must be in the range [-1, {0}] for this image." - msg = msg.format(max_rlevel) - raise IOError(msg) + msg = "rlevel must be in the range [-1, {0}] for this image." + msg = msg.format(max_rlevel) + raise IOError(msg) with ExitStack() as stack: try: @@ -797,7 +853,7 @@ class Jp2k(Jp2kBox): dparameters = opj.DecompressionParametersType() opj.set_default_decoder_parameters(ctypes.byref(dparameters)) - if no_cxform is True: + if ignore_pclr_cmap_cdef is True: # Return raw codestream components. dparameters.flags |= 1 @@ -845,7 +901,7 @@ class Jp2k(Jp2kBox): return data def _read_openjp2(self, rlevel=0, layer=0, area=None, tile=None, - verbose=False, no_cxform=False): + verbose=False, ignore_pclr_cmap_cdef=False): """Read a JPEG 2000 image using libopenjp2. Parameters @@ -875,7 +931,8 @@ class Jp2k(Jp2kBox): """ self._subsampling_sanity_check() - dparam = self._populate_dparam(layer, rlevel, area, tile, no_cxform) + dparam = self._populate_dparam(layer, rlevel, area, tile, + ignore_pclr_cmap_cdef) with ExitStack() as stack: if hasattr(opj2.OPENJP2, @@ -919,7 +976,8 @@ class Jp2k(Jp2kBox): return img_array - def _populate_dparam(self, layer, rlevel, area, tile, no_cxform): + def _populate_dparam(self, layer, rlevel, area, tile, + ignore_pclr_cmap_cdef): """Populate decompression structure with appropriate input parameters. Parameters @@ -933,8 +991,9 @@ class Jp2k(Jp2kBox): (first_row, first_col, last_row, last_col) tile : int Number of tile to decode. - no_cxform : bool - Whether or not to apply intended color transforms. + ignore_pclr_cmap_cdef : bool + Whether or not to ignore the pclr, cmap, or cdef boxes during any + color transformation. Defaults to False. Returns ------- @@ -972,14 +1031,14 @@ class Jp2k(Jp2kBox): dparam.tile_index = tile dparam.nb_tile_to_decode = 1 - if no_cxform is True: + if ignore_pclr_cmap_cdef is True: # Return raw codestream components. dparam.flags |= 1 return dparam def read_bands(self, rlevel=0, layer=0, area=None, tile=None, - verbose=False, no_cxform=False): + verbose=False, ignore_pclr_cmap_cdef=False): """Read a JPEG 2000 image. The only time you should use this method is when the image has @@ -997,8 +1056,9 @@ class Jp2k(Jp2kBox): (first_row, first_col, last_row, last_col) tile : int, optional Number of tile to decode. - no_cxform : bool - Whether or not to apply intended color transforms. + ignore_pclr_cmap_cdef : bool + Whether or not to ignore the pclr, cmap, or cdef boxes during any + color transformation. Defaults to False. verbose : bool, optional Print informational messages produced by the OpenJPEG library. @@ -1028,7 +1088,7 @@ class Jp2k(Jp2kBox): "of OpenJP2 installed before using " "this functionality.") - dparam = self._populate_dparam(layer, rlevel, area, tile, no_cxform) + dparam = self._populate_dparam(layer, rlevel, area, tile, ignore_pclr_cmap_cdef) with ExitStack() as stack: if hasattr(opj2.OPENJP2, @@ -1090,7 +1150,7 @@ class Jp2k(Jp2kBox): >>> codestream = jp2.get_codestream() >>> print(codestream.segment[1]) SIZ marker segment @ (3233, 47) - Profile: 2 + Profile: no profile Reference Grid Height, Width: (1456 x 2592) Vertical, Horizontal Reference Grid Offset: (0 x 0) Reference Tile Height, Width: (1456 x 2592) @@ -1190,12 +1250,26 @@ def _validate_jp2_box_sequence(boxes): if boxes[1].brand == 'jpx ': _validate_jpx_box_sequence(boxes) else: + # Validate the JP2 box IDs. count = _collect_box_count(boxes) - for id in count.keys(): - if id not in JP2_IDS: + for box_id in count.keys(): + if box_id not in JP2_IDS: msg = "The presence of a '{0}' box requires that the file type " msg += "brand be set to 'jpx '." - raise IOError(msg.format(id)) + raise IOError(msg.format(box_id)) + + _validate_jp2_colr(boxes) + +def _validate_jp2_colr(boxes): + """ + Validate JP2 requirements on colour specification boxes. + """ + lst = [box for box in boxes if box.box_id == 'jp2h'] + jp2h = lst[0] + for colr in [box for box in jp2h.box if box.box_id == 'colr']: + if colr.approximation != 0: + msg = "A JP2 colr box cannot have a non-zero approximation field." + raise IOError(msg) def _validate_jpx_box_sequence(boxes): """Run through series of tests for JPX box legality.""" @@ -1296,7 +1370,7 @@ def _check_jp2h_child_boxes(boxes, parent_box_name): """Certain boxes can only reside in the JP2 header.""" box_ids = set([box.box_id for box in boxes]) intersection = box_ids.intersection(JP2H_CHILDREN) - if len(intersection) > 0 and parent_box_name != 'jp2h': + if len(intersection) > 0 and parent_box_name not in ['jp2h', 'jpch']: msg = "A '{0}' box can only be nested in a JP2 header box." raise IOError(msg.format(list(intersection)[0])) diff --git a/glymur/test/fixtures.py b/glymur/test/fixtures.py index 8e8deb8..1ee7aa1 100644 --- a/glymur/test/fixtures.py +++ b/glymur/test/fixtures.py @@ -412,7 +412,7 @@ Contiguous Codestream Box (jp2c) @ (3223, 1132296) Main header: SOC marker segment @ (3231, 0) SIZ marker segment @ (3233, 47) - Profile: 2 + Profile: no profile Reference Grid Height, Width: (1456 x 2592) Vertical, Horizontal Reference Grid Offset: (0 x 0) Reference Tile Height, Width: (1456 x 2592) @@ -477,7 +477,7 @@ Contiguous Codestream Box (jp2c) @ (3223, 1132296) Main header: SOC marker segment @ (3231, 0) SIZ marker segment @ (3233, 47) - Profile: 2 + Profile: no profile Reference Grid Height, Width: (1456 x 2592) Vertical, Horizontal Reference Grid Offset: (0 x 0) Reference Tile Height, Width: (1456 x 2592) @@ -580,8 +580,9 @@ file1_xml = """XML Box (xml ) @ (36, 439) issue_182_cmap = """Component Mapping Box (cmap) @ (130, 24) Component 0 ==> palette column 0 - Component 1 ==> palette column 0 - Component 2 ==> 2""" + Component 0 ==> palette column 1 + Component 0 ==> palette column 2 + Component 0 ==> palette column 3""" issue_183_colr = """Colour Specification Box (colr) @ (62, 12) Method: restricted ICC profile @@ -610,3 +611,14 @@ issue_186_progression_order = """COD marker segment @ (174, 12) Vertically stripe causal context: False Predictable termination: False Segmentation symbols: False""" + +# Cinema 2K profile +cinema2k_profile = """SIZ marker segment @ (2, 47) + Profile: Cinema 2K + Reference Grid Height, Width: (1080 x 1920) + Vertical, Horizontal Reference Grid Offset: (0 x 0) + Reference Tile Height, Width: (1080 x 1920) + Vertical, Horizontal Reference Tile Offset: (0 x 0) + Bitdepth: (12, 12, 12) + Signed: (False, False, False) + Vertical, Horizontal Subsampling: ((1, 1), (1, 1), (1, 1))""" diff --git a/glymur/test/test_codestream.py b/glymur/test/test_codestream.py index 28ea0f8..8bac19c 100644 --- a/glymur/test/test_codestream.py +++ b/glymur/test/test_codestream.py @@ -51,6 +51,28 @@ class TestCodestreamOpjData(unittest.TestCase): def tearDown(self): pass + def test_bad_rsiz(self): + """Should warn if RSIZ is bad. Issue196""" + filename = opj_data_file('input/nonregression/edf_c2_1002767.jp2') + if sys.hexversion < 0x03000000: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + j = Jp2k(filename) + else: + with self.assertWarns(UserWarning): + j = Jp2k(filename) + + def test_bad_wavelet_transform(self): + """Should warn if wavelet transform is bad. Issue195""" + filename = opj_data_file('input/nonregression/edf_c2_10025.jp2') + if sys.hexversion < 0x03000000: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + j = Jp2k(filename) + else: + with self.assertWarns(UserWarning): + j = Jp2k(filename) + def test_invalid_progression_order(self): """Should still be able to parse even if prog order is invalid.""" jfile = opj_data_file('input/nonregression/2977.pdf.asan.67.2198.jp2') diff --git a/glymur/test/test_icc.py b/glymur/test/test_icc.py index 0ef166b..c49055e 100644 --- a/glymur/test/test_icc.py +++ b/glymur/test/test_icc.py @@ -9,6 +9,7 @@ import datetime import os import sys import unittest +import warnings import numpy as np @@ -30,7 +31,10 @@ class TestICC(unittest.TestCase): def test_file5(self): """basic ICC profile""" filename = opj_data_file('input/conformance/file5.jp2') - j = Jp2k(filename) + with warnings.catch_warnings(): + # The file has a bad compatibility list entry. Not important here. + warnings.simplefilter("ignore") + j = Jp2k(filename) profile = j.box[2].box[1].icc_profile self.assertEqual(profile['Size'], 546) self.assertEqual(profile['Preferred CMM Type'], 0) diff --git a/glymur/test/test_jp2box.py b/glymur/test/test_jp2box.py index 5dedbcc..1a00715 100644 --- a/glymur/test/test_jp2box.py +++ b/glymur/test/test_jp2box.py @@ -58,6 +58,23 @@ class TestDataEntryURL(unittest.TestCase): def setUp(self): self.jp2file = glymur.data.nemo() + def test_wrap_greyscale(self): + """A single component should be wrapped as GREYSCALE.""" + j = Jp2k(self.jp2file) + data = j.read() + red = data[:, :, 0] + + # Write it back out as a raw codestream. + with tempfile.NamedTemporaryFile(suffix=".j2k") as tfile1: + j2k = glymur.Jp2k(tfile1.name, 'wb') + j2k.write(data[:, :, 0]) + + # Ok, now rewrap it as JP2. The colorspace should be GREYSCALE. + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile2: + jp2 = j2k.wrap(tfile2.name) + self.assertEqual(jp2.box[2].box[1].colorspace, + glymur.core.GREYSCALE) + def test_basic_url(self): """Just your most basic URL box.""" # Wrap our j2k file in a JP2 box along with an interior url box. @@ -340,6 +357,7 @@ class TestChannelDefinition(unittest.TestCase): with self.assertRaises((IOError, OSError)): j2k.wrap(tfile.name, boxes=boxes) + @unittest.skipIf(sys.hexversion < 0x03000000, "Needs unittest in 3.x.") def test_bad_type(self): """Channel types are limited to 0, 1, 2, 65535 Should reject if not all of index, channel_type, association the @@ -347,17 +365,18 @@ class TestChannelDefinition(unittest.TestCase): """ channel_type = (COLOR, COLOR, 3) association = (RED, GREEN, BLUE) - with self.assertRaises(IOError): + with self.assertWarns(UserWarning): glymur.jp2box.ChannelDefinitionBox(channel_type=channel_type, association=association) + @unittest.skipIf(sys.hexversion < 0x03000000, "Needs unittest in 3.x.") def test_wrong_lengths(self): """Should reject if not all of index, channel_type, association the same length. """ channel_type = (COLOR, COLOR) association = (RED, GREEN, BLUE) - with self.assertRaises(IOError): + with self.assertWarns(UserWarning): glymur.jp2box.ChannelDefinitionBox(channel_type=channel_type, association=association) @@ -373,14 +392,19 @@ class TestFileTypeBox(unittest.TestCase): def test_brand_unknown(self): """A ftyp box brand must be 'jp2 ' or 'jpx '.""" - ftyp = glymur.jp2box.FileTypeBox(brand='jp3') + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ftyp = glymur.jp2box.FileTypeBox(brand='jp3') with self.assertRaises(IOError): with tempfile.TemporaryFile() as tfile: ftyp.write(tfile) def test_cl_entry_unknown(self): """A ftyp box cl list can only contain 'jp2 ', 'jpx ', or 'jpxb'.""" - ftyp = glymur.jp2box.FileTypeBox(compatibility_list=['jp3']) + with warnings.catch_warnings(): + # Bad compatibility list item. + warnings.simplefilter("ignore") + ftyp = glymur.jp2box.FileTypeBox(compatibility_list=['jp3']) with self.assertRaises(IOError): with tempfile.TemporaryFile() as tfile: ftyp.write(tfile) @@ -429,6 +453,18 @@ class TestColourSpecificationBox(unittest.TestCase): with self.assertRaises(IOError): j2k.wrap(tfile.name, boxes=boxes) + @unittest.skipIf(os.name == "nt", "Temporary file issue on window.") + def test_bad_approx_jp2_field(self): + """JP2 has requirements for approx field""" + j2k = Jp2k(self.j2kfile) + boxes = [self.jp2b, self.ftyp, self.jp2h, self.jp2c] + colr = ColourSpecificationBox(colorspace=glymur.core.SRGB, + approximation=1) + boxes[2].box = [self.ihdr, colr] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises(IOError): + j2k.wrap(tfile.name, boxes=boxes) + def test_default_colr(self): """basic colr instantiation""" colr = ColourSpecificationBox(colorspace=glymur.core.SRGB) @@ -438,27 +474,30 @@ class TestColourSpecificationBox(unittest.TestCase): self.assertEqual(colr.colorspace, glymur.core.SRGB) self.assertIsNone(colr.icc_profile) + @unittest.skipIf(sys.hexversion < 0x03030000, "Requires 3.3+") def test_colr_with_cspace_and_icc(self): """Colour specification boxes can't have both.""" - with self.assertRaises((OSError, IOError)): + with self.assertWarns(UserWarning): colorspace = glymur.core.SRGB rawb = b'\x01\x02\x03\x04' glymur.jp2box.ColourSpecificationBox(colorspace=colorspace, icc_profile=rawb) + @unittest.skipIf(sys.hexversion < 0x03030000, "Requires 3.3+") def test_colr_with_bad_method(self): """colr must have a valid method field""" colorspace = glymur.core.SRGB method = -1 - with self.assertRaises(IOError): + with self.assertWarns(UserWarning): glymur.jp2box.ColourSpecificationBox(colorspace=colorspace, method=method) + @unittest.skipIf(sys.hexversion < 0x03030000, "Requires 3.3+") def test_colr_with_bad_approx(self): - """colr must have a valid approximation field""" + """colr should have a valid approximation field""" colorspace = glymur.core.SRGB approx = -1 - with self.assertRaises(IOError): + with self.assertWarns(UserWarning): glymur.jp2box.ColourSpecificationBox(colorspace=colorspace, approximation=approx) @@ -484,21 +523,23 @@ class TestPaletteBox(unittest.TestCase): def tearDown(self): pass + @unittest.skipIf(sys.hexversion < 0x03000000, "Needs unittest in 3.x.") def test_mismatched_bitdepth_signed(self): """bitdepth and signed arguments must have equal length""" palette = np.array([[255, 0, 255], [0, 255, 0]], dtype=np.uint8) bps = (8, 8, 8) signed = (False, False) - with self.assertRaises(IOError): + with self.assertWarns(UserWarning): pclr = glymur.jp2box.PaletteBox(palette, bits_per_component=bps, signed=signed) + @unittest.skipIf(sys.hexversion < 0x03000000, "Needs unittest in 3.x.") def test_mismatched_signed_palette(self): """bitdepth and signed arguments must have equal length""" palette = np.array([[255, 0, 255], [0, 255, 0]], dtype=np.uint8) bps = (8, 8, 8, 8) signed = (False, False, False, False) - with self.assertRaises(IOError): + with self.assertWarns(UserWarning): pclr = glymur.jp2box.PaletteBox(palette, bits_per_component=bps, signed=signed) @@ -666,8 +707,9 @@ class TestWrap(unittest.TestCase): def test_jpx_to_jp2(self): """basic test for rewrapping a jpx file""" jpx = Jp2k(self.jpxfile) - idx = [0, 1, 3, 6] - boxes = [jpx.box[idx] for idx in [0, 1, 3, 6]] + # Use only the signature, file type, header, and 1st codestream. + lst = [0, 1, 2, 5] + boxes = [jpx.box[idx] for idx in lst] with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: jp2 = jpx.wrap(tfile.name, boxes=boxes) @@ -828,6 +870,67 @@ class TestWrap(unittest.TestCase): with self.assertRaises(IOError): j2k.wrap(tfile.name, boxes=boxes) + def test_wrap_jpx_to_jp2_with_unadorned_jpch(self): + """A JPX file rewrapped with plain jpch is not allowed.""" + with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile1: + jpx = Jp2k(self.jpxfile) + boxes = [jpx.box[0], jpx.box[1], jpx.box[2], + glymur.jp2box.ContiguousCodestreamBox()] + with self.assertRaises(IOError): + jpx.wrap(tfile1.name, boxes=boxes) + + def test_wrap_jpx_to_jp2_with_incorrect_jp2c_offset(self): + """Reject A JPX file rewrapped with bad jp2c offset.""" + with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile1: + jpx = Jp2k(self.jpxfile) + jpch = jpx.box[5] + + # The offset should be 902. + jpch.offset = 901 + jpch.length = 313274 + boxes = [jpx.box[0], jpx.box[1], jpx.box[2], jpch] + with self.assertRaises(IOError): + jpx.wrap(tfile1.name, boxes=boxes) + + def test_wrap_jpx_to_jp2_with_correctly_specified_jp2c(self): + """Accept A JPX file rewrapped with good jp2c.""" + with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile1: + jpx = Jp2k(self.jpxfile) + jpch = jpx.box[5] + + # This time get it right. + jpch.offset = 903 + jpch.length = 313274 + boxes = [jpx.box[0], jpx.box[1], jpx.box[2], jpch] + jp2 = jpx.wrap(tfile1.name, boxes=boxes) + + act_ids = [box.box_id for box in jp2.box] + exp_ids = ['jP ', 'ftyp', 'jp2h', 'jp2c'] + self.assertEqual(act_ids, exp_ids) + + act_offsets = [box.offset for box in jp2.box] + exp_offsets = [0, 12, 40, 887] + self.assertEqual(act_offsets, exp_offsets) + + act_lengths = [box.length for box in jp2.box] + exp_lengths = [12, 28, 847, 313274] + self.assertEqual(act_lengths, exp_lengths) + + def test_full_blown_jpx(self): + """Rewrap a jpx file.""" + with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile1: + jpx = Jp2k(self.jpxfile) + idx = list(range(5)) + list(range(9, 12)) + list(range(6, 9)) + [12] + boxes = [jpx.box[j] for j in idx] + jpx2 = jpx.wrap(tfile1.name, boxes=boxes) + exp_ids = [box.box_id for box in boxes] + lengths = [box.length for box in jpx.box] + exp_lengths = [lengths[j] for j in idx] + act_ids = [box.box_id for box in jpx2.box] + act_lengths = [box.length for box in jpx2.box] + self.assertEqual(exp_ids, act_ids) + self.assertEqual(exp_lengths, act_lengths) + class TestJp2Boxes(unittest.TestCase): """Tests for canonical JP2 boxes.""" diff --git a/glymur/test/test_jp2box_jpx.py b/glymur/test/test_jp2box_jpx.py index f8e9db8..7fe407a 100644 --- a/glymur/test/test_jp2box_jpx.py +++ b/glymur/test/test_jp2box_jpx.py @@ -8,18 +8,22 @@ import struct import sys import tempfile import unittest +import warnings + import lxml.etree as ET import glymur from glymur import Jp2k from glymur.jp2box import DataEntryURLBox, FileTypeBox, JPEG2000SignatureBox from glymur.jp2box import DataReferenceBox, FragmentListBox, FragmentTableBox +from glymur.jp2box import ColourSpecificationBox @unittest.skipIf(os.name == "nt", "Temporary file issue on window.") class TestJPXWrap(unittest.TestCase): """Test suite for wrapping JPX files.""" def setUp(self): + self.jpxfile = glymur.data.jpxfile() self.jp2file = glymur.data.nemo() self.j2kfile = glymur.data.goodstuff() @@ -107,6 +111,68 @@ class TestJPXWrap(unittest.TestCase): with self.assertRaises(IOError): jp2.wrap(tfile.name, boxes=boxes) + def test_jpch_jplh(self): + """Write a codestream header, compositing layer header box.""" + jp2 = Jp2k(self.jp2file) + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] + + # The ftyp box must be modified to jpx. + boxes[1].brand = 'jpx ' + boxes[1].compatibility_list = ['jp2 ', 'jpxb'] + + jpch = glymur.jp2box.CodestreamHeaderBox() + boxes.append(jpch) + jplh = glymur.jp2box.CompositingLayerHeaderBox() + boxes.append(jplh) + + with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: + jpx = jp2.wrap(tfile.name, boxes=boxes) + + self.assertEqual(jpx.box[-2].box_id, 'jpch') + self.assertEqual(jpx.box[-1].box_id, 'jplh') + + def test_cgrp(self): + """Write a color group box.""" + jp2 = Jp2k(self.jp2file) + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] + + # The ftyp box must be modified to jpx. + boxes[1].brand = 'jpx ' + boxes[1].compatibility_list = ['jp2 ', 'jpxb'] + + colr_rgb = ColourSpecificationBox(colorspace=glymur.core.SRGB) + colr_gr = ColourSpecificationBox(colorspace=glymur.core.GREYSCALE) + box = [colr_rgb, colr_gr] + + cgrp = glymur.jp2box.ColourGroupBox(box=box) + boxes.append(cgrp) + + with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: + jpx = jp2.wrap(tfile.name, boxes=boxes) + + self.assertEqual(jpx.box[-1].box_id, 'cgrp') + self.assertEqual(jpx.box[-1].box[0].box_id, 'colr') + self.assertEqual(jpx.box[-1].box[1].box_id, 'colr') + + def test_cgrp_neg(self): + """Can't write a cgrp with anything but colr sub boxes""" + jp2 = Jp2k(self.jp2file) + boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] + + # The ftyp box must be modified to jpx. + boxes[1].brand = 'jpx ' + boxes[1].compatibility_list = ['jp2 ', 'jpxb'] + + lblb = glymur.jp2box.LabelBox("Just a test") + box = [lblb] + + cgrp = glymur.jp2box.ColourGroupBox(box=box) + boxes.append(cgrp) + + with tempfile.NamedTemporaryFile(suffix=".jpx") as tfile: + with self.assertRaises(IOError): + jpx = jp2.wrap(tfile.name, boxes=boxes) + def test_ftbl(self): """Write a fragment table box.""" # Add a negative test where offset < 0 @@ -207,13 +273,14 @@ class TestJPXWrap(unittest.TestCase): with self.assertRaises(IOError): jp2.wrap(tfile.name, boxes=boxes) + @unittest.skipIf(sys.hexversion < 0x03000000, "Needs unittest in 3.x.") def test_deurl_child_of_dtbl(self): """Data reference boxes can only contain data entry url boxes.""" jp2 = Jp2k(self.jp2file) boxes = [jp2.box[idx] for idx in [0, 1, 2, 4]] ftyp = glymur.jp2box.FileTypeBox() - with self.assertRaises(IOError): + with self.assertWarns(UserWarning): dref = glymur.jp2box.DataReferenceBox([ftyp]) # Try to get around it by appending the ftyp box after creation. @@ -337,7 +404,9 @@ class TestJPX(unittest.TestCase): offset = [89] length = [1132288] reference = [0, 0] - flst = glymur.jp2box.FragmentListBox(offset, length, reference) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + flst = glymur.jp2box.FragmentListBox(offset, length, reference) with self.assertRaises(IOError): with tempfile.TemporaryFile() as tfile: flst.write(tfile) @@ -347,8 +416,10 @@ class TestJPX(unittest.TestCase): offset = [0] length = [1132288] reference = [0] - flst = glymur.jp2box.FragmentListBox(offset, length, reference) - with self.assertRaises(IOError): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + flst = glymur.jp2box.FragmentListBox(offset, length, reference) + with self.assertRaises((IOError, OSError)): with tempfile.TemporaryFile() as tfile: flst.write(tfile) @@ -357,14 +428,18 @@ class TestJPX(unittest.TestCase): offset = [89] length = [0] reference = [0] - flst = glymur.jp2box.FragmentListBox(offset, length, reference) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + flst = glymur.jp2box.FragmentListBox(offset, length, reference) with self.assertRaises(IOError): with tempfile.TemporaryFile() as tfile: flst.write(tfile) def test_ftbl_boxes_empty(self): """A fragment table box must have at least one child box.""" - ftbl = glymur.jp2box.FragmentTableBox() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ftbl = glymur.jp2box.FragmentTableBox() with self.assertRaises(IOError): with tempfile.TemporaryFile() as tfile: ftbl.write(tfile) @@ -377,6 +452,7 @@ class TestJPX(unittest.TestCase): with tempfile.TemporaryFile() as tfile: ftbl.write(tfile) + @unittest.skip("No such jpx file anymore.") def test_jpx_rreq_mask_length_3(self): """There are some JPX files with rreq mask length of 3.""" jpx = Jp2k(self.jpxfile) @@ -386,7 +462,7 @@ class TestJPX(unittest.TestCase): self.assertEqual(jpx.box[2].standard_flag, (5, 42, 45, 2, 18, 19, 1, 8, 12, 31, 20)) - @unittest.skipIf(sys.hexversion < 0x03000000, "Needs unittest in 3.x.") + @unittest.skip("Requires unnecessarily complicated code") def test_unknown_superbox(self): """Verify that we can handle an unknown superbox.""" with tempfile.NamedTemporaryFile(suffix='.jpx') as tfile: @@ -402,15 +478,9 @@ class TestJPX(unittest.TestCase): with self.assertWarns(UserWarning): jpx = Jp2k(tfile.name) - self.assertEqual(jpx.box[-1].box_id, 'grp ') + self.assertEqual(jpx.box[-1].box_id, b'grp ') self.assertEqual(jpx.box[-1].box[0].box_id, 'free') - def test_free_box(self): - """Verify that we can handle a free box.""" - j = Jp2k(self.jpxfile) - self.assertEqual(j.box[16].box[0].box_id, 'free') - self.assertEqual(type(j.box[16].box[0]), glymur.jp2box.FreeBox) - def test_data_reference_requires_dtbl(self): """The existance of a data reference box requires a ftbl box as well.""" flag = 0 @@ -483,17 +553,17 @@ class TestJPX(unittest.TestCase): self.assertEqual(jpx.box[-1].box[0].data_reference, (3,)) def test_nlst(self): - """Verify that we can handle a free box.""" + """Verify that we can handle a number list box.""" j = Jp2k(self.jpxfile) - self.assertEqual(j.box[16].box[1].box[0].box_id, 'nlst') - self.assertEqual(type(j.box[16].box[1].box[0]), - glymur.jp2box.NumberListBox) + nlst = j.box[12].box[0].box[0] + self.assertEqual(nlst.box_id, 'nlst') + self.assertEqual(type(nlst), glymur.jp2box.NumberListBox) # Two associations. - self.assertEqual(len(j.box[16].box[1].box[0].associations), 2) + self.assertEqual(len(nlst.associations), 2) # Codestream 0 - self.assertEqual(j.box[16].box[1].box[0].associations[0], 1 << 24) + self.assertEqual(nlst.associations[0], 1 << 24) # Compositing Layer 0 - self.assertEqual(j.box[16].box[1].box[0].associations[1], 2 << 24) + self.assertEqual(nlst.associations[1], 2 << 24) diff --git a/glymur/test/test_jp2k.py b/glymur/test/test_jp2k.py index f6eb691..6cd1ffd 100644 --- a/glymur/test/test_jp2k.py +++ b/glymur/test/test_jp2k.py @@ -63,6 +63,24 @@ class TestJp2k(unittest.TestCase): def tearDown(self): pass + def test_no_cxform_pclr_jpx(self): + """Indices for pclr jpxfile if no color transform""" + j = Jp2k(self.jpxfile) + rgb = j.read() + idx = j.read(ignore_pclr_cmap_cdef=True) + nr, nc = 1024, 1024 + self.assertEqual(rgb.shape, (nr, nc, 3)) + self.assertEqual(idx.shape, (nr, nc)) + + # Should be able to manually reconstruct the RGB image from the palette + # and indices. + palette = j.box[2].box[2].palette + rgb_from_idx = np.zeros(rgb.shape, dtype=np.uint8) + for r in np.arange(nr): + for c in np.arange(nc): + rgb_from_idx[r, c] = palette[idx[r, c]] + np.testing.assert_array_equal(rgb, rgb_from_idx) + def test_repr(self): """Verify that results of __repr__ are eval-able.""" j = Jp2k(self.j2kfile) @@ -92,23 +110,6 @@ class TestJp2k(unittest.TestCase): with self.assertRaises(IOError): Jp2k(filename) - def test_no_cxform_pclr_jpx(self): - """Indices for pclr jpxfile if no color transform""" - j = Jp2k(self.jpxfile) - rgb = j.read() - idx = j.read(no_cxform=True) - self.assertEqual(rgb.shape, (1024, 1024, 3)) - self.assertEqual(idx.shape, (1024, 1024)) - - # Should be able to manually reconstruct the RGB image from the palette - # and indices. - palette = j.box[3].box[2].palette - rgb_from_idx = np.zeros(rgb.shape, dtype=np.uint8) - for r in np.arange(1024): - for c in np.arange(1024): - rgb_from_idx[r, c] = palette[idx[r, c]] - np.testing.assert_array_equal(rgb, rgb_from_idx) - def test_file_not_present(self): """Should error out if reading from a file that does not exist""" # Verify that we error out appropriately if not given an existing file @@ -758,12 +759,48 @@ class TestJp2k_2_1(unittest.TestCase): class TestJp2kOpjDataRoot(unittest.TestCase): """These tests should be run by just about all configuration.""" + def test_undecodeable_box_id(self): + """Should warn in case of undecodeable box ID but not error out.""" + filename = opj_data_file('input/nonregression/edf_c2_1013627.jp2') + if sys.hexversion < 0x03000000: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + jp2 = Jp2k(filename) + else: + with self.assertWarns(UserWarning): + jp2 = Jp2k(filename) + + # Now make sure we got all of the boxes. Ignore the last, which was + # bad. + box_ids = [box.box_id for box in jp2.box[:-1]] + self.assertEqual(box_ids, ['jP ', 'ftyp', 'jp2h', 'jp2c']) + + def test_invalid_approximation(self): + """Should warn in case of bad ftyp brand.""" + filename = opj_data_file('input/nonregression/edf_c2_1000290.jp2') + with self.assertWarns(UserWarning): + jp2 = Jp2k(filename) + + @unittest.skipIf(sys.hexversion < 0x03000000, "Test requires Python 3.3+") + def test_invalid_approximation(self): + """Should warn in case of invalid approximation.""" + filename = opj_data_file('input/nonregression/edf_c2_1015644.jp2') + with self.assertWarns(UserWarning): + jp2 = Jp2k(filename) + + @unittest.skipIf(sys.hexversion < 0x03000000, "Test requires Python 3.3+") + def test_invalid_colorspace(self): + """Should warn in case of invalid colorspace.""" + filename = opj_data_file('input/nonregression/edf_c2_1103421.jp2') + with self.assertWarns(UserWarning): + jp2 = Jp2k(filename) + def test_no_cxform_pclr_jp2(self): """Indices for pclr jpxfile if no color transform""" filename = opj_data_file('input/conformance/file9.jp2') j = Jp2k(filename) rgb = j.read() - idx = j.read(no_cxform=True) + idx = j.read(ignore_pclr_cmap_cdef=True) self.assertEqual(rgb.shape, (512, 768, 3)) self.assertEqual(idx.shape, (512, 768)) @@ -803,9 +840,12 @@ class TestJp2kOpjDataRoot(unittest.TestCase): # This file has the components physically reversed. The cmap box # tells the decoder how to order them, but this flag prevents that. filename = opj_data_file('input/conformance/file2.jp2') - j = Jp2k(filename) + with warnings.catch_warnings(): + # The file has a bad compatibility list entry. Not important here. + warnings.simplefilter("ignore") + j = Jp2k(filename) ycbcr = j.read() - crcby = j.read(no_cxform=True) + crcby = j.read(ignore_pclr_cmap_cdef=True) expected = np.zeros(ycbcr.shape, ycbcr.dtype) for k in range(crcby.shape[2]): diff --git a/glymur/test/test_opj_suite.py b/glymur/test/test_opj_suite.py index 28e9a0f..efbc794 100644 --- a/glymur/test/test_opj_suite.py +++ b/glymur/test/test_opj_suite.py @@ -327,7 +327,10 @@ class TestSuite(unittest.TestCase): def test_ETS_JP2_file1(self): jfile = opj_data_file('input/conformance/file1.jp2') - jp2k = Jp2k(jfile) + with warnings.catch_warnings(): + # Bad compatibility list item. + warnings.simplefilter("ignore") + jp2k = Jp2k(jfile) jpdata = jp2k.read() self.assertEqual(jpdata.shape, (512, 768, 3)) @@ -3114,7 +3117,10 @@ class TestSuiteDump(unittest.TestCase): def test_NR_file1_dump(self): jfile = opj_data_file('input/conformance/file1.jp2') - jp2 = Jp2k(jfile) + with warnings.catch_warnings(): + # Bad compatibility list item. + warnings.simplefilter("ignore") + jp2 = Jp2k(jfile) ids = [box.box_id for box in jp2.box] self.assertEqual(ids, ['jP ', 'ftyp', 'xml ', 'jp2h', 'xml ', @@ -3441,7 +3447,7 @@ class TestSuiteDump(unittest.TestCase): # Image header self.assertEqual(jp2.box[2].box[0].height, 400) self.assertEqual(jp2.box[2].box[0].width, 700) - self.assertEqual(jp2.box[2].box[0].num_components, 1) + self.assertEqual(jp2.box[2].box[0].num_components, 3) self.assertEqual(jp2.box[2].box[0].bits_per_component, 8) self.assertEqual(jp2.box[2].box[0].signed, False) self.assertEqual(jp2.box[2].box[0].compression, 7) # wavelet @@ -5476,7 +5482,7 @@ class TestSuiteDump(unittest.TestCase): jp2 = Jp2k(jfile) ids = [box.box_id for box in jp2.box] - self.assertEqual(ids, ['jP ', 'ftyp', 'jp2h', 'XML ', 'jp2c']) + self.assertEqual(ids, ['jP ', 'ftyp', 'jp2h', b'XML ', 'jp2c']) ids = [box.box_id for box in jp2.box[2].box] self.assertEqual(ids, ['ihdr', 'colr']) @@ -5830,9 +5836,9 @@ class TestSuiteDump(unittest.TestCase): # Jp2 Header # Component mapping box - self.assertEqual(jp2.box[3].box[3].component_index, (0, 1, 2)) - self.assertEqual(jp2.box[3].box[3].mapping_type, (1, 1, 0)) - self.assertEqual(jp2.box[3].box[3].palette_index, (0, 0, 1)) + self.assertEqual(jp2.box[3].box[3].component_index, (0, 0, 0, 0)) + self.assertEqual(jp2.box[3].box[3].mapping_type, (1, 1, 1, 1)) + self.assertEqual(jp2.box[3].box[3].palette_index, (0, 1, 2, 3)) c = jp2.box[4].main_header diff --git a/glymur/test/test_printing.py b/glymur/test/test_printing.py index fe09583..5286419 100644 --- a/glymur/test/test_printing.py +++ b/glymur/test/test_printing.py @@ -50,6 +50,14 @@ class TestPrinting(unittest.TestCase): def tearDown(self): pass + def test_version_info(self): + """Should be able to print(glymur.version.info)""" + with patch('sys.stdout', new=StringIO()) as fake_out: + print(glymur.version.info) + actual = fake_out.getvalue().strip() + + self.assertTrue(True) + @unittest.skipIf(sys.hexversion < 0x03000000, "Needs unittest in 3.x.") def test_unknown_superbox(self): """Verify that we can handle an unknown superbox.""" @@ -60,6 +68,9 @@ class TestPrinting(unittest.TestCase): # Add the header for an unknwon superbox. write_buffer = struct.pack('>I4s', 20, 'grp '.encode()) tfile.write(write_buffer) + + # Add a free box inside of it. We won't be able to identify it, + # but it's there. write_buffer = struct.pack('>I4sI', 12, 'free'.encode(), 0) tfile.write(write_buffer) tfile.flush() @@ -70,8 +81,7 @@ class TestPrinting(unittest.TestCase): with patch('sys.stdout', new=StringIO()) as fake_out: print(jpx.box[-1]) actual = fake_out.getvalue().strip() - lines = ['Unknown Box (grp ) @ (695609, 20)', - ' Free Box (free) @ (695617, 12)'] + lines = ["Unknown Box (b'grp ') @ (1399071, 20)"] expected = '\n'.join(lines) self.assertEqual(actual, expected) @@ -341,7 +351,7 @@ class TestPrinting(unittest.TestCase): actual = fake_out.getvalue().strip() lines = ['SIZ marker segment @ (3233, 47)', - ' Profile: 2', + ' Profile: no profile', ' Reference Grid Height, Width: (1456 x 2592)', ' Vertical, Horizontal Reference Grid Offset: (0 x 0)', ' Reference Tile Height, Width: (1456 x 2592)', @@ -414,7 +424,7 @@ class TestPrinting(unittest.TestCase): lst = ['Codestream:', ' SOC marker segment @ (3231, 0)', ' SIZ marker segment @ (3233, 47)', - ' Profile: 2', + ' Profile: no profile', ' Reference Grid Height, Width: (1456 x 2592)', ' Vertical, Horizontal Reference Grid Offset: (0 x 0)', ' Reference Tile Height, Width: (1456 x 2592)', @@ -645,6 +655,43 @@ class TestPrintingOpjDataRoot(unittest.TestCase): def tearDown(self): pass + def test_cinema_profile(self): + """Should print Cinema 2K when the profile is 3.""" + filename = opj_data_file('input/nonregression/_00042.j2k') + j2k = Jp2k(filename) + with patch('sys.stdout', new=StringIO()) as fake_out: + c = j2k.get_codestream() + print(c.segment[1]) + actual = fake_out.getvalue().strip() + self.assertEqual(actual, fixtures.cinema2k_profile) + + def test_invalid_colorspace(self): + """An invalid colorspace shouldn't cause an error.""" + filename = opj_data_file('input/nonregression/edf_c2_1103421.jp2') + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + jp2 = Jp2k(filename) + with patch('sys.stdout', new=StringIO()) as fake_out: + print(jp2) + + def test_bad_rsiz(self): + """Should still be able to print if rsiz is bad, issue196""" + filename = opj_data_file('input/nonregression/edf_c2_1002767.jp2') + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + j = Jp2k(filename) + with patch('sys.stdout', new=StringIO()) as fake_out: + print(j) + + def test_bad_wavelet_transform(self): + """Should still be able to print if wavelet xform is bad, issue195""" + filename = opj_data_file('input/nonregression/edf_c2_10025.jp2') + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + j = Jp2k(filename) + with patch('sys.stdout', new=StringIO()) as fake_out: + print(j) + def test_invalid_progression_order(self): """Should still be able to print even if prog order is invalid.""" jfile = opj_data_file('input/nonregression/2977.pdf.asan.67.2198.jp2') @@ -818,7 +865,10 @@ class TestPrintingOpjDataRoot(unittest.TestCase): def test_channel_definition(self): """verify printing of cdef box""" filename = opj_data_file('input/conformance/file2.jp2') - j = glymur.Jp2k(filename) + with warnings.catch_warnings(): + # Bad compatibility list item. + warnings.simplefilter("ignore") + j = glymur.Jp2k(filename) with patch('sys.stdout', new=StringIO()) as fake_out: print(j.box[2].box[2]) actual = fake_out.getvalue().strip() diff --git a/glymur/version.py b/glymur/version.py index 4bce9dc..409db9a 100644 --- a/glymur/version.py +++ b/glymur/version.py @@ -9,9 +9,11 @@ License: MIT """ import sys -import numpy as np from distutils.version import LooseVersion +import lxml.etree +import numpy as np + from .lib import openjpeg as opj from .lib import openjp2 as opj2 @@ -48,10 +50,12 @@ OPENJPEG {openjpeg} Python {python} sys.platform {platform} sys.maxsize {maxsize} +lxml {elxml} numpy {numpy} """.format(glymur=version, openjpeg=openjpeg_version, python=sys.version, platform=sys.platform, maxsize=sys.maxsize, + elxml=lxml.etree.__version__, numpy=np.__version__)