Repository: python-pillow/Pillow

Issue:
JPEG2000 image read incorrectly
When doing
```python
from PIL import Image
Image.open('test.jp2').show()
```
on the file
[test.jp2.zip](https://github.com/python-pillow/Pillow/files/13223024/test.jp2.zip)
an apparently incorrect result is displayed.

I have checked this with both the latest Pillow-10.1.0 as well as my reference Pillow-9.4.0: both results are identical and both are incorrect.


Discussion:
Am I correct in saying that you created this image

<img src="https://github.com/python-pillow/Pillow/assets/3112309/71af2f2b-79e6-4dcf-869e-421a1433e4af" width="400" />


but were expecting this?

<img src="https://github.com/python-pillow/Pillow/assets/3112309/963488af-1728-4f6a-b588-4e76ef846107" width="400" />

I see there is a "pclr" box in the image. It looks like an RGB palette that we're not handling yet. Is the image you attached one that can be added a test image, and distributed under Pillow's license?
I vaguely remember that I got this image from some student lecture notes that were freely distributed on the web. That is to say, I am not the author of this image.
It seems to me that https://github.com/uclouvain/openjpeg/pull/1463 is relevant, and we might have an easier time addressing this once the next version of OpenJPEG is released.
It turns out that https://github.com/uclouvain/openjpeg/pull/1463 didn't help.

I've created https://github.com/python-pillow/Pillow/pull/7867 to resolve this.

Resolving patch:
diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py
--- a/src/PIL/Jpeg2KImagePlugin.py
+++ b/src/PIL/Jpeg2KImagePlugin.py
@@ -19,7 +19,7 @@
 import os
 import struct
 
-from . import Image, ImageFile, _binary
+from . import Image, ImageFile, ImagePalette, _binary
 
 
 class BoxReader:
@@ -162,6 +162,7 @@ def _parse_jp2_header(fp):
     bpc = None
     nc = None
     dpi = None  # 2-tuple of DPI info, or None
+    palette = None
 
     while header.has_next_box():
         tbox = header.next_box_type()
@@ -179,6 +180,14 @@ def _parse_jp2_header(fp):
                 mode = "RGB"
             elif nc == 4:
                 mode = "RGBA"
+        elif tbox == b"pclr" and mode in ("L", "LA"):
+            ne, npc = header.read_fields(">HB")
+            bitdepths = header.read_fields(">" + ("B" * npc))
+            if max(bitdepths) <= 8:
+                palette = ImagePalette.ImagePalette()
+                for i in range(ne):
+                    palette.getcolor(header.read_fields(">" + ("B" * npc)))
+                mode = "P" if mode == "L" else "PA"
         elif tbox == b"res ":
             res = header.read_boxes()
             while res.has_next_box():
@@ -195,7 +204,7 @@ def _parse_jp2_header(fp):
         msg = "Malformed JP2 header"
         raise SyntaxError(msg)
 
-    return size, mode, mimetype, dpi
+    return size, mode, mimetype, dpi, palette
 
 
 ##
@@ -217,7 +226,7 @@ def _open(self):
             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 = header
+                self._size, self._mode, self.custom_mimetype, dpi, self.palette = header
                 if dpi is not None:
                     self.info["dpi"] = dpi
                 if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"):


Test patch:
diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py
--- a/Tests/test_file_jpeg2k.py
+++ b/Tests/test_file_jpeg2k.py
@@ -364,6 +364,16 @@ def test_subsampling_decode(name: str) -> None:
         assert_image_similar(im, expected, epsilon)
 
 
+@pytest.mark.skipif(
+    not os.path.exists(EXTRA_DIR), reason="Extra image files not installed"
+)
+def test_pclr() -> None:
+    with Image.open(f"{EXTRA_DIR}/issue104_jpxstream.jp2") as im:
+        assert im.mode == "P"
+        assert len(im.palette.colors) == 256
+        assert im.palette.colors[(255, 255, 255)] == 0
+
+
 def test_comment() -> None:
     with Image.open("Tests/images/comment.jp2") as im:
         assert im.info["comment"] == b"Created by OpenJPEG version 2.5.0"