diff --git a/CHANGES.txt b/CHANGES.txt index ef8ef6c..efc70fc 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,7 @@ +Jun 27, 2013 - v0.1.10 Can wrap codestreams in custom JP2 jackets. Exposing + parameter to specify multi component transform. Added a raw codestream + file. + Jun 16, 2013 - v0.1.9 Reading ICC profile headers as ordered dicts. Exif dictionaries changed to ordered dicts. Honoring XDG_CONFIG_HOME environment variable. diff --git a/docs/source/api.rst b/docs/source/api.rst index e1efdac..7584718 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -5,7 +5,7 @@ API Jp2k ---- .. autoclass:: glymur.Jp2k - :members: read, write, read_bands, get_codestream + :members: read, write, wrap, read_bands, get_codestream Individual Boxes ---------------- @@ -24,9 +24,9 @@ ColourSpecificationBox .. autoclass:: glymur.jp2box.ColourSpecificationBox :members: -ComponentDefinitionBox +ChannelDefinitionBox '''''''''''''''''''''' -.. autoclass:: glymur.jp2box.ComponentDefinitionBox +.. autoclass:: glymur.jp2box.ChannelDefinitionBox :members: ComponentMappingBox diff --git a/docs/source/how_do_i.rst b/docs/source/how_do_i.rst index 355ff14..a15b865 100644 --- a/docs/source/how_do_i.rst +++ b/docs/source/how_do_i.rst @@ -34,6 +34,91 @@ codestream box, only the main header is printed. It is possible to print >>> print(j.get_codestream()) +Add XML Metadata? +================= +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`, you can use the **wrap** method with +no box argument: :: + + >>> import glymur + >>> jfile = glymur.data.goodstuff() + >>> j2k = glymur.Jp2k(jfile) + >>> jp2 = j2k.wrap("newfile.jp2") + >>> print(jp2) + File: newfile.jp2 + JPEG 2000 Signature Box (jP ) @ (0, 12) + Signature: 0d0a870a + File Type Box (ftyp) @ (12, 20) + Brand: jp2 + Compatibility: ['jp2 '] + JP2 Header Box (jp2h) @ (32, 45) + Image Header Box (ihdr) @ (40, 22) + Size: [800 480 3] + Bitdepth: 8 + Signed: False + Compression: wavelet + Colorspace Unknown: False + Colour Specification Box (colr) @ (62, 15) + Method: enumerated colorspace + Precedence: 0 + Colorspace: sRGB + Contiguous Codestream Box (jp2c) @ (77, 115228) + Main header: + . + . (truncated) + . + +The raw codestream was wrapped in a JP2 jacket with four boxes in the outer +layer (the signature, file type, JP2 header, and contiguous codestream), with +two additional boxes (image header and color specification) contained in the +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, we'll need to specify all of the +boxes. If you already have a JP2 jacket in place, you can just reuse it, +though. Take the following example content in an XML file `favorites.xml` : :: + + + + Light Ale + + +and add it after the JP2 header box, but before the codestream box :: + + >>> boxes = jp2.box # The box attribute is the list of JP2 boxes + >>> xmlbox = glymur.jp2box.XMLBox(file='favorites.xml') + >>> boxes.insert(3, xmlbox) + >>> jp2_xml = jp2.wrap("newfile_with_xml.jp2", boxes=boxes) + >>> print(jp2_xml) + File: newfile_with_xml.jp2 + JPEG 2000 Signature Box (jP ) @ (0, 12) + Signature: 0d0a870a + File Type Box (ftyp) @ (12, 20) + Brand: jp2 + Compatibility: ['jp2 '] + JP2 Header Box (jp2h) @ (32, 45) + Image Header Box (ihdr) @ (40, 22) + Size: [800 480 3] + Bitdepth: 8 + Signed: False + Compression: wavelet + Colorspace Unknown: False + Colour Specification Box (colr) @ (62, 15) + Method: enumerated colorspace + Precedence: 0 + Colorspace: sRGB + XML Box (xml ) @ (77, 76) + + Light Ale + + + Contiguous Codestream Box (jp2c) @ (153, 115236) + Main header: + . + . (truncated) + . + Work with XMP UUIDs? ==================== The example JP2 file shipped with glymur has an XMP UUID. :: diff --git a/glymur/codestream.py b/glymur/codestream.py index 04c208f..327dd19 100644 --- a/glymur/codestream.py +++ b/glymur/codestream.py @@ -175,7 +175,7 @@ class Codestream(object): segment = self._parseGenericSegment(f, marker_id) else: - msg = 'Invalid marker id encountered at byte {0:d}' + msg = 'Invalid marker id encountered at byte {0:d} ' msg += 'in codestream: "0x{1:x}"' msg = msg.format(offset, marker_id) raise IOError(msg) diff --git a/glymur/core.py b/glymur/core.py index 73a286e..6483558 100644 --- a/glymur/core.py +++ b/glymur/core.py @@ -74,6 +74,12 @@ _color_type_map_display = { _PRE_MULTIPLIED_OPACITY: 'pre-multiplied opacity', _UNSPECIFIED: 'unspecified'} +# color channel definitions. +RED = 1 +GREEN = 2 +BLUE = 3 +GREY = 1 + # enumerated color channel associations _rgb_colorspace = {"R": 1, "G": 2, "B": 3} _greyscale_colorspace = {"Y": 1} diff --git a/glymur/data/__init__.py b/glymur/data/__init__.py index ff39c0e..bebfb08 100644 --- a/glymur/data/__init__.py +++ b/glymur/data/__init__.py @@ -1,8 +1,10 @@ """Shipping JPEG 2000 files. -There is only one JP2 file at the moment, "nemo.jp2", converted from the -original JPEG photo of the aftermath of NEMO, the nor'easter that shutdown -Boston in February of 2013. +These include: + nemo.jp2: converted from the original JPEG photo of the aftermath of NEMO, + the nor'easter that shutdown Boston in February of 2013. + goodstuff.j2k: my favorite bevorage. + """ import pkg_resources @@ -17,3 +19,15 @@ def nemo(): """ file = pkg_resources.resource_filename(__name__, "nemo.jp2") return file + + +def goodstuff(): + """Shortcut for specifying path to goodstuff.j2k. + + Returns + ------- + file : str + Platform-independent path to goodstuff.j2k. + """ + file = pkg_resources.resource_filename(__name__, "goodstuff.j2k") + return file diff --git a/glymur/data/goodstuff.j2k b/glymur/data/goodstuff.j2k new file mode 100644 index 0000000..3e3c1b6 Binary files /dev/null and b/glymur/data/goodstuff.j2k differ diff --git a/glymur/jp2box.py b/glymur/jp2box.py index d4a44e9..e0107f3 100644 --- a/glymur/jp2box.py +++ b/glymur/jp2box.py @@ -31,6 +31,7 @@ from .core import _colorspace_map_display from .core import _color_type_map_display from .core import _method_display from .core import _reader_requirements_display +from .core import * class Jp2kBox(object): @@ -59,6 +60,12 @@ class Jp2kBox(object): msg += " @ ({0}, {1})".format(self.offset, self.length) return msg + def _write(self, f): + """Must be implemented in a subclass. + """ + msg = "Not supported for {0} box.".format(self.longname) + raise NotImplementedError(msg) + def _parse_superbox(self, f): """Parse a superbox (box consisting of nothing but other boxes. @@ -159,9 +166,22 @@ class ColourSpecificationBox(Jp2kBox): ICC profile header according to ICC profile specification. If colorspace is not None, then icc_profile must be empty. """ - def __init__(self, **kwargs): - Jp2kBox.__init__(self, id='', longname='Colour Specification') + def __init__(self, method=ENUMERATED_COLORSPACE, precedence=0, + approximation=0, colorspace=None, icc_profile=None, **kwargs): + Jp2kBox.__init__(self, id='colr', longname='Colour Specification') + + if colorspace is not None and icc_profile is not None: + raise IOError("colorspace and icc_profile cannot both be set.") + if method not in (1, 2, 3, 4): + raise IOError("Invalid method.") + if approximation not in (0, 1, 2, 3, 4): + raise IOError("Invalid approximation.") self.__dict__.update(**kwargs) + self.method = method + self.precedence = precedence + self.approximation = approximation + self.colorspace = colorspace + self.icc_profile = icc_profile def __str__(self): msg = Jp2kBox.__str__(self) @@ -188,6 +208,24 @@ class ColourSpecificationBox(Jp2kBox): return msg + def _write(self, f): + """Write an Colour Specification box to file. + """ + if self.colorspace is None: + msg = "Writing Colour Specification boxes without enumerated " + msg += "colorspaces is not supported at this time." + raise NotImplementedError(msg) + length = 15 if self.icc_profile is None else 11 + len(self.icc_profile) + f.write(struct.pack('>I', length)) + f.write('colr'.encode()) + + buffer = struct.pack('>BBBI', + self.method, + self.precedence, + self.approximation, + self.colorspace) + f.write(buffer) + @staticmethod def _parse(f, id, offset, length): """Parse JPEG 2000 color specification box. @@ -354,7 +392,7 @@ class _ICCProfile(object): self.header = header -class ComponentDefinitionBox(Jp2kBox): +class ChannelDefinitionBox(Jp2kBox): """Container for component definition box information. Attributes @@ -367,29 +405,58 @@ class ComponentDefinitionBox(Jp2kBox): offset of the box from the start of the file. longname : str more verbose description of the box. - component_number : int - number of the component - component_type : int - type of the component + index : int + number of the channel + channel_type : int + type of the channel association : int - number of the associated color + index of the associated color """ - def __init__(self, **kwargs): - Jp2kBox.__init__(self, id='', longname='Component Definition') + def __init__(self, index, channel_type, association, **kwargs): + Jp2kBox.__init__(self, id='cdef', longname='Channel Definition') + if len(index) != len(channel_type) or len(index) != len(association): + msg = "Length of channel definition box inputs must be the same." + raise IOError(msg) + + # channel types must be one of 0, 1, 2, 65535 + if any(x not in [0, 1, 2, 65535] for x in channel_type): + msg = "Channel types must be in the set of\n\n" + msg += " 0 - colour image data for associated color\n" + msg += " 1 - opacity\n" + msg += " 2 - premultiplied opacity\n" + msg += " 65535 - unspecified" + raise IOError(msg) + + self.index = index + self.channel_type = channel_type + self.association = association self.__dict__.update(**kwargs) def __str__(self): msg = Jp2kBox.__str__(self) for j in range(len(self.association)): - color_type_string = _color_type_map_display[self.component_type[j]] + color_type_string = _color_type_map_display[self.channel_type[j]] if self.association[j] == 0: assn = 'whole image' else: assn = str(self.association[j]) - msg += '\n Component {0} ({1}) ==> ({2})' - msg = msg.format(self.component_number[j], color_type_string, assn) + msg += '\n Channel {0} ({1}) ==> ({2})' + msg = msg.format(self.index[j], color_type_string, assn) return msg + def _write(self, f): + """Write a channel definition box to file. + """ + N = len(self.association) + f.write(struct.pack('>I', 8 + 2 + N * 6)) + f.write('cdef'.encode('utf-8')) + f.write(struct.pack('>H', N)) + for j in range(N): + f.write(struct.pack('>' + 'H' * 3, + self.index[j], + self.channel_type[j], + self.association[j])) + @staticmethod def _parse(f, id, offset, length): """Parse component definition box. @@ -418,17 +485,17 @@ class ComponentDefinitionBox(Jp2kBox): buffer = f.read(2) N, = struct.unpack('>H', buffer) - component_number = [] - component_type = [] + index = [] + chan_type = [] association = [] buffer = f.read(N * 6) data = struct.unpack('>' + 'HHH' * N, buffer) - kwargs['component_number'] = data[0:N * 6:3] - kwargs['component_type'] = data[1:N * 6:3] - kwargs['association'] = data[2:N * 6:3] + index = data[0:N * 6:3] + channel_type = data[1:N * 6:3] + association = data[2:N * 6:3] - box = ComponentDefinitionBox(**kwargs) + box = ChannelDefinitionBox(index, channel_type, association, **kwargs) return box @@ -524,9 +591,10 @@ class ContiguousCodestreamBox(Jp2kBox): List of segments in the codestream header. """ - def __init__(self, **kwargs): + def __init__(self, main_header=[], **kwargs): Jp2kBox.__init__(self, id='jp2c', longname='Contiguous Codestream') self.__dict__.update(**kwargs) + self.main_header = main_header def __str__(self): msg = Jp2kBox.__str__(self) @@ -591,8 +659,14 @@ class FileTypeBox(Jp2kBox): List of file conformance profiles. """ def __init__(self, **kwargs): - Jp2kBox.__init__(self, id='', longname='File Type') + Jp2kBox.__init__(self, id='ftyp', longname='File Type') self.__dict__.update(**kwargs) + if 'brand' not in kwargs.keys(): + self.brand = 'jp2 ' + if 'minor_version' not in kwargs.keys(): + self.minor_version = 0 + if 'compatibility_list' not in kwargs.keys(): + self.compatibility_list = ['jp2 '] def __str__(self): lst = [Jp2kBox.__str__(self), @@ -603,6 +677,18 @@ class FileTypeBox(Jp2kBox): return msg + def _write(self, f): + """Write a File Type box to file. + """ + length = 16 + 4*len(self.compatibility_list) + f.write(struct.pack('>I', length)) + f.write('ftyp'.encode()) + f.write(self.brand.encode()) + f.write(struct.pack('>I', self.minor_version)) + + for item in self.compatibility_list: + f.write(item.encode()) + @staticmethod def _parse(f, id, offset, length): """Parse JPEG 2000 file type box. @@ -673,30 +759,68 @@ class ImageHeaderBox(Jp2kBox): Bits per component. signed : bool False if the image components are unsigned. - compression : nt + compression : int The compression type, should be 7 if JP2. - cspace_unknown : int - 0 if the color space is known and correctly specified. - ip_provided : int - 0 if the file does not contain intellectual propery rights information. + colorspace_unknown : bool + False if the color space is known and correctly specified. + ip_provided : bool + False if the file does not contain intellectual propery rights + information. """ - def __init__(self, **kwargs): - Jp2kBox.__init__(self, id='', longname='Image Header') + def __init__(self, height, width, num_components=1, signed=False, + bits_per_component=8, compression=7, colorspace_unknown=False, + ip_provided=False, **kwargs): + """ + Examples + -------- + >>> import glymur + >>> box = glymur.jp2box.ImageHeaderBox(height=512, width=256) + """ + Jp2kBox.__init__(self, id='ihdr', longname='Image Header') + self.height = height + self.width = width + self.num_components = num_components + self.signed = signed + self.bits_per_component = bits_per_component + self.compression = compression + self.colorspace_unknown = colorspace_unknown + self.ip_provided = False self.__dict__.update(**kwargs) def __str__(self): - lst = [Jp2kBox.__str__(self)] - lst.append('Size: [{0} {1} {2}]'.format(self.height, self.width, - self.num_components)) - lst.append('Bitdepth: {0}'.format(self.bits_per_component)) - lst.append('Signed: {0}'.format(self.signed)) - if self.compression == 7: - lst.append('Compression: wavelet') - if self.cspace_unknown: - lst.append('Colorspace Unknown: True') - else: - lst.append('Colorspace Unknown: False') - return '\n '.join(lst) + msg = Jp2kBox.__str__(self) + msg = "{0}" + msg += '\n Size: [{1} {2} {3}]' + msg += '\n Bitdepth: {4}' + msg += '\n Signed: {5}' + msg += '\n Compression: {6}' + msg += '\n Colorspace Unknown: {7}' + msg = msg.format(Jp2kBox.__str__(self), + self.height, self.width, self.num_components, + self.bits_per_component, + self.signed, + 'wavelet' if self.compression == 7 else 'unknown', + self.colorspace_unknown) + return msg + + def _write(self, f): + """Write an Image Header box to file. + """ + f.write(struct.pack('>I', 22)) + f.write('ihdr'.encode()) + + # signedness and bps are stored together in a single byte + bit_depth_signedness = 0x80 if self.signed else 0x00 + bit_depth_signedness |= self.bits_per_component - 1 + buffer = struct.pack('>IIHBBBB', + self.height, + self.width, + self.num_components, + bit_depth_signedness, + self.compression, + 1 if self.colorspace_unknown else 0, + 1 if self.ip_provided else 0) + f.write(buffer) @staticmethod def _parse(f, id, offset, length): @@ -724,16 +848,16 @@ class ImageHeaderBox(Jp2kBox): # Read the box information buffer = f.read(14) params = struct.unpack('>IIHBBBB', buffer) - kwargs['height'] = params[0] - kwargs['width'] = params[1] + height = params[0] + width = params[1] kwargs['num_components'] = params[2] kwargs['bits_per_component'] = (params[3] & 0x7f) + 1 kwargs['signed'] = (params[3] & 0x80) > 1 kwargs['compression'] = params[4] - kwargs['cspace_unknown'] = params[5] - kwargs['ip_provided'] = params[6] + kwargs['colorspace_unknown'] = True if params[5] else False + kwargs['ip_provided'] = True if params[6] else False - box = ImageHeaderBox(**kwargs) + box = ImageHeaderBox(height, width, **kwargs) return box @@ -818,7 +942,7 @@ class JP2HeaderBox(Jp2kBox): List of boxes contained in this superbox. """ def __init__(self, **kwargs): - Jp2kBox.__init__(self, id='', longname='JP2 Header') + Jp2kBox.__init__(self, id='jp2h', longname='JP2 Header') self.__dict__.update(**kwargs) def __str__(self): @@ -831,6 +955,21 @@ class JP2HeaderBox(Jp2kBox): msg += ''.join(strs) return msg + def _write(self, f): + """Write a JP2 Header box to file. + """ + # Write the contained boxes, then come back and write the length. + orig_pos = f.tell() + f.write(struct.pack('>I', 0)) + f.write('jp2h'.encode()) + for box in self.box: + box._write(f) + + end_pos = f.tell() + f.seek(orig_pos) + f.write(struct.pack('>I', end_pos - orig_pos)) + f.seek(end_pos) + @staticmethod def _parse(f, id, offset, length): """Parse JPEG 2000 header box. @@ -882,8 +1021,10 @@ class JPEG2000SignatureBox(Jp2kBox): Four-byte tuple identifying the file as JPEG 2000. """ def __init__(self, **kwargs): - Jp2kBox.__init__(self, id='', longname='JPEG 2000 Signature') + Jp2kBox.__init__(self, id='jP ', longname='JPEG 2000 Signature') self.__dict__.update(**kwargs) + if 'signature' not in kwargs.keys(): + self.signature = (13, 10, 135, 10) def __str__(self): msg = Jp2kBox.__str__(self) @@ -891,6 +1032,13 @@ class JPEG2000SignatureBox(Jp2kBox): msg = msg.format(*self.signature) return msg + def _write(self, f): + """Write a JPEG 2000 Signature box to file. + """ + f.write(struct.pack('>I', 12)) + f.write(self.id.encode()) + f.write(struct.pack('>BBBB', *self.signature)) + @staticmethod def _parse(f, id, offset, length): """Parse JPEG 2000 signature box. @@ -1412,19 +1560,50 @@ class XMLBox(Jp2kBox): xml : ElementTree.Element XML section. """ - def __init__(self, **kwargs): - Jp2kBox.__init__(self, id='', longname='XML') + def __init__(self, xml=None, filename=None, **kwargs): + """ + Parameters + ---------- + xml : ElementTree + An ElementTree object already existing in python. + filename : str + File from which to read XML. If filename is not None, then the xml + keyword argument must be None. + """ + Jp2kBox.__init__(self, id='xml ', longname='XML') + if filename is not None and xml is not None: + msg = "Only one of either filename or xml should be provided." + raise IOError(msg) + if filename is not None: + self.xml = ET.parse(filename) + else: + self.xml = xml self.__dict__.update(**kwargs) def __str__(self): msg = Jp2kBox.__str__(self) xml = self.xml if self.xml is not None: - msg += _pretty_print_xml(self.xml) + try: + msg += _pretty_print_xml(self.xml) + except TypeError: + msg += _pretty_print_xml(self.xml.getroot()) else: msg += '\n {0}'.format(xml) return msg + def _write(self, f): + """Write an XML box to file. + """ + try: + buffer = ET.tostring(self.xml, encoding='utf-8') + except AttributeError: + buffer = ET.tostring(self.xml.getroot(), encoding='utf-8') + + f.write(struct.pack('>I', len(buffer) + 8)) + f.write(self.id.encode()) + f.write(buffer) + @staticmethod def _parse(f, id, offset, length): """Parse XML box. @@ -2273,7 +2452,7 @@ class _ExifInteroperabilityIfd(_Ifd): # Map each box ID to the corresponding class. _box_with_id = { 'asoc': AssociationBox, - 'cdef': ComponentDefinitionBox, + 'cdef': ChannelDefinitionBox, 'cmap': ComponentMappingBox, 'colr': ColourSpecificationBox, 'jP ': JPEG2000SignatureBox, diff --git a/glymur/jp2k.py b/glymur/jp2k.py index b09271e..96ead49 100644 --- a/glymur/jp2k.py +++ b/glymur/jp2k.py @@ -16,8 +16,8 @@ import warnings import numpy as np from .codestream import Codestream -from .core import progression_order -from .jp2box import Jp2kBox +from .core import * +from .jp2box import * from .lib import openjp2 as opj2 _cspace_map = {'rgb': opj2._CLRSPC_SRGB, @@ -141,7 +141,7 @@ class Jp2k(Jp2kBox): def write(self, img_array, cratios=None, eph=False, psnr=None, numres=None, cbsize=None, psizes=None, grid_offset=None, sop=False, subsam=None, tilesize=None, prog=None, modesw=None, - colorspace=None, verbose=False): + colorspace=None, verbose=False, mct=None): """Write image data to a JP2/JPX/J2k file. Intended usage of the various parameters follows that of OpenJPEG's opj_compress utility. @@ -167,6 +167,9 @@ class Jp2k(Jp2kBox): If true, write SOP marker after each header packet. grid_offset : tuple, optional Offset (DY, DX) of the origin of the image in the reference grid. + mct : bool, optional + Specifies usage of the multi component transform. If not + specified, defaults to True if the colorspace is RGB. modesw : int, optional Mode switch. 1 = BYPASS(LAZY) @@ -336,6 +339,18 @@ class Jp2k(Jp2kBox): else: colorspace = _cspace_map[colorspace] + if mct is None: + if colorspace == opj2._CLRSPC_SRGB: + cparams.tcp_mct = 1 + else: + cparams.tcp_mct = 0 + else: + if mct and colorspace == opj2._CLRSPC_GRAY: + msg = "Cannot specify usage of the multi component transform " + msg += "if the colorspace is gray." + raise IOError(msg) + cparams.tcp_mct = 1 if mct else 0 + if img_array.dtype == np.uint8: comp_prec = 8 elif img_array.dtype == np.uint16: @@ -372,12 +387,6 @@ class Jp2k(Jp2kBox): src = layer.ctypes.data ctypes.memmove(dest, src, layer.nbytes) - # set multi-component transform? - if image.contents.numcomps == 3: - cparams.tcp_mct = 1 - else: - cparams.tcp_mct = 0 - codec = opj2._create_compress(codec_fmt) if verbose: @@ -398,6 +407,143 @@ class Jp2k(Jp2kBox): self._parse() + def wrap(self, filename, boxes=None): + """Write the codestream back out to file, wrapped in new JP2 jacket. + + Parameters + ---------- + filename : str + JP2 file to be created from a raw codestream. + boxes : list + 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. + + Returns + ------- + jp2 : Jp2k object + Newly wrapped Jp2k object. + + Examples + -------- + >>> import glymur, tempfile + >>> jfile = glymur.data.goodstuff() + >>> j2k = glymur.Jp2k(jfile) + >>> tfile = tempfile.NamedTemporaryFile(suffix='jp2') + >>> jp2 = j2k.wrap(tfile.name) + """ + if boxes is None: + # Try to create a reasonable default. + boxes = [JPEG2000SignatureBox(), + FileTypeBox(), + JP2HeaderBox(), + ContiguousCodestreamBox()] + c = self.get_codestream() + height = c.segment[1].Ysiz + width = c.segment[1].Xsiz + num_components = len(c.segment[1].XRsiz) + boxes[2].box = [ImageHeaderBox(height=height, + width=width, + num_components=num_components), + ColourSpecificationBox(colorspace=SRGB)] + + # Check for a bad sequence of boxes. + # 1st two boxes must be 'jP ' and 'ftyp' + if boxes[0].id != 'jP ' or boxes[1].id != 'ftyp': + msg = "The first box must be the signature box and the second " + msg += "must be the file type box." + raise IOError(msg) + + # jp2c must be preceeded by jp2h + jp2h_lst = [idx for (idx, box) in enumerate(boxes) if box.id == 'jp2h'] + jp2h_idx = jp2h_lst[0] + jp2c_lst = [idx for (idx, box) in enumerate(boxes) if box.id == 'jp2c'] + if len(jp2c_lst) == 0: + msg = "A codestream box must be defined in the outermost " + msg += "list of boxes." + raise IOError(msg) + + jp2c_idx = jp2c_lst[0] + if jp2h_idx >= jp2c_idx: + msg = "The codestream box must be preceeded by a jp2 header box." + raise IOError(msg) + + # 1st jp2 header box must be ihdr + jp2h = boxes[jp2h_idx] + if jp2h.box[0].id != 'ihdr': + msg = "The first box in the jp2 header box must be the image " + msg += "header box." + raise IOError(msg) + + # colr must be present in jp2 header box. + jp2hb = jp2h.box + colr_lst = [j for (j, box) in enumerate(jp2h.box) if box.id == 'colr'] + if len(colr_lst) == 0: + msg = "The jp2 header box must contain a color definition box." + raise IOError(msg) + colr = jp2h.box[colr_lst[0]] + + # Any cdef box must be in the jp2 header following the image header. + cdef_lst = [j for (j, box) in enumerate(boxes) if box.id == 'cdef'] + if len(cdef_lst) != 0: + msg = "Any channel defintion box must be in the JP2 header " + msg += "following the image header." + raise IOError(msg) + + cdef_lst = [j for (j, box) in enumerate(jp2h.box) if box.id == 'cdef'] + if len(cdef_lst) > 1: + msg = "Only one channel definition box is allowed in the " + msg += "JP2 header." + raise IOError(msg) + elif len(cdef_lst) == 1: + cdef = jp2h.box[cdef_lst[0]] + assn = cdef.association + typ = cdef.channel_type + index = cdef.index + if colr.colorspace == SRGB: + if any([chan + 1 not in assn or typ[chan] != 0 + for chan in [0, 1, 2]]): + msg = "All color channels must be defined in the " + msg += "channel definition box." + raise IOError(msg) + elif colr.colorspace == GREYSCALE: + if 0 not in typ: + msg = "All color channels must be defined in the " + msg += "channel definition box." + raise IOError(msg) + + with open(filename, 'wb') as ofile: + for box in boxes: + if 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.id == 'jp2c'] + jp2c = jp2c[0] + ofile.write(struct.pack('>I', jp2c.length + 8)) + 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)) + + ofile.flush() + + jp2 = Jp2k(filename) + return jp2 + def read(self, reduce=0, layer=0, area=None, tile=None, verbose=False): """Read a JPEG 2000 image. diff --git a/glymur/test/__init__.py b/glymur/test/__init__.py index 1e19d88..aec2d72 100644 --- a/glymur/test/__init__.py +++ b/glymur/test/__init__.py @@ -6,3 +6,4 @@ from .test_printing import TestPrinting as printing from .test_opj_suite import TestSuite as suite from .test_opj_suite_write import TestSuiteWrite as suitew from .test_opj_suite_neg import TestSuiteNegative as suiteneg +from .test_jp2box import TestJp2Boxes as box diff --git a/glymur/test/test_jp2box.py b/glymur/test/test_jp2box.py new file mode 100644 index 0000000..e0500f5 --- /dev/null +++ b/glymur/test/test_jp2box.py @@ -0,0 +1,604 @@ +import doctest +import tempfile +import xml.etree.cElementTree as ET +import unittest + +import numpy as np +import pkg_resources + +import glymur +from glymur import Jp2k +from glymur.jp2box import * + + +# Doc tests should be run as well. +def load_tests(loader, tests, ignore): + tests.addTests(doctest.DocTestSuite('glymur.jp2box')) + return tests + + +class TestChannelDefinition(unittest.TestCase): + + @classmethod + def setUpClass(cls): + """Need a one_plane plane image for greyscale testing.""" + j2k = Jp2k(glymur.data.goodstuff()) + data = j2k.read() + # Write the first component back out to file. + with tempfile.NamedTemporaryFile(suffix=".j2k", delete=False) as tfile: + grey_j2k = Jp2k(tfile.name, 'wb') + grey_j2k.write(data[:, :, 0]) + cls.one_plane = tfile.name + # Write the first two components back out to file. + with tempfile.NamedTemporaryFile(suffix=".j2k", delete=False) as tfile: + grey_j2k = Jp2k(tfile.name, 'wb') + grey_j2k.write(data[:, :, 0:1]) + cls.two_planes = tfile.name + # Write four components back out to file. + with tempfile.NamedTemporaryFile(suffix=".j2k", delete=False) as tfile: + rgba_jp2 = Jp2k(tfile.name, 'wb') + shape = (data.shape[0], data.shape[1], 1) + alpha = np.zeros((shape), dtype=data.dtype) + data4 = np.concatenate((data, alpha), axis=2) + rgba_jp2.write(data4) + cls.four_planes = tfile.name + + @classmethod + def tearDownClass(cls): + os.unlink(cls.one_plane) + os.unlink(cls.two_planes) + os.unlink(cls.four_planes) + + def setUp(self): + self.jp2file = glymur.data.nemo() + self.j2kfile = glymur.data.goodstuff() + + j2k = Jp2k(self.j2kfile) + c = j2k.get_codestream() + height = c.segment[1].Ysiz + width = c.segment[1].Xsiz + num_components = len(c.segment[1].XRsiz) + + self.jP = JPEG2000SignatureBox() + self.ftyp = FileTypeBox() + self.jp2h = JP2HeaderBox() + self.jp2c = ContiguousCodestreamBox() + self.ihdr = ImageHeaderBox(height=height, width=width, + num_components=num_components) + self.colr_rgb = ColourSpecificationBox(colorspace=glymur.core.SRGB) + self.colr_gr = ColourSpecificationBox(colorspace=glymur.core.GREYSCALE) + + def tearDown(self): + pass + + def test_rgb(self): + """Just regular RGB.""" + j2k = Jp2k(self.j2kfile) + cdef = glymur.jp2box.ChannelDefinitionBox(index=[0, 1, 2], + channel_type=[0, 0, 0], + association=[1, 2, 3]) + boxes = [self.ihdr, self.colr_rgb, cdef] + self.jp2h.box = boxes + boxes = [self.jP, self.ftyp, self.jp2h, self.jp2c] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + j2k.wrap(tfile.name, boxes=boxes) + + jp2 = Jp2k(tfile.name) + jp2h = jp2.box[2] + boxes = [box.id for box in jp2h.box] + self.assertEqual(boxes, ['ihdr', 'colr', 'cdef']) + self.assertEqual(jp2h.box[2].index, (0, 1, 2)) + self.assertEqual(jp2h.box[2].channel_type, (0, 0, 0)) + self.assertEqual(jp2h.box[2].association, (1, 2, 3)) + + def test_rgba(self): + """Just regular RGBA.""" + j2k = Jp2k(self.four_planes) + cdef = glymur.jp2box.ChannelDefinitionBox(index=[0, 1, 2, 3], + channel_type=[0, 0, 0, 1], + association=[1, 2, 3, 0]) + boxes = [self.ihdr, self.colr_rgb, cdef] + self.jp2h.box = boxes + boxes = [self.jP, self.ftyp, self.jp2h, self.jp2c] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + j2k.wrap(tfile.name, boxes=boxes) + + jp2 = Jp2k(tfile.name) + jp2h = jp2.box[2] + boxes = [box.id for box in jp2h.box] + self.assertEqual(boxes, ['ihdr', 'colr', 'cdef']) + self.assertEqual(jp2h.box[2].index, (0, 1, 2, 3)) + self.assertEqual(jp2h.box[2].channel_type, (0, 0, 0, 1)) + self.assertEqual(jp2h.box[2].association, (1, 2, 3, 0)) + + def test_bad_rgba(self): + """R, G, and B must be specified.""" + j2k = Jp2k(self.four_planes) + cdef = glymur.jp2box.ChannelDefinitionBox(index=[0, 1, 2, 3], + channel_type=[0, 0, 1, 1], + association=[1, 2, 3, 0]) + boxes = [self.ihdr, self.colr_rgb, cdef] + self.jp2h.box = boxes + boxes = [self.jP, self.ftyp, self.jp2h, self.jp2c] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises(IOError) as ce: + j2k.wrap(tfile.name, boxes=boxes) + + def test_grey(self): + """Just regular greyscale.""" + j2k = Jp2k(self.one_plane) + cdef = glymur.jp2box.ChannelDefinitionBox(index=[0], + channel_type=[0], + association=[1]) + boxes = [self.ihdr, self.colr_gr, cdef] + self.jp2h.box = boxes + boxes = [self.jP, self.ftyp, self.jp2h, self.jp2c] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + j2k.wrap(tfile.name, boxes=boxes) + + jp2 = Jp2k(tfile.name) + jp2h = jp2.box[2] + boxes = [box.id for box in jp2h.box] + self.assertEqual(boxes, ['ihdr', 'colr', 'cdef']) + self.assertEqual(jp2h.box[2].index, (0,)) + self.assertEqual(jp2h.box[2].channel_type, (0,)) + self.assertEqual(jp2h.box[2].association, (1,)) + + def test_grey_alpha(self): + """Just regular greyscale plus alpha.""" + j2k = Jp2k(self.two_planes) + cdef = glymur.jp2box.ChannelDefinitionBox(index=[0, 1], + channel_type=[0, 1], + association=[1, 0]) + boxes = [self.ihdr, self.colr_gr, cdef] + self.jp2h.box = boxes + boxes = [self.jP, self.ftyp, self.jp2h, self.jp2c] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + j2k.wrap(tfile.name, boxes=boxes) + + jp2 = Jp2k(tfile.name) + jp2h = jp2.box[2] + boxes = [box.id for box in jp2h.box] + self.assertEqual(boxes, ['ihdr', 'colr', 'cdef']) + self.assertEqual(jp2h.box[2].index, (0, 1)) + self.assertEqual(jp2h.box[2].channel_type, (0, 1)) + self.assertEqual(jp2h.box[2].association, (1, 0)) + + def test_bad_grey_alpha(self): + """A greyscale image with alpha layer must specify Y""" + j2k = Jp2k(self.two_planes) + + # This cdef box + cdef = glymur.jp2box.ChannelDefinitionBox(index=[0, 1], + channel_type=[1, 1], + association=[0, 1]) + boxes = [self.ihdr, self.colr_gr, cdef] + self.jp2h.box = boxes + boxes = [self.jP, self.ftyp, self.jp2h, self.jp2c] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises((OSError, IOError)) as ce: + j2k.wrap(tfile.name, boxes=boxes) + + def test_only_one_cdef_in_jp2_header(self): + """There can only be one channel definition box in the jp2 header.""" + j2k = Jp2k(self.j2kfile) + + cdef = glymur.jp2box.ChannelDefinitionBox(index=[0, 1, 2], + channel_type=[0, 0, 0], + association=[1, 2, 3]) + + boxes = [self.ihdr, cdef, self.colr_rgb, cdef] + self.jp2h.box = boxes + + boxes = [self.jP, self.ftyp, self.jp2h, self.jp2c] + + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises(IOError): + j2k.wrap(tfile.name, boxes=boxes) + + def test_not_in_jp2_header(self): + j2k = Jp2k(self.j2kfile) + boxes = [self.ihdr, self.colr_rgb] + self.jp2h.box = boxes + + cdef = glymur.jp2box.ChannelDefinitionBox(index=[0, 1, 2], + channel_type=[0, 0, 0], + association=[1, 2, 3]) + + boxes = [self.jP, self.ftyp, self.jp2h, cdef, self.jp2c] + + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises(IOError): + j2k.wrap(tfile.name, boxes=boxes) + + 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 + # same length. + with self.assertRaises(IOError): + box = glymur.jp2box.ChannelDefinitionBox(index=[0, 1, 2], + channel_type=[0, 0, 3], + association=[1, 2, 3]) + + def test_wrong_lengths(self): + # Should reject if not all of index, channel_type, association the + # same length. + with self.assertRaises(IOError): + box = glymur.jp2box.ChannelDefinitionBox(index=[0, 1, 2], + channel_type=[0, 0], + association=[1, 2, 3]) + + +class TestXML(unittest.TestCase): + + def setUp(self): + self.jp2file = glymur.data.nemo() + self.j2kfile = glymur.data.goodstuff() + + raw_xml = b""" + + + 1 + 2008 + 141100 + + + + + 4 + 2011 + 59900 + + + + 68 + 2011 + 13600 + + + + """ + with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tfile: + tfile.write(raw_xml) + tfile.flush() + self.xmlfile = tfile.name + + j2k = Jp2k(self.j2kfile) + c = j2k.get_codestream() + height = c.segment[1].Ysiz + width = c.segment[1].Xsiz + num_components = len(c.segment[1].XRsiz) + + self.jP = JPEG2000SignatureBox() + self.ftyp = FileTypeBox() + self.jp2h = JP2HeaderBox() + self.jp2c = ContiguousCodestreamBox() + self.ihdr = ImageHeaderBox(height=height, width=width, + num_components=num_components) + self.colr = ColourSpecificationBox(colorspace=glymur.core.SRGB) + + def tearDown(self): + os.unlink(self.xmlfile) + pass + + def test_negative_both_file_and_xml_provided(self): + """The XML should come from only one source.""" + j2k = Jp2k(self.j2kfile) + xml_object = ET.parse(self.xmlfile) + with self.assertRaises((IOError, OSError)) as ce: + xmlb = glymur.jp2box.XMLBox(filename=self.xmlfile, xml=xml_object) + + def test_basic_xml(self): + # Should be able to write an XMLBox. + j2k = Jp2k(self.j2kfile) + + self.jp2h.box = [self.ihdr, self.colr] + + the_xml = ET.fromstring('0') + xmlb = glymur.jp2box.XMLBox(xml=the_xml) + self.assertEqual(ET.tostring(xmlb.xml), + b'0') + + boxes = [self.jP, self.ftyp, self.jp2h, xmlb, self.jp2c] + + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + j2k.wrap(tfile.name, boxes=boxes) + jp2 = Jp2k(tfile.name) + self.assertEqual(jp2.box[3].id, 'xml ') + self.assertEqual(ET.tostring(jp2.box[3].xml), + b'0') + + def test_xml_from_file(self): + j2k = Jp2k(self.j2kfile) + + self.jp2h.box = [self.ihdr, self.colr] + + xmlb = glymur.jp2box.XMLBox(filename=self.xmlfile) + boxes = [self.jP, self.ftyp, self.jp2h, xmlb, self.jp2c] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + j2k.wrap(tfile.name, boxes=boxes) + jp2 = Jp2k(tfile.name) + + output_boxes = [box.id for box in jp2.box] + self.assertEqual(output_boxes, ['jP ', 'ftyp', 'jp2h', 'xml ', + 'jp2c']) + + elts = jp2.box[3].xml.findall('country') + self.assertEqual(len(elts), 3) + + neighbor = elts[1].find('neighbor') + self.assertEqual(neighbor.attrib['name'], 'Malaysia') + self.assertEqual(neighbor.attrib['direction'], 'N') + + +class TestColourSpecificationBox(unittest.TestCase): + + def setUp(self): + self.j2kfile = glymur.data.goodstuff() + + j2k = Jp2k(self.j2kfile) + c = j2k.get_codestream() + height = c.segment[1].Ysiz + width = c.segment[1].Xsiz + num_components = len(c.segment[1].XRsiz) + + self.jP = JPEG2000SignatureBox() + self.ftyp = FileTypeBox() + self.jp2h = JP2HeaderBox() + self.jp2c = ContiguousCodestreamBox() + self.ihdr = ImageHeaderBox(height=height, width=width, + num_components=num_components) + + def tearDown(self): + pass + + def test_color_specification_box_with_out_enumerated_colorspace(self): + j2k = Jp2k(self.j2kfile) + + boxes = [self.jP, self.ftyp, self.jp2h, self.jp2c] + boxes[2].box = [self.ihdr, ColourSpecificationBox(colorspace=None)] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises(NotImplementedError): + j2k.wrap(tfile.name, boxes=boxes) + + def test_missing_colr_box(self): + j2k = Jp2k(self.j2kfile) + boxes = [self.jP, self.ftyp, self.jp2h, self.jp2c] + boxes[2].box = [self.ihdr] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises(IOError): + j2k.wrap(tfile.name, boxes=boxes) + + def test_default_ColourSpecificationBox(self): + b = glymur.jp2box.ColourSpecificationBox(colorspace=glymur.core.SRGB) + self.assertEqual(b.method, glymur.core.ENUMERATED_COLORSPACE) + self.assertEqual(b.precedence, 0) + self.assertEqual(b.approximation, 0) + self.assertEqual(b.colorspace, glymur.core.SRGB) + self.assertIsNone(b.icc_profile) + + def test_ColourSpecificationBox_with_colorspace_and_icc(self): + # Colour specification boxes can't have both. + with self.assertRaises((OSError, IOError)): + colorspace = glymur.core.SRGB + icc_profile = b'\x01\x02\x03\x04' + b = glymur.jp2box.ColourSpecificationBox(colorspace=colorspace, + icc_profile=icc_profile) + + def test_ColourSpecificationBox_with_bad_method(self): + colorspace = glymur.core.SRGB + method = -1 + with self.assertRaises(IOError): + b = glymur.jp2box.ColourSpecificationBox(colorspace=colorspace, + method=method) + + def test_ColourSpecificationBox_with_bad_approximation(self): + colorspace = glymur.core.SRGB + approx = -1 + with self.assertRaises(IOError): + b = glymur.jp2box.ColourSpecificationBox(colorspace=colorspace, + approximation=approx) + + +@unittest.skipIf(glymur.lib.openjp2._OPENJP2 is None, + "Missing openjp2 library.") +class TestJp2Boxes(unittest.TestCase): + + def setUp(self): + self.j2kfile = glymur.data.goodstuff() + + def tearDown(self): + pass + + def test_default_JPEG2000SignatureBox(self): + # Should be able to instantiate a JPEG2000SignatureBox + b = glymur.jp2box.JPEG2000SignatureBox() + self.assertEqual(b.signature, (13, 10, 135, 10)) + + def test_default_FileTypeBox(self): + # Should be able to instantiate a FileTypeBox + b = glymur.jp2box.FileTypeBox() + self.assertEqual(b.brand, 'jp2 ') + self.assertEqual(b.minor_version, 0) + self.assertEqual(b.compatibility_list, ['jp2 ']) + + def test_default_ImageHeaderBox(self): + # Should be able to instantiate an image header box. + b = glymur.jp2box.ImageHeaderBox(height=512, width=256, + num_components=3) + self.assertEqual(b.height, 512) + self.assertEqual(b.width, 256) + self.assertEqual(b.num_components, 3) + self.assertEqual(b.bits_per_component, 8) + self.assertFalse(b.signed) + self.assertFalse(b.colorspace_unknown) + + def test_default_JP2HeaderBox(self): + b1 = JP2HeaderBox() + b1.box = [ImageHeaderBox(height=512, width=256), + ColourSpecificationBox(colorspace=glymur.core.GREYSCALE)] + + def test_default_ContiguousCodestreamBox(self): + b = ContiguousCodestreamBox() + self.assertEqual(b.id, 'jp2c') + self.assertEqual(b.main_header, []) + + def verify_wrapped_raw(self, jp2file): + # Shared method by at least two tests. + jp2 = Jp2k(jp2file) + self.assertEqual(len(jp2.box), 4) + + self.assertEqual(jp2.box[0].id, 'jP ') + self.assertEqual(jp2.box[0].offset, 0) + self.assertEqual(jp2.box[0].length, 12) + self.assertEqual(jp2.box[0].longname, 'JPEG 2000 Signature') + + self.assertEqual(jp2.box[1].id, 'ftyp') + self.assertEqual(jp2.box[1].offset, 12) + self.assertEqual(jp2.box[1].length, 20) + self.assertEqual(jp2.box[1].longname, 'File Type') + + self.assertEqual(jp2.box[2].id, 'jp2h') + self.assertEqual(jp2.box[2].offset, 32) + self.assertEqual(jp2.box[2].length, 45) + self.assertEqual(jp2.box[2].longname, 'JP2 Header') + + self.assertEqual(jp2.box[3].id, 'jp2c') + self.assertEqual(jp2.box[3].offset, 77) + self.assertEqual(jp2.box[3].length, 115228) + + # jp2h super box + self.assertEqual(len(jp2.box[2].box), 2) + + self.assertEqual(jp2.box[2].box[0].id, 'ihdr') + self.assertEqual(jp2.box[2].box[0].offset, 40) + self.assertEqual(jp2.box[2].box[0].length, 22) + self.assertEqual(jp2.box[2].box[0].longname, 'Image Header') + self.assertEqual(jp2.box[2].box[0].height, 800) + self.assertEqual(jp2.box[2].box[0].width, 480) + 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) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].ip_provided, False) + + self.assertEqual(jp2.box[2].box[1].id, 'colr') + self.assertEqual(jp2.box[2].box[1].offset, 62) + self.assertEqual(jp2.box[2].box[1].length, 15) + self.assertEqual(jp2.box[2].box[1].longname, 'Colour Specification') + self.assertEqual(jp2.box[2].box[1].precedence, 0) + self.assertEqual(jp2.box[2].box[1].approximation, 0) + self.assertEqual(jp2.box[2].box[1].colorspace, glymur.core.SRGB) + self.assertIsNone(jp2.box[2].box[1].icc_profile) + + def test_wrap(self): + j2k = Jp2k(self.j2kfile) + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + j2k.wrap(tfile.name) + self.verify_wrapped_raw(tfile.name) + + def test_wrap_jp2(self): + j2k = Jp2k(self.j2kfile) + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + jp2 = j2k.wrap(tfile.name) + boxes = [box.id for box in jp2.box] + self.assertEqual(boxes, ['jP ', 'ftyp', 'jp2h', 'jp2c']) + + def test_default_layout_but_with_specified_boxes(self): + j2k = Jp2k(self.j2kfile) + boxes = [JPEG2000SignatureBox(), + FileTypeBox(), + JP2HeaderBox(), + ContiguousCodestreamBox()] + c = j2k.get_codestream() + height = c.segment[1].Ysiz + width = c.segment[1].Xsiz + num_components = len(c.segment[1].XRsiz) + boxes[2].box = [ImageHeaderBox(height=height, + width=width, + num_components=num_components), + ColourSpecificationBox(colorspace=glymur.core.SRGB)] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + j2k.wrap(tfile.name, boxes=boxes) + self.verify_wrapped_raw(tfile.name) + + def test_image_header_box_not_first_in_jp2_header(self): + # The specification says that ihdr must be the first box in jp2h. + j2k = Jp2k(self.j2kfile) + boxes = [JPEG2000SignatureBox(), + FileTypeBox(), + JP2HeaderBox(), + ContiguousCodestreamBox()] + c = j2k.get_codestream() + height = c.segment[1].Ysiz + width = c.segment[1].Xsiz + num_components = len(c.segment[1].XRsiz) + boxes[2].box = [ColourSpecificationBox(colorspace=glymur.core.SRGB), + ImageHeaderBox(height=height, + width=width, + num_components=num_components)] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises(IOError): + j2k.wrap(tfile.name, boxes=boxes) + + def test_first_2_boxes_not_jP_and_ftyp(self): + j2k = Jp2k(self.j2kfile) + c = j2k.get_codestream() + height = c.segment[1].Ysiz + width = c.segment[1].Xsiz + num_components = len(c.segment[1].XRsiz) + + jP = JPEG2000SignatureBox() + ftyp = FileTypeBox() + jp2h = JP2HeaderBox() + jp2c = ContiguousCodestreamBox() + colr = ColourSpecificationBox(colorspace=glymur.core.SRGB) + ihdr = ImageHeaderBox(height=height, width=width, + num_components=num_components) + jp2h.box = [ihdr, colr] + boxes = [ftyp, jP, jp2h, jp2c] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises(IOError): + j2k.wrap(tfile.name, boxes=boxes) + + def test_jp2h_not_preceeding_jp2c(self): + j2k = Jp2k(self.j2kfile) + c = j2k.get_codestream() + height = c.segment[1].Ysiz + width = c.segment[1].Xsiz + num_components = len(c.segment[1].XRsiz) + + jP = JPEG2000SignatureBox() + ftyp = FileTypeBox() + jp2h = JP2HeaderBox() + jp2c = ContiguousCodestreamBox() + colr = ColourSpecificationBox(colorspace=glymur.core.SRGB) + ihdr = ImageHeaderBox(height=height, width=width, + num_components=num_components) + jp2h.box = [ihdr, colr] + boxes = [jP, ftyp, jp2c, jp2h] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises(IOError): + j2k.wrap(tfile.name, boxes=boxes) + + def test_missing_codestream(self): + j2k = Jp2k(self.j2kfile) + c = j2k.get_codestream() + height = c.segment[1].Ysiz + width = c.segment[1].Xsiz + num_components = len(c.segment[1].XRsiz) + + jP = JPEG2000SignatureBox() + ftyp = FileTypeBox() + jp2h = JP2HeaderBox() + ihdr = ImageHeaderBox(height=height, width=width, + num_components=num_components) + jp2h.box = [ihdr] + boxes = [jP, ftyp, jp2h] + with tempfile.NamedTemporaryFile(suffix=".jp2") as tfile: + with self.assertRaises(IOError): + j2k.wrap(tfile.name, boxes=boxes) + +if __name__ == "__main__": + unittest.main() diff --git a/glymur/test/test_jp2k.py b/glymur/test/test_jp2k.py index 031b989..d9549c5 100644 --- a/glymur/test/test_jp2k.py +++ b/glymur/test/test_jp2k.py @@ -15,7 +15,6 @@ if sys.hexversion <= 0x03030000: else: from unittest.mock import patch import warnings -from xml.etree import cElementTree as ET import numpy as np import pkg_resources @@ -77,6 +76,8 @@ class TestJp2k(unittest.TestCase): def setUp(self): self.jp2file = pkg_resources.resource_filename(glymur.__name__, "data/nemo.jp2") + self.j2kfile = pkg_resources.resource_filename(glymur.__name__, + "data/goodstuff.j2k") def tearDown(self): pass @@ -153,6 +154,27 @@ class TestJp2k(unittest.TestCase): subsetdata = j.read(area=(0, 0, 512, 512)) np.testing.assert_array_equal(tiledata, subsetdata) + def test_write_srgb_without_mct(self): + j2k = Jp2k(self.j2kfile) + expdata = j2k.read() + with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile: + ofile = Jp2k(tfile.name, 'wb') + ofile.write(expdata, mct=False) + actdata = ofile.read() + np.testing.assert_array_equal(actdata, expdata) + + c = ofile.get_codestream() + self.assertEqual(c.segment[2].SPcod[3], 0) # no mct + + def test_write_grayscale_with_mct(self): + # MCT usage makes no sense for grayscale images. + j2k = Jp2k(self.j2kfile) + expdata = j2k.read() + with tempfile.NamedTemporaryFile(suffix='.jp2') as tfile: + ofile = Jp2k(tfile.name, 'wb') + with self.assertRaises(IOError): + ofile.write(expdata[:, :, 0], mct=True) + def test_write_cprl(self): # Issue 17 j = Jp2k(self.jp2file) @@ -213,7 +235,7 @@ class TestJp2k(unittest.TestCase): self.assertEqual(jp2k.box[2].box[0].bits_per_component, 8) self.assertEqual(jp2k.box[2].box[0].signed, False) self.assertEqual(jp2k.box[2].box[0].compression, 7) - self.assertEqual(jp2k.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2k.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2k.box[2].box[0].ip_provided, False) self.assertEqual(jp2k.box[2].box[1].id, 'colr') diff --git a/glymur/test/test_opj_suite.py b/glymur/test/test_opj_suite.py index 9a7efde..4e47971 100644 --- a/glymur/test/test_opj_suite.py +++ b/glymur/test/test_opj_suite.py @@ -4116,7 +4116,7 @@ class TestSuiteDump(unittest.TestCase): self.assertEqual(jp2.box[3].box[0].bits_per_component, 8) self.assertEqual(jp2.box[3].box[0].signed, False) self.assertEqual(jp2.box[3].box[0].compression, 7) # wavelet - self.assertEqual(jp2.box[3].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[3].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[3].box[0].ip_provided, False) # Jp2 Header @@ -4158,7 +4158,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -4170,8 +4170,8 @@ class TestSuiteDump(unittest.TestCase): # Jp2 Header # Channel Definition - self.assertEqual(jp2.box[2].box[2].component_number, (0, 1, 2)) - self.assertEqual(jp2.box[2].box[2].component_type, (0, 0, 0)) # color + self.assertEqual(jp2.box[2].box[2].index, (0, 1, 2)) + self.assertEqual(jp2.box[2].box[2].channel_type, (0, 0, 0)) # color self.assertEqual(jp2.box[2].box[2].association, (3, 2, 1)) # reverse def test_NR_file3_dump(self): @@ -4204,7 +4204,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -4250,7 +4250,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -4292,7 +4292,7 @@ class TestSuiteDump(unittest.TestCase): self.assertEqual(jp2.box[3].box[0].num_components, 3) self.assertEqual(jp2.box[3].box[0].signed, False) self.assertEqual(jp2.box[3].box[0].compression, 7) # wavelet - self.assertEqual(jp2.box[3].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[3].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[3].box[0].ip_provided, False) # Jp2 Header @@ -4338,7 +4338,7 @@ class TestSuiteDump(unittest.TestCase): self.assertEqual(jp2.box[2].box[0].bits_per_component, 12) self.assertEqual(jp2.box[2].box[0].signed, False) self.assertEqual(jp2.box[2].box[0].compression, 7) # wavelet - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -4387,7 +4387,7 @@ class TestSuiteDump(unittest.TestCase): self.assertEqual(jp2.box[3].box[0].bits_per_component, 16) self.assertEqual(jp2.box[3].box[0].signed, False) self.assertEqual(jp2.box[3].box[0].compression, 7) # wavelet - self.assertEqual(jp2.box[3].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[3].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[3].box[0].ip_provided, False) # Jp2 Header @@ -4436,7 +4436,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -4488,7 +4488,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Palette box. @@ -5985,7 +5985,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -6118,7 +6118,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -6246,7 +6246,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -6386,7 +6386,7 @@ class TestSuiteDump(unittest.TestCase): self.assertEqual(jp2.box[3].box[0].bits_per_component, 8) self.assertEqual(jp2.box[3].box[0].signed, False) self.assertEqual(jp2.box[3].box[0].compression, 7) # wavelet - self.assertEqual(jp2.box[3].box[0].cspace_unknown, True) + self.assertEqual(jp2.box[3].box[0].colorspace_unknown, True) self.assertEqual(jp2.box[3].box[0].ip_provided, False) # Jp2 Header @@ -6497,7 +6497,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, True) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, True) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -6597,7 +6597,7 @@ class TestSuiteDump(unittest.TestCase): self.assertEqual(jp2.box[3].box[0].bits_per_component, 8) self.assertEqual(jp2.box[3].box[0].signed, False) self.assertEqual(jp2.box[3].box[0].compression, 7) # wavelet - self.assertEqual(jp2.box[3].box[0].cspace_unknown, True) + self.assertEqual(jp2.box[3].box[0].colorspace_unknown, True) self.assertEqual(jp2.box[3].box[0].ip_provided, False) # Jp2 Header @@ -6694,7 +6694,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, True) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, True) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -6706,8 +6706,8 @@ class TestSuiteDump(unittest.TestCase): # Jp2 Header # Channel Definition - self.assertEqual(jp2.box[2].box[2].component_number, (0, 1)) - self.assertEqual(jp2.box[2].box[2].component_type, (0, 1)) # opacity + self.assertEqual(jp2.box[2].box[2].index, (0, 1)) + self.assertEqual(jp2.box[2].box[2].channel_type, (0, 1)) # opacity self.assertEqual(jp2.box[2].box[2].association, (0, 0)) # both main c = jp2.box[3].main_header @@ -6813,7 +6813,7 @@ class TestSuiteDump(unittest.TestCase): self.assertEqual(jp2.box[3].box[0].bits_per_component, 1) self.assertEqual(jp2.box[3].box[0].signed, False) self.assertEqual(jp2.box[3].box[0].compression, 7) # wavelet - self.assertEqual(jp2.box[3].box[0].cspace_unknown, True) + self.assertEqual(jp2.box[3].box[0].colorspace_unknown, True) self.assertEqual(jp2.box[3].box[0].ip_provided, False) # Jp2 Header @@ -6927,7 +6927,7 @@ class TestSuiteDump(unittest.TestCase): self.assertEqual(jp2.box[3].box[0].bits_per_component, 4) self.assertEqual(jp2.box[3].box[0].signed, False) self.assertEqual(jp2.box[3].box[0].compression, 7) # wavelet - self.assertEqual(jp2.box[3].box[0].cspace_unknown, True) + self.assertEqual(jp2.box[3].box[0].colorspace_unknown, True) self.assertEqual(jp2.box[3].box[0].ip_provided, False) # Jp2 Header @@ -7035,7 +7035,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -7142,7 +7142,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -7241,7 +7241,7 @@ class TestSuiteDump(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header @@ -7343,7 +7343,7 @@ class TestSuiteDump(unittest.TestCase): self.assertEqual(jp2.box[3].box[0].bits_per_component, 8) self.assertEqual(jp2.box[3].box[0].signed, False) self.assertEqual(jp2.box[3].box[0].compression, 7) # wavelet - self.assertEqual(jp2.box[3].box[0].cspace_unknown, True) + self.assertEqual(jp2.box[3].box[0].colorspace_unknown, True) self.assertEqual(jp2.box[3].box[0].ip_provided, False) # Jp2 Header diff --git a/glymur/test/test_opj_suite_write.py b/glymur/test/test_opj_suite_write.py index 74237c2..50e0e48 100644 --- a/glymur/test/test_opj_suite_write.py +++ b/glymur/test/test_opj_suite_write.py @@ -686,7 +686,7 @@ class TestSuiteWrite(unittest.TestCase): 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 - self.assertEqual(jp2.box[2].box[0].cspace_unknown, False) + self.assertEqual(jp2.box[2].box[0].colorspace_unknown, False) self.assertEqual(jp2.box[2].box[0].ip_provided, False) # Jp2 Header diff --git a/glymur/test/test_printing.py b/glymur/test/test_printing.py index f948c3e..4090a2d 100644 --- a/glymur/test/test_printing.py +++ b/glymur/test/test_printing.py @@ -30,8 +30,7 @@ class TestPrintingNeedsLib(unittest.TestCase): @classmethod def setUpClass(cls): # Setup a plain JP2 file without the two UUID boxes. - jp2file = pkg_resources.resource_filename(glymur.__name__, - "data/nemo.jp2") + jp2file = glymur.data.nemo() with tempfile.NamedTemporaryFile(suffix='.jp2', delete=False) as tfile: cls._plain_nemo_file = tfile.name ijfile = Jp2k(jp2file) @@ -676,15 +675,15 @@ class TestPrinting(unittest.TestCase): @unittest.skipIf(data_root is None, "OPJ_DATA_ROOT environment variable not set") - def test_component_definition(self): + def test_channel_definition(self): filename = os.path.join(data_root, 'input/conformance/file2.jp2') j = glymur.Jp2k(filename) print(j.box[2].box[2]) actual = sys.stdout.getvalue().strip() - lines = ['Component Definition Box (cdef) @ (81, 28)', - ' Component 0 (color) ==> (3)', - ' Component 1 (color) ==> (2)', - ' Component 2 (color) ==> (1)'] + lines = ['Channel Definition Box (cdef) @ (81, 28)', + ' Channel 0 (color) ==> (3)', + ' Channel 1 (color) ==> (2)', + ' Channel 2 (color) ==> (1)'] expected = '\n'.join(lines) self.assertEqual(actual, expected)