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