Repository: pydicom/pydicom

Issue:
Dataset.pixel_array doesn't change unless PixelData does
#### Description
Currently `ds.pixel_array` produces a numpy array that depends on element values for Rows, Columns, Samples Per Pixel, etc, however the code for `ds.pixel_array` only changes the returned array if the value for `ds.PixelData` changes. This may lead to confusion/undesirable behaviour if the values for related elements are changed after `ds.pixel_array` is called but not the underlying pixel data.

I can't think of any real use cases except maybe in an interactive session when debugging a non-conformant dataset, but I suggest we change the way `Dataset._pixel_id` is calculated so that it takes into account changes in related elements as well.



Discussion:

Patch:
diff --git a/pydicom/dataset.py b/pydicom/dataset.py
--- a/pydicom/dataset.py
+++ b/pydicom/dataset.py
@@ -34,8 +34,9 @@
 from pydicom.datadict import (tag_for_keyword, keyword_for_tag,
                               repeater_has_keyword)
 from pydicom.dataelem import DataElement, DataElement_from_raw, RawDataElement
-from pydicom.pixel_data_handlers.util import (convert_color_space,
-                                              reshape_pixel_array)
+from pydicom.pixel_data_handlers.util import (
+    convert_color_space, reshape_pixel_array, get_image_pixel_ids
+)
 from pydicom.tag import Tag, BaseTag, tag_in_exception
 from pydicom.uid import (ExplicitVRLittleEndian, ImplicitVRLittleEndian,
                          ExplicitVRBigEndian, PYDICOM_IMPLEMENTATION_UID)
@@ -1302,20 +1303,11 @@ def convert_pixel_data(self, handler_name=''):
         decompressed and any related data elements are changed accordingly.
         """
         # Check if already have converted to a NumPy array
-        # Also check if self.PixelData has changed. If so, get new NumPy array
-        keywords = ['PixelData', 'FloatPixelData', 'DoubleFloatPixelData']
-        px_keyword = [kw for kw in keywords if kw in self]
-        if len(px_keyword) != 1:
-            raise AttributeError(
-                "Unable to convert the pixel data: one of Pixel Data, Float "
-                "Pixel Data or Double Float Pixel Data must be present in "
-                "the dataset"
-            )
-
+        # Also check if pixel data has changed. If so, get new NumPy array
         already_have = True
         if not hasattr(self, "_pixel_array"):
             already_have = False
-        elif self._pixel_id != id(getattr(self, px_keyword[0])):
+        elif self._pixel_id != get_image_pixel_ids(self):
             already_have = False
 
         if already_have:
@@ -1451,9 +1443,7 @@ def _do_pixel_data_conversion(self, handler):
                 self._pixel_array, 'YBR_FULL', 'RGB'
             )
 
-        keywords = ['PixelData', 'FloatPixelData', 'DoubleFloatPixelData']
-        px_keyword = [kw for kw in keywords if kw in self]
-        self._pixel_id = id(getattr(self, px_keyword[0]))
+        self._pixel_id = get_image_pixel_ids(self)
 
     def decompress(self, handler_name=''):
         """Decompresses *Pixel Data* and modifies the :class:`Dataset`
diff --git a/pydicom/pixel_data_handlers/util.py b/pydicom/pixel_data_handlers/util.py
--- a/pydicom/pixel_data_handlers/util.py
+++ b/pydicom/pixel_data_handlers/util.py
@@ -750,6 +750,59 @@ def get_expected_length(ds, unit='bytes'):
     return length
 
 
+def get_image_pixel_ids(ds):
+    """Return a dict of the pixel data affecting element's :func:`id` values.
+
+    +------------------------------------------------+
+    | Element                                        |
+    +-------------+---------------------------+------+
+    | Tag         | Keyword                   | Type |
+    +=============+===========================+======+
+    | (0028,0002) | SamplesPerPixel           | 1    |
+    +-------------+---------------------------+------+
+    | (0028,0004) | PhotometricInterpretation | 1    |
+    +-------------+---------------------------+------+
+    | (0028,0006) | PlanarConfiguration       | 1C   |
+    +-------------+---------------------------+------+
+    | (0028,0008) | NumberOfFrames            | 1C   |
+    +-------------+---------------------------+------+
+    | (0028,0010) | Rows                      | 1    |
+    +-------------+---------------------------+------+
+    | (0028,0011) | Columns                   | 1    |
+    +-------------+---------------------------+------+
+    | (0028,0100) | BitsAllocated             | 1    |
+    +-------------+---------------------------+------+
+    | (0028,0103) | PixelRepresentation       | 1    |
+    +-------------+---------------------------+------+
+    | (7FE0,0008) | FloatPixelData            | 1C   |
+    +-------------+---------------------------+------+
+    | (7FE0,0009) | DoubleFloatPixelData      | 1C   |
+    +-------------+---------------------------+------+
+    | (7FE0,0010) | PixelData                 | 1C   |
+    +-------------+---------------------------+------+
+
+    Parameters
+    ----------
+    ds : Dataset
+        The :class:`~pydicom.dataset.Dataset` containing the pixel data.
+
+    Returns
+    -------
+    dict
+        A dict containing the :func:`id` values for the elements that affect
+        the pixel data.
+
+    """
+    keywords = [
+        'SamplesPerPixel', 'PhotometricInterpretation', 'PlanarConfiguration',
+        'NumberOfFrames', 'Rows', 'Columns', 'BitsAllocated',
+        'PixelRepresentation', 'FloatPixelData', 'DoubleFloatPixelData',
+        'PixelData'
+    ]
+
+    return {kw: id(getattr(ds, kw, None)) for kw in keywords}
+
+
 def pixel_dtype(ds, as_float=False):
     """Return a :class:`numpy.dtype` for the pixel data in `ds`.
 


Test patch:
diff --git a/pydicom/tests/test_dataset.py b/pydicom/tests/test_dataset.py
--- a/pydicom/tests/test_dataset.py
+++ b/pydicom/tests/test_dataset.py
@@ -11,6 +11,7 @@
 from pydicom import dcmread
 from pydicom.filebase import DicomBytesIO
 from pydicom.overlay_data_handlers import numpy_handler as NP_HANDLER
+from pydicom.pixel_data_handlers.util import get_image_pixel_ids
 from pydicom.sequence import Sequence
 from pydicom.tag import Tag
 from pydicom.uid import (
@@ -1142,10 +1143,10 @@ def test_pixel_array_already_have(self):
         # Test that _pixel_array is returned unchanged unless required
         fpath = get_testdata_files("CT_small.dcm")[0]
         ds = dcmread(fpath)
-        ds._pixel_id = id(ds.PixelData)
+        ds._pixel_id = get_image_pixel_ids(ds)
         ds._pixel_array = 'Test Value'
         ds.convert_pixel_data()
-        assert id(ds.PixelData) == ds._pixel_id
+        assert get_image_pixel_ids(ds) == ds._pixel_id
         assert 'Test Value' == ds._pixel_array
 
     def test_pixel_array_id_changed(self):
@@ -1277,17 +1278,6 @@ def test_update_with_dataset(self):
         ds2.update(ds)
         assert 'TestC' == ds2.PatientName
 
-    def test_convert_pixel_data_no_px(self):
-        """Test convert_pixel_data() with no pixel data elements."""
-        ds = Dataset()
-        msg = (
-            r"Unable to convert the pixel data: one of Pixel Data, Float "
-            r"Pixel Data or Double Float Pixel Data must be present in "
-            r"the dataset"
-        )
-        with pytest.raises(AttributeError, match=msg):
-            ds.convert_pixel_data()
-
 
 class TestDatasetElements(object):
     """Test valid assignments of data elements"""
diff --git a/pydicom/tests/test_numpy_pixel_data.py b/pydicom/tests/test_numpy_pixel_data.py
--- a/pydicom/tests/test_numpy_pixel_data.py
+++ b/pydicom/tests/test_numpy_pixel_data.py
@@ -146,6 +146,9 @@
 JPEG_2K = get_testdata_files("JPEG2000.dcm")[0]
 # RLE Lossless
 RLE = get_testdata_files("MR_small_RLE.dcm")[0]
+# No Image Pixel module
+NO_PIXEL = get_testdata_files("rtplan.dcm")[0]
+
 
 # Transfer Syntaxes (non-retired + Explicit VR Big Endian)
 SUPPORTED_SYNTAXES = [
@@ -471,6 +474,17 @@ def needs_convert(ds):
         # Reset
         NP_HANDLER.needs_to_convert_to_RGB = orig_fn
 
+    def test_dataset_pixel_array_no_pixels(self):
+        """Test good exception message if no pixel data in dataset."""
+        ds = dcmread(NO_PIXEL)
+        msg = (
+            r"Unable to convert the pixel data: one of Pixel Data, Float "
+            r"Pixel Data or Double Float Pixel Data must be present in the "
+            r"dataset"
+        )
+        with pytest.raises(AttributeError, match=msg):
+            ds.pixel_array
+
     @pytest.mark.parametrize("fpath, data", REFERENCE_DATA_UNSUPPORTED)
     def test_can_access_unsupported_dataset(self, fpath, data):
         """Test can read and access elements in unsupported datasets."""