1# Copyright 2008-2018 pydicom authors. See LICENSE file for details.
2"""Utility functions used in the pixel data handlers."""
3
4from struct import unpack
5from sys import byteorder
6from typing import (
7    Dict, Optional, Union, List, Tuple, TYPE_CHECKING, cast, Iterable
8)
9import warnings
10
11try:
12    import numpy as np
13    HAVE_NP = True
14except ImportError:
15    HAVE_NP = False
16
17from pydicom.data import get_palette_files
18from pydicom.uid import UID
19
20if TYPE_CHECKING:  # pragma: no cover
21    from pydicom.dataset import Dataset, FileMetaDataset, FileDataset
22
23
24def apply_color_lut(
25    arr: "np.ndarray",
26    ds: Optional["Dataset"] = None,
27    palette: Optional[Union[str, UID]] = None
28) -> "np.ndarray":
29    """Apply a color palette lookup table to `arr`.
30
31    .. versionadded:: 1.4
32
33    If (0028,1201-1203) *Palette Color Lookup Table Data* are missing
34    then (0028,1221-1223) *Segmented Palette Color Lookup Table Data* must be
35    present and vice versa. The presence of (0028,1204) *Alpha Palette Color
36    Lookup Table Data* or (0028,1224) *Alpha Segmented Palette Color Lookup
37    Table Data* is optional.
38
39    Use of this function with the :dcm:`Enhanced Palette Color Lookup Table
40    Module<part03/sect_C.7.6.23.html>` or :dcm:`Supplemental Palette Color LUT
41    Module<part03/sect_C.7.6.19.html>` is not currently supported.
42
43    Parameters
44    ----------
45    arr : numpy.ndarray
46        The pixel data to apply the color palette to.
47    ds : dataset.Dataset, optional
48        Required if `palette` is not supplied. A
49        :class:`~pydicom.dataset.Dataset` containing a suitable
50        :dcm:`Image Pixel<part03/sect_C.7.6.3.html>` or
51        :dcm:`Palette Color Lookup Table<part03/sect_C.7.9.html>` Module.
52    palette : str or uid.UID, optional
53        Required if `ds` is not supplied. The name of one of the
54        :dcm:`well-known<part06/chapter_B.html>` color palettes defined by the
55        DICOM Standard. One of: ``'HOT_IRON'``, ``'PET'``,
56        ``'HOT_METAL_BLUE'``, ``'PET_20_STEP'``, ``'SPRING'``, ``'SUMMER'``,
57        ``'FALL'``, ``'WINTER'`` or the corresponding well-known (0008,0018)
58        *SOP Instance UID*.
59
60    Returns
61    -------
62    numpy.ndarray
63        The RGB or RGBA pixel data as an array of ``np.uint8`` or ``np.uint16``
64        values, depending on the 3rd value of (0028,1201) *Red Palette Color
65        Lookup Table Descriptor*.
66
67    References
68    ----------
69
70    * :dcm:`Image Pixel Module<part03/sect_C.7.6.3.html>`
71    * :dcm:`Supplemental Palette Color LUT Module<part03/sect_C.7.6.19.html>`
72    * :dcm:`Enhanced Palette Color LUT Module<part03/sect_C.7.6.23.html>`
73    * :dcm:`Palette Colour LUT Module<part03/sect_C.7.9.html>`
74    * :dcm:`Supplemental Palette Color LUTs
75      <part03/sect_C.8.16.2.html#sect_C.8.16.2.1.1.1>`
76    """
77    # Note: input value (IV) is the stored pixel value in `arr`
78    # LUTs[IV] -> [R, G, B] values at the IV pixel location in `arr`
79    if not ds and not palette:
80        raise ValueError("Either 'ds' or 'palette' is required")
81
82    if palette:
83        # Well-known palettes are all 8-bits per entry
84        datasets = {
85            '1.2.840.10008.1.5.1': 'hotiron.dcm',
86            '1.2.840.10008.1.5.2': 'pet.dcm',
87            '1.2.840.10008.1.5.3': 'hotmetalblue.dcm',
88            '1.2.840.10008.1.5.4': 'pet20step.dcm',
89            '1.2.840.10008.1.5.5': 'spring.dcm',
90            '1.2.840.10008.1.5.6': 'summer.dcm',
91            '1.2.840.10008.1.5.7': 'fall.dcm',
92            '1.2.840.10008.1.5.8': 'winter.dcm',
93        }
94        if not UID(palette).is_valid:
95            try:
96                uids = {
97                    'HOT_IRON': '1.2.840.10008.1.5.1',
98                    'PET': '1.2.840.10008.1.5.2',
99                    'HOT_METAL_BLUE': '1.2.840.10008.1.5.3',
100                    'PET_20_STEP': '1.2.840.10008.1.5.4',
101                    'SPRING': '1.2.840.10008.1.5.5',
102                    'SUMMER': '1.2.840.10008.1.5.6',
103                    'FALL': '1.2.840.10008.1.5.8',
104                    'WINTER': '1.2.840.10008.1.5.7',
105                }
106                palette = uids[palette]
107            except KeyError:
108                raise ValueError("Unknown palette '{}'".format(palette))
109
110        try:
111            from pydicom import dcmread
112            fname = datasets[palette]
113            ds = dcmread(get_palette_files(fname)[0])
114        except KeyError:
115            raise ValueError("Unknown palette '{}'".format(palette))
116
117    ds = cast("Dataset", ds)
118
119    # C.8.16.2.1.1.1: Supplemental Palette Color LUT
120    # TODO: Requires greyscale visualisation pipeline
121    if getattr(ds, 'PixelPresentation', None) in ['MIXED', 'COLOR']:
122        raise ValueError(
123            "Use of this function with the Supplemental Palette Color Lookup "
124            "Table Module is not currently supported"
125        )
126
127    if 'RedPaletteColorLookupTableDescriptor' not in ds:
128        raise ValueError("No suitable Palette Color Lookup Table Module found")
129
130    # All channels are supposed to be identical
131    lut_desc = cast(List[int], ds.RedPaletteColorLookupTableDescriptor)
132    # A value of 0 = 2^16 entries
133    nr_entries = lut_desc[0] or 2**16
134
135    # May be negative if Pixel Representation is 1
136    first_map = lut_desc[1]
137    # Actual bit depth may be larger (8 bit entries in 16 bits allocated)
138    nominal_depth = lut_desc[2]
139    dtype = np.dtype('uint{:.0f}'.format(nominal_depth))
140
141    luts = []
142    if 'RedPaletteColorLookupTableData' in ds:
143        # LUT Data is described by PS3.3, C.7.6.3.1.6
144        r_lut = cast(bytes, ds.RedPaletteColorLookupTableData)
145        g_lut = cast(bytes, ds.GreenPaletteColorLookupTableData)
146        b_lut = cast(bytes, ds.BluePaletteColorLookupTableData)
147        a_lut = cast(
148            Optional[bytes],
149            getattr(ds, 'AlphaPaletteColorLookupTableData', None)
150        )
151
152        actual_depth = len(r_lut) / nr_entries * 8
153        dtype = np.dtype('uint{:.0f}'.format(actual_depth))
154
155        for lut_bytes in [ii for ii in [r_lut, g_lut, b_lut, a_lut] if ii]:
156            luts.append(np.frombuffer(lut_bytes, dtype=dtype))
157    elif 'SegmentedRedPaletteColorLookupTableData' in ds:
158        # Segmented LUT Data is described by PS3.3, C.7.9.2
159        r_lut = cast(bytes, ds.SegmentedRedPaletteColorLookupTableData)
160        g_lut = cast(bytes, ds.SegmentedGreenPaletteColorLookupTableData)
161        b_lut = cast(bytes, ds.SegmentedBluePaletteColorLookupTableData)
162        a_lut = cast(
163            Optional[bytes],
164            getattr(ds, 'SegmentedAlphaPaletteColorLookupTableData', None)
165        )
166
167        endianness = '<' if ds.is_little_endian else '>'
168        byte_depth = nominal_depth // 8
169        fmt = 'B' if byte_depth == 1 else 'H'
170        actual_depth = nominal_depth
171
172        for seg in [ii for ii in [r_lut, g_lut, b_lut, a_lut] if ii]:
173            len_seg = len(seg) // byte_depth
174            s_fmt = endianness + str(len_seg) + fmt
175            lut_ints = _expand_segmented_lut(unpack(s_fmt, seg), s_fmt)
176            luts.append(np.asarray(lut_ints, dtype=dtype))
177    else:
178        raise ValueError("No suitable Palette Color Lookup Table Module found")
179
180    if actual_depth not in [8, 16]:
181        raise ValueError(
182            f"The bit depth of the LUT data '{actual_depth:.1f}' "
183            "is invalid (only 8 or 16 bits per entry allowed)"
184        )
185
186    lut_lengths = [len(ii) for ii in luts]
187    if not all(ii == lut_lengths[0] for ii in lut_lengths[1:]):
188        raise ValueError("LUT data must be the same length")
189
190    # IVs < `first_map` get set to first LUT entry (i.e. index 0)
191    clipped_iv = np.zeros(arr.shape, dtype=dtype)
192    # IVs >= `first_map` are mapped by the Palette Color LUTs
193    # `first_map` may be negative, positive or 0
194    mapped_pixels = arr >= first_map
195    clipped_iv[mapped_pixels] = arr[mapped_pixels] - first_map
196    # IVs > number of entries get set to last entry
197    np.clip(clipped_iv, 0, nr_entries - 1, out=clipped_iv)
198
199    # Output array may be RGB or RGBA
200    out = np.empty(list(arr.shape) + [len(luts)], dtype=dtype)
201    for ii, lut in enumerate(luts):
202        out[..., ii] = lut[clipped_iv]
203
204    return out
205
206
207def apply_modality_lut(arr: "np.ndarray", ds: "Dataset") -> "np.ndarray":
208    """Apply a modality lookup table or rescale operation to `arr`.
209
210    .. versionadded:: 1.4
211
212    Parameters
213    ----------
214    arr : numpy.ndarray
215        The :class:`~numpy.ndarray` to apply the modality LUT or rescale
216        operation to.
217    ds : dataset.Dataset
218        A dataset containing a :dcm:`Modality LUT Module
219        <part03/sect_C.11.html#sect_C.11.1>`.
220
221    Returns
222    -------
223    numpy.ndarray
224        An array with applied modality LUT or rescale operation. If
225        (0028,3000) *Modality LUT Sequence* is present then returns an array
226        of ``np.uint8`` or ``np.uint16``, depending on the 3rd value of
227        (0028,3002) *LUT Descriptor*. If (0028,1052) *Rescale Intercept* and
228        (0028,1053) *Rescale Slope* are present then returns an array of
229        ``np.float64``. If neither are present then `arr` will be returned
230        unchanged.
231
232    Notes
233    -----
234    When *Rescale Slope* and *Rescale Intercept* are used, the output range
235    is from (min. pixel value * Rescale Slope + Rescale Intercept) to
236    (max. pixel value * Rescale Slope + Rescale Intercept), where min. and
237    max. pixel value are determined from (0028,0101) *Bits Stored* and
238    (0028,0103) *Pixel Representation*.
239
240    References
241    ----------
242    * DICOM Standard, Part 3, :dcm:`Annex C.11.1
243      <part03/sect_C.11.html#sect_C.11.1>`
244    * DICOM Standard, Part 4, :dcm:`Annex N.2.1.1
245      <part04/sect_N.2.html#sect_N.2.1.1>`
246    """
247    if 'ModalityLUTSequence' in ds:
248        item = cast(List["Dataset"], ds.ModalityLUTSequence)[0]
249        nr_entries = cast(List[int], item.LUTDescriptor)[0] or 2**16
250        first_map = cast(List[int], item.LUTDescriptor)[1]
251        nominal_depth = cast(List[int], item.LUTDescriptor)[2]
252
253        dtype = 'uint{}'.format(nominal_depth)
254
255        # Ambiguous VR, US or OW
256        unc_data: Iterable[int]
257        if item['LUTData'].VR == 'OW':
258            endianness = '<' if ds.is_little_endian else '>'
259            unpack_fmt = '{}{}H'.format(endianness, nr_entries)
260            unc_data = unpack(unpack_fmt, cast(bytes, item.LUTData))
261        else:
262            unc_data = cast(List[int], item.LUTData)
263
264        lut_data: "np.ndarray" = np.asarray(unc_data, dtype=dtype)
265
266        # IVs < `first_map` get set to first LUT entry (i.e. index 0)
267        clipped_iv = np.zeros(arr.shape, dtype=arr.dtype)
268        # IVs >= `first_map` are mapped by the Modality LUT
269        # `first_map` may be negative, positive or 0
270        mapped_pixels = arr >= first_map
271        clipped_iv[mapped_pixels] = arr[mapped_pixels] - first_map
272        # IVs > number of entries get set to last entry
273        np.clip(clipped_iv, 0, nr_entries - 1, out=clipped_iv)
274
275        return cast("np.ndarray", lut_data[clipped_iv])
276    elif 'RescaleSlope' in ds and 'RescaleIntercept' in ds:
277        arr = arr.astype(np.float64) * cast(float, ds.RescaleSlope)
278        arr += cast(float, ds.RescaleIntercept)
279
280    return arr
281
282
283def apply_voi_lut(
284    arr: "np.ndarray",
285    ds: "Dataset",
286    index: int = 0,
287    prefer_lut: bool = True
288) -> "np.ndarray":
289    """Apply a VOI lookup table or windowing operation to `arr`.
290
291    .. versionadded:: 1.4
292
293    .. versionchanged:: 2.1
294
295        Added the `prefer_lut` keyword parameter
296
297    Parameters
298    ----------
299    arr : numpy.ndarray
300        The :class:`~numpy.ndarray` to apply the VOI LUT or windowing operation
301        to.
302    ds : dataset.Dataset
303        A dataset containing a :dcm:`VOI LUT Module<part03/sect_C.11.2.html>`.
304        If (0028,3010) *VOI LUT Sequence* is present then returns an array
305        of ``np.uint8`` or ``np.uint16``, depending on the 3rd value of
306        (0028,3002) *LUT Descriptor*. If (0028,1050) *Window Center* and
307        (0028,1051) *Window Width* are present then returns an array of
308        ``np.float64``. If neither are present then `arr` will be returned
309        unchanged.
310    index : int, optional
311        When the VOI LUT Module contains multiple alternative views, this is
312        the index of the view to return (default ``0``).
313    prefer_lut : bool
314        When the VOI LUT Module contains both *Window Width*/*Window Center*
315        and *VOI LUT Sequence*, if ``True`` (default) then apply the VOI LUT,
316        otherwise apply the windowing operation.
317
318    Returns
319    -------
320    numpy.ndarray
321        An array with applied VOI LUT or windowing operation.
322
323    Notes
324    -----
325    When the dataset requires a modality LUT or rescale operation as part of
326    the Modality LUT module then that must be applied before any windowing
327    operation.
328
329    See Also
330    --------
331    :func:`~pydicom.pixel_data_handlers.util.apply_modality_lut`
332    :func:`~pydicom.pixel_data_handlers.util.apply_voi`
333    :func:`~pydicom.pixel_data_handlers.util.apply_windowing`
334
335    References
336    ----------
337    * DICOM Standard, Part 3, :dcm:`Annex C.11.2
338      <part03/sect_C.11.html#sect_C.11.2>`
339    * DICOM Standard, Part 3, :dcm:`Annex C.8.11.3.1.5
340      <part03/sect_C.8.11.3.html#sect_C.8.11.3.1.5>`
341    * DICOM Standard, Part 4, :dcm:`Annex N.2.1.1
342      <part04/sect_N.2.html#sect_N.2.1.1>`
343    """
344    valid_voi = False
345    if 'VOILUTSequence' in ds:
346        ds.VOILUTSequence = cast(List["Dataset"], ds.VOILUTSequence)
347        valid_voi = None not in [
348            ds.VOILUTSequence[0].get('LUTDescriptor', None),
349            ds.VOILUTSequence[0].get('LUTData', None)
350        ]
351    valid_windowing = None not in [
352        ds.get('WindowCenter', None),
353        ds.get('WindowWidth', None)
354    ]
355
356    if valid_voi and valid_windowing:
357        if prefer_lut:
358            return apply_voi(arr, ds, index)
359
360        return apply_windowing(arr, ds, index)
361
362    if valid_voi:
363        return apply_voi(arr, ds, index)
364
365    if valid_windowing:
366        return apply_windowing(arr, ds, index)
367
368    return arr
369
370
371def apply_voi(
372    arr: "np.ndarray", ds: "Dataset", index: int = 0
373) -> "np.ndarray":
374    """Apply a VOI lookup table to `arr`.
375
376    .. versionadded:: 2.1
377
378    Parameters
379    ----------
380    arr : numpy.ndarray
381        The :class:`~numpy.ndarray` to apply the VOI LUT to.
382    ds : dataset.Dataset
383        A dataset containing a :dcm:`VOI LUT Module<part03/sect_C.11.2.html>`.
384        If (0028,3010) *VOI LUT Sequence* is present then returns an array
385        of ``np.uint8`` or ``np.uint16``, depending on the 3rd value of
386        (0028,3002) *LUT Descriptor*, otherwise `arr` will be returned
387        unchanged.
388    index : int, optional
389        When the VOI LUT Module contains multiple alternative views, this is
390        the index of the view to return (default ``0``).
391
392    Returns
393    -------
394    numpy.ndarray
395        An array with applied VOI LUT.
396
397    See Also
398    --------
399    :func:`~pydicom.pixel_data_handlers.util.apply_modality_lut`
400    :func:`~pydicom.pixel_data_handlers.util.apply_windowing`
401
402    References
403    ----------
404    * DICOM Standard, Part 3, :dcm:`Annex C.11.2
405      <part03/sect_C.11.html#sect_C.11.2>`
406    * DICOM Standard, Part 3, :dcm:`Annex C.8.11.3.1.5
407      <part03/sect_C.8.11.3.html#sect_C.8.11.3.1.5>`
408    * DICOM Standard, Part 4, :dcm:`Annex N.2.1.1
409      <part04/sect_N.2.html#sect_N.2.1.1>`
410    """
411    if "VOILUTSequence" not in ds:
412        return arr
413
414    if not np.issubdtype(arr.dtype, np.integer):
415        warnings.warn(
416            "Applying a VOI LUT on a float input array may give "
417            "incorrect results"
418        )
419
420    # VOI LUT Sequence contains one or more items
421    item = cast(List["Dataset"], ds.VOILUTSequence)[index]
422    lut_descriptor = cast(List[int], item.LUTDescriptor)
423    nr_entries = lut_descriptor[0] or 2**16
424    first_map = lut_descriptor[1]
425
426    # PS3.3 C.8.11.3.1.5: may be 8, 10-16
427    nominal_depth = lut_descriptor[2]
428    if nominal_depth in list(range(10, 17)):
429        dtype = 'uint16'
430    elif nominal_depth == 8:
431        dtype = 'uint8'
432    else:
433        raise NotImplementedError(
434            f"'{nominal_depth}' bits per LUT entry is not supported"
435        )
436
437    # Ambiguous VR, US or OW
438    unc_data: Iterable[int]
439    if item['LUTData'].VR == 'OW':
440        endianness = '<' if ds.is_little_endian else '>'
441        unpack_fmt = f'{endianness}{nr_entries}H'
442        unc_data = unpack(unpack_fmt, cast(bytes, item.LUTData))
443    else:
444        unc_data = cast(List[int], item.LUTData)
445
446    lut_data: "np.ndarray" = np.asarray(unc_data, dtype=dtype)
447
448    # IVs < `first_map` get set to first LUT entry (i.e. index 0)
449    clipped_iv = np.zeros(arr.shape, dtype=dtype)
450    # IVs >= `first_map` are mapped by the VOI LUT
451    # `first_map` may be negative, positive or 0
452    mapped_pixels = arr >= first_map
453    clipped_iv[mapped_pixels] = arr[mapped_pixels] - first_map
454    # IVs > number of entries get set to last entry
455    np.clip(clipped_iv, 0, nr_entries - 1, out=clipped_iv)
456
457    return cast("np.ndarray", lut_data[clipped_iv])
458
459
460def apply_windowing(
461    arr: "np.ndarray", ds: "Dataset", index: int = 0
462) -> "np.ndarray":
463    """Apply a windowing operation to `arr`.
464
465    .. versionadded:: 2.1
466
467    Parameters
468    ----------
469    arr : numpy.ndarray
470        The :class:`~numpy.ndarray` to apply the windowing operation to.
471    ds : dataset.Dataset
472        A dataset containing a :dcm:`VOI LUT Module<part03/sect_C.11.2.html>`.
473        If (0028,1050) *Window Center* and (0028,1051) *Window Width* are
474        present then returns an array of ``np.float64``, otherwise `arr` will
475        be returned unchanged.
476    index : int, optional
477        When the VOI LUT Module contains multiple alternative views, this is
478        the index of the view to return (default ``0``).
479
480    Returns
481    -------
482    numpy.ndarray
483        An array with applied windowing operation.
484
485    Notes
486    -----
487    When the dataset requires a modality LUT or rescale operation as part of
488    the Modality LUT module then that must be applied before any windowing
489    operation.
490
491    See Also
492    --------
493    :func:`~pydicom.pixel_data_handlers.util.apply_modality_lut`
494    :func:`~pydicom.pixel_data_handlers.util.apply_voi`
495
496    References
497    ----------
498    * DICOM Standard, Part 3, :dcm:`Annex C.11.2
499      <part03/sect_C.11.html#sect_C.11.2>`
500    * DICOM Standard, Part 3, :dcm:`Annex C.8.11.3.1.5
501      <part03/sect_C.8.11.3.html#sect_C.8.11.3.1.5>`
502    * DICOM Standard, Part 4, :dcm:`Annex N.2.1.1
503      <part04/sect_N.2.html#sect_N.2.1.1>`
504    """
505    if "WindowWidth" not in ds and "WindowCenter" not in ds:
506        return arr
507
508    if ds.PhotometricInterpretation not in ['MONOCHROME1', 'MONOCHROME2']:
509        raise ValueError(
510            "When performing a windowing operation only 'MONOCHROME1' and "
511            "'MONOCHROME2' are allowed for (0028,0004) Photometric "
512            "Interpretation"
513        )
514
515    # May be LINEAR (default), LINEAR_EXACT, SIGMOID or not present, VM 1
516    voi_func = cast(str, getattr(ds, 'VOILUTFunction', 'LINEAR')).upper()
517    # VR DS, VM 1-n
518    elem = ds['WindowCenter']
519    center = (
520        cast(List[float], elem.value)[index] if elem.VM > 1 else elem.value
521    )
522    center = cast(float, center)
523    elem = ds['WindowWidth']
524    width = cast(List[float], elem.value)[index] if elem.VM > 1 else elem.value
525    width = cast(float, width)
526
527    # The output range depends on whether or not a modality LUT or rescale
528    #   operation has been applied
529    ds.BitsStored = cast(int, ds.BitsStored)
530    y_min: float
531    y_max: float
532    if 'ModalityLUTSequence' in ds:
533        # Unsigned - see PS3.3 C.11.1.1.1
534        y_min = 0
535        item = cast(List["Dataset"], ds.ModalityLUTSequence)[0]
536        bit_depth = cast(List[int], item.LUTDescriptor)[2]
537        y_max = 2**bit_depth - 1
538    elif ds.PixelRepresentation == 0:
539        # Unsigned
540        y_min = 0
541        y_max = 2**ds.BitsStored - 1
542    else:
543        # Signed
544        y_min = -2**(ds.BitsStored - 1)
545        y_max = 2**(ds.BitsStored - 1) - 1
546
547    slope = ds.get('RescaleSlope', None)
548    intercept = ds.get('RescaleIntercept', None)
549    if slope is not None and intercept is not None:
550        ds.RescaleSlope = cast(float, ds.RescaleSlope)
551        ds.RescaleIntercept = cast(float, ds.RescaleIntercept)
552        # Otherwise its the actual data range
553        y_min = y_min * ds.RescaleSlope + ds.RescaleIntercept
554        y_max = y_max * ds.RescaleSlope + ds.RescaleIntercept
555
556    y_range = y_max - y_min
557    arr = arr.astype('float64')
558
559    if voi_func in ['LINEAR', 'LINEAR_EXACT']:
560        # PS3.3 C.11.2.1.2.1 and C.11.2.1.3.2
561        if voi_func == 'LINEAR':
562            if width < 1:
563                raise ValueError(
564                    "The (0028,1051) Window Width must be greater than or "
565                    "equal to 1 for a 'LINEAR' windowing operation"
566                )
567            center -= 0.5
568            width -= 1
569        elif width <= 0:
570            raise ValueError(
571                "The (0028,1051) Window Width must be greater than 0 "
572                "for a 'LINEAR_EXACT' windowing operation"
573            )
574
575        below = arr <= (center - width / 2)
576        above = arr > (center + width / 2)
577        between = np.logical_and(~below, ~above)
578
579        arr[below] = y_min
580        arr[above] = y_max
581        if between.any():
582            arr[between] = (
583                ((arr[between] - center) / width + 0.5) * y_range + y_min
584            )
585    elif voi_func == 'SIGMOID':
586        # PS3.3 C.11.2.1.3.1
587        if width <= 0:
588            raise ValueError(
589                "The (0028,1051) Window Width must be greater than 0 "
590                "for a 'SIGMOID' windowing operation"
591            )
592
593        arr = y_range / (1 + np.exp(-4 * (arr - center) / width)) + y_min
594    else:
595        raise ValueError(
596            f"Unsupported (0028,1056) VOI LUT Function value '{voi_func}'"
597        )
598
599    return arr
600
601
602def convert_color_space(
603    arr: "np.ndarray", current: str, desired: str, per_frame: bool = False
604) -> "np.ndarray":
605    """Convert the image(s) in `arr` from one color space to another.
606
607    .. versionchanged:: 1.4
608
609        Added support for ``YBR_FULL_422``
610
611    .. versionchanged:: 2.2
612
613        Added `per_frame` keyword parameter.
614
615    Parameters
616    ----------
617    arr : numpy.ndarray
618        The image(s) as a :class:`numpy.ndarray` with
619        :attr:`~numpy.ndarray.shape` (frames, rows, columns, 3)
620        or (rows, columns, 3).
621    current : str
622        The current color space, should be a valid value for (0028,0004)
623        *Photometric Interpretation*. One of ``'RGB'``, ``'YBR_FULL'``,
624        ``'YBR_FULL_422'``.
625    desired : str
626        The desired color space, should be a valid value for (0028,0004)
627        *Photometric Interpretation*. One of ``'RGB'``, ``'YBR_FULL'``,
628        ``'YBR_FULL_422'``.
629    per_frame : bool, optional
630        If ``True`` and the input array contains multiple frames then process
631        each frame individually to reduce memory usage. Default ``False``.
632
633    Returns
634    -------
635    numpy.ndarray
636        The image(s) converted to the desired color space.
637
638    References
639    ----------
640
641    * DICOM Standard, Part 3,
642      :dcm:`Annex C.7.6.3.1.2<part03/sect_C.7.6.3.html#sect_C.7.6.3.1.2>`
643    * ISO/IEC 10918-5:2012 (`ITU T.871
644      <https://www.ijg.org/files/T-REC-T.871-201105-I!!PDF-E.pdf>`_),
645      Section 7
646    """
647    def _no_change(arr: "np.ndarray") -> "np.ndarray":
648        return arr
649
650    _converters = {
651        'YBR_FULL_422': {
652            'YBR_FULL_422': _no_change,
653            'YBR_FULL': _no_change,
654            'RGB': _convert_YBR_FULL_to_RGB,
655        },
656        'YBR_FULL': {
657            'YBR_FULL': _no_change,
658            'YBR_FULL_422': _no_change,
659            'RGB': _convert_YBR_FULL_to_RGB,
660        },
661        'RGB': {
662            'RGB': _no_change,
663            'YBR_FULL': _convert_RGB_to_YBR_FULL,
664            'YBR_FULL_422': _convert_RGB_to_YBR_FULL,
665        }
666    }
667    try:
668        converter = _converters[current][desired]
669    except KeyError:
670        raise NotImplementedError(
671            f"Conversion from {current} to {desired} is not supported."
672        )
673
674    if len(arr.shape) == 4 and per_frame:
675        for idx, frame in enumerate(arr):
676            arr[idx] = converter(frame)
677
678        return arr
679
680    return converter(arr)
681
682
683def _convert_RGB_to_YBR_FULL(arr: "np.ndarray") -> "np.ndarray":
684    """Return an ndarray converted from RGB to YBR_FULL color space.
685
686    Parameters
687    ----------
688    arr : numpy.ndarray
689        An ndarray of an 8-bit per channel images in RGB color space.
690
691    Returns
692    -------
693    numpy.ndarray
694        The array in YBR_FULL color space.
695
696    References
697    ----------
698
699    * DICOM Standard, Part 3,
700      :dcm:`Annex C.7.6.3.1.2<part03/sect_C.7.6.3.html#sect_C.7.6.3.1.2>`
701    * ISO/IEC 10918-5:2012 (`ITU T.871
702      <https://www.ijg.org/files/T-REC-T.871-201105-I!!PDF-E.pdf>`_),
703      Section 7
704    """
705    orig_dtype = arr.dtype
706
707    rgb_to_ybr = np.asarray(
708        [[+0.299, -0.299 / 1.772, +0.701 / 1.402],
709         [+0.587, -0.587 / 1.772, -0.587 / 1.402],
710         [+0.114, +0.886 / 1.772, -0.114 / 1.402]],
711        dtype=np.float32
712    )
713
714    arr = np.matmul(arr, rgb_to_ybr, dtype=np.float32)
715    arr += [0.5, 128.5, 128.5]
716    # Round(x) -> floor of (arr + 0.5) : 0.5 added in previous step
717    np.floor(arr, out=arr)
718    # Max(0, arr) -> 0 if 0 >= arr, arr otherwise
719    # Min(arr, 255) -> arr if arr <= 255, 255 otherwise
720    np.clip(arr, 0, 255, out=arr)
721
722    return arr.astype(orig_dtype)
723
724
725def _convert_YBR_FULL_to_RGB(arr: "np.ndarray") -> "np.ndarray":
726    """Return an ndarray converted from YBR_FULL to RGB color space.
727
728    Parameters
729    ----------
730    arr : numpy.ndarray
731        An ndarray of an 8-bit per channel images in YBR_FULL color space.
732
733    Returns
734    -------
735    numpy.ndarray
736        The array in RGB color space.
737
738    References
739    ----------
740
741    * DICOM Standard, Part 3,
742      :dcm:`Annex C.7.6.3.1.2<part03/sect_C.7.6.3.html#sect_C.7.6.3.1.2>`
743    * ISO/IEC 10918-5:2012, Section 7
744    """
745    orig_dtype = arr.dtype
746
747    ybr_to_rgb = np.asarray(
748        [[1.000, 1.000, 1.000],
749         [0.000, -0.114 * 1.772 / 0.587, 1.772],
750         [1.402, -0.299 * 1.402 / 0.587, 0.000]],
751        dtype=np.float32
752    )
753
754    arr = arr.astype(np.float32)
755    arr -= [0, 128, 128]
756
757    # Round(x) -> floor of (arr + 0.5)
758    np.matmul(arr, ybr_to_rgb, out=arr)
759    arr += 0.5
760    np.floor(arr, out=arr)
761    # Max(0, arr) -> 0 if 0 >= arr, arr otherwise
762    # Min(arr, 255) -> arr if arr <= 255, 255 otherwise
763    np.clip(arr, 0, 255, out=arr)
764
765    return arr.astype(orig_dtype)
766
767
768def dtype_corrected_for_endianness(
769    is_little_endian: bool, numpy_dtype: "np.dtype"
770) -> "np.dtype":
771    """Return a :class:`numpy.dtype` corrected for system and :class:`Dataset`
772    endianness.
773
774    Parameters
775    ----------
776    is_little_endian : bool
777        The endianness of the affected :class:`~pydicom.dataset.Dataset`.
778    numpy_dtype : numpy.dtype
779        The numpy data type used for the *Pixel Data* without considering
780        endianness.
781
782    Raises
783    ------
784    ValueError
785        If `is_little_endian` is ``None``, e.g. not initialized.
786
787    Returns
788    -------
789    numpy.dtype
790        The numpy data type used for the *Pixel Data* without considering
791        endianness.
792    """
793    if is_little_endian is None:
794        raise ValueError("Dataset attribute 'is_little_endian' "
795                         "has to be set before writing the dataset")
796
797    if is_little_endian != (byteorder == 'little'):
798        return numpy_dtype.newbyteorder('S')
799
800    return numpy_dtype
801
802
803def _expand_segmented_lut(
804    data: Tuple[int, ...],
805    fmt: str,
806    nr_segments: Optional[int] = None,
807    last_value: Optional[int] = None
808) -> List[int]:
809    """Return a list containing the expanded lookup table data.
810
811    Parameters
812    ----------
813    data : tuple of int
814        The decoded segmented palette lookup table data. May be padded by a
815        trailing null.
816    fmt : str
817        The format of the data, should contain `'B'` for 8-bit, `'H'` for
818        16-bit, `'<'` for little endian and `'>'` for big endian.
819    nr_segments : int, optional
820        Expand at most `nr_segments` from the data. Should be used when
821        the opcode is ``2`` (indirect). If used then `last_value` should also
822        be used.
823    last_value : int, optional
824        The previous value in the expanded lookup table. Should be used when
825        the opcode is ``2`` (indirect). If used then `nr_segments` should also
826        be used.
827
828    Returns
829    -------
830    list of int
831        The reconstructed lookup table data.
832
833    References
834    ----------
835
836    * DICOM Standard, Part 3, Annex C.7.9
837    """
838    # Indirect segment byte offset is dependent on endianness for 8-bit
839    # Little endian: e.g. 0x0302 0x0100, big endian, e.g. 0x0203 0x0001
840    indirect_ii = [3, 2, 1, 0] if '<' in fmt else [2, 3, 0, 1]
841
842    lut: List[int] = []
843    offset = 0
844    segments_read = 0
845    # Use `offset + 1` to account for possible trailing null
846    #   can do this because all segment types are longer than 2
847    while offset + 1 < len(data):
848        opcode = data[offset]
849        length = data[offset + 1]
850        offset += 2
851
852        if opcode == 0:
853            # C.7.9.2.1: Discrete segment
854            lut.extend(data[offset:offset + length])
855            offset += length
856        elif opcode == 1:
857            # C.7.9.2.2: Linear segment
858            if lut:
859                y0 = lut[-1]
860            elif last_value:
861                # Indirect segment with linear segment at 0th offset
862                y0 = last_value
863            else:
864                raise ValueError(
865                    "Error expanding a segmented palette color lookup table: "
866                    "the first segment cannot be a linear segment"
867                )
868
869            y1 = data[offset]
870            offset += 1
871
872            if y0 == y1:
873                lut.extend([y1] * length)
874            else:
875                step = (y1 - y0) / length
876                vals = np.around(np.linspace(y0 + step, y1, length))
877                lut.extend([int(vv) for vv in vals])
878        elif opcode == 2:
879            # C.7.9.2.3: Indirect segment
880            if not lut:
881                raise ValueError(
882                    "Error expanding a segmented palette color lookup table: "
883                    "the first segment cannot be an indirect segment"
884                )
885
886            if 'B' in fmt:
887                # 8-bit segment entries
888                ii = [data[offset + vv] for vv in indirect_ii]
889                byte_offset = (ii[0] << 8 | ii[1]) << 16 | (ii[2] << 8 | ii[3])
890                offset += 4
891            else:
892                # 16-bit segment entries
893                byte_offset = data[offset + 1] << 16 | data[offset]
894                offset += 2
895
896            lut.extend(
897                _expand_segmented_lut(data[byte_offset:], fmt, length, lut[-1])
898            )
899        else:
900            raise ValueError(
901                "Error expanding a segmented palette lookup table: "
902                f"unknown segment type '{opcode}'"
903            )
904
905        segments_read += 1
906        if segments_read == nr_segments:
907            return lut
908
909    return lut
910
911
912def get_expected_length(ds: "Dataset", unit: str = 'bytes') -> int:
913    """Return the expected length (in terms of bytes or pixels) of the *Pixel
914    Data*.
915
916    +------------------------------------------------+-------------+
917    | Element                                        | Required or |
918    +-------------+---------------------------+------+ optional    |
919    | Tag         | Keyword                   | Type |             |
920    +=============+===========================+======+=============+
921    | (0028,0002) | SamplesPerPixel           | 1    | Required    |
922    +-------------+---------------------------+------+-------------+
923    | (0028,0004) | PhotometricInterpretation | 1    | Required    |
924    +-------------+---------------------------+------+-------------+
925    | (0028,0008) | NumberOfFrames            | 1C   | Optional    |
926    +-------------+---------------------------+------+-------------+
927    | (0028,0010) | Rows                      | 1    | Required    |
928    +-------------+---------------------------+------+-------------+
929    | (0028,0011) | Columns                   | 1    | Required    |
930    +-------------+---------------------------+------+-------------+
931    | (0028,0100) | BitsAllocated             | 1    | Required    |
932    +-------------+---------------------------+------+-------------+
933
934    .. versionchanged:: 1.4
935
936        Added support for a *Photometric Interpretation* of  ``YBR_FULL_422``
937
938    Parameters
939    ----------
940    ds : Dataset
941        The :class:`~pydicom.dataset.Dataset` containing the Image Pixel module
942        and *Pixel Data*.
943    unit : str, optional
944        If ``'bytes'`` then returns the expected length of the *Pixel Data* in
945        whole bytes and NOT including an odd length trailing NULL padding
946        byte. If ``'pixels'`` then returns the expected length of the *Pixel
947        Data* in terms of the total number of pixels (default ``'bytes'``).
948
949    Returns
950    -------
951    int
952        The expected length of the *Pixel Data* in either whole bytes or
953        pixels, excluding the NULL trailing padding byte for odd length data.
954    """
955    rows = cast(int, ds.Rows)
956    columns = cast(int, ds.Columns)
957    samples_per_pixel = cast(int, ds.SamplesPerPixel)
958    bits_allocated = cast(int, ds.BitsAllocated)
959
960    length = rows * columns * samples_per_pixel
961    length *= get_nr_frames(ds)
962
963    if unit == 'pixels':
964        return length
965
966    # Correct for the number of bytes per pixel
967    if bits_allocated == 1:
968        # Determine the nearest whole number of bytes needed to contain
969        #   1-bit pixel data. e.g. 10 x 10 1-bit pixels is 100 bits, which
970        #   are packed into 12.5 -> 13 bytes
971        length = length // 8 + (length % 8 > 0)
972    else:
973        length *= bits_allocated // 8
974
975    # DICOM Standard, Part 4, Annex C.7.6.3.1.2
976    if ds.PhotometricInterpretation == 'YBR_FULL_422':
977        length = length // 3 * 2
978
979    return length
980
981
982def get_image_pixel_ids(ds: "Dataset") -> Dict[str, int]:
983    """Return a dict of the pixel data affecting element's :func:`id` values.
984
985    .. versionadded:: 1.4
986
987    +------------------------------------------------+
988    | Element                                        |
989    +-------------+---------------------------+------+
990    | Tag         | Keyword                   | Type |
991    +=============+===========================+======+
992    | (0028,0002) | SamplesPerPixel           | 1    |
993    +-------------+---------------------------+------+
994    | (0028,0004) | PhotometricInterpretation | 1    |
995    +-------------+---------------------------+------+
996    | (0028,0006) | PlanarConfiguration       | 1C   |
997    +-------------+---------------------------+------+
998    | (0028,0008) | NumberOfFrames            | 1C   |
999    +-------------+---------------------------+------+
1000    | (0028,0010) | Rows                      | 1    |
1001    +-------------+---------------------------+------+
1002    | (0028,0011) | Columns                   | 1    |
1003    +-------------+---------------------------+------+
1004    | (0028,0100) | BitsAllocated             | 1    |
1005    +-------------+---------------------------+------+
1006    | (0028,0101) | BitsStored                | 1    |
1007    +-------------+---------------------------+------+
1008    | (0028,0103) | PixelRepresentation       | 1    |
1009    +-------------+---------------------------+------+
1010    | (7FE0,0008) | FloatPixelData            | 1C   |
1011    +-------------+---------------------------+------+
1012    | (7FE0,0009) | DoubleFloatPixelData      | 1C   |
1013    +-------------+---------------------------+------+
1014    | (7FE0,0010) | PixelData                 | 1C   |
1015    +-------------+---------------------------+------+
1016
1017    Parameters
1018    ----------
1019    ds : Dataset
1020        The :class:`~pydicom.dataset.Dataset` containing the pixel data.
1021
1022    Returns
1023    -------
1024    dict
1025        A dict containing the :func:`id` values for the elements that affect
1026        the pixel data.
1027
1028    """
1029    keywords = [
1030        'SamplesPerPixel', 'PhotometricInterpretation', 'PlanarConfiguration',
1031        'NumberOfFrames', 'Rows', 'Columns', 'BitsAllocated', 'BitsStored',
1032        'PixelRepresentation', 'FloatPixelData', 'DoubleFloatPixelData',
1033        'PixelData'
1034    ]
1035
1036    return {kw: id(getattr(ds, kw, None)) for kw in keywords}
1037
1038
1039def get_j2k_parameters(codestream: bytes) -> Dict[str, object]:
1040    """Return a dict containing JPEG 2000 component parameters.
1041
1042    .. versionadded:: 2.1
1043
1044    Parameters
1045    ----------
1046    codestream : bytes
1047        The JPEG 2000 (ISO/IEC 15444-1) codestream to be parsed.
1048
1049    Returns
1050    -------
1051    dict
1052        A dict containing parameters for the first component sample in the
1053        JPEG 2000 `codestream`, or an empty dict if unable to parse the data.
1054        Available parameters are ``{"precision": int, "is_signed": bool}``.
1055    """
1056    try:
1057        # First 2 bytes must be the SOC marker - if not then wrong format
1058        if codestream[0:2] != b'\xff\x4f':
1059            return {}
1060
1061        # SIZ is required to be the second marker - Figure A-3 in 15444-1
1062        if codestream[2:4] != b'\xff\x51':
1063            return {}
1064
1065        # See 15444-1 A.5.1 for format of the SIZ box and contents
1066        ssiz = codestream[42]
1067        if ssiz & 0x80:
1068            return {"precision": (ssiz & 0x7F) + 1, "is_signed": True}
1069
1070        return {"precision": ssiz + 1, "is_signed": False}
1071    except (IndexError, TypeError):
1072        pass
1073
1074    return {}
1075
1076
1077def get_nr_frames(ds: "Dataset") -> int:
1078    """Return NumberOfFrames or 1 if NumberOfFrames is None.
1079
1080    Parameters
1081    ----------
1082    ds : dataset.Dataset
1083        The :class:`~pydicom.dataset.Dataset` containing the Image Pixel module
1084        corresponding to the data in `arr`.
1085
1086    Returns
1087    -------
1088    int
1089        An integer for the NumberOfFrames or 1 if NumberOfFrames is None
1090    """
1091    nr_frames: Optional[int] = getattr(ds, 'NumberOfFrames', 1)
1092    # 'NumberOfFrames' may exist in the DICOM file but have value equal to None
1093    if nr_frames is None:
1094        warnings.warn("A value of None for (0028,0008) 'Number of Frames' is "
1095                      "non-conformant. It's recommended that this value be "
1096                      "changed to 1")
1097        nr_frames = 1
1098
1099    return nr_frames
1100
1101
1102def pixel_dtype(ds: "Dataset", as_float: bool = False) -> "np.dtype":
1103    """Return a :class:`numpy.dtype` for the pixel data in `ds`.
1104
1105    Suitable for use with IODs containing the Image Pixel module (with
1106    ``as_float=False``) and the Floating Point Image Pixel and Double Floating
1107    Point Image Pixel modules (with ``as_float=True``).
1108
1109    +------------------------------------------+------------------+
1110    | Element                                  | Supported        |
1111    +-------------+---------------------+------+ values           |
1112    | Tag         | Keyword             | Type |                  |
1113    +=============+=====================+======+==================+
1114    | (0028,0101) | BitsAllocated       | 1    | 1, 8, 16, 32, 64 |
1115    +-------------+---------------------+------+------------------+
1116    | (0028,0103) | PixelRepresentation | 1    | 0, 1             |
1117    +-------------+---------------------+------+------------------+
1118
1119    .. versionchanged:: 1.4
1120
1121        Added `as_float` keyword parameter and support for float dtypes.
1122
1123
1124    Parameters
1125    ----------
1126    ds : Dataset
1127        The :class:`~pydicom.dataset.Dataset` containing the pixel data you
1128        wish to get the data type for.
1129    as_float : bool, optional
1130        If ``True`` then return a float dtype, otherwise return an integer
1131        dtype (default ``False``). Float dtypes are only supported when
1132        (0028,0101) *Bits Allocated* is 32 or 64.
1133
1134    Returns
1135    -------
1136    numpy.dtype
1137        A :class:`numpy.dtype` suitable for containing the pixel data.
1138
1139    Raises
1140    ------
1141    NotImplementedError
1142        If the pixel data is of a type that isn't supported by either numpy
1143        or *pydicom*.
1144    """
1145    if not HAVE_NP:
1146        raise ImportError("Numpy is required to determine the dtype.")
1147
1148    if ds.is_little_endian is None:
1149        ds.is_little_endian = ds.file_meta.TransferSyntaxUID.is_little_endian
1150
1151    if not as_float:
1152        # (0028,0103) Pixel Representation, US, 1
1153        #   Data representation of the pixel samples
1154        #   0x0000 - unsigned int
1155        #   0x0001 - 2's complement (signed int)
1156        pixel_repr = cast(int, ds.PixelRepresentation)
1157        if pixel_repr == 0:
1158            dtype_str = 'uint'
1159        elif pixel_repr == 1:
1160            dtype_str = 'int'
1161        else:
1162            raise ValueError(
1163                "Unable to determine the data type to use to contain the "
1164                f"Pixel Data as a value of '{pixel_repr}' for '(0028,0103) "
1165                "Pixel Representation' is invalid"
1166            )
1167    else:
1168        dtype_str = 'float'
1169
1170    # (0028,0100) Bits Allocated, US, 1
1171    #   The number of bits allocated for each pixel sample
1172    #   PS3.5 8.1.1: Bits Allocated shall either be 1 or a multiple of 8
1173    #   For bit packed data we use uint8
1174    bits_allocated = cast(int, ds.BitsAllocated)
1175    if bits_allocated == 1:
1176        dtype_str = 'uint8'
1177    elif bits_allocated > 0 and bits_allocated % 8 == 0:
1178        dtype_str += str(bits_allocated)
1179    else:
1180        raise ValueError(
1181            "Unable to determine the data type to use to contain the "
1182            f"Pixel Data as a value of '{bits_allocated}' for '(0028,0100) "
1183            "Bits Allocated' is invalid"
1184        )
1185
1186    # Check to see if the dtype is valid for numpy
1187    try:
1188        dtype = np.dtype(dtype_str)
1189    except TypeError:
1190        raise NotImplementedError(
1191            f"The data type '{dtype_str}' needed to contain the Pixel Data "
1192            "is not supported by numpy"
1193        )
1194
1195    # Correct for endianness of the system vs endianness of the dataset
1196    if ds.is_little_endian != (byteorder == 'little'):
1197        # 'S' swap from current to opposite
1198        dtype = dtype.newbyteorder('S')
1199
1200    return dtype
1201
1202
1203def reshape_pixel_array(ds: "Dataset", arr: "np.ndarray") -> "np.ndarray":
1204    """Return a reshaped :class:`numpy.ndarray` `arr`.
1205
1206    +------------------------------------------+-----------+----------+
1207    | Element                                  | Supported |          |
1208    +-------------+---------------------+------+ values    |          |
1209    | Tag         | Keyword             | Type |           |          |
1210    +=============+=====================+======+===========+==========+
1211    | (0028,0002) | SamplesPerPixel     | 1    | N > 0     | Required |
1212    +-------------+---------------------+------+-----------+----------+
1213    | (0028,0006) | PlanarConfiguration | 1C   | 0, 1      | Optional |
1214    +-------------+---------------------+------+-----------+----------+
1215    | (0028,0008) | NumberOfFrames      | 1C   | N > 0     | Optional |
1216    +-------------+---------------------+------+-----------+----------+
1217    | (0028,0010) | Rows                | 1    | N > 0     | Required |
1218    +-------------+---------------------+------+-----------+----------+
1219    | (0028,0011) | Columns             | 1    | N > 0     | Required |
1220    +-------------+---------------------+------+-----------+----------+
1221
1222    (0028,0008) *Number of Frames* is required when *Pixel Data* contains
1223    more than 1 frame. (0028,0006) *Planar Configuration* is required when
1224    (0028,0002) *Samples per Pixel* is greater than 1. For certain
1225    compressed transfer syntaxes it is always taken to be either 0 or 1 as
1226    shown in the table below.
1227
1228    +---------------------------------------------+-----------------------+
1229    | Transfer Syntax                             | Planar Configuration  |
1230    +------------------------+--------------------+                       |
1231    | UID                    | Name               |                       |
1232    +========================+====================+=======================+
1233    | 1.2.840.10008.1.2.4.50 | JPEG Baseline      | 0                     |
1234    +------------------------+--------------------+-----------------------+
1235    | 1.2.840.10008.1.2.4.57 | JPEG Lossless,     | 0                     |
1236    |                        | Non-hierarchical   |                       |
1237    +------------------------+--------------------+-----------------------+
1238    | 1.2.840.10008.1.2.4.70 | JPEG Lossless,     | 0                     |
1239    |                        | Non-hierarchical,  |                       |
1240    |                        | SV1                |                       |
1241    +------------------------+--------------------+-----------------------+
1242    | 1.2.840.10008.1.2.4.80 | JPEG-LS Lossless   | 0                     |
1243    +------------------------+--------------------+-----------------------+
1244    | 1.2.840.10008.1.2.4.81 | JPEG-LS Lossy      | 0                     |
1245    +------------------------+--------------------+-----------------------+
1246    | 1.2.840.10008.1.2.4.90 | JPEG 2000 Lossless | 0                     |
1247    +------------------------+--------------------+-----------------------+
1248    | 1.2.840.10008.1.2.4.91 | JPEG 2000 Lossy    | 0                     |
1249    +------------------------+--------------------+-----------------------+
1250    | 1.2.840.10008.1.2.5    | RLE Lossless       | 1                     |
1251    +------------------------+--------------------+-----------------------+
1252
1253    .. versionchanged:: 2.1
1254
1255        JPEG-LS transfer syntaxes changed to *Planar Configuration* of 0
1256
1257    Parameters
1258    ----------
1259    ds : dataset.Dataset
1260        The :class:`~pydicom.dataset.Dataset` containing the Image Pixel module
1261        corresponding to the data in `arr`.
1262    arr : numpy.ndarray
1263        The 1D array containing the pixel data.
1264
1265    Returns
1266    -------
1267    numpy.ndarray
1268        A reshaped array containing the pixel data. The shape of the array
1269        depends on the contents of the dataset:
1270
1271        * For single frame, single sample data (rows, columns)
1272        * For single frame, multi-sample data (rows, columns, planes)
1273        * For multi-frame, single sample data (frames, rows, columns)
1274        * For multi-frame, multi-sample data (frames, rows, columns, planes)
1275
1276    References
1277    ----------
1278
1279    * DICOM Standard, Part 3,
1280      :dcm:`Annex C.7.6.3.1<part03/sect_C.7.6.3.html#sect_C.7.6.3.1>`
1281    * DICOM Standard, Part 5, :dcm:`Section 8.2<part05/sect_8.2.html>`
1282    """
1283    if not HAVE_NP:
1284        raise ImportError("Numpy is required to reshape the pixel array.")
1285
1286    nr_frames = get_nr_frames(ds)
1287    nr_samples = cast(int, ds.SamplesPerPixel)
1288
1289    if nr_frames < 1:
1290        raise ValueError(
1291            f"Unable to reshape the pixel array as a value of {nr_frames} for "
1292            "(0028,0008) 'Number of Frames' is invalid."
1293        )
1294
1295    if nr_samples < 1:
1296        raise ValueError(
1297            f"Unable to reshape the pixel array as a value of {nr_samples} "
1298            "for (0028,0002) 'Samples per Pixel' is invalid."
1299        )
1300
1301    # Valid values for Planar Configuration are dependent on transfer syntax
1302    if nr_samples > 1:
1303        transfer_syntax = ds.file_meta.TransferSyntaxUID
1304        if transfer_syntax in ['1.2.840.10008.1.2.4.50',
1305                               '1.2.840.10008.1.2.4.57',
1306                               '1.2.840.10008.1.2.4.70',
1307                               '1.2.840.10008.1.2.4.80',
1308                               '1.2.840.10008.1.2.4.81',
1309                               '1.2.840.10008.1.2.4.90',
1310                               '1.2.840.10008.1.2.4.91']:
1311            planar_configuration = 0
1312        elif transfer_syntax in ['1.2.840.10008.1.2.5']:
1313            planar_configuration = 1
1314        else:
1315            planar_configuration = ds.PlanarConfiguration
1316
1317        if planar_configuration not in [0, 1]:
1318            raise ValueError(
1319                "Unable to reshape the pixel array as a value of "
1320                f"{planar_configuration} for (0028,0006) 'Planar "
1321                "Configuration' is invalid."
1322            )
1323
1324    rows = cast(int, ds.Rows)
1325    columns = cast(int, ds.Columns)
1326    if nr_frames > 1:
1327        # Multi-frame
1328        if nr_samples == 1:
1329            # Single plane
1330            arr = arr.reshape(nr_frames, rows, columns)
1331        else:
1332            # Multiple planes, usually 3
1333            if planar_configuration == 0:
1334                arr = arr.reshape(nr_frames, rows, columns, nr_samples)
1335            else:
1336                arr = arr.reshape(nr_frames, nr_samples, rows, columns)
1337                arr = arr.transpose(0, 2, 3, 1)
1338    else:
1339        # Single frame
1340        if nr_samples == 1:
1341            # Single plane
1342            arr = arr.reshape(rows, columns)
1343        else:
1344            # Multiple planes, usually 3
1345            if planar_configuration == 0:
1346                arr = arr.reshape(rows, columns, nr_samples)
1347            else:
1348                arr = arr.reshape(nr_samples, rows, columns)
1349                arr = arr.transpose(1, 2, 0)
1350
1351    return arr
1352