Repository: pydicom/pydicom

Issue:
Generators in encaps don't handle single fragment per frame correctly with no BOT value
#### Description
Generators in `encaps.py` handling of encapsulated pixel data incorrect when the Basic Offset Table has no value and each frame is a single fragment.

#### Steps/Code to Reproduce
```python
from pydicom import dcmread
from pydicom.encaps import generate_pixel_data_frame

fpath = 'pydicom/data/test_files/emri_small_jpeg_2k_lossless.dcm'
ds = dcmread(fpath)
ds.NumberOfFrames  # 10
frame_generator = generate_pixel_data_frame(ds.PixelData)
next(frame_generator)
next(frame_generator) # StopIteration raised
```

#### Expected Results
All 10 frames of the pixel data should be accessible.

#### Actual Results
Only the first frame is accessible.

Discussion:

Patch:
diff --git a/doc/release_notes/v1.4.0.rst b/doc/release_notes/v1.4.0.rst
index 9eae3372a..5fbe878ea 100644
--- a/doc/release_notes/v1.4.0.rst
+++ b/doc/release_notes/v1.4.0.rst
@@ -26,6 +26,10 @@ Fixes
   longer returns RGB data when the (0028,0004) *Photometric Interpretation* is
   YBR_FULL or YBR_FULL_422. (:issue:`263`, :issue:`273`, :issue:`826`)
 * Avoid possible high memory usage while reading sequences (:issue:`994`)
+* Fixed :func:`~pydicom.encaps.generate_pixel_data` not returning all available
+  frames when the Basic Offset Table was empty. This may still occur when
+  multiple fragments per frame are present for non-JPEG transfer syntaxes or
+  where no JPEG EOI/EOC marker is present (:issue:`685`)
 
 Enhancements
 ............
@@ -41,7 +45,7 @@ Enhancements
   applying color palette LUTs (:issue:`205`)
 * Added :func:`~pydicom.pixel_data_handlers.util.apply_modality_lut` function
   for applying modality LUTs.
-* Added :func:`~pydicom.data.get_palette_files` for retrieving well-known
+* Added :func:`~pydicom.data.data_manager.get_palette_files` for retrieving well-known
   palette color datasets.
 * Raise on end of file errors if `config.enforce_valid_values` is set
   (:issue:`277`)
@@ -56,3 +60,8 @@ Enhancements
 * Added support for (7fe0,0008) *Float Pixel Data* and (7fe0,0009) *Double
   Float Pixel Data* to :attr:`~pydicom.dataset.Dataset.pixel_array`
   (:issue:`452`)
+
+Changes
+.......
+  
+* :func:`~pydicom.encaps.get_frame_offsets` now returns whether the Basic Offset Table is empty and a list of the offsets.
diff --git a/pydicom/dataset.py b/pydicom/dataset.py
index 0e10642de..c8e8698e0 100644
--- a/pydicom/dataset.py
+++ b/pydicom/dataset.py
@@ -34,8 +34,9 @@ from pydicom.datadict import dictionary_VR
 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)
@@ -1300,20 +1301,11 @@ class Dataset(dict):
         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:
@@ -1449,9 +1441,7 @@ class Dataset(dict):
                 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/encaps.py b/pydicom/encaps.py
index e0b3d4aa3..e5282052a 100644
--- a/pydicom/encaps.py
+++ b/pydicom/encaps.py
@@ -2,6 +2,7 @@
 """Functions for working with encapsulated (compressed) pixel data."""
 
 from struct import pack
+import warnings
 
 import pydicom.config
 from pydicom.filebase import DicomBytesIO
@@ -50,9 +51,10 @@ def get_frame_offsets(fp):
 
     Returns
     -------
-    list of int
-        The byte offsets to the first fragment of each frame, as measured from
-        the start of the first item following the Basic Offset Table item.
+    bool, list of int
+        Whether or not the BOT is empty, and a list of the byte offsets
+        to the first fragment of each frame, as measured from the start of the
+        first item following the Basic Offset Table item.
 
     Raises
     ------
