From c38c99db688b2bfa0f37179e6c4d02d7d96c06ab Mon Sep 17 00:00:00 2001 From: sfarestam Date: Wed, 23 Sep 2026 17:34:31 +0200 Subject: [PATCH 1/4] Apply JPEG 2000 channel definitions when decoding A JP2 channel definition (cdef) box can declare that the codestream components are stored in a different order from the color space, such as blue, green, red, or with the opacity channel first. Pillow decodes tile by tile, which bypasses OpenJPEG's own handling of the box, so the box was ignored and the channels came out swapped. Parse the box, and when it describes a reordering of the components, unpack them in that order. This is done before the conversion from YCbCr, so sYCC images are also handled, such as file2.jp2 from the JPEG 2000 conformance suite. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/test_file_jpeg2k.py | 121 ++++++++++++++++++++++++++++++++++ docs/releasenotes/13.0.0.rst | 7 ++ src/PIL/Jpeg2KImagePlugin.py | 72 ++++++++++++++++++-- src/decode.c | 22 ++++++- src/libImaging/Jpeg2K.h | 5 ++ src/libImaging/Jpeg2KDecode.c | 56 +++++++++++++++- 6 files changed, 275 insertions(+), 8 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index d0f13d84f75..b2844396e92 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,126 @@ 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) + + +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")) + + +@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 694093b01ab..b6935d28481 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -184,6 +184,13 @@ Restore image position if ImageSequence.all_frames() fails If processing a frame in :py:func:`~PIL.ImageSequence.all_frames` raises an exception, the given image will now seek back to its original frame. +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 ImageChops.offset() for 16-bit images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 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 bdb8b0a2dbc..4353c600611 100644 --- a/src/decode.c +++ b/src/decode.c @@ -923,13 +923,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) { @@ -955,6 +971,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); } From 14e075c58172f7f8050b45cc2eae3e322560a9bc Mon Sep 17 00:00:00 2001 From: sfarestam Date: Thu, 24 Sep 2026 08:37:05 +0200 Subject: [PATCH 2/4] Skip sYCC channel definition test for OpenJPEG < 2.5.1 Before OpenJPEG 2.5.1, opj_read_header() does not set the image color space, so the tile-by-tile decoder cannot identify sYCC images and returns the YCbCr values as RGB, whether or not there is a channel definition box. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/test_file_jpeg2k.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index b2844396e92..b96eb8f5f43 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -431,6 +431,9 @@ def test_channel_definitions( 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") From 4741724bc0f5d5bd28d300f71e811fb15c79b391 Mon Sep 17 00:00:00 2001 From: sfarestam Date: Fri, 25 Sep 2026 15:20:18 +0200 Subject: [PATCH 3/4] Test sYCC with alpha, and subsampled YCbCr codestreams Cover two YCbCr cases that the channel definition changes must keep working: an sYCC image with an opacity channel, stored in either order, and a J2K codestream with horizontally subsampled chroma but no color space, which is decoded as YCbCr. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/images/ycbcr_422.j2k | Bin 0 -> 5269 bytes Tests/test_file_jpeg2k.py | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 Tests/images/ycbcr_422.j2k diff --git a/Tests/images/ycbcr_422.j2k b/Tests/images/ycbcr_422.j2k new file mode 100644 index 0000000000000000000000000000000000000000..d29606daabd77b31b3edfed09987c0302ed8bc1c GIT binary patch literal 5269 zcmV;G6l&}LPybN>F8}}l002M$002M$0000000000002M$002M$00000000000|x;C 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 b68~6cYMXjXFtW>&HefW=lj{IGOFRGBv`F+x literal 0 HcmV?d00001 diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index b96eb8f5f43..d72d8c973f8 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -448,6 +448,51 @@ def test_channel_definitions_sycc() -> None: 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", ( From 96d248e4a614842009268a2ad4f5fbd4d2924345 Mon Sep 17 00:00:00 2001 From: sfarestam Date: Mon, 5 Oct 2026 11:16:08 +0200 Subject: [PATCH 4/4] Reorder JPEG 2000 channels in Python As suggested in review, apply the channel definition box in the plugin instead of in the C decoder. After decoding, the bands are reordered with Image.core.merge. An sYCC image is decoded into a YCbCr image instead, so that the channels can be reordered before they are converted to RGB. For that, the decoder gains unpackers that copy YCbCr components without converting them. Since the plugin identifies sYCC from the JP2 header, this also works with OpenJPEG < 2.5.1. An sYCC image with alpha is not reordered, as there is no YCbCr mode with alpha. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/test_file_jpeg2k.py | 45 +++++++++++++------------- src/PIL/Jpeg2KImagePlugin.py | 43 +++++++++++++++++------- src/decode.c | 22 +------------ src/libImaging/Jpeg2K.h | 5 --- src/libImaging/Jpeg2KDecode.c | 61 ++++------------------------------- 5 files changed, 62 insertions(+), 114 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index d72d8c973f8..3ff58333d1a 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -431,9 +431,6 @@ def test_channel_definitions( 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") @@ -451,24 +448,15 @@ def test_channel_definitions_sycc() -> None: @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: +def test_channel_definitions_sycc_alpha() -> 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) + Image.merge("RGBA", (*ycbcr.split(), alpha)).save(out, "JPEG2000", mct=0) + data = _set_channel_definitions( + out.getvalue(), ((0, 0, 1), (1, 0, 2), (2, 0, 3), (3, 1, 0)), enumcs=18 + ) with Image.open(BytesIO(data)) as reloaded: assert reloaded.mode == "RGBA" @@ -476,6 +464,24 @@ def test_channel_definitions_sycc_alpha( assert_image_equal(reloaded.convert("RGB"), ycbcr.convert("RGB")) +def test_channel_definitions_sycc_alpha_reordered() -> None: + # There is no YCbCr mode with alpha to reorder the channels in before they are + # converted to RGB, so the channel definitions are ignored + out = BytesIO() + hopper("RGBA").save(out, "JPEG2000", mct=0) + data = out.getvalue() + reordered = _set_channel_definitions( + data, ((0, 1, 0), (1, 0, 1), (2, 0, 2), (3, 0, 3)), enumcs=18 + ) + in_order = _set_channel_definitions( + data, ((0, 0, 1), (1, 0, 2), (2, 0, 3), (3, 1, 0)), enumcs=18 + ) + + with Image.open(BytesIO(reordered)) as im: + with Image.open(BytesIO(in_order)) as expected: + assert_image_equal(im, expected) + + 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. @@ -524,11 +530,6 @@ def test_channel_definitions_ignored( 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/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index e3ef68c1c42..49ea4e5ed0f 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -189,10 +189,12 @@ def _parse_jp2_header( tuple[float, float] | None, ImagePalette.ImagePalette | None, tuple[int, ...] | None, + str | None, ]: """Parse the JP2 header box to extract size, component count, color space information, and optionally DPI information and channel order, - returning a (size, mode, mimetype, dpi, palette, channel_order) tuple.""" + returning a (size, mode, mimetype, dpi, palette, channel_order, decode_mode) + tuple.""" # Find the JP2 header box reader = BoxReader(fp) @@ -249,6 +251,8 @@ def _parse_jp2_header( mode = "CMYK" elif enumcs == 17: colr = "L" + elif enumcs == 18: + colr = "YCbCr" elif tbox == b"pclr" and mode in ("L", "LA") and colr not in ("1", "L"): ne, npc = header.read_fields(">HB") assert isinstance(ne, int) @@ -304,11 +308,19 @@ def _parse_jp2_header( raise SyntaxError(msg) channel_order = None + decode_mode = 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) + if channel_order is not None and colr == "YCbCr": + if nc == 3: + # Reorder the channels before converting them to RGB + decode_mode = "YCbCr" + else: + # There is no YCbCr mode with alpha to reorder them in + channel_order = None - return size, mode, mimetype, dpi, palette, channel_order + return size, mode, mimetype, dpi, palette, channel_order, decode_mode ## @@ -322,6 +334,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): def _open(self) -> None: assert self.fp is not None self._channel_order: tuple[int, ...] | None = None + self._decode_mode: str | None = None sig = self.fp.read(4) if sig == b"\xff\x4f\xff\x51": self.codec = "j2k" @@ -340,6 +353,7 @@ def _open(self) -> None: dpi, self.palette, self._channel_order, + self._decode_mode, ) = header if dpi is not None: self.info["dpi"] = dpi @@ -376,14 +390,7 @@ def _open(self) -> None: "jpeg2k", (0, 0, *self.size), 0, - ( - self.codec, - self._reduce, - self.layers, - fd, - length, - bytes(self._channel_order or ()), - ), + (self.codec, self._reduce, self.layers, fd, length), ) ] @@ -437,11 +444,25 @@ 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:]) + t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) self.tile = [ImageFile._Tile(t[0], (0, 0, *self.size), t[2], t3)] return ImageFile.ImageFile.load(self) + def load_prepare(self) -> None: + if self._im is None and self._decode_mode is not None: + self.im = Image.core.new(self._decode_mode, self.size) + ImageFile.ImageFile.load_prepare(self) + + def load_end(self) -> None: + if self._channel_order is not None: + # Reorder the channels as described by the channel definition box + self.im = Image.core.merge( + self.im.mode, *[self.im.getband(i) for i in self._channel_order] + ) + if self.im.mode != self.mode: + self.im = self.im.convert(self.mode) + def _accept(prefix: bytes) -> bool: return prefix.startswith( diff --git a/src/decode.c b/src/decode.c index 464bb9d7e5d..caeac1aa77a 100644 --- a/src/decode.c +++ b/src/decode.c @@ -918,29 +918,13 @@ 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|iiiLy#", - &mode, - &format, - &reduce, - &layers, - &fd, - &length, - &channel_order, - &channel_order_length + args, "ss|iiiL", &mode, &format, &reduce, &layers, &fd, &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) { @@ -966,10 +950,6 @@ 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 73aa59ef956..6fbbf7311a1 100644 --- a/src/libImaging/Jpeg2K.h +++ b/src/libImaging/Jpeg2K.h @@ -38,11 +38,6 @@ 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 b05766af02e..47bf1537667 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -610,6 +610,11 @@ static const struct j2k_decode_unpacker j2k_unpackers[] = { {IMAGING_MODE_RGB, OPJ_CLRSPC_GRAY, 2, 0, j2ku_gray_rgb}, {IMAGING_MODE_RGB, OPJ_CLRSPC_SRGB, 3, 1, j2ku_srgb_rgb}, {IMAGING_MODE_RGB, OPJ_CLRSPC_SYCC, 3, 1, j2ku_sycc_rgb}, + /* YCbCr components unpacked without conversion, so that they can be reordered + before conversion. Without the JP2 colour space, OpenJPEG < 2.5.1 reports + an image without subsampling as sRGB. */ + {IMAGING_MODE_YCbCr, OPJ_CLRSPC_SYCC, 3, 1, j2ku_srgb_rgb}, + {IMAGING_MODE_YCbCr, OPJ_CLRSPC_SRGB, 3, 1, j2ku_srgb_rgb}, {IMAGING_MODE_RGB, OPJ_CLRSPC_SRGB, 4, 1, j2ku_srgb_rgb}, {IMAGING_MODE_RGB, OPJ_CLRSPC_SYCC, 4, 1, j2ku_sycc_rgb}, {IMAGING_MODE_RGBA, OPJ_CLRSPC_GRAY, 1, 0, j2ku_gray_rgb}, @@ -645,10 +650,6 @@ 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); @@ -787,26 +788,6 @@ 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 (;;) { @@ -923,36 +904,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { goto quick_exit; } - 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); - } + unpack(image, &tile_info, state->buffer, im); } if (!opj_end_decompress(codec, stream)) { @@ -971,7 +923,6 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { } quick_exit: - free(reordered_buffer); if (codec) { opj_destroy_codec(codec); }