Repository: pydicom/pydicom

Issue:
apply_voi_lut - unclear what it does if both WL/VOILUTFunction _and_ VOILUTSequence are present
https://pydicom.github.io/pydicom/dev/reference/generated/pydicom.pixel_data_handlers.util.html#pydicom.pixel_data_handlers.util.apply_voi_lut

Hi all,

I'm working with some mammo image (digital) that have both 
- window/level (0028,1050 0028,1051) plus VOILUTFunction (0028,1056) (set to SIGMOID) (set of 3 WL values)
- VOILUT sequences (0028, 3010)

specified.

Problem
---

It's unclear from the documentation when both a VOILUT (0028,3010) _and_ WL (0028,1051...) are present which is applied - the lut or the wl.

It just says if a LUT's present, it will apply that, and if a WL set is present it will apply that.

Questions
---

- If both LUT and WL are supplied, by the dicom standard, which should be applied?
- Separately to the above question about which is applied, if _both_ LUT and WL sequences are supplied, is there a way in `apply_voi_lut` to specify applying one or the other?  (ie force application of the WL instead of LUT etc)

- Also, if an image has a sequence of WL values rather than being single valued (so 0028,1050 & 0028,1051 are sequences), does the `index` parameter to `apply_voi_lut` apply to specify which in the sequence you want to use?

Thanks!

apply_voi_lut can't handle missing DICOM meta info
I have encountered two real life examples where `apply_voi_lut` does not handle corruption in DICOM meta fields

case 1:
```
(0028, 1050) Window Center                       DS: "128.0"
(0028, 1051) Window Width                        DS: "256.0"
(0028, 1052) Rescale Intercept                   DS: None
(0028, 1053) Rescale Slope                       DS: None
```
throws an exception

```
  File "python3.7/site-packages/pydicom/pixel_data_handlers/util.py", line 380, in apply_voi_lut
    y_min = y_min * ds.RescaleSlope + ds.RescaleIntercept
TypeError: unsupported operand type(s) for *: 'int' and 'NoneType' 
```


case 2:

```
(0028, 1050) Window Center                       DS: "2607.0"
(0028, 1051) Window Width                        DS: "2785.0"
(0028, 1052) Rescale Intercept                   DS: "0.0"
(0028, 1053) Rescale Slope                       DS: "1.0"
(0028, 1054) Rescale Type                        LO: 'US'
(0028, 2110) Lossy Image Compression             CS: '00'
(0028, 3010)  VOI LUT Sequence   1 item(s) ---- 
   (0028, 3002) LUT Descriptor                      SS: None
   (0028, 3003) LUT Explanation                     LO: 'Noramal'
   (0028, 3006) LUT Data                            OW: None
```

throws an exception

```
  File "python3.7/site-packages/pydicom/pixel_data_handlers/util.py", line 312, in apply_voi_lut
    nr_entries = item.LUTDescriptor[0] or 2**16
TypeError: 'NoneType' object is not subscriptable
```


So far I have handled this with:

```
    def _lut_convert(self):
        return apply_voi_lut(self.input_dicom.pixel_array, self.input_dicom)

    def _get_raw_data(self):

        # convert to presentation LUT
        try:
            data = self._lut_convert()
        # many things can be corrupted in the VOILUTSequence attribute,
        # fall back to default WC/WW conversion
        except Exception as e:
            try:
                if "VOILUTSequence" in self.input_dicom:
                    del self.input_dicom["VOILUTSequence"]
                    data = self._lut_convert()
            except Exception as e:
                raise InvalidImage(f"Could not convert to presentation LUT due to: {e}")
```

While the case 1 could be seen as an expected behavior (?), I imagine case 2 should be handled by WC/WW transformations if followed DICOM standard?











Discussion:
For some reason when I wrote it, I assumed it was a case of either/or for *VOI LUT Sequence*/*Window Center*, but now that I look at the Standard again I see it possible that both can be present (although only one can be applied). I'll probably add a flag to allow selecting which one is used when both are present.
> If both LUT and WL are supplied, by the dicom standard, which should be applied?

That should be up to the user

> Separately to the above question about which is applied, if both LUT and WL sequences are supplied, is there a way in apply_voi_lut to specify applying one or the other? (ie force application of the WL instead of LUT etc)

Not at the moment, although you could force which is applied by deleting the corresponding element(s) for the operation you don't want

> Also, if an image has a sequence of WL values rather than being single valued (so 0028,1050 & 0028,1051 are sequences), does the index parameter to apply_voi_lut apply to specify which in the sequence you want to use?

Yes
Is it possible that you could attach an anonymised dataset we could add to our test data? It'd be helpful to have a real dataset.


Patch:
diff --git a/pydicom/pixel_data_handlers/__init__.py b/pydicom/pixel_data_handlers/__init__.py
--- a/pydicom/pixel_data_handlers/__init__.py
+++ b/pydicom/pixel_data_handlers/__init__.py
@@ -1,7 +1,7 @@
 
 from pydicom.pixel_data_handlers.util import (
     apply_color_lut, apply_modality_lut, apply_voi_lut, convert_color_space,
+    apply_voi, apply_windowing
 )
 
 apply_rescale = apply_modality_lut
-apply_windowing = apply_voi_lut
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
@@ -3,7 +3,7 @@
 
 from struct import unpack
 from sys import byteorder
-from typing import Dict
+from typing import Dict, Optional, Union, List, Tuple, TYPE_CHECKING, cast
 import warnings
 
 try:
@@ -15,8 +15,15 @@
 from pydicom.data import get_palette_files
 from pydicom.uid import UID
 
+if TYPE_CHECKING:
+    from pydicom.dataset import Dataset
 
-def apply_color_lut(arr, ds=None, palette=None):
+
+def apply_color_lut(
+    arr: "np.ndarray",
+    ds: Optional["Dataset"] = None,
+    palette: Optional[Union[str, UID]] = None
+) -> "np.ndarray":
     """Apply a color palette lookup table to `arr`.
 
     .. versionadded:: 1.4
@@ -187,7 +194,7 @@ def apply_color_lut(arr, ds=None, palette=None):
     return out
 
 
-def apply_modality_lut(arr, ds):
+def apply_modality_lut(arr: "np.ndarray", ds: "Dataset") -> "np.ndarray":
     """Apply a modality lookup table or rescale operation to `arr`.
 
     .. versionadded:: 1.4
@@ -261,11 +268,20 @@ def apply_modality_lut(arr, ds):
     return arr
 
 
-def apply_voi_lut(arr, ds, index=0):
+def apply_voi_lut(
+    arr: "np.ndarray",
+    ds: "Dataset",
+    index: int = 0,
+    prefer_lut: bool = True
+) -> "np.ndarray":
     """Apply a VOI lookup table or windowing operation to `arr`.
 
     .. versionadded:: 1.4
 
+    .. versionchanged:: 2.1
+
+        Added the `prefer_lut` keyword parameter
+
     Parameters
     ----------
     arr : numpy.ndarray
@@ -280,8 +296,12 @@ def apply_voi_lut(arr, ds, index=0):
         ``np.float64``. If neither are present then `arr` will be returned
         unchanged.
     index : int, optional
-        Where the VOI LUT Module contains multiple possible views, this is
+        When the VOI LUT Module contains multiple alternative views, this is
         the index of the view to return (default ``0``).
+    prefer_lut : bool
+        When the VOI LUT Module contains both *Window Width*/*Window Center*
+        and *VOI LUT Sequence*, if ``True`` (default) then apply the VOI LUT,
+        otherwise apply the windowing operation.
 
     Returns
     -------
@@ -297,6 +317,8 @@ def apply_voi_lut(arr, ds, index=0):
     See Also
     --------
     :func:`~pydicom.pixel_data_handlers.util.apply_modality_lut`
+    :func:`~pydicom.pixel_data_handlers.util.apply_voi`
+    :func:`~pydicom.pixel_data_handlers.util.apply_windowing`
 
     References
     ----------
