Skip to content

Apply JPEG 2000 channel definitions when decoding - #10049

Open
sfarestam-iproov wants to merge 4 commits into
python-pillow:mainfrom
sfarestam-iproov:jpeg2000-cdef
Open

sfarestam-iproov wants to merge 4 commits into
python-pillow:mainfrom
sfarestam-iproov:jpeg2000-cdef

Conversation

@sfarestam-iproov

@sfarestam-iproov sfarestam-iproov commented Sep 23, 2026 •

Copy link
Copy Markdown

Changes proposed in this pull request:

  • Parse the JP2 channel definition (cdef) box, and when it describes a reordering of the codestream components, unpack them in that order
  • Reorder in the C decoder, before the conversion from YCbCr, so that sYCC images are handled as well
  • Leave images unchanged when the box is already in order, is not understood, uses a palette, or the components are subsampled

A cdef box can declare that the components are stored in a different order from the color space: for example blue, green, red, or with the opacity channel first. OpenJPEG applies the box in opj_decode(), but Pillow decodes tile by tile with opj_read_tile_header() and opj_decode_tile_data(), which skip that step, so the box was ignored and the channels came out swapped.

import struct
from io import BytesIO
from PIL import Image

im = Image.new("RGB", (1, 1), (255, 0, 0))
out = BytesIO()
Image.merge("RGB", im.split()[::-1]).save(out, "JPEG2000")  # stored as B, G, R
data = out.getvalue()

cdef = struct.pack(">H", 3) + struct.pack(">6H", 0, 0, 3, 1, 0, 2) + struct.pack(">3H", 2, 0, 1)
cdef = struct.pack(">I4s", 8 + len(cdef), b"cdef") + cdef
i = data.index(b"jp2h") - 4
length = struct.unpack(">I", data[i : i + 4])[0]
data = data[:i] + struct.pack(">I", length + len(cdef)) + data[i + 4 : i + length] + cdef + data[i + length :]

print(Image.open(BytesIO(data)).getpixel((0, 0)))
# (255, 0, 0) with opj_decompress; (0, 0, 255) with Pillow before this change

This is the cause of uclouvain/openjpeg#1382, where a portrait from a Belgian ePassport decodes with red and blue swapped. That file stores its components as blue, green, red and declares this in a cdef box. With this change, Pillow's output for it matches opj_decompress byte for byte.

This also fixes file2.jp2 from the JPEG 2000 conformance suite in openjpeg-data, an sYCC image stored as Cr, Cb, Y. This needs OpenJPEG 2.5.1 or later, which is the first version to identify sYCC images when decoding by tile. Compared with opj_decompress, its largest difference drops from 255 to within YCbCr rounding. In the rest of the conformance and non-regression JP2 files, no other decoded pixel changes. Those files already had other differences from opj_decompress, from ICC profiles and subsampled YCbCr, and they are unchanged.

The tests build their images by adding a cdef box to a file that Pillow has saved, so no new test images are needed. They cover RGB, RGBA and LA, tiling, reduce, sYCC, and boxes that should be ignored. The new tests and a cdef fuzzer were also run under AddressSanitizer.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
@radarhere

Copy link
Copy Markdown
Member

Hi. You can see in our CI that one of your new tests, test_channel_definitions_sycc, is failing.

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) <noreply@anthropic.com>
@sfarestam-iproov

Copy link
Copy Markdown
Author

Thanks. That test fails wherever OpenJPEG is older than 2.5.1: 2.5.0 on the Ubuntu jobs, and 2.4.0 on CentOS Stream 9. Before uclouvain/openjpeg@0f528e9, opj_read_header() doesn't set the image's color space, so the tile-by-tile decoder can't identify sYCC images. It returns the YCbCr values as RGB, with or without a cdef box. I've reproduced this on Ubuntu 24.04.

I've skipped the test for OpenJPEG < 2.5.1, the same way as test_cmyk(). The other new tests pass on 2.5.0.

@sfarestam-iproov

Copy link
Copy Markdown
Author

With the skip, the Ubuntu and Docker jobs now pass. The remaining failures look unrelated to this change:

  • Windows (Python 3.15, PyPy 3.11) and Fuzzing: these fail before the tests run, when cloning dav1d for libavif. code.videolan.org refuses the connection.
  • macOS PyPy 3.11: Tests/test_file_pcx.py::test_odd[511-RGB] fails with "buffer overrun when reading image file". This job passed on the previous commit, and the only change since is the skip decorator. I couldn't reproduce it locally in 150 runs on CPython.

These jobs should pass when re-run once code.videolan.org is reachable again.

@radarhere

Copy link
Copy Markdown
Member

Reorder in the C decoder, before the conversion from YCbCr, so that sYCC images are handled as well

If we delay the YCbCr conversion until we return to Python, it is actually possible to reorder the channels in Python. I think it makes the code simpler. See what you think - sfarestam-iproov#1

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) <noreply@anthropic.com>
@sfarestam-iproov

Copy link
Copy Markdown
Author

Thanks, it is much simpler, and it also removes the need to skip the sYCC test for OpenJPEG < 2.5.1. However, I found two things that it changes when I compared both versions against 12.3.0 and opj_decompress.

  1. The YCbCr conversion is now only done when the JP2 header declares sYCC, but the C decoder also selects the sYCC unpackers for codestreams with subsampled chroma and no color space. Those now come out unconverted. For example, issue142.j2k from openjpeg-data (1920x1080, 4:2:2) matched opj_decompress to within 2 before, and now differs by up to 242.
  2. An sYCC image with an alpha channel now raises ValueError: conversion from YCbCr to RGBA not supported, because there is no YCbCr mode with alpha to convert from.

It also slows down every sYCC image, not just those with a cdef box, because putdata() copies the image pixel by pixel. With the same OpenJPEG 2.5.4:

12 MP image main this PR Python reordering
RGB, reordered 346 ms, 233 MB 358 ms, 267 MB 367 ms, 313 MB
sYCC, not reordered 373 ms, 233 MB 376 ms, 233 MB 1262 ms, 1335 MB
sYCC, reordered 373 ms, 233 MB 373 ms, 267 MB 1245 ms, 1416 MB

The existing tests don't cover either case, so I've pushed tests for them to this PR: an sYCC image with alpha stored in either order, and a small 4:2:2 codestream, Tests/images/ycbcr_422.j2k. They pass on main (apart from the reordered alpha case, which this PR fixes) and fail with the Python version.

The OpenJPEG < 2.5.1 problem can be handled separately. It affects every sYCC JP2 there, not just those with a cdef box. It is also why test_cmyk() needs 2.5.1: on 2.5.0, issue205.jp2 fails with "broken data stream". Before 2.5.1, OpenJPEG doesn't set the color space from the colr box when decoding tile by tile. So the plugin could pass the header's enumerated color space to the decoder, which would use it only when OpenJPEG doesn't report one. That's the same mapping that 2.5.1 added, and it needs no version check.

I have that working locally as a separate change, about 65 lines. On OpenJPEG 2.5.0 and 2.4.0, sYCC decodes correctly and test_cmyk() passes without its version skip. Combined with this PR, all of the sYCC tests pass without skips on 2.4.0, 2.5.0 and 2.5.4. I can open it as a follow-up PR if that sounds good.

If you'd still prefer the reordering in Python, I'm happy to look at a version that only defers the YCbCr conversion for sYCC images that are actually reordered.

# Conflicts:
#	docs/releasenotes/13.0.0.rst

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants