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..3ff58333d1a 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,175 @@ 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")) + + +@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_alpha() -> None: + im = hopper("RGBA") + alpha = im.getchannel("A").point(lambda value: value * 7 % 256) + ycbcr = im.convert("RGB").convert("YCbCr") + out = BytesIO() + 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" + assert_image_equal(reloaded.getchannel("A"), 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. + 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) + + @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..49ea4e5ed0f 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,13 @@ def _parse_jp2_header( str | None, 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, - 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, decode_mode) + tuple.""" # Find the JP2 header box reader = BoxReader(fp) @@ -188,6 +218,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 +227,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: @@ -219,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) @@ -241,6 +275,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 +307,20 @@ def _parse_jp2_header( msg = "Malformed JP2 header" raise SyntaxError(msg) - return size, mode, mimetype, dpi, palette + 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, decode_mode ## @@ -276,6 +333,8 @@ 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" @@ -287,7 +346,15 @@ 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, + self._decode_mode, + ) = header if dpi is not None: self.info["dpi"] = dpi if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"): @@ -382,6 +449,20 @@ def load(self) -> Image.core.PixelAccess | None: 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/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index 4fbb04a7da3..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},