@@ -307,134 +329,253 @@ def apply_voi_lut(arr, ds, index=0):
     * DICOM Standard, Part 4, :dcm:`Annex N.2.1.1
       <part04/sect_N.2.html#sect_N.2.1.1>`
     """
+    valid_voi = False
     if 'VOILUTSequence' in ds:
-        if not np.issubdtype(arr.dtype, np.integer):
-            warnings.warn(
-                "Applying a VOI LUT on a float input array may give "
-                "incorrect results"
-            )
+        valid_voi = None not in [
+            ds.VOILUTSequence[0].get('LUTDescriptor', None),
+            ds.VOILUTSequence[0].get('LUTData', None)
+        ]
+    valid_windowing = None not in [
+        ds.get('WindowCenter', None),
+        ds.get('WindowWidth', None)
+    ]
 
-        # VOI LUT Sequence contains one or more items
-        item = ds.VOILUTSequence[index]
-        nr_entries = item.LUTDescriptor[0] or 2**16
-        first_map = item.LUTDescriptor[1]
+    if valid_voi and valid_windowing:
+        if prefer_lut:
+            return apply_voi(arr, ds, index)
 
-        # PS3.3 C.8.11.3.1.5: may be 8, 10-16
-        nominal_depth = item.LUTDescriptor[2]
-        if nominal_depth in list(range(10, 17)):
-            dtype = 'uint16'
-        elif nominal_depth == 8:
-            dtype = 'uint8'
-        else:
-            raise NotImplementedError(
-                "'{}' bits per LUT entry is not supported"
-                .format(nominal_depth)
-            )
+        return apply_windowing(arr, ds, index)
 
-        # Ambiguous VR, US or OW
-        if item['LUTData'].VR == 'OW':
-            endianness = '<' if ds.is_little_endian else '>'
-            unpack_fmt = '{}{}H'.format(endianness, nr_entries)
-            lut_data = unpack(unpack_fmt, item.LUTData)
-        else:
-            lut_data = item.LUTData
-        lut_data = np.asarray(lut_data, dtype=dtype)
+    if valid_voi:
+        return apply_voi(arr, ds, index)
 
-        # IVs < `first_map` get set to first LUT entry (i.e. index 0)
-        clipped_iv = np.zeros(arr.shape, dtype=dtype)
-        # IVs >= `first_map` are mapped by the VOI LUT
-        # `first_map` may be negative, positive or 0
-        mapped_pixels = arr >= first_map
-        clipped_iv[mapped_pixels] = arr[mapped_pixels] - first_map
-        # IVs > number of entries get set to last entry
-        np.clip(clipped_iv, 0, nr_entries - 1, out=clipped_iv)
+    if valid_windowing:
+        return apply_windowing(arr, ds, index)
 
-        return lut_data[clipped_iv]
-    elif 'WindowCenter' in ds and 'WindowWidth' in ds:
-        if ds.PhotometricInterpretation not in ['MONOCHROME1', 'MONOCHROME2']:
-            raise ValueError(
-                "When performing a windowing operation only 'MONOCHROME1' and "
-                "'MONOCHROME2' are allowed for (0028,0004) Photometric "
-                "Interpretation"
-            )
+    return arr
 
-        # May be LINEAR (default), LINEAR_EXACT, SIGMOID or not present, VM 1
-        voi_func = getattr(ds, 'VOILUTFunction', 'LINEAR').upper()
-        # VR DS, VM 1-n
-        elem = ds['WindowCenter']
-        center = elem.value[index] if elem.VM > 1 else elem.value
-        elem = ds['WindowWidth']
-        width = elem.value[index] if elem.VM > 1 else elem.value
-
-        # The output range depends on whether or not a modality LUT or rescale
-        #   operation has been applied
-        if 'ModalityLUTSequence' in ds:
-            # Unsigned - see PS3.3 C.11.1.1.1
-            y_min = 0
-            bit_depth = ds.ModalityLUTSequence[0].LUTDescriptor[2]
-            y_max = 2**bit_depth - 1
-        elif ds.PixelRepresentation == 0:
-            # Unsigned
-            y_min = 0
-            y_max = 2**ds.BitsStored - 1
-        else:
-            # Signed
-            y_min = -2**(ds.BitsStored - 1)
-            y_max = 2**(ds.BitsStored - 1) - 1
-
-        if 'RescaleSlope' in ds and 'RescaleIntercept' in ds:
-            # Otherwise its the actual data range
-            y_min = y_min * ds.RescaleSlope + ds.RescaleIntercept
-            y_max = y_max * ds.RescaleSlope + ds.RescaleIntercept
-
-        y_range = y_max - y_min
-        arr = arr.astype('float64')
-
-        if voi_func in ['LINEAR', 'LINEAR_EXACT']:
-            # PS3.3 C.11.2.1.2.1 and C.11.2.1.3.2
-            if voi_func == 'LINEAR':
-                if width < 1:
-                    raise ValueError(
-                        "The (0028,1051) Window Width must be greater than or "
-                        "equal to 1 for a 'LINEAR' windowing operation"
-                    )
-                center -= 0.5
-                width -= 1
-            elif width <= 0:
-                raise ValueError(
-                    "The (0028,1051) Window Width must be greater than 0 "
-                    "for a 'LINEAR_EXACT' windowing operation"
-                )
 
-            below = arr <= (center - width / 2)
-            above = arr > (center + width / 2)
-            between = np.logical_and(~below, ~above)
+def apply_voi(
+    arr: "np.ndarray", ds: "Dataset", index: int = 0
+) -> "np.ndarray":
+    """Apply a VOI lookup table to `arr`.
 