@@ -86,7 +88,41 @@ def get_frame_offsets(fp):
     for ii in range(length // 4):
         offsets.append(fp.read_UL())
 
-    return offsets
+    return bool(length), offsets
+
+
+def get_nr_fragments(fp):
+    """Return the number of fragments in `fp`."""
+    if not fp.is_little_endian:
+        raise ValueError("'fp.is_little_endian' must be True")
+
+    nr_fragments = 0
+    start = fp.tell()
+    while True:
+        try:
+            tag = Tag(fp.read_tag())
+        except EOFError:
+            break
+
+        if tag == 0xFFFEE000:
+            # Item
+            length = fp.read_UL()
+            if length == 0xFFFFFFFF:
+                raise ValueError("Undefined item length at offset {} when "
+                                 "parsing the encapsulated pixel data "
+                                 "fragments.".format(fp.tell() - 4))
+            fp.seek(length, 1)
+            nr_fragments += 1
+        elif tag == 0xFFFEE0DD:
+            # Sequence Delimiter
+            break
+        else:
+            raise ValueError("Unexpected tag '{}' at offset {} when parsing "
+                             "the encapsulated pixel data fragment items."
+                             .format(tag, fp.tell() - 4))
+
+    fp.seek(start)
+    return nr_fragments
 
 
 def generate_pixel_data_fragment(fp):
@@ -175,7 +211,7 @@ def generate_pixel_data_fragment(fp):
                              .format(tag, fp.tell() - 4))
 
 
-def generate_pixel_data_frame(bytestream):
+def generate_pixel_data_frame(bytestream, nr_frames=None):
     """Yield an encapsulated pixel data frame.
 
     Parameters
@@ -184,6 +220,10 @@ def generate_pixel_data_frame(bytestream):
         The value of the (7fe0, 0010) *Pixel Data* element from an encapsulated
         dataset. The Basic Offset Table item should be present and the
         Sequence Delimiter item may or may not be present.
+    nr_frames : int, optional
+        Required for multi-frame data when the Basic Offset Table is empty
+        and there are multiple frames. This should be the value of (0028,0008)
+        *Number of Frames*.
 
     Yields
     ------
@@ -194,11 +234,11 @@ def generate_pixel_data_frame(bytestream):
     ----------
     DICOM Standard Part 5, :dcm:`Annex A <part05/chapter_A.html>`
     """
-    for fragmented_frame in generate_pixel_data(bytestream):
+    for fragmented_frame in generate_pixel_data(bytestream, nr_frames):
         yield b''.join(fragmented_frame)
 
 
