Repository: pydicom/pydicom

Issue:
pixel_array returns wrong pixel data in JPEG2000Lossless
While I was dealing with some JPEG2000Lossless compressed dcms, I obervesd some wired results.
But all these files can be displayed correctly in other dicom viewers.
In the first case (first row), `BitsStored` value is 12, return pixel data in range 256, and in the second case (second row),  `BitsStored` value is 16, return pixel data in range 65536; Though I believe the correct pixel data range should be in 4096 according to `RescaleSlope` and `RescaleIntercept`.
The left column shows the output I get, and the right column is the result supposed to be.
![image](https://github.com/pydicom/pydicom/assets/24197939/73ea917d-3f82-4843-a5fb-5fa24c024d50)

* The output from `python -m pydicom.env_info`
```bash
(base) root@a4000:~# python -m pydicom.env_info
module       | version
------       | -------
platform     | Linux-5.15.0-112-generic-x86_64-with-glibc2.35
Python       | 3.12.3 | packaged by Anaconda, Inc. | (main, May  6 2024, 19:46:43) [GCC 11.2.0]
pydicom      | 2.4.4
gdcm         | _module not found_
jpeg_ls      | _module not found_
numpy        | 1.26.4
PIL          | 10.3.0
pylibjpeg    | _module not found_
openjpeg     | _module not found_
libjpeg      | _module not found_

```

**Steps to reproduce**
* Display result in lung window.
```python
import cv2
import pydicom
import numpy as np


if __name__ == '__main__':
    ds = pydicom.read_file('test.dcm')
    pixel_arr = ds.pixel_array
    ct_arr = pixel_arr * ds.RescaleSlope + ds.RescaleIntercept

    wl, ww = -400, 1500
    ct_arr -= wl - ww / 2
    ct_arr = ct_arr / ww * 255
    ct_arr[ct_arr < 0] = 0
    ct_arr[ct_arr > 255] = 255

    cv2.imshow('test', ct_arr.astype(np.uint8))
    cv2.waitKey(60000)
```

* The **anonymized** DICOM files were attached as [test.zip](https://github.com/user-attachments/files/15830517/test.zip).

This problem has troubled me for a long time, really appreciate your help.



Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pydicom/pydicom/pull/2074?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.25%. Comparing base [(`b0d51e5`)](https://app.codecov.io/gh/pydicom/pydicom/commit/b0d51e5604b20f8074cdaf7312698bbd8b9cc3ad?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) to head [(`4364fd6`)](https://app.codecov.io/gh/pydicom/pydicom/commit/4364fd6d3cc625453dc63451e8070c7e424b85f7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom).
> Report is 1 commits behind head on main.


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #2074   +/-   ##
=======================================
  Coverage   98.25%   98.25%           
=======================================
  Files          76       76           
  Lines       12471    12487   +16     
=======================================
+ Hits        12253    12269   +16     
  Misses        218      218           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pydicom/pydicom/pull/2074?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom).


Patch:
diff --git a/doc/release_notes/v3.0.0.rst b/doc/release_notes/v3.0.0.rst
index 5302d61df..08b0c255e 100644
--- a/doc/release_notes/v3.0.0.rst
+++ b/doc/release_notes/v3.0.0.rst
@@ -203,6 +203,8 @@ Enhancements
   <pydicom.dataset.Dataset.pixel_array>` with the new :mod:`~pydicom.pixels` backend.
 * Improve support for reading and resolving inline binary data with `VR=UN` from Json
   (:issue:`2062`).
+* :func:`~pydicom.pixels.utils.get_j2k_parameters` now takes into account the JP2 header
+  (if present, although it's non-conformant for it to be) (:issue:`2073`)
 
 Fixes
 -----
diff --git a/src/pydicom/pixels/utils.py b/src/pydicom/pixels/utils.py
index f52330cb4..2bc2a5cfd 100644
--- a/src/pydicom/pixels/utils.py
+++ b/src/pydicom/pixels/utils.py
@@ -778,7 +778,7 @@ def get_image_pixel_ids(ds: "Dataset") -> dict[str, int]:
     return {kw: id(getattr(ds, kw, None)) for kw in keywords}
 
 
-def get_j2k_parameters(codestream: bytes) -> dict[str, object]:
+def get_j2k_parameters(codestream: bytes) -> dict[str, Any]:
     """Return a dict containing JPEG 2000 component parameters.
 
     .. versionadded:: 2.1
@@ -795,21 +795,44 @@ def get_j2k_parameters(codestream: bytes) -> dict[str, object]:
         JPEG 2000 `codestream`, or an empty dict if unable to parse the data.
         Available parameters are ``{"precision": int, "is_signed": bool}``.
     """
+    offset = 0
+    info: dict[str, Any] = {"jp2": False}
+
+    # Account for the JP2 header (if present)
+    # The first box is always 12 bytes long
+    if codestream.startswith(b"\x00\x00\x00\x0C\x6A\x50\x20\x20"):
+        info["jp2"] = True
+        total_length = len(codestream)
+        offset = 12
+        # Iterate through the boxes, looking for the jp2c box
+        while offset < total_length:
+            length = int.from_bytes(codestream[offset : offset + 4], byteorder="big")
+            if codestream[offset + 4 : offset + 8] == b"\x6A\x70\x32\x63":
+                # The offset to the start of the J2K codestream
+                offset += 8
+                break
+
+            offset += length
+
     try:
         # First 2 bytes must be the SOC marker - if not then wrong format
-        if codestream[0:2] != b"\xff\x4f":
+        if codestream[offset : offset + 2] != b"\xff\x4f":
             return {}
 
         # SIZ is required to be the second marker - Figure A-3 in 15444-1
-        if codestream[2:4] != b"\xff\x51":
+        if codestream[offset + 2 : offset + 4] != b"\xff\x51":
             return {}
 
         # See 15444-1 A.5.1 for format of the SIZ box and contents
-        ssiz = codestream[42]
+        ssiz = codestream[offset + 42]
         if ssiz & 0x80:
-            return {"precision": (ssiz & 0x7F) + 1, "is_signed": True}
+            info["precision"] = (ssiz & 0x7F) + 1
+            info["is_signed"] = True
+            return info
 
-        return {"precision": ssiz + 1, "is_signed": False}
+        info["precision"] = ssiz + 1
+        info["is_signed"] = False
+        return info
     except (IndexError, TypeError):
         pass
 


Test patch:
diff --git a/tests/pixels/test_decoder_pillow.py b/tests/pixels/test_decoder_pillow.py
index 5f7992a80..b843b5e6c 100644
--- a/tests/pixels/test_decoder_pillow.py
+++ b/tests/pixels/test_decoder_pillow.py
@@ -158,7 +158,11 @@ class TestOpenJpegDecoder:
     def test_u4_raises(self):
         """Test decoding greyscale with bits stored > 16 raises exception."""
         decoder = get_decoder(JPEG2000Lossless)
-        ds = J2KR_08_08_3_0_1F_YBR_RCT.ds
+        ds = dcmread(J2KR_08_08_3_0_1F_YBR_RCT.path)
+        # Manually edit the precision in the J2K codestream
+        data = bytearray(ds.PixelData)
+        data[1716:1717] = b"\x10"
+        ds.PixelData = bytes(data)
 
         msg = (
             "Unable to decode as exceptions were raised by all available plugins:\n"
@@ -166,9 +170,7 @@ class TestOpenJpegDecoder:
             "to 16 are supported"
         )
         with pytest.raises(RuntimeError, match=msg):
-            decoder.as_array(
-                ds, decoding_plugin="pillow", bits_stored=17, bits_allocated=32
-            )
+            decoder.as_array(ds, decoding_plugin="pillow")
 
     def test_multisample_16bit_raises(self):
         """Test that > 8-bit RGB datasets raise exception"""
diff --git a/tests/pixels/test_utils.py b/tests/pixels/test_utils.py
index 18e69fc3c..ec53498aa 100644
--- a/tests/pixels/test_utils.py
+++ b/tests/pixels/test_utils.py
@@ -1181,12 +1181,14 @@ class TestGetJ2KParameters:
             params = get_j2k_parameters(base + bytes([ii]))
             assert ii - 127 == params["precision"]
             assert params["is_signed"]
+            assert params["jp2"] is False
 
         # Unsigned
         for ii in range(7, 16):
             params = get_j2k_parameters(base + bytes([ii]))
             assert ii + 1 == params["precision"]
             assert not params["is_signed"]
+            assert params["jp2"] is False
 
     def test_not_j2k(self):
         """Test result when no JPEG2K SOF marker present"""
@@ -1203,6 +1205,14 @@ class TestGetJ2KParameters:
         assert {} == get_j2k_parameters(b"")
         assert {} == get_j2k_parameters(b"\xff\x4f\xff\x51" + b"\x00" * 20)
 
+    def test_jp2(self):
+        """Test result when JP2 file format is used."""
+        ds = J2KR_08_08_3_0_1F_YBR_RCT.ds
+        info = get_j2k_parameters(get_frame(ds.PixelData, 0))
+        assert info["precision"] == 8
+        assert info["is_signed"] is False
+        assert info["jp2"] is True
+
 
 class TestGetNrFrames:
     """Tests for get_nr_frames()."""