-            arr[below] = y_min
-            arr[above] = y_max
-            if between.any():
-                arr[between] = (
-                    ((arr[between] - center) / width + 0.5) * y_range + y_min
-                )
-        elif voi_func == 'SIGMOID':
-            # PS3.3 C.11.2.1.3.1
-            if width <= 0:
+    .. versionadded:: 2.1
+
+    Parameters
+    ----------
+    arr : numpy.ndarray
+        The :class:`~numpy.ndarray` to apply the VOI LUT to.
+    ds : dataset.Dataset
+        A dataset containing a :dcm:`VOI LUT Module<part03/sect_C.11.2.html>`.
+        If (0028,3010) *VOI LUT Sequence* is present then returns an array
+        of ``np.uint8`` or ``np.uint16``, depending on the 3rd value of
+        (0028,3002) *LUT Descriptor*, otherwise `arr` will be returned
+        unchanged.
+    index : int, optional
+        When the VOI LUT Module contains multiple alternative views, this is
+        the index of the view to return (default ``0``).
+
+    Returns
+    -------
+    numpy.ndarray
+        An array with applied VOI LUT.
+
+    See Also
+    --------
+    :func:`~pydicom.pixel_data_handlers.util.apply_modality_lut`
+    :func:`~pydicom.pixel_data_handlers.util.apply_windowing`
+
+    References
+    ----------
+    * DICOM Standard, Part 3, :dcm:`Annex C.11.2
+      <part03/sect_C.11.html#sect_C.11.2>`
+    * DICOM Standard, Part 3, :dcm:`Annex C.8.11.3.1.5
+      <part03/sect_C.8.11.3.html#sect_C.8.11.3.1.5>`
+    * DICOM Standard, Part 4, :dcm:`Annex N.2.1.1
+      <part04/sect_N.2.html#sect_N.2.1.1>`
+    """
+    if "VOILUTSequence" not in ds:
+        return arr
+
+    if not np.issubdtype(arr.dtype, np.integer):
+        warnings.warn(
+            "Applying a VOI LUT on a float input array may give "
+            "incorrect results"
+        )
+
+    # VOI LUT Sequence contains one or more items
+    item = ds.VOILUTSequence[index]
+    nr_entries = item.LUTDescriptor[0] or 2**16
+    first_map = item.LUTDescriptor[1]
+
+    # PS3.3 C.8.11.3.1.5: may be 8, 10-16
+    nominal_depth = item.LUTDescriptor[2]
+    if nominal_depth in list(range(10, 17)):
+        dtype = 'uint16'
+    elif nominal_depth == 8:
+        dtype = 'uint8'
+    else:
+        raise NotImplementedError(
+            f"'{nominal_depth}' bits per LUT entry is not supported"
+        )
+
+    # Ambiguous VR, US or OW
+    if item['LUTData'].VR == 'OW':
+        endianness = '<' if ds.is_little_endian else '>'
+        unpack_fmt = f'{endianness}{nr_entries}H'
+        lut_data = unpack(unpack_fmt, item.LUTData)
+    else:
+        lut_data = item.LUTData
+    lut_data = np.asarray(lut_data, dtype=dtype)
+
+    # IVs < `first_map` get set to first LUT entry (i.e. index 0)
+    clipped_iv = np.zeros(arr.shape, dtype=dtype)
+    # IVs >= `first_map` are mapped by the VOI LUT
+    # `first_map` may be negative, positive or 0
+    mapped_pixels = arr >= first_map
+    clipped_iv[mapped_pixels] = arr[mapped_pixels] - first_map
+    # IVs > number of entries get set to last entry
+    np.clip(clipped_iv, 0, nr_entries - 1, out=clipped_iv)
+
+    return lut_data[clipped_iv]
+
+
+def apply_windowing(
+    arr: "np.ndarray", ds: "Dataset", index: int = 0
+) -> "np.ndarray":
+    """Apply a windowing operation to `arr`.
+
+    .. versionadded:: 2.1
+
+    Parameters
+    ----------
+    arr : numpy.ndarray
+        The :class:`~numpy.ndarray` to apply the windowing operation to.
+    ds : dataset.Dataset
+        A dataset containing a :dcm:`VOI LUT Module<part03/sect_C.11.2.html>`.
+        If (0028,1050) *Window Center* and (0028,1051) *Window Width* are
+        present then returns an array of ``np.float64``, otherwise `arr` will
+        be returned unchanged.
+    index : int, optional
+        When the VOI LUT Module contains multiple alternative views, this is
+        the index of the view to return (default ``0``).
+
+    Returns
+    -------
+    numpy.ndarray
+        An array with applied windowing operation.
+
+    Notes
+    -----
+    When the dataset requires a modality LUT or rescale operation as part of
+    the Modality LUT module then that must be applied before any windowing
+    operation.
+
+    See Also
+    --------
+    :func:`~pydicom.pixel_data_handlers.util.apply_modality_lut`
+    :func:`~pydicom.pixel_data_handlers.util.apply_voi`
+
+    References
+    ----------
+    * DICOM Standard, Part 3, :dcm:`Annex C.11.2
+      <part03/sect_C.11.html#sect_C.11.2>`
+    * DICOM Standard, Part 3, :dcm:`Annex C.8.11.3.1.5
+      <part03/sect_C.8.11.3.html#sect_C.8.11.3.1.5>`
+    * DICOM Standard, Part 4, :dcm:`Annex N.2.1.1
+      <part04/sect_N.2.html#sect_N.2.1.1>`
+    """
+    if "WindowWidth" not in ds and "WindowCenter" not in ds:
+        return arr
+
+    if ds.PhotometricInterpretation not in ['MONOCHROME1', 'MONOCHROME2']:
+        raise ValueError(
+            "When performing a windowing operation only 'MONOCHROME1' and "
+            "'MONOCHROME2' are allowed for (0028,0004) Photometric "
+            "Interpretation"
+        )
+
+    # May be LINEAR (default), LINEAR_EXACT, SIGMOID or not present, VM 1
+    voi_func = cast(str, getattr(ds, 'VOILUTFunction', 'LINEAR')).upper()
+    # VR DS, VM 1-n
+    elem = ds['WindowCenter']
+    center = elem.value[index] if elem.VM > 1 else elem.value
+    elem = ds['WindowWidth']
+    width = elem.value[index] if elem.VM > 1 else elem.value
+
+    # The output range depends on whether or not a modality LUT or rescale
+    #   operation has been applied
+    if 'ModalityLUTSequence' in ds:
+        # Unsigned - see PS3.3 C.11.1.1.1
+        y_min = 0
+        bit_depth = ds.ModalityLUTSequence[0].LUTDescriptor[2]
+        y_max = 2**bit_depth - 1
+    elif ds.PixelRepresentation == 0:
+        # Unsigned
+        y_min = 0
+        y_max = 2**ds.BitsStored - 1
+    else:
+        # Signed
+        y_min = -2**(ds.BitsStored - 1)
+        y_max = 2**(ds.BitsStored - 1) - 1
+
+    slope = ds.get('RescaleSlope', None)
+    intercept = ds.get('RescaleIntercept', None)
+    if slope is not None and intercept is not None:
+        # Otherwise its the actual data range
+        y_min = y_min * ds.RescaleSlope + ds.RescaleIntercept
+        y_max = y_max * ds.RescaleSlope + ds.RescaleIntercept
+
+    y_range = y_max - y_min
+    arr = arr.astype('float64')
+
+    if voi_func in ['LINEAR', 'LINEAR_EXACT']:
+        # PS3.3 C.11.2.1.2.1 and C.11.2.1.3.2
+        if voi_func == 'LINEAR':
+            if width < 1:
                 raise ValueError(
-                    "The (0028,1051) Window Width must be greater than 0 "
-                    "for a 'SIGMOID' windowing operation"
+                    "The (0028,1051) Window Width must be greater than or "
+                    "equal to 1 for a 'LINEAR' windowing operation"
                 )
+            center -= 0.5
+            width -= 1
+        elif width <= 0:
+            raise ValueError(
+                "The (0028,1051) Window Width must be greater than 0 "
+                "for a 'LINEAR_EXACT' windowing operation"
+            )
 
-            arr = y_range / (1 + np.exp(-4 * (arr - center) / width)) + y_min
-        else:
+        below = arr <= (center - width / 2)
+        above = arr > (center + width / 2)
+        between = np.logical_and(~below, ~above)
+
+        arr[below] = y_min
+        arr[above] = y_max
+        if between.any():
+            arr[between] = (
+                ((arr[between] - center) / width + 0.5) * y_range + y_min
+            )
+    elif voi_func == 'SIGMOID':
+        # PS3.3 C.11.2.1.3.1
+        if width <= 0:
             raise ValueError(
-                "Unsupported (0028,1056) VOI LUT Function value '{}'"
-                .format(voi_func)
+                "The (0028,1051) Window Width must be greater than 0 "
+                "for a 'SIGMOID' windowing operation"
             )
 
+        arr = y_range / (1 + np.exp(-4 * (arr - center) / width)) + y_min
+    else:
+        raise ValueError(
+            f"Unsupported (0028,1056) VOI LUT Function value '{voi_func}'"
+        )
+
     return arr
 
 
-def convert_color_space(arr, current, desired):
+def convert_color_space(
+    arr: "np.ndarray", current: str, desired: str
+) -> "np.ndarray":
     """Convert the image(s) in `arr` from one color space to another.
 
     .. versionchanged:: 1.4
@@ -501,7 +642,7 @@ def _no_change(arr):
     return converter(arr)
 
 
-def _convert_RGB_to_YBR_FULL(arr):
+def _convert_RGB_to_YBR_FULL(arr: "np.ndarray") -> "np.ndarray":
     """Return an ndarray converted from RGB to YBR_FULL color space.
 
     Parameters
@@ -543,7 +684,7 @@ def _convert_RGB_to_YBR_FULL(arr):
     return arr.astype(orig_dtype)
 
 
-def _convert_YBR_FULL_to_RGB(arr):
+def _convert_YBR_FULL_to_RGB(arr: "np.ndarray") -> "np.ndarray":
     """Return an ndarray converted from YBR_FULL to RGB color space.
 
     Parameters
