diff --git a/Tests/images/ycbcr_422.j2k b/Tests/images/ycbcr_422.j2k new file mode 100644 index 00000000000..d29606daabd Binary files /dev/null and b/Tests/images/ycbcr_422.j2k differ diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index d0f13d84f75..d72d8c973f8 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -21,6 +21,7 @@ assert_image_equal, assert_image_similar, assert_image_similar_tofile, + hopper, skip_unless_feature, skip_unless_feature_version, ) @@ -360,6 +361,174 @@ def test_grayscale_four_channels() -> None: assert im.mode == "RGBA" +def _set_channel_definitions( + data: bytes, channels: tuple[tuple[int, int, int], ...], enumcs: int | None = None +) -> bytes: + # Replace any channel definition box in the JP2 header box, + # and optionally change the enumerated color space + header = data.index(b"jp2h") - 4 + header_length = _binary.i32be(data, header) + boxes = b"" + offset = header + 8 + while offset < header + header_length: + length = _binary.i32be(data, offset) + box = data[offset : offset + length] + if box[4:8] == b"colr" and enumcs is not None: + box = box[:-4] + struct.pack(">I", enumcs) + if box[4:8] != b"cdef": + boxes += box + offset += length + cdef = struct.pack(">H", len(channels)) + for channel in channels: + cdef += struct.pack(">HHH", *channel) + boxes += struct.pack(">I4s", 8 + len(cdef), b"cdef") + cdef + return ( + data[:header] + + struct.pack(">I4s", 8 + len(boxes), b"jp2h") + + boxes + + data[header + header_length :] + ) + + +@pytest.mark.parametrize( + "mode, order, channels", + ( + ("RGB", (2, 1, 0), ((0, 0, 3), (1, 0, 2), (2, 0, 1))), + ("RGB", (1, 2, 0), ((0, 0, 2), (1, 0, 3), (2, 0, 1))), + ("RGBA", (3, 0, 1, 2), ((0, 1, 0), (1, 0, 1), (2, 0, 2), (3, 0, 3))), + ("RGBA", (2, 1, 0, 3), ((0, 0, 3), (1, 0, 2), (2, 0, 1), (3, 2, 0))), + ("LA", (1, 0), ((0, 1, 0), (1, 0, 1))), + ), +) +@pytest.mark.parametrize("tile_size", (None, (48, 48))) +def test_channel_definitions( + mode: str, + order: tuple[int, ...], + channels: tuple[tuple[int, int, int], ...], + tile_size: tuple[int, int] | None, +) -> None: + im = hopper(mode) + stored = Image.merge(mode, [im.getchannel(i) for i in order]) + out = BytesIO() + stored.save(out, "JPEG2000", tile_size=tile_size) + data = _set_channel_definitions(out.getvalue(), channels) + + with Image.open(BytesIO(data)) as reloaded: + assert_image_equal(reloaded, im) + + # The components are also reordered when reducing + with Image.open(BytesIO(out.getvalue())) as unordered: + assert isinstance(unordered, Jpeg2KImagePlugin.Jpeg2KImageFile) + unordered.reduce = 1 + unordered.load() + expected = Image.merge( + mode, [unordered.getchannel(order.index(i)) for i in range(len(order))] + ) + with Image.open(BytesIO(data)) as reloaded: + assert isinstance(reloaded, Jpeg2KImagePlugin.Jpeg2KImageFile) + reloaded.reduce = 1 + reloaded.load() + assert_image_equal(reloaded, expected) + + +@skip_unless_feature_version( + "jpg_2000", "2.5.1", "sYCC is only identified from the header since OpenJPEG 2.5.1" +) +def test_channel_definitions_sycc() -> None: + # The components are reordered before the conversion from YCbCr to RGB + im = hopper("YCbCr") + stored = Image.merge("RGB", [im.getchannel(i) for i in (2, 1, 0)]) + out = BytesIO() + stored.save(out, "JPEG2000", mct=0) + data = _set_channel_definitions( + out.getvalue(), ((0, 0, 3), (1, 0, 2), (2, 0, 1)), enumcs=18 + ) + + with Image.open(BytesIO(data)) as reloaded: + assert_image_equal(reloaded, im.convert("RGB")) + + +@skip_unless_feature_version( + "jpg_2000", "2.5.1", "sYCC is only identified from the header since OpenJPEG 2.5.1" +) +@pytest.mark.parametrize( + "order, channels", + ( + ((0, 1, 2, 3), ((0, 0, 1), (1, 0, 2), (2, 0, 3), (3, 1, 0))), + ((3, 0, 1, 2), ((0, 1, 0), (1, 0, 1), (2, 0, 2), (3, 0, 3))), + ), +) +def test_channel_definitions_sycc_alpha( + order: tuple[int, ...], channels: tuple[tuple[int, int, int], ...] +) -> None: + im = hopper("RGBA") + alpha = im.getchannel("A").point(lambda value: value * 7 % 256) + ycbcr = im.convert("RGB").convert("YCbCr") + bands = (*ycbcr.split(), alpha) + stored = Image.merge("RGBA", [bands[i] for i in order]) + out = BytesIO() + stored.save(out, "JPEG2000", mct=0) + data = _set_channel_definitions(out.getvalue(), channels, enumcs=18) + + with Image.open(BytesIO(data)) as reloaded: + assert reloaded.mode == "RGBA" + assert_image_equal(reloaded.getchannel("A"), alpha) + assert_image_equal(reloaded.convert("RGB"), ycbcr.convert("RGB")) + + +def test_subsampled_ycbcr_codestream() -> None: + # A J2K codestream has no color space, so components with subsampled chroma + # are decoded as YCbCr. The chroma of this image is subsampled horizontally. + im = hopper("YCbCr").resize((64, 64), Image.Resampling.BOX) + y, cb, cr = im.split() + chroma = [ + band.resize((32, 64), Image.Resampling.BOX).resize( + (64, 64), Image.Resampling.NEAREST + ) + for band in (cb, cr) + ] + expected = Image.merge("YCbCr", (y, *chroma)).convert("RGB") + + with Image.open("Tests/images/ycbcr_422.j2k") as reloaded: + assert_image_equal(reloaded, expected) + + +@pytest.mark.parametrize( + "channels", + ( + # Already in order + ((0, 0, 1), (1, 0, 2), (2, 0, 3)), + # Unspecified channel type + ((0, 0, 3), (1, 0, 2), (2, 65535, 65535)), + # Color channel associated with the whole image + ((0, 0, 0), (1, 0, 2), (2, 0, 1)), + # Association beyond the number of components + ((0, 0, 4), (1, 0, 2), (2, 0, 1)), + # Two channels with the same association + ((0, 0, 3), (1, 0, 3), (2, 0, 1)), + # Component index beyond the number of components + ((3, 0, 3), (1, 0, 2), (2, 0, 1)), + # Not every component described + ((0, 0, 3), (2, 0, 1)), + ), +) +def test_channel_definitions_ignored( + channels: tuple[tuple[int, int, int], ...], +) -> None: + im = hopper("RGB") + out = BytesIO() + im.save(out, "JPEG2000") + data = _set_channel_definitions(out.getvalue(), channels) + + with Image.open(BytesIO(data)) as reloaded: + assert_image_equal(reloaded, im) + + +def test_channel_order_decoder_args() -> None: + with pytest.raises(ValueError, match="too many channels"): + Image.core.jpeg2k_decoder("RGB", "jp2", 0, 0, -1, -1, bytes(5)) + + @pytest.mark.skipif( not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" ) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index b9906b7f19d..2e504134931 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -186,6 +186,13 @@ Preserved transparency when padding or expanding images in ImageOps pixels in L, P and RGB images previously became opaque when a border was added. New palette colors are allocated without using reserved transparency entries. +JPEG 2000 channel definitions +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When a JP2 image has a channel definition box that places its components in a +different order, such as blue, green, red, Pillow now reorders them when decoding. +Previously, the box was ignored, and the channels were swapped. + Fixed ImageStat variance rounding ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 30143ff533e..e3ef68c1c42 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -153,6 +153,33 @@ def _res_to_dpi(num: int, denom: int, exp: int) -> float | None: return (254 * num * (10**exp)) / (10000 * denom) +def _channel_order( + channels: list[tuple[int, int, int]], nc: int +) -> tuple[int, ...] | None: + """Use the (component, type, association) entries of a JP2 channel + definition box to find the order of decoded components that puts the color + channels first, in their associated order, followed by any opacity channel. + Returns None if the components are already in that order, or if the box does + not describe a simple reordering of them.""" + positions: dict[int, int] = {} + for cn, typ, asoc in channels: + if typ == 0 and 1 <= asoc <= nc: + # Color channel + position = asoc - 1 + elif typ in (1, 2) and asoc == 0: + # Opacity or premultiplied opacity channel for the whole image + position = nc - 1 + else: + return None + if position in positions: + return None + positions[position] = cn + order = tuple(positions.get(position, -1) for position in range(nc)) + if sorted(order) != list(range(nc)) or order == tuple(range(nc)): + return None + return order + + def _parse_jp2_header( fp: IO[bytes], ) -> tuple[ @@ -161,10 +188,11 @@ def _parse_jp2_header( str | None, tuple[float, float] | None, ImagePalette.ImagePalette | None, + tuple[int, ...] | None, ]: """Parse the JP2 header box to extract size, component count, - color space information, and optionally DPI information, - returning a (size, mode, mimetype, dpi) tuple.""" + color space information, and optionally DPI information and channel order, + returning a (size, mode, mimetype, dpi, palette, channel_order) tuple.""" # Find the JP2 header box reader = BoxReader(fp) @@ -188,6 +216,7 @@ def _parse_jp2_header( dpi = None # 2-tuple of DPI info, or None palette = None colr = None + channels = None while header.has_next_box(): tbox = header.next_box_type() @@ -196,6 +225,7 @@ def _parse_jp2_header( height, width, nc, bpc = header.read_fields(">IIHB") assert isinstance(height, int) assert isinstance(width, int) + assert isinstance(nc, int) assert isinstance(bpc, int) size = (width, height) if nc == 1 and (bpc & 0x7F) > 8: @@ -241,6 +271,16 @@ def _parse_jp2_header( color.append(value) palette.getcolor(tuple(color)) mode = "P" if mode == "L" else "PA" + elif tbox == b"cdef": + (n,) = header.read_fields(">H") + assert isinstance(n, int) + channels = [] + for _ in range(n): + cn, typ, asoc = header.read_fields(">HHH") + assert isinstance(cn, int) + assert isinstance(typ, int) + assert isinstance(asoc, int) + channels.append((cn, typ, asoc)) elif tbox == b"res ": res = header.read_boxes() while res.has_next_box(): @@ -263,7 +303,12 @@ def _parse_jp2_header( msg = "Malformed JP2 header" raise SyntaxError(msg) - return size, mode, mimetype, dpi, palette + channel_order = None + if channels and isinstance(nc, int) and nc > 1 and palette is None: + # With a palette, the channel definitions describe the palette entries + channel_order = _channel_order(channels, nc) + + return size, mode, mimetype, dpi, palette, channel_order ## @@ -276,6 +321,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): def _open(self) -> None: assert self.fp is not None + self._channel_order: tuple[int, ...] | None = None sig = self.fp.read(4) if sig == b"\xff\x4f\xff\x51": self.codec = "j2k" @@ -287,7 +333,14 @@ def _open(self) -> None: if sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a": self.codec = "jp2" header = _parse_jp2_header(self.fp) - self._size, self._mode, self.custom_mimetype, dpi, self.palette = header + ( + self._size, + self._mode, + self.custom_mimetype, + dpi, + self.palette, + self._channel_order, + ) = header if dpi is not None: self.info["dpi"] = dpi if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"): @@ -323,7 +376,14 @@ def _open(self) -> None: "jpeg2k", (0, 0, *self.size), 0, - (self.codec, self._reduce, self.layers, fd, length), + ( + self.codec, + self._reduce, + self.layers, + fd, + length, + bytes(self._channel_order or ()), + ), ) ] @@ -377,7 +437,7 @@ def load(self) -> Image.core.PixelAccess | None: # Update the reduce and layers settings t = self.tile[0] assert isinstance(t[3], tuple) - t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) + t3 = (t[3][0], self._reduce, self.layers, *t[3][3:]) self.tile = [ImageFile._Tile(t[0], (0, 0, *self.size), t[2], t3)] return ImageFile.ImageFile.load(self) diff --git a/src/decode.c b/src/decode.c index caeac1aa77a..464bb9d7e5d 100644 --- a/src/decode.c +++ b/src/decode.c @@ -918,13 +918,29 @@ PyImaging_Jpeg2KDecoderNew(PyObject *self, PyObject *args) { int layers = 0; int fd = -1; PY_LONG_LONG length = -1; + const char *channel_order = NULL; + Py_ssize_t channel_order_length = 0; if (!PyArg_ParseTuple( - args, "ss|iiiL", &mode, &format, &reduce, &layers, &fd, &length + args, + "ss|iiiLy#", + &mode, + &format, + &reduce, + &layers, + &fd, + &length, + &channel_order, + &channel_order_length )) { return NULL; } + if (channel_order_length > 4) { + PyErr_SetString(PyExc_ValueError, "too many channels"); + return NULL; + } + if (strcmp(format, "j2k") == 0) { codec_format = OPJ_CODEC_J2K; } else if (strcmp(format, "jp2") == 0) { @@ -950,6 +966,10 @@ PyImaging_Jpeg2KDecoderNew(PyObject *self, PyObject *args) { context->format = codec_format; context->reduce = reduce; context->layers = layers; + context->channel_order_length = (int)channel_order_length; + if (channel_order_length) { + memcpy(context->channel_order, channel_order, channel_order_length); + } return (PyObject *)decoder; } diff --git a/src/libImaging/Jpeg2K.h b/src/libImaging/Jpeg2K.h index 6fbbf7311a1..73aa59ef956 100644 --- a/src/libImaging/Jpeg2K.h +++ b/src/libImaging/Jpeg2K.h @@ -38,6 +38,11 @@ typedef struct { /* Set to limit the number of quality layers to decode (0 = all layers) */ int layers; + /* Order in which to unpack the components, from the channel definition box + (a length of 0 keeps the codestream order) */ + int channel_order_length; + UINT8 channel_order[4]; + /* PRIVATE CONTEXT (set by decoder) */ const char *error_msg; diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index 4fbb04a7da3..b05766af02e 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -645,6 +645,10 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { size_t tile_bytes = 0; unsigned n, tile_height, tile_width; int subsampling; + int reorder = 0; + opj_image_t reordered_image; + opj_image_comp_t reordered_comps[4]; + UINT8 *reordered_buffer = NULL; stream = opj_stream_create(BUFFER_SIZE, OPJ_TRUE); @@ -783,6 +787,26 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { goto quick_exit; } + /* Unpack the components in the order given by the channel definition box. + Only done without subsampling, when each component in a tile has one + sample per pixel. */ + if (context->channel_order_length == (int)image->numcomps && subsampling == -1) { + unsigned seen = 0; + reorder = 1; + for (n = 0; n < image->numcomps; n++) { + UINT8 c = context->channel_order[n]; + if (c >= image->numcomps || seen & (1 << c)) { + /* Not a permutation of the components */ + reorder = 0; + break; + } + seen |= 1 << c; + reordered_comps[n] = image->comps[c]; + } + reordered_image = *image; + reordered_image.comps = reordered_comps; + } + /* Decode the image tile-by-tile; this means we only need use as much memory as is required for one tile's worth of components. */ for (;;) { @@ -899,7 +923,36 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { goto quick_exit; } - unpack(image, &tile_info, state->buffer, im); + if (reorder) { + /* Each component's samples are contiguous in the tile data */ + size_t offsets[4], sizes[4], offset = 0; + for (n = 0; n < image->numcomps; n++) { + int csize = (image->comps[n].prec + 7) >> 3; + csize = (csize == 3) ? 4 : csize; + sizes[n] = (size_t)csize * tile_width * tile_height; + offsets[n] = offset; + offset += sizes[n]; + } + + /* malloc check ok, tile_bytes is the sum of the sizes */ + UINT8 *new = realloc(reordered_buffer, tile_bytes); + if (!new) { + state->errcode = IMAGING_CODEC_MEMORY; + state->state = J2K_STATE_FAILED; + goto quick_exit; + } + reordered_buffer = new; + + offset = 0; + for (n = 0; n < image->numcomps; n++) { + UINT8 c = context->channel_order[n]; + memcpy(reordered_buffer + offset, state->buffer + offsets[c], sizes[c]); + offset += sizes[c]; + } + unpack(&reordered_image, &tile_info, reordered_buffer, im); + } else { + unpack(image, &tile_info, state->buffer, im); + } } if (!opj_end_decompress(codec, stream)) { @@ -918,6 +971,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { } quick_exit: + free(reordered_buffer); if (codec) { opj_destroy_codec(codec); }