-def generate_pixel_data(bytestream):
+def generate_pixel_data(bytestream, nr_frames=None):
     """Yield an encapsulated pixel data frame.
 
     For the following transfer syntaxes, a fragment may not contain encoded
@@ -230,6 +270,10 @@ def generate_pixel_data(bytestream):
         The value of the (7fe0, 0010) *Pixel Data* element from an encapsulated
         dataset. The Basic Offset Table item should be present and the
         Sequence Delimiter item may or may not be present.
+    nr_frames : int, optional
+        Required for multi-frame data when the Basic Offset Table is empty
+        and there are multiple frames. This should be the value of (0028,0008)
+        *Number of Frames*.
 
     Yields
     -------
@@ -237,6 +281,14 @@ def generate_pixel_data(bytestream):
         An encapsulated pixel data frame, with the contents of the
         :class:`tuple` the frame's fragmented data.
 
+    Notes
+    -----
+    If the Basic Offset Table is empty and there are multiple fragments per
+    frame then an attempt will be made to locate the frame boundaries by
+    searching for the JPEG/JPEG-LS/JPEG2000 EOI/EOC marker (``0xFFD9``). If the
+    marker is not present or the pixel data hasn't been compressed using one of
+    the JPEG standards then the generated pixel data may be incorrect.
+
     References
     ----------
     DICOM Standard Part 5, :dcm:`Annex A <part05/chapter_A.html>`
@@ -245,28 +297,90 @@ def generate_pixel_data(bytestream):
     fp.is_little_endian = True
 
     # `offsets` is a list of the offsets to the first fragment in each frame
-    offsets = get_frame_offsets(fp)
+    has_bot, offsets = get_frame_offsets(fp)
     # Doesn't actually matter what the last offset value is, as long as its
     # greater than the total number of bytes in the fragments
     offsets.append(len(bytestream))
 
-    frame = []
-    frame_length = 0
-    frame_number = 0
-    for fragment in generate_pixel_data_fragment(fp):
-        if frame_length < offsets[frame_number + 1]:
-            frame.append(fragment)
+    if has_bot:
+        # Use the BOT to determine the frame boundaries
+        frame = []
+        frame_length = 0
+        frame_number = 0
+        for fragment in generate_pixel_data_fragment(fp):
+            if frame_length < offsets[frame_number + 1]:
+                frame.append(fragment)
+            else:
+                yield tuple(frame)
+                frame = [fragment]
+                frame_number += 1
+
+            frame_length += len(fragment) + 8
+
+        # Yield the final frame - required here because the frame_length will
+        # never be greater than offsets[-1] and thus never trigger the final
+        # yield within the for block
+        yield tuple(frame)
+    else:
+        nr_fragments = get_nr_fragments(fp)
+        if nr_fragments == 1:
+            # Single fragment: 1 frame
+            for fragment in generate_pixel_data_fragment(fp):
+                yield tuple([fragment])
+        elif nr_frames:
+            # Multiple fragments: 1 or more frames
+            if nr_fragments == nr_frames:
+                # 1 fragment per frame
+                # Covers RLE and others if 1:1 ratio
+                for fragment in generate_pixel_data_fragment(fp):
+                    yield tuple([fragment])
+            elif nr_frames == 1:
+                # Multiple fragments: 1 frame
+                frame = []
+                for fragment in generate_pixel_data_fragment(fp):
+                    frame.append(fragment)
+                yield tuple(frame)
+            elif nr_fragments > nr_frames:
+                # More fragments then frames
+                # Search for JPEG/JPEG-LS/JPEG2K EOI/EOC marker
+                # Should be the last two bytes of a frame
+                # May fail if no EOI/EOC marker or not JPEG
+                eoi_marker = b'\xff\xd9'
+                frame = []
+                frame_nr = 0
+                for fragment in generate_pixel_data_fragment(fp):
+                    frame.append(fragment)
+                    if eoi_marker in fragment[-10:]:
+                        yield tuple(frame)
+                        frame_nr += 1
+                        frame = []
+
+                if frame or frame_nr != nr_frames:
+                    # If data in `frame` or fewer frames yielded then we
+                    #   must've missed a frame boundary
+                    warnings.warn(
+                        "The end of the encapsulated pixel data has been "
+                        "reached but one or more frame boundaries may have "
+                        "been missed; please confirm that the generated frame "
+                        "data is correct"
+                    )
+                    if frame:
+                        yield tuple(frame)
+
+            else:
+                # Fewer fragments than frames
+                raise ValueError(
+                    "Unable to parse encapsulated pixel data as the Basic "
+                    "Offset Table is empty and there are fewer fragments then "
+                    "frames; the dataset may be corrupt"
+                )
         else:
-            yield tuple(frame)
-            frame = [fragment]
-            frame_number += 1
-
-        frame_length += len(fragment) + 8
-
-    # Yield the final frame - required here because the frame_length will
-    # never be greater than offsets[-1] and thus never trigger the final yield
-    # within the for block
-    yield tuple(frame)
+            # Multiple fragments but unknown number of frames
+            raise ValueError(
+                "Unable to determine the frame boundaries for the "
+                "encapsulated pixel data as the Basic Offset Table is empty "
+                "and `nr_frames` parameter is None"
+            )
 
 
 def decode_data_sequence(data):
diff --git a/pydicom/pixel_data_handlers/util.py b/pydicom/pixel_data_handlers/util.py
index a03a90dcf..ff324bada 100644
--- 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
index 7efcda5cf..19909325b 100644
--- a/pydicom/tests/test_dataset.py
+++ b/pydicom/tests/test_dataset.py
@@ -11,6 +11,7 @@ from pydicom.dataset import Dataset, FileDataset, validate_file_meta
 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 @@ class TestDataset(object):
         # 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 @@ class TestDataset(object):
         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_encaps.py b/pydicom/tests/test_encaps.py
index 15e0d3cd8..af5d4a980 100644
--- a/pydicom/tests/test_encaps.py
+++ b/pydicom/tests/test_encaps.py
@@ -4,10 +4,11 @@
 import pytest
 
 from pydicom import dcmread
-from pydicom.data import get_testdata_files
+from pydicom.data import get_testdata_file
 from pydicom.encaps import (
     generate_pixel_data_fragment,
     get_frame_offsets,
+    get_nr_fragments,
     generate_pixel_data_frame,
     generate_pixel_data,
     decode_data_sequence,
@@ -20,7 +21,7 @@ from pydicom.encaps import (
 from pydicom.filebase import DicomBytesIO
 
 
-JP2K_10FRAME_NOBOT = get_testdata_files('emri_small_jpeg_2k_lossless.dcm')[0]
+JP2K_10FRAME_NOBOT = get_testdata_file('emri_small_jpeg_2k_lossless.dcm')
 
 
 class TestGetFrameOffsets(object):
@@ -57,7 +58,7 @@ class TestGetFrameOffsets(object):
                      b'\x00\x00\x00\x00'
         fp = DicomBytesIO(bytestream)
         fp.is_little_endian = True
-        assert [0] == get_frame_offsets(fp)
+        assert (False, [0]) == get_frame_offsets(fp)
 
     def test_multi_frame(self):
         """Test reading multi-frame BOT item"""
@@ -69,7 +70,7 @@ class TestGetFrameOffsets(object):
                      b'\xFE\x37\x00\x00'
         fp = DicomBytesIO(bytestream)
         fp.is_little_endian = True
-        assert [0, 4966, 9716, 14334] == get_frame_offsets(fp)
+        assert (True, [0, 4966, 9716, 14334]) == get_frame_offsets(fp)
 
     def test_single_frame(self):
         """Test reading single-frame BOT item"""
@@ -78,7 +79,7 @@ class TestGetFrameOffsets(object):
                      b'\x00\x00\x00\x00'
         fp = DicomBytesIO(bytestream)
         fp.is_little_endian = True
-        assert [0] == get_frame_offsets(fp)
+        assert (True, [0]) == get_frame_offsets(fp)
 
     def test_not_little_endian(self):
         """Test reading big endian raises exception"""
@@ -91,6 +92,113 @@ class TestGetFrameOffsets(object):
             get_frame_offsets(fp)
 
 
+class TestGetNrFragments(object):
+    """Test encaps.get_nr_fragments"""
+    def test_item_undefined_length(self):
+        """Test exception raised if item length undefined."""
+        bytestream = (
+            b'\xFE\xFF\x00\xE0'
+            b'\xFF\xFF\xFF\xFF'
+            b'\x00\x00\x00\x01'
+        )
+        fp = DicomBytesIO(bytestream)
+        fp.is_little_endian = True
+        with pytest.raises(ValueError):
+            get_nr_fragments(fp)
+
+    def test_item_sequence_delimiter(self):
+        """Test that the fragments are returned if seq delimiter hit."""
+        bytestream = (
+            b'\xFE\xFF\x00\xE0'
+            b'\x04\x00\x00\x00'
+            b'\x01\x00\x00\x00'
+            b'\xFE\xFF\xDD\xE0'
+            b'\x00\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0'
+            b'\x04\x00\x00\x00'
+            b'\x02\x00\x00\x00'
+        )
+        fp = DicomBytesIO(bytestream)
+        fp.is_little_endian = True
+        assert 1 == get_nr_fragments(fp)
+
+    def test_item_bad_tag(self):
+        """Test exception raised if item has unexpected tag"""
+        bytestream = (
+            b'\xFE\xFF\x00\xE0'
+            b'\x04\x00\x00\x00'
+            b'\x01\x00\x00\x00'
+            b'\x10\x00\x10\x00'
+            b'\x00\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0'
+            b'\x04\x00\x00\x00'
+            b'\x02\x00\x00\x00'
+        )
+        fp = DicomBytesIO(bytestream)
+        fp.is_little_endian = True
+        msg = (
+            r"Unexpected tag '\(0010, 0010\)' at offset 12 when parsing the "
+            r"encapsulated pixel data fragment items."
+        )
+        with pytest.raises(ValueError, match=msg):
+            get_nr_fragments(fp)
+
+    def test_single_fragment_no_delimiter(self):
+        """Test single fragment is returned OK"""
+        bytestream = b'\xFE\xFF\x00\xE0' \
+                     b'\x04\x00\x00\x00' \
+                     b'\x01\x00\x00\x00'
+        fp = DicomBytesIO(bytestream)
+        fp.is_little_endian = True
+        assert 1 == get_nr_fragments(fp)
+
+    def test_multi_fragments_no_delimiter(self):
+        """Test multi fragments are returned OK"""
+        bytestream = b'\xFE\xFF\x00\xE0' \
+                     b'\x04\x00\x00\x00' \
+                     b'\x01\x00\x00\x00' \
+                     b'\xFE\xFF\x00\xE0' \
+                     b'\x06\x00\x00\x00' \
+                     b'\x01\x02\x03\x04\x05\x06'
+        fp = DicomBytesIO(bytestream)
+        fp.is_little_endian = True
+        assert 2 == get_nr_fragments(fp)
+
+    def test_single_fragment_delimiter(self):
+        """Test single fragment is returned OK with sequence delimiter item"""
+        bytestream = b'\xFE\xFF\x00\xE0' \
+                     b'\x04\x00\x00\x00' \
+                     b'\x01\x00\x00\x00' \
+                     b'\xFE\xFF\xDD\xE0'
+        fp = DicomBytesIO(bytestream)
+        fp.is_little_endian = True
+        assert 1 == get_nr_fragments(fp)
+
+    def test_multi_fragments_delimiter(self):
+        """Test multi fragments are returned OK with sequence delimiter item"""
+        bytestream = b'\xFE\xFF\x00\xE0' \
+                     b'\x04\x00\x00\x00' \
+                     b'\x01\x00\x00\x00' \
+                     b'\xFE\xFF\x00\xE0' \
+                     b'\x06\x00\x00\x00' \
+                     b'\x01\x02\x03\x04\x05\x06' \
+                     b'\xFE\xFF\xDD\xE0'
+        fp = DicomBytesIO(bytestream)
+        fp.is_little_endian = True
+        assert 2 == get_nr_fragments(fp)
+
+    def test_not_little_endian(self):
+        """Test reading big endian raises exception"""
+        bytestream = b'\xFE\xFF\x00\xE0' \
+                     b'\x04\x00\x00\x00' \
+                     b'\x01\x00\x00\x00'
+        fp = DicomBytesIO(bytestream)
+        fp.is_little_endian = False
+        with pytest.raises(ValueError,
+                           match="'fp.is_little_endian' must be True"):
+            get_nr_fragments(fp)
+
+
 class TestGeneratePixelDataFragment(object):
     """Test encaps.generate_pixel_data_fragment"""
     def test_item_undefined_length(self):
@@ -242,7 +350,7 @@ class TestGeneratePixelDataFrames(object):
                      b'\xFE\xFF\x00\xE0' \
                      b'\x04\x00\x00\x00' \
                      b'\x03\x00\x00\x00'
-        frames = generate_pixel_data_frame(bytestream)
+        frames = generate_pixel_data_frame(bytestream, 1)
         assert next(frames) == (
             b'\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00'
         )
@@ -362,6 +470,18 @@ class TestGeneratePixelDataFrames(object):
         assert next(frames) == b'\x03\x00\x00\x00\x02\x04'
         pytest.raises(StopIteration, next, frames)
 
+    def test_empty_bot_multi_fragments_per_frame(self):
+        """Test multi-frame where multiple frags per frame and no BOT."""
+        # Regression test for #685
+        ds = dcmread(JP2K_10FRAME_NOBOT)
+        assert 10 == ds.NumberOfFrames
+        frame_gen = generate_pixel_data_frame(ds.PixelData, ds.NumberOfFrames)
+        for ii in range(10):
+            next(frame_gen)
+
+        with pytest.raises(StopIteration):
+            next(frame_gen)
+
 
 class TestGeneratePixelData(object):
     """Test encaps.generate_pixel_data"""
@@ -391,12 +511,122 @@ class TestGeneratePixelData(object):
                      b'\xFE\xFF\x00\xE0' \
                      b'\x04\x00\x00\x00' \
                      b'\x03\x00\x00\x00'
-        frames = generate_pixel_data(bytestream)
+        frames = generate_pixel_data(bytestream, 1)
         assert next(frames) == (b'\x01\x00\x00\x00',
                                 b'\x02\x00\x00\x00',
                                 b'\x03\x00\x00\x00')
         pytest.raises(StopIteration, next, frames)
 
+    def test_empty_bot_no_nr_frames_raises(self):
+        """Test parsing raises if not BOT and no nr_frames."""
+        # 1 frame, 3 fragments long
+        bytestream = b'\xFE\xFF\x00\xE0' \
+                     b'\x00\x00\x00\x00' \
+                     b'\xFE\xFF\x00\xE0' \
+                     b'\x04\x00\x00\x00' \
+                     b'\x01\x00\x00\x00' \
+                     b'\xFE\xFF\x00\xE0' \
+                     b'\x04\x00\x00\x00' \
+                     b'\x02\x00\x00\x00' \
+                     b'\xFE\xFF\x00\xE0' \
+                     b'\x04\x00\x00\x00' \
+                     b'\x03\x00\x00\x00'
+        msg = (
+            r"Unable to determine the frame boundaries for the "
+            r"encapsulated pixel data as the Basic Offset Table is empty "
+            r"and `nr_frames` parameter is None"
+        )
+        with pytest.raises(ValueError, match=msg):
+            next(generate_pixel_data(bytestream))
+
+    def test_empty_bot_too_few_fragments(self):
+        """Test parsing with too few fragments."""
+        ds = dcmread(JP2K_10FRAME_NOBOT)
+        assert 10 == ds.NumberOfFrames
+
+        msg = (
+            r"Unable to parse encapsulated pixel data as the Basic "
+            r"Offset Table is empty and there are fewer fragments then "
+            r"frames; the dataset may be corrupt"
+        )
+        with pytest.raises(ValueError, match=msg):
+            next(generate_pixel_data_frame(ds.PixelData, 20))
+
+    def test_empty_bot_multi_fragments_per_frame(self):
+        """Test parsing with multiple fragments per frame."""
+        # 4 frames in 6 fragments with JPEG EOI marker
+        bytestream = (
+            b'\xFE\xFF\x00\xE0\x00\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\xD9\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\x00\xFF\xD9'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\xD9\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\xD9\x00'
+        )
+
+        frames = generate_pixel_data(bytestream, 4)
+        for ii in range(4):
+            next(frames)
+
+        with pytest.raises(StopIteration):
+            next(frames)
+
+    def test_empty_bot_no_marker(self):
+        """Test parsing not BOT and no final marker with multi fragments."""
+        # 4 frames in 6 fragments with JPEG EOI marker (1 missing EOI)
+        bytestream = (
+            b'\xFE\xFF\x00\xE0\x00\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\xD9\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\xD9\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\xFF\xD9'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\x00\x00'
+        )
+
+        frames = generate_pixel_data(bytestream, 4)
+        for ii in range(3):
+            next(frames)
+
+        msg = (
+            r"The end of the encapsulated pixel data has been "
+            r"reached but one or more frame boundaries may have "
+            r"been missed; please confirm that the generated frame "
+            r"data is correct"
+        )
+        with pytest.warns(UserWarning, match=msg):
+            next(frames)
+
+        with pytest.raises(StopIteration):
+            next(frames)
+
+    def test_empty_bot_missing_marker(self):
+        """Test parsing not BOT and missing marker with multi fragments."""
+        # 4 frames in 6 fragments with JPEG EOI marker (1 missing EOI)
+        bytestream = (
+            b'\xFE\xFF\x00\xE0\x00\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\xD9\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\x00\x00\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\x00\x00'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\xFF\xD9'
+            b'\xFE\xFF\x00\xE0\x04\x00\x00\x00\x01\xFF\xD9\x00'
+        )
+
+        msg = (
+            r"The end of the encapsulated pixel data has been "
+            r"reached but one or more frame boundaries may have "
+            r"been missed; please confirm that the generated frame "
+            r"data is correct"
+        )
+        with pytest.warns(UserWarning, match=msg):
+            ii = 0
+            for frames in generate_pixel_data(bytestream, 4):
+                ii += 1
+
+        assert 3 == ii
+
     def test_bot_single_fragment(self):
         """Test a single-frame image where the frame is one fragment"""
         # 1 frame, 1 fragment long
@@ -932,7 +1162,7 @@ class TestEncapsulate(object):
 
         fp = DicomBytesIO(data)
         fp.is_little_endian = True
-        offsets = get_frame_offsets(fp)
+        length, offsets = get_frame_offsets(fp)
         assert offsets == [
             0x0000,  # 0
             0x0eee,  # 3822
diff --git a/pydicom/tests/test_numpy_pixel_data.py b/pydicom/tests/test_numpy_pixel_data.py
index e3658711c..b6828a947 100644
--- a/pydicom/tests/test_numpy_pixel_data.py
+++ b/pydicom/tests/test_numpy_pixel_data.py
@@ -146,6 +146,9 @@ JPEG_2K_LOSSLESS = get_testdata_files("emri_small_jpeg_2k_lossless.dcm")[0]
 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 @@ class TestNumpy_NumpyHandler(object):
         # 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."""