@@ -585,7 +726,9 @@ def _convert_YBR_FULL_to_RGB(arr):
     return arr.astype(orig_dtype)
 
 
-def dtype_corrected_for_endianness(is_little_endian, numpy_dtype):
+def dtype_corrected_for_endianness(
+    is_little_endian: bool, numpy_dtype: "np.dtype"
+) -> "np.dtype":
     """Return a :class:`numpy.dtype` corrected for system and :class:`Dataset`
     endianness.
 
@@ -618,7 +761,12 @@ def dtype_corrected_for_endianness(is_little_endian, numpy_dtype):
     return numpy_dtype
 
 
-def _expand_segmented_lut(data, fmt, nr_segments=None, last_value=None):
+def _expand_segmented_lut(
+    data: Tuple[int, ...],
+    fmt: str,
+    nr_segments: Optional[int] = None,
+    last_value: Optional[int] = None
+) -> List[int]:
     """Return a list containing the expanded lookup table data.
 
     Parameters
@@ -652,7 +800,7 @@ def _expand_segmented_lut(data, fmt, nr_segments=None, last_value=None):
     # Little endian: e.g. 0x0302 0x0100, big endian, e.g. 0x0203 0x0001
     indirect_ii = [3, 2, 1, 0] if '<' in fmt else [2, 3, 0, 1]
 
-    lut = []
+    lut: List[int] = []
     offset = 0
     segments_read = 0
     # Use `offset + 1` to account for possible trailing null
@@ -722,7 +870,7 @@ def _expand_segmented_lut(data, fmt, nr_segments=None, last_value=None):
     return lut
 
 
-def get_expected_length(ds, unit='bytes'):
+def get_expected_length(ds: "Dataset", unit: str = 'bytes') -> int:
     """Return the expected length (in terms of bytes or pixels) of the *Pixel
     Data*.
 
@@ -765,14 +913,14 @@ def get_expected_length(ds, unit='bytes'):
         The expected length of the *Pixel Data* in either whole bytes or
         pixels, excluding the NULL trailing padding byte for odd length data.
     """
-    length = ds.Rows * ds.Columns * ds.SamplesPerPixel
+    length: int = ds.Rows * ds.Columns * ds.SamplesPerPixel
     length *= get_nr_frames(ds)
 
     if unit == 'pixels':
         return length
 
     # Correct for the number of bytes per pixel
-    bits_allocated = ds.BitsAllocated
+    bits_allocated = cast(int, ds.BitsAllocated)
     if bits_allocated == 1:
         # Determine the nearest whole number of bytes needed to contain
         #   1-bit pixel data. e.g. 10 x 10 1-bit pixels is 100 bits, which
@@ -788,7 +936,7 @@ def get_expected_length(ds, unit='bytes'):
     return length
 
 
-def get_image_pixel_ids(ds) -> Dict[str, int]:
+def get_image_pixel_ids(ds: "Dataset") -> Dict[str, int]:
     """Return a dict of the pixel data affecting element's :func:`id` values.
 
     .. versionadded:: 1.4
@@ -845,7 +993,7 @@ def get_image_pixel_ids(ds) -> Dict[str, int]:
     return {kw: id(getattr(ds, kw, None)) for kw in keywords}
 
 
-def get_j2k_parameters(codestream):
+def get_j2k_parameters(codestream: bytes) -> Dict[str, object]:
     """Return a dict containing JPEG 2000 component parameters.
 
     .. versionadded:: 2.1
@@ -883,7 +1031,7 @@ def get_j2k_parameters(codestream):
     return {}
 
 
-def get_nr_frames(ds):
+def get_nr_frames(ds: "Dataset") -> int:
     """Return NumberOfFrames or 1 if NumberOfFrames is None.
 
     Parameters
@@ -897,7 +1045,7 @@ def get_nr_frames(ds):
     int
         An integer for the NumberOfFrames or 1 if NumberOfFrames is None
     """
-    nr_frames = getattr(ds, 'NumberOfFrames', 1)
+    nr_frames: Optional[int] = 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 "
@@ -908,7 +1056,7 @@ def get_nr_frames(ds):
     return nr_frames
 
 
-def pixel_dtype(ds, as_float=False):
+def pixel_dtype(ds: "Dataset", as_float: bool = False) -> "np.dtype":
     """Return a :class:`numpy.dtype` for the pixel data in `ds`.
 
     Suitable for use with IODs containing the Image Pixel module (with
@@ -1009,7 +1157,7 @@ def pixel_dtype(ds, as_float=False):
     return dtype
 
 
-def reshape_pixel_array(ds, arr):
+def reshape_pixel_array(ds: "Dataset", arr: "np.ndarray") -> "np.ndarray":
     """Return a reshaped :class:`numpy.ndarray` `arr`.
 
     +------------------------------------------+-----------+----------+


Test patch:
diff --git a/pydicom/tests/test_handler_util.py b/pydicom/tests/test_handler_util.py
--- a/pydicom/tests/test_handler_util.py
+++ b/pydicom/tests/test_handler_util.py
@@ -28,7 +28,9 @@
     apply_modality_lut,
     apply_voi_lut,
     get_j2k_parameters,
-    get_nr_frames
+    get_nr_frames,
+    apply_voi,
+    apply_windowing
 )
 from pydicom.uid import (ExplicitVRLittleEndian, ImplicitVRLittleEndian,
                          UncompressedPixelTransferSyntaxes)
@@ -1412,191 +1414,8 @@ def test_unknown_opcode_raises(self):
 
 
 @pytest.mark.skipif(not HAVE_NP, reason="Numpy is not available")
