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.
169 changes: 169 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,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"
)
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
72 changes: 66 additions & 6 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,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)
Expand All @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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():
Expand All @@ -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


##
Expand All @@ -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"
Expand All @@ -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"):
Expand Down Expand Up @@ -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 ()),
),
)
]

Expand Down Expand Up @@ -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)
Expand Down
22 changes: 21 additions & 1 deletion src/decode.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions src/libImaging/Jpeg2K.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading