Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added Tests/images/ycbcr_422.j2k
Binary file not shown.
170 changes: 170 additions & 0 deletions Tests/test_file_jpeg2k.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
assert_image_equal,
assert_image_similar,
assert_image_similar_tofile,
hopper,
skip_unless_feature,
skip_unless_feature_version,
)
Expand Down Expand Up @@ -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"
)
Expand Down
7 changes: 7 additions & 0 deletions docs/releasenotes/13.0.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
89 changes: 85 additions & 4 deletions src/PIL/Jpeg2KImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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():
Expand All @@ -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


##
Expand All @@ -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"
Expand All @@ -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"):
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/libImaging/Jpeg2KDecode.c
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down