-class TestNumpy_VOILUT:
-    """Tests for util.apply_voi_lut()."""
-    def test_voi_single_view(self):
-        """Test VOI LUT with a single view."""
-        ds = dcmread(VOI_08_1F)
-        assert 8 == ds.BitsAllocated
-        assert 8 == ds.BitsStored
-        assert 0 == ds.PixelRepresentation
-        item = ds.VOILUTSequence[0]
-        assert [256, 0, 16] == item.LUTDescriptor
-        lut = item.LUTData
-        assert 0 == lut[0]
-        assert 19532 == lut[76]
-        assert 45746 == lut[178]
-        assert 65535 == lut[255]
-
-        arr = ds.pixel_array
-        assert 0 == arr[387, 448]
-        assert 76 == arr[178, 126]
-        assert 178 == arr[186, 389]
-        assert 255 == arr[129, 79]
-
-        out = apply_voi_lut(arr, ds)
-        assert 0 == out[387, 448]
-        assert 19532 == out[178, 126]
-        assert 45746 == out[186, 389]
-        assert 65535 == out[129, 79]
-
-    def test_voi_multi_view(self):
-        """Test VOI LUT with multiple views."""
-        ds = dcmread(VOI_08_1F)
-        assert 8 == ds.BitsAllocated
-        assert 8 == ds.BitsStored
-        assert 0 == ds.PixelRepresentation
-        item0 = ds.VOILUTSequence[0]
-        # Add another view thats the inverse
-        ds.VOILUTSequence.append(Dataset())
-        item1 = ds.VOILUTSequence[1]
-        item1.LUTDescriptor = [256, 0, 16]
-        item1.LUTData = item0.LUTData[::-1]
-
-        arr = ds.pixel_array
-        assert 0 == arr[387, 448]
-        assert 76 == arr[178, 126]
-        assert 178 == arr[186, 389]
-        assert 255 == arr[129, 79]
-
-        out0 = apply_voi_lut(arr, ds)
-        assert 0 == out0[387, 448]
-        assert 19532 == out0[178, 126]
-        assert 45746 == out0[186, 389]
-        assert 65535 == out0[129, 79]
-
-        out1 = apply_voi_lut(arr, ds, index=1)
-        assert 65535 == out1[387, 448]
-        assert 46003 == out1[178, 126]
-        assert 19789 == out1[186, 389]
-        assert 0 == out1[129, 79]
-
-    def test_voi_multi_frame(self):
-        """Test VOI with a multiple frames."""
-        ds = dcmread(VOI_08_1F)
-        assert 8 == ds.BitsAllocated
-        assert 8 == ds.BitsStored
-        assert 0 == ds.PixelRepresentation
-
-        arr = ds.pixel_array
-        arr = np.stack([arr, 255 - arr])
-        assert (2, 512, 512) == arr.shape
-
-        out = apply_voi_lut(arr, ds)
-        assert 0 == out[0, 387, 448]
-        assert 19532 == out[0, 178, 126]
-        assert 45746 == out[0, 186, 389]
-        assert 65535 == out[0, 129, 79]
-        assert 65535 == out[1, 387, 448]
-        assert 46003 == out[1, 178, 126]
-        assert 19789 == out[1, 186, 389]
-        assert 0 == out[1, 129, 79]
-
-    def test_voi_zero_entries(self):
-        """Test that 0 entries is interpreted correctly."""
-        ds = dcmread(VOI_08_1F)
-        seq = ds.VOILUTSequence[0]
-        seq.LUTDescriptor = [0, 0, 16]
-        assert 256 == len(seq.LUTData)
-        arr = np.asarray([0, 255, 256, 65535])
-        msg = r"index 256 is out of bounds"
-        with pytest.raises(IndexError, match=msg):
-            apply_voi_lut(arr, ds)
-
-        # LUTData with 65536 entries
-        seq.LUTData = [0] * 65535 + [1]
-        out = apply_voi_lut(arr, ds)
-        assert [0, 0, 0, 1] == list(out)
-
-    def test_voi_uint8(self):
-        """Test uint VOI LUT with an 8-bit LUT."""
-        ds = Dataset()
-        ds.PixelRepresentation = 0
-        ds.BitsStored = 8
-        ds.VOILUTSequence = [Dataset()]
-        item = ds.VOILUTSequence[0]
-        item.LUTDescriptor = [4, 0, 8]
-        item.LUTData = [0, 127, 128, 255]
-        arr = np.asarray([0, 1, 128, 254, 255], dtype='uint8')
-        out = apply_voi_lut(arr, ds)
-        assert 'uint8' == out.dtype
-        assert [0, 127, 255, 255, 255] == out.tolist()
-
-    def test_voi_uint16(self):
-        """Test uint VOI LUT with an 16-bit LUT."""
-        ds = Dataset()
-        ds.PixelRepresentation = 0
-        ds.BitsStored = 16
-        ds.VOILUTSequence = [Dataset()]
-        item = ds.VOILUTSequence[0]
-        item.LUTDescriptor = [4, 0, 16]
-        item.LUTData = [0, 127, 32768, 65535]
-        arr = np.asarray([0, 1, 2, 3, 255], dtype='uint16')
-        out = apply_voi_lut(arr, ds)
-        assert 'uint16' == out.dtype
-        assert [0, 127, 32768, 65535, 65535] == out.tolist()
-
-    def test_voi_int8(self):
-        """Test int VOI LUT with an 8-bit LUT."""
-        ds = Dataset()
-        ds.PixelRepresentation = 1
-        ds.BitsStored = 8
-        ds.VOILUTSequence = [Dataset()]
-        item = ds.VOILUTSequence[0]
-        item.LUTDescriptor = [4, 0, 8]
-        item.LUTData = [0, 127, 128, 255]
-        arr = np.asarray([0, -1, 2, -128, 127], dtype='int8')
-        out = apply_voi_lut(arr, ds)
-        assert 'uint8' == out.dtype
-        assert [0, 0, 128, 0, 255] == out.tolist()
-
-    def test_voi_int16(self):
-        """Test int VOI LUT with an 16-bit LUT."""
-        ds = Dataset()
-        ds.PixelRepresentation = 0
-        ds.BitsStored = 16
-        ds.VOILUTSequence = [Dataset()]
-        item = ds.VOILUTSequence[0]
-        item.LUTDescriptor = [4, 0, 16]
-        item.LUTData = [0, 127, 32768, 65535]
-        arr = np.asarray([0, -1, 2, -128, 255], dtype='int16')
-        out = apply_voi_lut(arr, ds)
-        assert 'uint16' == out.dtype
-        assert [0, 0, 32768, 0, 65535] == out.tolist()
-
-    def test_voi_bad_depth(self):
-        """Test bad LUT depth raises exception."""
-        ds = dcmread(VOI_08_1F)
-        item = ds.VOILUTSequence[0]
-        item.LUTDescriptor[2] = 7
-        msg = r"'7' bits per LUT entry is not supported"
-        with pytest.raises(NotImplementedError, match=msg):
-            apply_voi_lut(ds.pixel_array, ds)
-
-        item.LUTDescriptor[2] = 17
-        msg = r"'17' bits per LUT entry is not supported"
-        with pytest.raises(NotImplementedError, match=msg):
-            apply_voi_lut(ds.pixel_array, ds)
-
-    def test_voi_uint16_array_float(self):
-        """Test warning when array is float and VOI LUT with an 16-bit LUT"""
-        ds = Dataset()
-        ds.PixelRepresentation = 0
-        ds.BitsStored = 16
-        ds.VOILUTSequence = [Dataset()]
-        item = ds.VOILUTSequence[0]
-        item.LUTDescriptor = [4, 0, 16]
-        item.LUTData = [0, 127, 32768, 65535]
-        arr = np.asarray([0, 1, 2, 3, 255], dtype='float64')
-        msg = (
-            r"Applying a VOI LUT on a float input array may give "
-            r"incorrect results"
-        )
-
-        with pytest.warns(UserWarning, match=msg):
-            out = apply_voi_lut(arr, ds)
-            assert [0, 127, 32768, 65535, 65535] == out.tolist()
-
+class TestNumpy_ApplyWindowing:
+    """Tests for util.apply_windowing()."""
     def test_window_single_view(self):
         """Test windowing with a single view."""
         # 12-bit unsigned
@@ -1611,7 +1430,7 @@ def test_window_single_view(self):
 
         arr = ds.pixel_array
         assert 642 == arr[326, 130]
-        out = apply_voi_lut(arr, ds)
+        out = apply_windowing(arr, ds)
         assert 3046.6 == pytest.approx(out[326, 130], abs=0.1)
 
     def test_window_multi_view(self):
@@ -1631,9 +1450,9 @@ def test_window_multi_view(self):
 
         arr = ds.pixel_array
         assert 642 == arr[326, 130]
-        out = apply_voi_lut(arr, ds)
+        out = apply_windowing(arr, ds)
         assert 3046.6 == pytest.approx(out[326, 130], abs=0.1)
-        out = apply_voi_lut(arr, ds, index=1)
+        out = apply_windowing(arr, ds, index=1)
         assert 4095.0 == pytest.approx(out[326, 130], abs=0.1)
 
     def test_window_uint8(self):
@@ -1647,24 +1466,24 @@ def test_window_uint8(self):
         # Linear
         ds.WindowWidth = 1
         ds.WindowCenter = 0
-        assert [255, 255, 255, 255, 255] == apply_voi_lut(arr, ds).tolist()
+        assert [255, 255, 255, 255, 255] == apply_windowing(arr, ds).tolist()
 
         ds.WindowWidth = 128
         ds.WindowCenter = 254
         assert [0, 0, 0, 128.5, 130.5] == pytest.approx(
-            apply_voi_lut(arr, ds).tolist(), abs=0.1
+            apply_windowing(arr, ds).tolist(), abs=0.1
         )
 
         # Linear exact
         ds.VOILUTFunction = 'LINEAR_EXACT'
         assert [0, 0, 0, 127.5, 129.5] == pytest.approx(
-            apply_voi_lut(arr, ds).tolist(), abs=0.1
+            apply_windowing(arr, ds).tolist(), abs=0.1
         )
 
         # Sigmoid
         ds.VOILUTFunction = 'SIGMOID'
         assert [0.1, 0.1, 4.9, 127.5, 129.5] == pytest.approx(
-            apply_voi_lut(arr, ds).tolist(), abs=0.1
+            apply_windowing(arr, ds).tolist(), abs=0.1
         )
 
     def test_window_uint16(self):
