Repository: python-pillow/Pillow

Issue:
JPEG2000 irreversible option misleading
The [documentation](https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#jpeg-2000) for the J2K plugin says:
> **irreversible**
If `True`, use the lossy Irreversible Color Transformation followed by DWT 9-7. Defaults to `False`, which means to use the Reversible Color Transformation with DWT 5-3.

I think the "color transformation" here is referring to the multiple component transformation (MCT), however looking at the source code, MCT isn't being applied, so no color transform is occurring (as I understand it `irreversible` in openjpeg only controls which DWT is used). This is visible in the output codestream:

```python
from io import BytesIO
import numpy as np
from PIL import Image
from struct import unpack

# https://github.com/python-pillow/Pillow/blob/master/Tests/images/xmp_test.jpg
im = Image.open("xmp_test.jpg")
assert im.mode == "RGB"
ref = np.asarray(im)
b = BytesIO()
b.name = "foo.j2k"  # workaround so no JP2
im.save(b, format="JPEG2000", irreversible=False)

# Parse J2K codestream
codestream = b.getvalue()
print(f"len: {len(codestream)}")
if codestream[0:2] == b'\xff\x4f':
    if codestream[2:4] == b'\xff\x51':
        lsiz = unpack('>H', codestream[4:6])[0]
        ssiz = codestream[42]
        if ssiz & 0x80:
            print(f"precision: {(ssiz & 0x7F) + 1}")
            print("signed: true")
        else:
            print(f"precision: {ssiz + 1}")
            print("signed: false")

        cod_offset = lsiz + 4
        if codestream[cod_offset:cod_offset + 2] == b'\xff\x52':
            # 0 for none, 1 for applied to components 0, 1, 2
            print(f"MCT: {codestream[cod_offset + 8]}")
            # 0 for DWT 9-7 (irreversible), 1 for DWT 5-3 (reversible)
            print(f"DWT: {codestream[cod_offset + 13]}")

im = Image.open(b)
assert np.array_equal(ref, np.asarray(im))
```
Produces:
```
len: 3506536
precision: 8
signed: false
MCT: 0
DWT: 1
```

For comparison, with Jpeg2KEncode.c modified to use MCT:
```
len: 2682646
precision: 8
signed: false
MCT: 1
DWT: 1
```
With the transform into YCbCr the compression ratio is higher, as expected, while still being reversible.

### What are your OS, Python and Pillow versions?

* OS: Ubuntu 20.04
* Python: 3.9
* Pillow: current master d393cfb



Discussion:
Looking at the Pillow source code, we're not doing anything beyond just passing the `irreversible` option onto OpenJPEG.

Searching for documentation about this option in OpenJPEG, I found https://github.com/uclouvain/openjpeg/wiki/DocJ2KCodec
> Irreversible compression (ICT + DWT 9-7). This option offers the possibility to work with the Irreversible Color Transformation (ICT) in place of the Reversible Color Transformation (RCT) and with the irreversible DWT 9-7 in place of the 5-3 filter. Default: off.

So our documentation pretty closely matches that official description.

Resolving patch:
diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py
--- a/src/PIL/Jpeg2KImagePlugin.py
+++ b/src/PIL/Jpeg2KImagePlugin.py
@@ -290,14 +290,14 @@ def _accept(prefix):
 
 
 def _save(im, fp, filename):
-    if filename.endswith(".j2k"):
+    # Get the keyword arguments
+    info = im.encoderinfo
+
+    if filename.endswith(".j2k") or info.get("no_jp2", False):
         kind = "j2k"
     else:
         kind = "jp2"
 
-    # Get the keyword arguments
-    info = im.encoderinfo
-
     offset = info.get("offset", None)
     tile_offset = info.get("tile_offset", None)
     tile_size = info.get("tile_size", None)
@@ -320,6 +320,7 @@ def _save(im, fp, filename):
     irreversible = info.get("irreversible", False)
     progression = info.get("progression", "LRCP")
     cinema_mode = info.get("cinema_mode", "no")
+    mct = info.get("mct", 0)
     fd = -1
 
     if hasattr(fp, "fileno"):
@@ -340,6 +341,7 @@ def _save(im, fp, filename):
         irreversible,
         progression,
         cinema_mode,
+        mct,
         fd,
     )
 


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
@@ -209,6 +209,49 @@ def test_layers():
         assert_image_similar(im, test_card, 0.4)
 
 
+@pytest.mark.parametrize(
+    "name, args, offset, data",
+    (
+        ("foo.j2k", {}, 0, b"\xff\x4f"),
+        ("foo.jp2", {}, 4, b"jP"),
+        (None, {"no_jp2": True}, 0, b"\xff\x4f"),
+        ("foo.j2k", {"no_jp2": True}, 0, b"\xff\x4f"),
+        ("foo.jp2", {"no_jp2": True}, 0, b"\xff\x4f"),
+        ("foo.j2k", {"no_jp2": False}, 0, b"\xff\x4f"),
+        ("foo.jp2", {"no_jp2": False}, 4, b"jP"),
+        ("foo.jp2", {"no_jp2": False}, 4, b"jP"),
+    ),
+)
+def test_no_jp2(name, args, offset, data):
+    out = BytesIO()
+    if name:
+        out.name = name
+    test_card.save(out, "JPEG2000", **args)
+    out.seek(offset)
+    assert out.read(2) == data
+
+
+def test_mct():
+    # Three component
+    for val in (0, 1):
+        out = BytesIO()
+        test_card.save(out, "JPEG2000", mct=val, no_jp2=True)
+
+        assert out.getvalue()[59] == val
+        with Image.open(out) as im:
+            assert_image_similar(im, test_card, 1.0e-3)
+
+    # Single component should have MCT disabled
+    for val in (0, 1):
+        out = BytesIO()
+        with Image.open("Tests/images/16bit.cropped.jp2") as jp2:
+            jp2.save(out, "JPEG2000", mct=val, no_jp2=True)
+
+        assert out.getvalue()[53] == 0
+        with Image.open(out) as im:
+            assert_image_similar(im, jp2, 1.0e-3)
+
+
 def test_rgba():
     # Arrange
     with Image.open("Tests/images/rgb_trns_ycbc.j2k") as j2k: