Repository: pydicom/pydicom

Issue:
Incorrect work of pixel_array if value of tag NumberOfFrames is 0
**Describe the bug**
I got a hold of datasets, which has tag NumberOfFrames but its value is zero. So when I tried to get pixel_array I got unexpected error.

**Expected behavior**
Some kind of handling such exception, like interpreting that it's value is 1 at least, instead of actually taking zero and getting errors. 

**Steps To Reproduce**
```
from pydicom import Dataset, dcmread
ds = dcmread("mentioned.dcm")
print(ds.pixel_array)
```

Such code with that dicom file causes error.
Alternatively if I set 1 to NumberOfFrames than it starts to work correctly.
```
from pydicom import Dataset, dcmread
ds = dcmread("mentioned.dcm")
ds.NumberOfFrames = 1
print(ds.pixel_array)
```

Don't have now DICOM file for reference, but if you need I will try to provide it.


Discussion:

Patch:
diff --git a/doc/release_notes/v3.0.0.rst b/doc/release_notes/v3.0.0.rst
index 23f9153cf..7762e2504 100644
--- a/doc/release_notes/v3.0.0.rst
+++ b/doc/release_notes/v3.0.0.rst
@@ -7,6 +7,8 @@ Changes
 * compat module removed
 * values with VR AE with an incorrect value length are now handled
   gracefully (extra bytes are ignored with a warning)
+* a value of 0 for ``NumberOfFrames`` is now handled as 1 frame, with a user warning issued
+  on reading the pixel data (:issue:`1844`)
 
 Enhancements
 ------------
diff --git a/src/pydicom/dataset.py b/src/pydicom/dataset.py
index 48cd66342..182f662fc 100644
--- a/src/pydicom/dataset.py
+++ b/src/pydicom/dataset.py
@@ -73,6 +73,7 @@ from pydicom.pixel_data_handlers.util import (
     convert_color_space,
     reshape_pixel_array,
     get_image_pixel_ids,
+    get_nr_frames,
 )
 from pydicom.tag import Tag, BaseTag, tag_in_exception, TagType
 from pydicom.uid import (
@@ -1761,7 +1762,7 @@ class Dataset:
         encoded = [f for f in frame_iterator]
 
         # Encapsulate the encoded *Pixel Data*
-        nr_frames = getattr(self, "NumberOfFrames", 1) or 1
+        nr_frames = get_nr_frames(self)
         total = (nr_frames - 1) * 8 + sum([len(f) for f in encoded[:-1]])
         if encapsulate_ext or total > 2**32 - 1:
             (
diff --git a/src/pydicom/encoders/base.py b/src/pydicom/encoders/base.py
index 9ce18fec3..044f9c4bf 100644
--- a/src/pydicom/encoders/base.py
+++ b/src/pydicom/encoders/base.py
@@ -6,6 +6,7 @@ import sys
 from typing import Union, cast, TYPE_CHECKING, Any
 from collections.abc import Callable, Iterator, Iterable
 
+from pydicom.pixel_data_handlers.util import get_nr_frames
 from pydicom.uid import (
     UID,
     JPEGBaseline8Bit,
@@ -400,7 +401,7 @@ class Encoder:
         from pydicom.dataset import Dataset
 
         if isinstance(src, Dataset):
-            nr_frames = cast(str | None, src.get("NumberOfFrames", 1))
+            nr_frames = get_nr_frames(src, warn=False)
             for idx in range(int(nr_frames or 1)):
                 yield self._encode_dataset(
                     src, idx, encoding_plugin, decoding_plugin, **kwargs
@@ -474,7 +475,7 @@ class Encoder:
         photometric_interpretation = cast(str, ds.PhotometricInterpretation)
 
         # IS, may be missing, None or "1", "2", ...
-        nr_frames = cast(str | None, ds.get("NumberOfFrames", 1))
+        nr_frames = get_nr_frames(ds, warn=False)
 
         return {
             "rows": rows,
diff --git a/src/pydicom/pixel_data_handlers/gdcm_handler.py b/src/pydicom/pixel_data_handlers/gdcm_handler.py
index f6e99e4c0..0215d687a 100644
--- a/src/pydicom/pixel_data_handlers/gdcm_handler.py
+++ b/src/pydicom/pixel_data_handlers/gdcm_handler.py
@@ -36,6 +36,7 @@ from pydicom.pixel_data_handlers.util import (
     get_expected_length,
     pixel_dtype,
     get_j2k_parameters,
+    get_nr_frames,
 )
 
 
@@ -112,7 +113,7 @@ def create_data_element(ds: "Dataset") -> "DataElement":
     tsyntax = ds.file_meta.TransferSyntaxUID
     data_element = gdcm.DataElement(gdcm.Tag(0x7FE0, 0x0010))
     if tsyntax.is_compressed:
-        if getattr(ds, "NumberOfFrames", 1) > 1:
+        if get_nr_frames(ds, warn=False) > 1:
             pixel_data_sequence = pydicom.encaps.decode_data_sequence(ds.PixelData)
         else:
             pixel_data_sequence = [pydicom.encaps.defragment_data(ds.PixelData)]
@@ -145,7 +146,7 @@ def create_image(ds: "Dataset", data_element: "DataElement") -> "gdcm.Image":
     gdcm.Image
     """
     image = gdcm.Image()
-    number_of_frames = getattr(ds, "NumberOfFrames", 1)
+    number_of_frames = get_nr_frames(ds, warn=False)
     image.SetNumberOfDimensions(2 if number_of_frames == 1 else 3)
     image.SetDimensions((ds.Columns, ds.Rows, number_of_frames))
     image.SetDataElement(data_element)
@@ -283,7 +284,7 @@ def get_pixeldata(ds: "Dataset") -> "numpy.ndarray":
 
     tsyntax = ds.file_meta.TransferSyntaxUID
     if config.APPLY_J2K_CORRECTIONS and tsyntax in [JPEG2000, JPEG2000Lossless]:
-        nr_frames = getattr(ds, "NumberOfFrames", 1)
+        nr_frames = get_nr_frames(ds)
         codestream = next(generate_pixel_data(ds.PixelData, nr_frames))[0]
 
         params = get_j2k_parameters(codestream)
diff --git a/src/pydicom/pixel_data_handlers/jpeg_ls_handler.py b/src/pydicom/pixel_data_handlers/jpeg_ls_handler.py
index 96955deec..4cd5400df 100644
--- a/src/pydicom/pixel_data_handlers/jpeg_ls_handler.py
+++ b/src/pydicom/pixel_data_handlers/jpeg_ls_handler.py
@@ -20,7 +20,7 @@ except ImportError:
     HAVE_JPEGLS = False
 
 from pydicom.encaps import decode_data_sequence, defragment_data
-from pydicom.pixel_data_handlers.util import pixel_dtype
+from pydicom.pixel_data_handlers.util import pixel_dtype, get_nr_frames
 import pydicom.uid
 
 if TYPE_CHECKING:  # pragma: no cover
@@ -107,7 +107,7 @@ def get_pixeldata(ds: "Dataset") -> "numpy.ndarray":
 
     pixel_bytes = bytearray()
 
-    nr_frames = getattr(ds, "NumberOfFrames", 1) or 1
+    nr_frames = get_nr_frames(ds)
     if nr_frames > 1:
         for src in decode_data_sequence(ds.PixelData):
             frame = jpeg_ls.decode(numpy.frombuffer(src, dtype="u1"))
diff --git a/src/pydicom/pixel_data_handlers/pillow_handler.py b/src/pydicom/pixel_data_handlers/pillow_handler.py
index 94d12d572..321162a05 100644
--- a/src/pydicom/pixel_data_handlers/pillow_handler.py
+++ b/src/pydicom/pixel_data_handlers/pillow_handler.py
@@ -31,7 +31,11 @@ except ImportError:
 
 from pydicom import config
 from pydicom.encaps import defragment_data, decode_data_sequence
-from pydicom.pixel_data_handlers.util import pixel_dtype, get_j2k_parameters
+from pydicom.pixel_data_handlers.util import (
+    pixel_dtype,
+    get_j2k_parameters,
+    get_nr_frames,
+)
 from pydicom.uid import (
     UID,
     JPEG2000,
@@ -195,7 +199,7 @@ def get_pixeldata(ds: "Dataset") -> "numpy.ndarray":
     columns = cast(int, ds.Columns)
     bits_stored = cast(int, ds.BitsStored)
     bits_allocated = cast(int, ds.BitsAllocated)
-    nr_frames = getattr(ds, "NumberOfFrames", 1) or 1
+    nr_frames = get_nr_frames(ds)
 
     pixel_bytes = bytearray()
     if nr_frames > 1:
diff --git a/src/pydicom/pixel_data_handlers/pylibjpeg_handler.py b/src/pydicom/pixel_data_handlers/pylibjpeg_handler.py
index 79792e59c..fab903baa 100644
--- a/src/pydicom/pixel_data_handlers/pylibjpeg_handler.py
+++ b/src/pydicom/pixel_data_handlers/pylibjpeg_handler.py
@@ -103,6 +103,7 @@ from pydicom.pixel_data_handlers.util import (
     get_expected_length,
     reshape_pixel_array,
     get_j2k_parameters,
+    get_nr_frames,
 )
 from pydicom.uid import (
     JPEGBaseline8Bit,
@@ -262,7 +263,7 @@ def generate_frames(ds: "Dataset", reshape: bool = True) -> Iterable["np.ndarray
     decoder = _DECODERS[tsyntax]
     LOGGER.debug(f"Decoding {tsyntax.name} encoded Pixel Data using {decoder}")
 
-    nr_frames = getattr(ds, "NumberOfFrames", 1)
+    nr_frames = get_nr_frames(ds, warn=False)
     pixel_module = ds.group_dataset(0x0028)
     dtype = pixel_dtype(ds)
 
@@ -324,7 +325,7 @@ def get_pixeldata(ds: "Dataset") -> "np.ndarray":
         The contents of (7FE0,0010) *Pixel Data* as a 1D array.
     """
     expected_len = get_expected_length(ds, "pixels")
-    frame_len = expected_len // getattr(ds, "NumberOfFrames", 1)
+    frame_len = expected_len // get_nr_frames(ds)
     # Empty destination array for our decoded pixel data
     arr = np.empty(expected_len, pixel_dtype(ds))
 
diff --git a/src/pydicom/pixel_data_handlers/rle_handler.py b/src/pydicom/pixel_data_handlers/rle_handler.py
index d1861fd47..8b6c31203 100644
--- a/src/pydicom/pixel_data_handlers/rle_handler.py
+++ b/src/pydicom/pixel_data_handlers/rle_handler.py
@@ -49,7 +49,7 @@ except ImportError:
     HAVE_RLE = False
 
 from pydicom.encaps import decode_data_sequence, defragment_data
-from pydicom.pixel_data_handlers.util import pixel_dtype
+from pydicom.pixel_data_handlers.util import pixel_dtype, get_nr_frames
 from pydicom.encoders.native import _encode_frame
 import pydicom.uid
 
@@ -155,7 +155,7 @@ def get_pixeldata(ds: "Dataset", rle_segment_order: str = ">") -> "np.ndarray":
 
     nr_bits = cast(int, ds.BitsAllocated)
     nr_samples = cast(int, ds.SamplesPerPixel)
-    nr_frames = cast(int, getattr(ds, "NumberOfFrames", 1) or 1)
+    nr_frames = get_nr_frames(ds)
     rows = cast(int, ds.Rows)
     cols = cast(int, ds.Columns)
 
diff --git a/src/pydicom/pixel_data_handlers/util.py b/src/pydicom/pixel_data_handlers/util.py
index 8eb6d5f89..249050f46 100644
--- a/src/pydicom/pixel_data_handlers/util.py
+++ b/src/pydicom/pixel_data_handlers/util.py
@@ -1121,28 +1121,32 @@ def get_j2k_parameters(codestream: bytes) -> dict[str, object]:
     return {}
 
 
-def get_nr_frames(ds: "Dataset") -> int:
-    """Return NumberOfFrames or 1 if NumberOfFrames is None.
+def get_nr_frames(ds: "Dataset", warn: bool = True) -> int:
+    """Return NumberOfFrames or 1 if NumberOfFrames is None or 0.
 
     Parameters
     ----------
     ds : dataset.Dataset
         The :class:`~pydicom.dataset.Dataset` containing the Image Pixel module
         corresponding to the data in `arr`.
+    warn : bool
+        If ``True`` (the default), a warning is issued if NumberOfFrames
+        has an invalid value.
 
     Returns
     -------
     int
-        An integer for the NumberOfFrames or 1 if NumberOfFrames is None
+        An integer for the NumberOfFrames or 1 if NumberOfFrames is None or 0
     """
     nr_frames: int | None = getattr(ds, "NumberOfFrames", 1)
     # 'NumberOfFrames' may exist in the DICOM file but have value equal to None
-    if nr_frames is None:
-        warnings.warn(
-            "A value of None for (0028,0008) 'Number of Frames' is "
-            "non-conformant. It's recommended that this value be "
-            "changed to 1"
-        )
+    if not nr_frames:  # None or 0
+        if warn:
+            warnings.warn(
+                f"A value of {nr_frames} for (0028,0008) 'Number of Frames' is "
+                "non-conformant. It's recommended that this value be "
+                "changed to 1"
+            )
         nr_frames = 1
 
     return nr_frames
@@ -1403,12 +1407,6 @@ def reshape_pixel_array(ds: "Dataset", arr: "np.ndarray") -> "np.ndarray":
     nr_frames = get_nr_frames(ds)
     nr_samples = cast(int, ds.SamplesPerPixel)
 
-    if nr_frames < 1:
-        raise ValueError(
-            f"Unable to reshape the pixel array as a value of {nr_frames} for "
-            "(0028,0008) 'Number of Frames' is invalid."
-        )
-
     if nr_samples < 1:
         raise ValueError(
             f"Unable to reshape the pixel array as a value of {nr_samples} "


Test patch:
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
index c0e84a158..7d49c434d 100644
--- a/tests/test_dataset.py
+++ b/tests/test_dataset.py
@@ -1540,6 +1540,15 @@ class TestDataset:
         assert get_image_pixel_ids(ds) == ds._pixel_id
         assert "Test Value" == ds._pixel_array
 
+    @pytest.mark.skipif(not HAVE_NP, reason="Numpy is not available")
+    def test_invalid_nr_frames_warns(self):
+        """Test an invalid Number of Frames value shows an warning."""
+        fpath = get_testdata_file("CT_small.dcm")
+        ds = dcmread(fpath)
+        ds.NumberOfFrames = 0
+        with pytest.warns(UserWarning, match=r"value of 0 for \(0028,0008\)"):
+            assert ds.pixel_array is not None
+
     def test_pixel_array_id_changed(self):
         """Test that we try to get new pixel data if the id has changed."""
         fpath = get_testdata_file("CT_small.dcm")
diff --git a/tests/test_handler_util.py b/tests/test_handler_util.py
index 485d7b879..665b35958 100644
--- a/tests/test_handler_util.py
+++ b/tests/test_handler_util.py
@@ -874,12 +874,12 @@ class TestNumpy_ReshapePixelArray:
             assert (4, 5, 3) == arr.shape
             assert np.array_equal(arr, self.ref_1_3)
 
-    def test_invalid_nr_frames_raises(self):
-        """Test an invalid Number of Frames value raises exception."""
+    def test_invalid_nr_frames_warns(self):
+        """Test an invalid Number of Frames value shows an warning."""
         self.ds.SamplesPerPixel = 1
         self.ds.NumberOfFrames = 0
         # Need to escape brackets
-        with pytest.raises(ValueError, match=r"value of 0 for \(0028,0008\)"):
+        with pytest.warns(UserWarning, match=r"value of 0 for \(0028,0008\)"):
             reshape_pixel_array(self.ds, RESHAPE_ARRAYS["1frame_1sample"])
 
     def test_invalid_samples_raises(self):
@@ -2603,6 +2603,18 @@ class TestGetNrFrames:
         with pytest.warns(UserWarning, match=msg):
             assert 1 == get_nr_frames(ds)
 
+    def test_zero(self):
+        """Test warning when (0028,0008) 'Number of Frames' has a value of 0"""
+        ds = Dataset()
+        ds.NumberOfFrames = 0
+        msg = (
+            r"A value of 0 for \(0028,0008\) 'Number of Frames' is "
+            r"non-conformant. It's recommended that this value be "
+            r"changed to 1"
+        )
+        with pytest.warns(UserWarning, match=msg):
+            assert 1 == get_nr_frames(ds)
+
     def test_missing(self):
         """Test return value when (0028,0008) 'Number of Frames' does not
         exist"""