@@ -1677,22 +1496,22 @@ def test_window_uint16(self):
 
         ds.WindowWidth = 1
         ds.WindowCenter = 0
-        assert [65535] * 5 == apply_voi_lut(arr, ds).tolist()
+        assert [65535] * 5 == apply_windowing(arr, ds).tolist()
 
         ds.WindowWidth = 32768
         ds.WindowCenter = 254
         assert [32260.5, 32262.5, 65535, 65535, 65535] == pytest.approx(
-            apply_voi_lut(arr, ds).tolist(), abs=0.1
+            apply_windowing(arr, ds).tolist(), abs=0.1
         )
 
         ds.VOILUTFunction = 'LINEAR_EXACT'
         assert [32259.5, 32261.5, 65535, 65535, 65535] == pytest.approx(
-            apply_voi_lut(arr, ds).tolist(), abs=0.1
+            apply_windowing(arr, ds).tolist(), abs=0.1
         )
 
         ds.VOILUTFunction = 'SIGMOID'
         assert [32259.5, 32261.5, 64319.8, 65512.3, 65512.3] == pytest.approx(
-            apply_voi_lut(arr, ds).tolist(), abs=0.1
+            apply_windowing(arr, ds).tolist(), abs=0.1
         )
 
     def test_window_uint32(self):
@@ -1706,14 +1525,14 @@ def test_window_uint32(self):
 
         ds.WindowWidth = 1
         ds.WindowCenter = 0
-        assert [y_max] * 5 == apply_voi_lut(arr, ds).tolist()
+        assert [y_max] * 5 == apply_windowing(arr, ds).tolist()
 
         ds.WindowWidth = 342423423423
         ds.WindowCenter = 757336
         assert (
             [2147474148.4, 2147474148.4,
              2174409724, 2201345299.7, 2201345299.7] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1721,7 +1540,7 @@ def test_window_uint32(self):
         assert (
             [2147474148.3, 2147474148.4,
              2174409724, 2201345299.7, 2201345299.7] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1729,7 +1548,7 @@ def test_window_uint32(self):
         assert (
             [2147474148.3, 2147474148.4,
              2174408313.1, 2201334008.2, 2201334008.3] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1745,25 +1564,25 @@ def test_window_int8(self):
         ds.WindowWidth = 1
         ds.WindowCenter = 0
         assert [-128, -128, -128, 127, 127, 127, 127] == pytest.approx(
-            apply_voi_lut(arr, ds).tolist()
+            apply_windowing(arr, ds).tolist()
         )
 
         ds.WindowWidth = 128
         ds.WindowCenter = -5
         assert [-128, -128, 8.5, 10.5, 12.6, 127, 127] == pytest.approx(
-            apply_voi_lut(arr, ds).tolist(), abs=0.1
+            apply_windowing(arr, ds).tolist(), abs=0.1
         )
 
         # Linear exact
         ds.VOILUTFunction = 'LINEAR_EXACT'
         assert [-128, -128, 7.5, 9.5, 11.5, 127, 127] == pytest.approx(
-            apply_voi_lut(arr, ds).tolist(), abs=0.1
+            apply_windowing(arr, ds).tolist(), abs=0.1
         )
 
         # Sigmoid
         ds.VOILUTFunction = 'SIGMOID'
         assert [-122.7, -122.5, 7.5, 9.4, 11.4, 122.8, 122.9] == pytest.approx(
-            apply_voi_lut(arr, ds).tolist(), abs=0.1
+            apply_windowing(arr, ds).tolist(), abs=0.1
         )
 
     def test_window_int16(self):
@@ -1780,7 +1599,7 @@ def test_window_int16(self):
         assert (
             [-32768, -32768, -32768,
              32767, 32767, 32767, 32767] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1789,7 +1608,7 @@ def test_window_int16(self):
         assert (
             [-32768, -32768, 2321.6,
              2837.6, 3353.7, 32767, 32767] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1798,7 +1617,7 @@ def test_window_int16(self):
         assert (
             [-32768, -32768, 2047.5,
              2559.5, 3071.5, 32767, 32767] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1807,7 +1626,7 @@ def test_window_int16(self):
         assert (
             [-31394.1, -31351.4, 2044.8,
              2554.3, 3062.5, 31692, 31724.6] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1825,7 +1644,7 @@ def test_window_int32(self):
         assert (
             [-2**31, -2**31, -2**31,
              2**31 - 1, 2**31 - 1, 2**31 - 1, 2**31 - 1] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1834,7 +1653,7 @@ def test_window_int32(self):
         assert (
             [-2147483648, -2147483648, 152183880, 186002520.1,
              219821160.3, 2147483647, 2147483647] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1843,7 +1662,7 @@ def test_window_int32(self):
         assert (
             [-2147483648, -2147483648, 134217727.5, 167772159.5,
              201326591.5, 2147483647, 2147483647] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1852,7 +1671,7 @@ def test_window_int32(self):
         assert (
             [-2057442919.3, -2054646500.7, 134043237.4, 167431657.4,
              200738833.7, 2077033158.8, 2079166214.8] == pytest.approx(
-                apply_voi_lut(arr, ds).tolist(), abs=0.1
+                apply_windowing(arr, ds).tolist(), abs=0.1
             )
         )
 
@@ -1872,7 +1691,7 @@ def test_window_multi_frame(self):
         assert (2, 484, 484) == arr.shape
         assert 642 == arr[0, 326, 130]
         assert 3453 == arr[1, 326, 130]
-        out = apply_voi_lut(arr, ds)
+        out = apply_windowing(arr, ds)
         assert 3046.6 == pytest.approx(out[0, 326, 130], abs=0.1)
         assert 4095.0 == pytest.approx(out[1, 326, 130], abs=0.1)
 
@@ -1902,7 +1721,7 @@ def test_window_rescale(self):
         assert 770.4 == hu[326, 130]
         assert 1347.6 == hu[316, 481]
         # With rescale -> output range is 0 to 4914
-        out = apply_voi_lut(hu, ds)
+        out = apply_windowing(hu, ds)
         assert 0 == pytest.approx(out[16, 60], abs=0.1)
         assert 4455.6 == pytest.approx(out[326, 130], abs=0.1)
         assert 4914.0 == pytest.approx(out[316, 481], abs=0.1)
@@ -1930,7 +1749,7 @@ def test_window_modality_lut(self):
         hu = apply_modality_lut(arr, ds)
         assert 65535 == hu[16, 60]
         assert 49147 == hu[0, 1]
-        out = apply_voi_lut(hu, ds)
+        out = apply_windowing(hu, ds)
         assert 65535.0 == pytest.approx(out[16, 60], abs=0.1)
         assert 32809.0 == pytest.approx(out[0, 1], abs=0.1)
         # Output range must be 0 to 2**16 - 1
@@ -1943,7 +1762,7 @@ def test_window_bad_photometric_interp(self):
         ds.PhotometricInterpretation = 'RGB'
         msg = r"only 'MONOCHROME1' and 'MONOCHROME2' are allowed"
         with pytest.raises(ValueError, match=msg):
-            apply_voi_lut(ds.pixel_array, ds)
+            apply_windowing(ds.pixel_array, ds)
 
     def test_window_bad_parameters(self):
         """Test bad windowing parameters raise exceptions."""
@@ -1952,22 +1771,22 @@ def test_window_bad_parameters(self):
         ds.VOILUTFunction = 'LINEAR'
         msg = r"Width must be greater than or equal to 1"
         with pytest.raises(ValueError, match=msg):
-            apply_voi_lut(ds.pixel_array, ds)
+            apply_windowing(ds.pixel_array, ds)
 
         ds.VOILUTFunction = 'LINEAR_EXACT'
         msg = r"Width must be greater than 0"
         with pytest.raises(ValueError, match=msg):
-            apply_voi_lut(ds.pixel_array, ds)
+            apply_windowing(ds.pixel_array, ds)
 
         ds.VOILUTFunction = 'SIGMOID'
         msg = r"Width must be greater than 0"
         with pytest.raises(ValueError, match=msg):
-            apply_voi_lut(ds.pixel_array, ds)
+            apply_windowing(ds.pixel_array, ds)
 
         ds.VOILUTFunction = 'UNKNOWN'
         msg = r"Unsupported \(0028,1056\) VOI LUT Function value 'UNKNOWN'"
         with pytest.raises(ValueError, match=msg):
-            apply_voi_lut(ds.pixel_array, ds)
+            apply_windowing(ds.pixel_array, ds)
 
     def test_window_bad_index(self, no_numpy_use):
         """Test windowing with a bad view index."""
@@ -1975,7 +1794,7 @@ def test_window_bad_index(self, no_numpy_use):
         assert 2 == len(ds.WindowWidth)
         arr = ds.pixel_array
         with pytest.raises(IndexError, match=r"list index out of range"):
-            apply_voi_lut(arr, ds, index=2)
+            apply_windowing(arr, ds, index=2)
 
     def test_unchanged(self):
         """Test input array is unchanged if no VOI LUT"""
@@ -1984,7 +1803,219 @@ def test_unchanged(self):
         ds.PixelRepresentation = 1
         ds.BitsStored = 8
         arr = np.asarray([-128, -127, -1, 0, 1, 126, 127], dtype='int8')
-        out = apply_voi_lut(arr, ds)
+        out = apply_windowing(arr, ds)
+        assert [-128, -127, -1, 0, 1, 126, 127] == out.tolist()
+
+    def test_rescale_empty(self):
+        """Test RescaleSlope and RescaleIntercept being empty."""
+        ds = dcmread(WIN_12_1F)
+        ds.RescaleSlope = None
+        ds.RescaleIntercept = None
+
+        arr = ds.pixel_array
+        assert 0 == arr[16, 60]
+        assert 642 == arr[326, 130]
+        assert 1123 == arr[316, 481]
+        out = apply_windowing(arr, ds)
+        assert 0 == pytest.approx(out[16, 60], abs=0.1)
+        assert 3046.6 == pytest.approx(out[326, 130], abs=0.1)
+        assert 4095.0 == pytest.approx(out[316, 481], abs=0.1)
+
+
+@pytest.mark.skipif(not HAVE_NP, reason="Numpy is not available")
+class TestNumpy_ApplyVOI:
+    """Tests for util.apply_voi()."""
+    def test_voi_single_view(self):
+        """Test VOI LUT with a single view."""
+        ds = dcmread(VOI_08_1F)
+        assert 8 == ds.BitsAllocated
+        assert 8 == ds.BitsStored
+        assert 0 == ds.PixelRepresentation
+        item = ds.VOILUTSequence[0]
+        assert [256, 0, 16] == item.LUTDescriptor
+        lut = item.LUTData
+        assert 0 == lut[0]
+        assert 19532 == lut[76]
+        assert 45746 == lut[178]
+        assert 65535 == lut[255]
+
+        arr = ds.pixel_array
+        assert 0 == arr[387, 448]
+        assert 76 == arr[178, 126]
+        assert 178 == arr[186, 389]
+        assert 255 == arr[129, 79]
+
+        out = apply_voi(arr, ds)
+        assert 0 == out[387, 448]
+        assert 19532 == out[178, 126]
+        assert 45746 == out[186, 389]
+        assert 65535 == out[129, 79]
+
+    def test_voi_multi_view(self):
+        """Test VOI LUT with multiple views."""
+        ds = dcmread(VOI_08_1F)
+        assert 8 == ds.BitsAllocated
+        assert 8 == ds.BitsStored
+        assert 0 == ds.PixelRepresentation
+        item0 = ds.VOILUTSequence[0]
+        # Add another view thats the inverse
+        ds.VOILUTSequence.append(Dataset())
+        item1 = ds.VOILUTSequence[1]
+        item1.LUTDescriptor = [256, 0, 16]
+        item1.LUTData = item0.LUTData[::-1]
+
+        arr = ds.pixel_array
+        assert 0 == arr[387, 448]
+        assert 76 == arr[178, 126]
+        assert 178 == arr[186, 389]
+        assert 255 == arr[129, 79]
+
+        out0 = apply_voi(arr, ds)
+        assert 0 == out0[387, 448]
+        assert 19532 == out0[178, 126]
+        assert 45746 == out0[186, 389]
+        assert 65535 == out0[129, 79]
+
+        out1 = apply_voi(arr, ds, index=1)
+        assert 65535 == out1[387, 448]
+        assert 46003 == out1[178, 126]
+        assert 19789 == out1[186, 389]
+        assert 0 == out1[129, 79]
+
+    def test_voi_multi_frame(self):
+        """Test VOI with a multiple frames."""
+        ds = dcmread(VOI_08_1F)
+        assert 8 == ds.BitsAllocated
+        assert 8 == ds.BitsStored
+        assert 0 == ds.PixelRepresentation
+
+        arr = ds.pixel_array
+        arr = np.stack([arr, 255 - arr])
+        assert (2, 512, 512) == arr.shape
+
+        out = apply_voi(arr, ds)
+        assert 0 == out[0, 387, 448]
+        assert 19532 == out[0, 178, 126]
+        assert 45746 == out[0, 186, 389]
+        assert 65535 == out[0, 129, 79]
+        assert 65535 == out[1, 387, 448]
+        assert 46003 == out[1, 178, 126]
+        assert 19789 == out[1, 186, 389]
+        assert 0 == out[1, 129, 79]
+
+    def test_voi_zero_entries(self):
+        """Test that 0 entries is interpreted correctly."""
+        ds = dcmread(VOI_08_1F)
+        seq = ds.VOILUTSequence[0]
+        seq.LUTDescriptor = [0, 0, 16]
+        assert 256 == len(seq.LUTData)
+        arr = np.asarray([0, 255, 256, 65535])
+        msg = r"index 256 is out of bounds"
+        with pytest.raises(IndexError, match=msg):
+            apply_voi(arr, ds)
+
+        # LUTData with 65536 entries
+        seq.LUTData = [0] * 65535 + [1]
+        out = apply_voi(arr, ds)
+        assert [0, 0, 0, 1] == list(out)
+
+    def test_voi_uint8(self):
+        """Test uint VOI LUT with an 8-bit LUT."""
+        ds = Dataset()
+        ds.PixelRepresentation = 0
+        ds.BitsStored = 8
+        ds.VOILUTSequence = [Dataset()]
+        item = ds.VOILUTSequence[0]
+        item.LUTDescriptor = [4, 0, 8]
+        item.LUTData = [0, 127, 128, 255]
+        arr = np.asarray([0, 1, 128, 254, 255], dtype='uint8')
+        out = apply_voi(arr, ds)
+        assert 'uint8' == out.dtype
+        assert [0, 127, 255, 255, 255] == out.tolist()
+
+    def test_voi_uint16(self):
+        """Test uint VOI LUT with an 16-bit LUT."""
+        ds = Dataset()
+        ds.PixelRepresentation = 0
+        ds.BitsStored = 16
+        ds.VOILUTSequence = [Dataset()]
+        item = ds.VOILUTSequence[0]
+        item.LUTDescriptor = [4, 0, 16]
+        item.LUTData = [0, 127, 32768, 65535]
+        arr = np.asarray([0, 1, 2, 3, 255], dtype='uint16')
+        out = apply_voi(arr, ds)
+        assert 'uint16' == out.dtype
+        assert [0, 127, 32768, 65535, 65535] == out.tolist()
+
+    def test_voi_int8(self):
+        """Test int VOI LUT with an 8-bit LUT."""
+        ds = Dataset()
+        ds.PixelRepresentation = 1
+        ds.BitsStored = 8
+        ds.VOILUTSequence = [Dataset()]
+        item = ds.VOILUTSequence[0]
+        item.LUTDescriptor = [4, 0, 8]
+        item.LUTData = [0, 127, 128, 255]
+        arr = np.asarray([0, -1, 2, -128, 127], dtype='int8')
+        out = apply_voi(arr, ds)
+        assert 'uint8' == out.dtype
+        assert [0, 0, 128, 0, 255] == out.tolist()
+
+    def test_voi_int16(self):
+        """Test int VOI LUT with an 16-bit LUT."""
+        ds = Dataset()
+        ds.PixelRepresentation = 0
+        ds.BitsStored = 16
+        ds.VOILUTSequence = [Dataset()]
+        item = ds.VOILUTSequence[0]
+        item.LUTDescriptor = [4, 0, 16]
+        item.LUTData = [0, 127, 32768, 65535]
+        arr = np.asarray([0, -1, 2, -128, 255], dtype='int16')
+        out = apply_voi(arr, ds)
+        assert 'uint16' == out.dtype
+        assert [0, 0, 32768, 0, 65535] == out.tolist()
+
+    def test_voi_bad_depth(self):
+        """Test bad LUT depth raises exception."""
+        ds = dcmread(VOI_08_1F)
+        item = ds.VOILUTSequence[0]
+        item.LUTDescriptor[2] = 7
+        msg = r"'7' bits per LUT entry is not supported"
+        with pytest.raises(NotImplementedError, match=msg):
+            apply_voi(ds.pixel_array, ds)
+
+        item.LUTDescriptor[2] = 17
+        msg = r"'17' bits per LUT entry is not supported"
+        with pytest.raises(NotImplementedError, match=msg):
+            apply_voi(ds.pixel_array, ds)
+
+    def test_voi_uint16_array_float(self):
+        """Test warning when array is float and VOI LUT with an 16-bit LUT"""
+        ds = Dataset()
+        ds.PixelRepresentation = 0
+        ds.BitsStored = 16
+        ds.VOILUTSequence = [Dataset()]
+        item = ds.VOILUTSequence[0]
+        item.LUTDescriptor = [4, 0, 16]
+        item.LUTData = [0, 127, 32768, 65535]
+        arr = np.asarray([0, 1, 2, 3, 255], dtype='float64')
+        msg = (
+            r"Applying a VOI LUT on a float input array may give "
+            r"incorrect results"
+        )
+
+        with pytest.warns(UserWarning, match=msg):
+            out = apply_voi(arr, ds)
+            assert [0, 127, 32768, 65535, 65535] == out.tolist()
+
+    def test_unchanged(self):
+        """Test input array is unchanged if no VOI LUT"""
+        ds = Dataset()
+        ds.PhotometricInterpretation = 'MONOCHROME1'
+        ds.PixelRepresentation = 1
+        ds.BitsStored = 8
+        arr = np.asarray([-128, -127, -1, 0, 1, 126, 127], dtype='int8')
+        out = apply_voi(arr, ds)
         assert [-128, -127, -1, 0, 1, 126, 127] == out.tolist()
 
     def test_voi_lutdata_ow(self):
@@ -2001,11 +2032,94 @@ def test_voi_lutdata_ow(self):
         item.LUTData = pack('<4H', *item.LUTData)
         item['LUTData'].VR = 'OW'
         arr = np.asarray([0, 1, 2, 3, 255], dtype='uint16')
-        out = apply_voi_lut(arr, ds)
+        out = apply_voi(arr, ds)
         assert 'uint16' == out.dtype
         assert [0, 127, 32768, 65535, 65535] == out.tolist()
 
 
+@pytest.mark.skipif(not HAVE_NP, reason="Numpy is not available")
+class TestNumpy_ApplyVOILUT:
+    def test_unchanged(self):
+        """Test input array is unchanged if no VOI LUT"""
+        ds = Dataset()
+        ds.PhotometricInterpretation = 'MONOCHROME1'
+        ds.PixelRepresentation = 1
+        ds.BitsStored = 8
+        arr = np.asarray([-128, -127, -1, 0, 1, 126, 127], dtype='int8')
+        out = apply_voi_lut(arr, ds)
+        assert [-128, -127, -1, 0, 1, 126, 127] == out.tolist()
+
+    def test_only_windowing(self):
+        """Test only windowing operation elements present."""
+        ds = Dataset()
+        ds.PhotometricInterpretation = 'MONOCHROME1'
+        ds.PixelRepresentation = 0
+        ds.BitsStored = 8
+        arr = np.asarray([0, 1, 128, 254, 255], dtype='uint8')
+
+        ds.WindowWidth = 1
+        ds.WindowCenter = 0
+        assert [255, 255, 255, 255, 255] == apply_voi_lut(arr, ds).tolist()
+
+    def test_only_voi(self):
+        """Test only LUT operation elements present."""
+        ds = Dataset()
+        ds.PixelRepresentation = 0
+        ds.BitsStored = 8
+        ds.VOILUTSequence = [Dataset()]
+        item = ds.VOILUTSequence[0]
+        item.LUTDescriptor = [4, 0, 8]
+        item.LUTData = [0, 127, 128, 255]
+        arr = np.asarray([0, 1, 128, 254, 255], dtype='uint8')
+        out = apply_voi_lut(arr, ds)
+        assert 'uint8' == out.dtype
+        assert [0, 127, 255, 255, 255] == out.tolist()
+
+    def test_voi_windowing(self):
+        """Test both LUT and windowing operation elements present."""
+        ds = Dataset()
+        ds.PhotometricInterpretation = 'MONOCHROME1'
+        ds.PixelRepresentation = 0
+        ds.BitsStored = 8
+        ds.WindowWidth = 1
+        ds.WindowCenter = 0
+        ds.VOILUTSequence = [Dataset()]
+        item = ds.VOILUTSequence[0]
+        item.LUTDescriptor = [4, 0, 8]
+        item.LUTData = [0, 127, 128, 255]
+        arr = np.asarray([0, 1, 128, 254, 255], dtype='uint8')
+
+        # Defaults to LUT
+        out = apply_voi_lut(arr, ds)
+        assert [0, 127, 255, 255, 255] == out.tolist()
+
+        out = apply_voi_lut(arr, ds, prefer_lut=False)
+        assert [255, 255, 255, 255, 255] == out.tolist()
+
+    def test_voi_windowing_empty(self):
+        """Test empty VOI elements."""
+        ds = Dataset()
+        ds.PhotometricInterpretation = 'MONOCHROME1'
+        ds.PixelRepresentation = 0
+        ds.BitsStored = 8
+        ds.WindowWidth = 1
+        ds.WindowCenter = 0
+        ds.VOILUTSequence = [Dataset()]
+        item = ds.VOILUTSequence[0]
+        item.LUTDescriptor = [4, 0, 8]
+        item.LUTData = [0, 127, 128, 255]
+        arr = np.asarray([0, 1, 128, 254, 255], dtype='uint8')
+
+        # Test empty VOI elements
+        item.LUTData = None
+        out = apply_voi_lut(arr, ds)
+        assert [255, 255, 255, 255, 255] == out.tolist()
+
+        # Test empty windowing elements
+        ds.WindowWidth = None
+        out = apply_voi_lut(arr, ds)
+        assert [0, 1, 128, 254, 255] == out.tolist()
+
 class TestGetJ2KParameters:
     """Tests for get_j2k_parameters."""
     def test_precision(self):