Repository: pydicom/pydicom

Issue:
deepcopy on dataset with private block fails
Recursion in deep copy when running:
```
    ds = pydicom.data.get_testdata_file("MR_small.dcm", read=True)
    block = ds.private_block(0x000b, "My company 001", create=True)
    ds2 = copy.deepcopy(ds)
```

```
../venv/lib/python3.11/site-packages/pydicom/dataset.py:2803: in _copy_implementation
    copied.__dict__[k] = copy_function(v)
E   RecursionError: maximum recursion depth exceeded while calling a Python object

```

The reason for this recursion may be that class PrivateBlock has a reference to it parent dataset.

**Your environment**
module       | version
------       | -------
platform     | Linux-6.7.9-arch1-1-x86_64-with-glibc2.39
Python       | 3.11.8 (main, Feb 12 2024, 14:50:05) [GCC 13.2.1 20230801]
pydicom      | 2.4.4
gdcm         | _module not found_
jpeg_ls      | _module not found_
numpy        | 1.26.4
PIL          | _module not found_
pylibjpeg    | 2.0.0
openjpeg     | 2.1.1
libjpeg      | 2.0.2



Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pydicom/pydicom/pull/2071?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) Report
Attention: Patch coverage is `93.33333%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 98.24%. 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 [(`edfafe2`)](https://app.codecov.io/gh/pydicom/pydicom/commit/edfafe26aad1ab3d58df3e167b49a8e62bc63cec?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom).

| [Files](https://app.codecov.io/gh/pydicom/pydicom/pull/2071?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) | Patch % | Lines |
|---|---|---|
| [src/pydicom/dataset.py](https://app.codecov.io/gh/pydicom/pydicom/pull/2071?src=pr&el=tree&filepath=src%2Fpydicom%2Fdataset.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom#diff-c3JjL3B5ZGljb20vZGF0YXNldC5weQ==) | 93.33% | [1 Missing :warning: ](https://app.codecov.io/gh/pydicom/pydicom/pull/2071?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) |

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


```diff
@@            Coverage Diff             @@
##             main    #2071      +/-   ##
==========================================
- Coverage   98.25%   98.24%   -0.01%     
==========================================
  Files          76       76              
  Lines       12471    12485      +14     
==========================================
+ Hits        12253    12266      +13     
- Misses        218      219       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pydicom/pydicom/pull/2071?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..253f8b32b 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
 -----
@@ -240,6 +242,8 @@ Fixes
 * Fixed a rare issue with converting pixel data to an :class:`~numpy.ndarray` when
   *Bits Stored* is less than *Bits Allocated* and the unused bits haven't been
   set to an appropriate value for correct interpretation of the data.
+* Fixed a ``RecursionError`` when using :func:`copy.deepcopy` with a dataset containing
+  a private block (:issue:`2025`)
 
 
 Deprecations
diff --git a/src/pydicom/dataset.py b/src/pydicom/dataset.py
index e65c4a35b..4e5f74946 100644
--- a/src/pydicom/dataset.py
+++ b/src/pydicom/dataset.py
@@ -209,6 +209,16 @@ class PrivateBlock:
         self.dataset.add_new(tag, VR, value)
         self.dataset[tag].private_creator = self.private_creator
 
+    def __deepcopy__(self, memo: Any) -> "PrivateBlock":
+        copied = self.__class__(
+            (self.group, self.private_creator),
+            self.dataset,
+            self.block_start >> 8,
+        )
+        memo[id(self)] = copied
+
+        return copied
+
 
 def _dict_equal(a: "Dataset", b: Any, exclude: list[str] | None = None) -> bool:
     """Common method for Dataset.__eq__ and FileDataset.__eq__
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()."""
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
index 5c2c08813..73014af6a 100644
--- a/tests/test_dataset.py
+++ b/tests/test_dataset.py
@@ -1141,6 +1141,23 @@ class TestDataset:
         item = ds.get_private_item(0x0009, 0x02, "Creator 2.0")
         assert 2 == item.value
 
+    def test_private_block_deepcopy(self):
+        """Test deepcopying a private block."""
+        ds = Dataset()
+        ds.private_block(0x000B, "Foo", create=True)
+
+        ds2 = copy.deepcopy(ds)
+        assert ds2[0x000B0010].value == "Foo"
+
+    def test_private_block_pickle(self):
+        """Test pickling a private block."""
+        ds = Dataset()
+        ds.private_block(0x000B, "Foo", create=True)
+
+        s = pickle.dumps({"ds": ds})
+        ds2 = pickle.loads(s)["ds"]
+        assert ds2[0x000B0010].value == "Foo"
+
     def test_private_creator_from_raw_ds(self):
         # regression test for #1078
         ct_filename = get_testdata_file("CT_small.dcm")
diff --git a/tests/test_overlay_np.py b/tests/test_overlay_np.py
index 3646e7a0a..3330626a6 100644
--- a/tests/test_overlay_np.py
+++ b/tests/test_overlay_np.py
@@ -130,12 +130,6 @@ class TestNumpy_NumpyHandler:
         assert arr.min() == 0
         assert 29 == sum(arr[422, 393:422])
 
-    @pytest.mark.skip(reason="No dataset available")
-    def test_little_1bit_1sample_3frame(self):
-        """Test pixel_array for little 1-bit, 1 sample/pixel, 3 frame."""
-        ds = dcmread(EXPL_1_1_3F)
-        ds.overlay_array(0x6000)
-
     def test_read_only(self):
         """Test for #717, returned array read-only."""
         ds = dcmread(EXPL_1_1_1F)
diff --git a/tests/test_pillow_pixel_data.py b/tests/test_pillow_pixel_data.py
index 24e0912cf..c6053c487 100644
--- a/tests/test_pillow_pixel_data.py
+++ b/tests/test_pillow_pixel_data.py
@@ -280,7 +280,6 @@ JPEG_MATCHING_DATASETS = [
             (192, 192, 192),
             (255, 255, 255),
         ],
-        marks=pytest.mark.xfail(reason="Resulting image is a bad match"),
     ),
     pytest.param(
         JPGB_08_08_3_0_1F_YBR_FULL_422_422,
@@ -313,7 +312,6 @@ JPEG_MATCHING_DATASETS = [
             (192, 192, 192),
             (255, 255, 255),
         ],
-        marks=pytest.mark.xfail(reason="Resulting image is a bad match"),
     ),
     pytest.param(
         JPGB_08_08_3_0_1F_YBR_FULL_422,
@@ -699,7 +697,14 @@ class TestPillowHandler_JPEG:
             assert tuple(arr[85, 50, :]) == values[8]
             assert tuple(arr[95, 50, :]) == values[9]
 
-        assert np.array_equal(arr, ref)
+        close = [
+            JPGB_08_08_3_0_1F_YBR_FULL_422_411,
+            JPGB_08_08_3_0_1F_YBR_FULL_411,
+        ]
+        if fpath in close:
+            assert np.allclose(arr, ref, atol=1)
+        else:
+            assert np.array_equal(arr, ref)
 
     def test_color_3d(self):
         """Test decoding JPEG with pillow handler succeeds."""