# Copyright 2008-2024 pydicom authors. See LICENSE file for details. """Utilities for pixel data handling.""" from collections.abc import Iterable, Iterator, ByteString import importlib import logging from pathlib import Path from struct import unpack, Struct from sys import byteorder from typing import BinaryIO, Any, cast, TYPE_CHECKING try: import numpy as np HAVE_NP = True except ImportError: HAVE_NP = False from pydicom.charset import default_encoding from pydicom._dicom_dict import DicomDictionary from pydicom.encaps import encapsulate, encapsulate_extended from pydicom.misc import warn_and_log from pydicom.tag import BaseTag from pydicom.uid import ( UID, JPEGLSNearLossless, JPEG2000, ExplicitVRLittleEndian, generate_uid, ) from pydicom.valuerep import VR if TYPE_CHECKING: # pragma: no cover from os import PathLike from pydicom.dataset import Dataset LOGGER = logging.getLogger(__name__) # All non-retired group 0x0028 elements _GROUP_0028 = { k for k, v in DicomDictionary.items() if k >> 16 == 0x0028 and v[3] == "" } # The minimum required Image Pixel module elements used by encoding/decoding _IMAGE_PIXEL = { 0x00280002: "samples_per_pixel", 0x00280004: "photometric_interpretation", 0x00280006: "planar_configuration", 0x00280008: "number_of_frames", 0x00280010: "rows", 0x00280011: "columns", 0x00280100: "bits_allocated", 0x00280101: "bits_stored", 0x00280103: "pixel_representation", } # Default tags to look for with pixel_array() and iter_pixels() _DEFAULT_TAGS = {k for k in _IMAGE_PIXEL.keys()} | {0x7FE00001, 0x7FE00002} _PIXEL_KEYWORDS = { (0x7FE0, 0x0008): "FloatPixelData", (0x7FE0, 0x0009): "DoubleFloatPixelData", (0x7FE0, 0x0010): "PixelData", } # Lookup table for unpacking bit-packed data _UNPACK_LUT: dict[int, bytes] = { k: bytes(int(s) for s in reversed(f"{k:08b}")) for k in range(256) } # JPEG/JPEG-LS SOF markers _SOF = { b"\xFF\xC0", b"\xFF\xC1", b"\xFF\xC2", b"\xFF\xC3", b"\xFF\xC5", b"\xFF\xC6", b"\xFF\xC7", b"\xFF\xC9", b"\xFF\xCA", b"\xFF\xCB", b"\xFF\xCD", b"\xFF\xCE", b"\xFF\xCF", b"\xFF\xF7", } # JPEG APP markers, all in range (0xFFE0, 0xFFEF) _APP = {x.to_bytes(length=2, byteorder="big") for x in range(0xFFE0, 0xFFF0)} _UNPACK_SHORT = Struct(">H").unpack def _array_common( f: BinaryIO, specific_tags: list[BaseTag | int], **kwargs: Any ) -> tuple["Dataset", dict[str, Any]]: """Return a dataset from `f` and a corresponding decoding options dict. Parameters ---------- f : BinaryIO The opened file-like containing the DICOM dataset, positioned at the start of the file. specific_tags : list[BaseTag | int] A list of additional tags to be read from the dataset and possibly returned via the `ds_out` dataset. kwargs : dict[str, Any] Required and optional arguments for the pixel data decoding functions. Returns ------- tuple[Dataset, dict[str, Any]] * A dataset containing the group 0x0028 elements, the extended offset elements (if any) and elements from `specific_tags`. * The required and optional arguments for the pixel data decoding functions. """ from pydicom.filereader import ( read_preamble, _read_file_meta_info, read_dataset, _at_pixel_data, ) # Read preamble (if present) read_preamble(f, force=True) # Read the File Meta (if present) file_meta = _read_file_meta_info(f) tsyntax = kwargs.setdefault( "transfer_syntax_uid", file_meta.get("TransferSyntaxUID", None), ) if not tsyntax: raise AttributeError( "'transfer_syntax_uid' is required if the dataset in 'src' is not " "in the DICOM File Format" ) tsyntax = UID(tsyntax) # Get the *Image Pixel* module 0028 elements, any extended offsets and # any other tags wanted by the user ds = read_dataset( f, is_implicit_VR=tsyntax.is_implicit_VR, is_little_endian=tsyntax.is_little_endian, stop_when=_at_pixel_data, specific_tags=specific_tags, ) ds.file_meta = file_meta opts = as_pixel_options(ds, **kwargs) opts["transfer_syntax_uid"] = tsyntax # We are either at the start of the element tag for a pixel data # element or at EOF because there were none try: data = f.read(8) assert len(data) == 8 except Exception: raise AttributeError( "The dataset in 'src' has no 'Pixel Data', 'Float Pixel Data' or " "'Double Float Pixel Data' element, no pixel data to decode" ) endianness = "><"[tsyntax.is_little_endian] if tsyntax.is_implicit_VR: vr = None group, elem, length = unpack(f"{endianness}HHL", data) else: # Is always 32-bit extended length for pixel data VRs group, elem, vr, length = unpack(f"{endianness}HH2sH", data) opts["pixel_vr"] = vr.decode(default_encoding) unpack(f"{endianness}L", f.read(4)) # We should now be positioned at the start of the pixel data value opts["pixel_keyword"] = _PIXEL_KEYWORDS[(group, elem)] return ds, opts def as_pixel_options(ds: "Dataset", **kwargs: Any) -> dict[str, Any]: """Return a dict containing the image pixel element values from `ds`. .. versionadded:: 3.0 Parameters ---------- ds : pydicom.dataset.Dataset A dataset containing Image Pixel module elements. **kwargs A :class:`dict` containing (key, value) pairs to be used to override the values taken from `ds`. For example, if ``kwargs = {'rows': 64}`` then the returned :class:`dict` will have a 'rows' value of 64 rather than whatever ``ds.Rows`` may be. Returns ------- dict[str, Any] A dictionary which may contain the following keys, depending on which elements are present in `ds` and the contents of `kwargs`: * `samples_per_pixel` * `photometric_interpretation` * `planar_configuration` * `number_of_frames` (always present) * `rows` * `columns` * `bits_allocated` * `bits_stored` * `pixel_representation` """ opts = { attr: ds[tag].value for tag, attr in _IMAGE_PIXEL.items() if tag in ds._dict } # Ensure we have a valid 'number_of_frames' if 0x00280008 not in ds._dict: opts["number_of_frames"] = 1 nr_frames = opts["number_of_frames"] nr_frames = int(nr_frames) if isinstance(nr_frames, str) else nr_frames if nr_frames in (None, 0): warn_and_log( f"A value of '{nr_frames}' for (0028,0008) 'Number of Frames' is invalid, " "assuming 1 frame" ) nr_frames = 1 opts["number_of_frames"] = nr_frames # Extended Offset Table if 0x7FE00001 in ds._dict and 0x7FE00001 in ds._dict: opts["extended_offsets"] = ( ds.ExtendedOffsetTable, ds.ExtendedOffsetTableLengths, ) opts.update(kwargs) return opts def compress( ds: "Dataset", transfer_syntax_uid: str, arr: "np.ndarray | None" = None, *, encoding_plugin: str = "", encapsulate_ext: bool = False, generate_instance_uid: bool = True, jls_error: int | None = None, j2k_cr: list[float] | None = None, j2k_psnr: list[float] | None = None, **kwargs: Any, ) -> "Dataset": """Compress uncompressed pixel data and update `ds` in-place with the resulting :dcm:`encapsulated` codestream. .. versionadded:: 3.0 The dataset `ds` must already have the following :dcm:`Image Pixel` module elements present with correct values that correspond to the resulting compressed pixel data: * (0028,0002) *Samples per Pixel* * (0028,0004) *Photometric Interpretation* * (0028,0008) *Number of Frames* (if more than 1 frame will be present) * (0028,0010) *Rows* * (0028,0011) *Columns* * (0028,0100) *Bits Allocated* * (0028,0101) *Bits Stored* * (0028,0103) *Pixel Representation* If *Samples per Pixel* is greater than 1 then the following element is also required: * (0028,0006) *Planar Configuration* This method will add the file meta dataset if none is present and add or modify the following elements: * (0002,0010) *Transfer Syntax UID* * (7FE0,0010) *Pixel Data* If the compressed pixel data is too large for encapsulation using a basic offset table then an :dcm:`extended offset table ` will also be used, in which case the following elements will also be added: * (7FE0,0001) *Extended Offset Table* * (7FE0,0002) *Extended Offset Table Lengths* If `generate_instance_uid` is ``True`` (default) then a new (0008,0018) *SOP Instance UID* value will be generated. **Supported Transfer Syntax UIDs** +-----------------------------------------------+-----------+----------------------------------+ | UID | Plugins | Encoding Guide | +------------------------+----------------------+ | | | Name | Value | | | +========================+======================+===========+==================================+ | *JPEG-LS Lossless* |1.2.840.10008.1.2.4.80| pyjpegls | :doc:`JPEG-LS | +------------------------+----------------------+ | ` | | *JPEG-LS Near Lossless*|1.2.840.10008.1.2.4.81| | | +------------------------+----------------------+-----------+----------------------------------+ | *JPEG 2000 Lossless* |1.2.840.10008.1.2.4.90| pylibjpeg | :doc:`JPEG 2000 | +------------------------+----------------------+ | ` | | *JPEG 2000* |1.2.840.10008.1.2.4.91| | | +------------------------+----------------------+-----------+----------------------------------+ | *RLE Lossless* | 1.2.840.10008.1.2.5 | pydicom, | :doc:`RLE Lossless | | | | pylibjpeg,| ` | | | | gdcm | | +------------------------+----------------------+-----------+----------------------------------+ Examples -------- Compress the existing uncompressed *Pixel Data* in place: >>> from pydicom import examples >>> from pydicom.pixels import compress >>> from pydicom.uid import RLELossless >>> ds = examples.ct >>> compress(ds, RLELossless) >>> ds.save_as("ct_rle_lossless.dcm") Parameters ---------- ds : pydicom.dataset.Dataset The dataset to be compressed. transfer_syntax_uid : pydicom.uid.UID The UID of the :dcm:`transfer syntax` to use when compressing the pixel data. arr : numpy.ndarray, optional Compress the uncompressed pixel data in `arr` and use it to set the *Pixel Data*. If `arr` is not used then the existing uncompressed *Pixel Data* in the dataset will be compressed instead. The :attr:`~numpy.ndarray.shape`, :class:`~numpy.dtype` and contents of the array should match the dataset. encoding_plugin : str, optional Use `encoding_plugin` to compress the pixel data. See the :doc:`user guide ` for a list of plugins available for each UID and their dependencies. If not specified then all available plugins will be tried (default). encapsulate_ext : bool, optional If ``True`` then force the addition of an extended offset table. If ``False`` (default) then an extended offset table will be added if needed for large amounts of compressed *Pixel Data*, otherwise just the basic offset table will be used. generate_instance_uid : bool, optional If ``True`` (default) then generate a new (0008,0018) *SOP Instance UID* value for the dataset using :func:`~pydicom.uid.generate_uid`, otherwise keep the original value. jls_error : int, optional **JPEG-LS Near Lossless only**. The allowed absolute compression error in the pixel values. j2k_cr : list[float], optional **JPEG 2000 only**. A list of the compression ratios to use for each quality layer. There must be at least one quality layer and the minimum allowable compression ratio is ``1``. When using multiple quality layers they should be ordered in decreasing value from left to right. For example, to use 2 quality layers with 20x and 5x compression ratios then `j2k_cr` should be ``[20, 5]``. Cannot be used with `j2k_psnr`. j2k_psnr : list[float], optional **JPEG 2000 only**. A list of the peak signal-to-noise ratios (in dB) to use for each quality layer. There must be at least one quality layer and when using multiple quality layers they should be ordered in increasing value from left to right. For example, to use 2 quality layers with PSNR of 80 and 300 then `j2k_psnr` should be ``[80, 300]``. Cannot be used with `j2k_cr`. **kwargs Optional keyword parameters for the encoding plugin may also be present. See the :doc:`encoding plugins options ` for more information. """ from pydicom.dataset import FileMetaDataset from pydicom.pixels import get_encoder # Disallow overriding the dataset's image pixel module element values for option in _IMAGE_PIXEL.values(): kwargs.pop(option, None) uid = UID(transfer_syntax_uid) encoder = get_encoder(uid) if not encoder.is_available: missing = "\n".join([f" {s}" for s in encoder.missing_dependencies]) raise RuntimeError( f"The pixel data encoder for '{uid.name}' is unavailable because all " f"of its plugins are missing dependencies:\n{missing}" ) if uid == JPEGLSNearLossless and jls_error is not None: kwargs["jls_error"] = jls_error if uid == JPEG2000: if j2k_cr is not None: kwargs["j2k_cr"] = j2k_cr if j2k_psnr is not None: kwargs["j2k_psnr"] = j2k_psnr if arr is None: # Check the dataset compression state file_meta = ds.get("file_meta", {}) tsyntax = file_meta.get("TransferSyntaxUID", "") if not tsyntax: raise AttributeError( "Unable to determine the initial compression state of the dataset " "as there's no (0002,0010) 'Transfer Syntax UID' element in the " "dataset's 'file_meta' attribute" ) if tsyntax.is_compressed: raise ValueError("Only uncompressed datasets may be compressed") # Encode the current uncompressed *Pixel Data* frame_iterator = encoder.iter_encode( ds, encoding_plugin=encoding_plugin, **kwargs ) else: # Encode from an array - no need to check dataset compression state # because we'll be using new pixel data opts = as_pixel_options(ds, **kwargs) frame_iterator = encoder.iter_encode( arr, encoding_plugin=encoding_plugin, **opts ) # Encode! encoded = [f for f in frame_iterator] # Encapsulate the encoded *Pixel Data* nr_frames = len(encoded) total = (nr_frames - 1) * 8 + sum([len(f) for f in encoded[:-1]]) if encapsulate_ext or total > 2**32 - 1: ( ds.PixelData, ds.ExtendedOffsetTable, ds.ExtendedOffsetTableLengths, ) = encapsulate_extended(encoded) else: ds.PixelData = encapsulate(encoded) # PS3.5 Annex A.4 - encapsulated pixel data uses undefined length elem = ds["PixelData"] elem.is_undefined_length = True # PS3.5 Section 8.2 and Annex A.4 - encapsulated pixel data uses OB elem.VR = VR.OB # Clear `pixel_array` as lossy compression may give different results ds._pixel_array = None ds._pixel_id = {} # Set the correct *Transfer Syntax UID* if not hasattr(ds, "file_meta"): ds.file_meta = FileMetaDataset() ds.file_meta.TransferSyntaxUID = uid if generate_instance_uid: instance_uid = generate_uid() ds.SOPInstanceUID = instance_uid ds.file_meta.MediaStorageSOPInstanceUID = instance_uid return ds def decompress( ds: "Dataset", *, as_rgb: bool = True, generate_instance_uid: bool = True, decoding_plugin: str = "", **kwargs: Any, ) -> "Dataset": """Perform an in-place decompression of a dataset with a compressed *Transfer Syntax UID*. .. versionadded:: 3.0 .. warning:: This function requires `NumPy `_ and may require the installation of additional packages to perform the actual pixel data decoding. See the :doc:`pixel data decompression documentation ` for more information. * The dataset's *Transfer Syntax UID* will be set to *Explicit VR Little Endian*. * The *Pixel Data* will be decompressed in its entirety and the *Pixel Data* element's value updated with the uncompressed data, padded to an even length. * The *Pixel Data* element's VR will be set to **OB** if *Bits Allocated* <= 8, otherwise it will be set to **OW**. * The :attr:`DataElement.is_undefined_length ` attribute for the *Pixel Data* element will be set to ``False``. * Any :dcm:`image pixel` module elements may be modified as required to match the uncompressed *Pixel Data*. * If `generate_instance_uid` is ``True`` (default) then a new (0008,0018) *SOP Instance UID* value will be generated. Parameters ---------- ds : pydicom.dataset.Dataset A dataset containing compressed *Pixel Data* to be decoded and the corresponding *Image Pixel* module elements, along with a :attr:`~pydicom.dataset.FileDataset.file_meta` attribute containing a suitable (0002,0010) *Transfer Syntax UID*. as_rgb : bool, optional if ``True`` (default) then convert pixel data with a YCbCr :ref:`photometric interpretation` such as ``"YBR_FULL_422"`` to RGB. generate_instance_uid : bool, optional If ``True`` (default) then generate a new (0008,0018) *SOP Instance UID* value for the dataset using :func:`~pydicom.uid.generate_uid`, otherwise keep the original value. decoding_plugin : str, optional The name of the decoding plugin to use when decoding compressed pixel data. If no `decoding_plugin` is specified (default) then all available plugins will be tried and the result from the first successful one yielded. For information on the available plugins for each decoder see the :doc:`API documentation`. kwargs : dict[str, Any], optional Optional keyword parameters for the decoding plugin may also be present. See the :doc:`decoding plugins options ` for more information. Returns ------- pydicom.dataset.Dataset The dataset `ds` decompressed in-place. """ # TODO: v4.0 remove support for `pixel_data_handlers` module from pydicom.pixels import get_decoder if "PixelData" not in ds: raise AttributeError( "Unable to decompress as the dataset has no (7FE0,0010) 'Pixel Data' element" ) file_meta = ds.get("file_meta", {}) tsyntax = file_meta.get("TransferSyntaxUID", "") if not tsyntax: raise AttributeError( "Unable to determine the initial compression state as there's no " "(0002,0010) 'Transfer Syntax UID' element in the dataset's 'file_meta' " "attribute" ) uid = UID(tsyntax) if not uid.is_compressed: raise ValueError("The dataset is already uncompressed") use_pdh = kwargs.get("use_pdh", False) frames: list[bytes] if use_pdh: ds.convert_pixel_data(decoding_plugin) frames = [ds.pixel_array.tobytes()] else: decoder = get_decoder(uid) if not decoder.is_available: missing = "\n".join([f" {s}" for s in decoder.missing_dependencies]) raise RuntimeError( f"Unable to decompress as the plugins for the '{uid.name}' decoder " f"are all missing dependencies:\n{missing}" ) # Disallow decompression of individual frames kwargs.pop("index", None) frame_generator = decoder.iter_array( ds, decoding_plugin=decoding_plugin, as_rgb=as_rgb, **kwargs, ) frames = [] for arr, image_pixel in frame_generator: frames.append(arr.tobytes()) # Part 5, Section 8.1.1: 32-bit Value Length field value_length = sum(len(frame) for frame in frames) if value_length >= 2**32 - 1: raise ValueError( "Unable to decompress as the length of the uncompressed pixel data " "will be greater than the maximum allowed by the DICOM Standard" ) # Pad with 0x00 if odd length nr_frames = len(frames) if value_length % 2: frames.append(b"\x00") elem = ds["PixelData"] elem.value = b"".join(frame for frame in frames) elem.is_undefined_length = False elem.VR = VR.OB if ds.BitsAllocated <= 8 else VR.OW # Update the transfer syntax ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian if generate_instance_uid: instance_uid = generate_uid() ds.SOPInstanceUID = instance_uid ds.file_meta.MediaStorageSOPInstanceUID = instance_uid if not use_pdh: # Update the image pixel elements ds.PhotometricInterpretation = image_pixel["photometric_interpretation"] if cast(int, image_pixel["samples_per_pixel"]) > 1: ds.PlanarConfiguration = cast(int, image_pixel["planar_configuration"]) if "NumberOfFrames" in ds or nr_frames > 1: ds.NumberOfFrames = nr_frames ds._pixel_array = None ds._pixel_id = {} return ds def expand_ybr422(src: ByteString, bits_allocated: int) -> bytes: """Return ``YBR_FULL_422`` data expanded to ``YBR_FULL``. Uncompressed datasets with a (0028,0004) *Photometric Interpretation* of ``"YBR_FULL_422"`` are subsampled in the horizontal direction by halving the number of Cb and Cr pixels (i.e. there are two Y pixels for every Cb and Cr pixel). This function expands the ``YBR_FULL_422`` data to remove the subsampling and the output is therefore ``YBR_FULL``. Parameters ---------- src : bytes or bytearray The YBR_FULL_422 pixel data to be expanded. bits_allocated : int The number of bits used to store each pixel, as given by (0028,0100) *Bits Allocated*. Returns ------- bytes The expanded data (as YBR_FULL). """ # YBR_FULL_422 is Y Y Cb Cr (i.e. 2 Y pixels for every Cb and Cr pixel) n_bytes = bits_allocated // 8 length = len(src) // 2 * 3 dst = bytearray(length) step_src = n_bytes * 4 step_dst = n_bytes * 6 for ii in range(n_bytes): c_b = src[2 * n_bytes + ii :: step_src] c_r = src[3 * n_bytes + ii :: step_src] dst[0 * n_bytes + ii :: step_dst] = src[0 * n_bytes + ii :: step_src] dst[1 * n_bytes + ii :: step_dst] = c_b dst[2 * n_bytes + ii :: step_dst] = c_r dst[3 * n_bytes + ii :: step_dst] = src[1 * n_bytes + ii :: step_src] dst[4 * n_bytes + ii :: step_dst] = c_b dst[5 * n_bytes + ii :: step_dst] = c_r return bytes(dst) def get_expected_length(ds: "Dataset", unit: str = "bytes") -> int: """Return the expected length (in terms of bytes or pixels) of the *Pixel Data*. +------------------------------------------------+-------------+ | Element | Required or | +-------------+---------------------------+------+ optional | | Tag | Keyword | Type | | +=============+===========================+======+=============+ | (0028,0002) | SamplesPerPixel | 1 | Required | +-------------+---------------------------+------+-------------+ | (0028,0004) | PhotometricInterpretation | 1 | Required | +-------------+---------------------------+------+-------------+ | (0028,0008) | NumberOfFrames | 1C | Optional | +-------------+---------------------------+------+-------------+ | (0028,0010) | Rows | 1 | Required | +-------------+---------------------------+------+-------------+ | (0028,0011) | Columns | 1 | Required | +-------------+---------------------------+------+-------------+ | (0028,0100) | BitsAllocated | 1 | Required | +-------------+---------------------------+------+-------------+ Parameters ---------- ds : Dataset The :class:`~pydicom.dataset.Dataset` containing the Image Pixel module and *Pixel Data*. unit : str, optional If ``'bytes'`` then returns the expected length of the *Pixel Data* in whole bytes and NOT including an odd length trailing NULL padding byte. If ``'pixels'`` then returns the expected length of the *Pixel Data* in terms of the total number of pixels (default ``'bytes'``). Returns ------- int The expected length of the *Pixel Data* in either whole bytes or pixels, excluding the NULL trailing padding byte for odd length data. """ rows = cast(int, ds.Rows) columns = cast(int, ds.Columns) samples_per_pixel = cast(int, ds.SamplesPerPixel) bits_allocated = cast(int, ds.BitsAllocated) length = rows * columns * samples_per_pixel length *= get_nr_frames(ds) if unit == "pixels": return length # Correct for the number of bytes per pixel if bits_allocated == 1: # Determine the nearest whole number of bytes needed to contain # 1-bit pixel data. e.g. 10 x 10 1-bit pixels is 100 bits, which # are packed into 12.5 -> 13 bytes length = length // 8 + (length % 8 > 0) else: length *= bits_allocated // 8 # DICOM Standard, Part 4, Annex C.7.6.3.1.2 if ds.PhotometricInterpretation == "YBR_FULL_422": length = length // 3 * 2 return length def get_image_pixel_ids(ds: "Dataset") -> dict[str, int]: """Return a dict of the pixel data affecting element's :func:`id` values. +------------------------------------------------+ | Element | +-------------+---------------------------+------+ | Tag | Keyword | Type | +=============+===========================+======+ | (0028,0002) | SamplesPerPixel | 1 | +-------------+---------------------------+------+ | (0028,0004) | PhotometricInterpretation | 1 | +-------------+---------------------------+------+ | (0028,0006) | PlanarConfiguration | 1C | +-------------+---------------------------+------+ | (0028,0008) | NumberOfFrames | 1C | +-------------+---------------------------+------+ | (0028,0010) | Rows | 1 | +-------------+---------------------------+------+ | (0028,0011) | Columns | 1 | +-------------+---------------------------+------+ | (0028,0100) | BitsAllocated | 1 | +-------------+---------------------------+------+ | (0028,0101) | BitsStored | 1 | +-------------+---------------------------+------+ | (0028,0103) | PixelRepresentation | 1 | +-------------+---------------------------+------+ | (7FE0,0008) | FloatPixelData | 1C | +-------------+---------------------------+------+ | (7FE0,0009) | DoubleFloatPixelData | 1C | +-------------+---------------------------+------+ | (7FE0,0010) | PixelData | 1C | +-------------+---------------------------+------+ Parameters ---------- ds : Dataset The :class:`~pydicom.dataset.Dataset` containing the pixel data. Returns ------- dict A dict containing the :func:`id` values for the elements that affect the pixel data. """ keywords = [ "SamplesPerPixel", "PhotometricInterpretation", "PlanarConfiguration", "NumberOfFrames", "Rows", "Columns", "BitsAllocated", "BitsStored", "PixelRepresentation", "FloatPixelData", "DoubleFloatPixelData", "PixelData", ] return {kw: id(getattr(ds, kw, None)) for kw in keywords} def get_j2k_parameters(codestream: bytes) -> dict[str, Any]: """Return a dict containing JPEG 2000 component parameters. .. versionadded:: 2.1 Parameters ---------- codestream : bytes The JPEG 2000 (ISO/IEC 15444-1) codestream to be parsed. Returns ------- dict A dict containing parameters for the first component sample in the JPEG 2000 `codestream`, or an empty dict if unable to parse the data. Available parameters are ``{"precision": int, "is_signed": bool}``. """ offset = 0 info: dict[str, Any] = {"jp2": False} # Account for the JP2 header (if present) # The first box is always 12 bytes long if codestream.startswith(b"\x00\x00\x00\x0C\x6A\x50\x20\x20"): info["jp2"] = True total_length = len(codestream) offset = 12 # Iterate through the boxes, looking for the jp2c box while offset < total_length: length = int.from_bytes(codestream[offset : offset + 4], byteorder="big") if codestream[offset + 4 : offset + 8] == b"\x6A\x70\x32\x63": # The offset to the start of the J2K codestream offset += 8 break offset += length try: # First 2 bytes must be the SOC marker - if not then wrong format if codestream[offset : offset + 2] != b"\xff\x4f": return {} # SIZ is required to be the second marker - Figure A-3 in 15444-1 if codestream[offset + 2 : offset + 4] != b"\xff\x51": return {} # See 15444-1 A.5.1 for format of the SIZ box and contents ssiz = codestream[offset + 42] if ssiz & 0x80: info["precision"] = (ssiz & 0x7F) + 1 info["is_signed"] = True return info info["precision"] = ssiz + 1 info["is_signed"] = False return info except (IndexError, TypeError): pass return {} def _get_jpg_parameters(src: bytes) -> dict[str, Any]: """Return a dict containing JPEG or JPEG-LS encoding parameters. Parameters ---------- src : bytes The JPEG (ISO/IEC 10918-1) or JPEG-LS (ISO/IEC 14495-1) codestream to be parsed. Returns ------- dict[str, int | dict[bytes, bytes] | list[int]] A dict containing JPEG or JPEG-LS encoding parameters or an empty dict if unable to parse the data. Available parameters are: * ``precision``: int * ``height``: int * ``width``: int * ``components``: int * ``component_ids``: list[int] * ``app``: dict[bytes: bytes] * ``interleave_mode``: int, JPEG-LS only * ``lossy_error``: int, JPEG-LS only """ info: dict[str, Any] = {} try: # First 2 bytes should be the SOI marker - otherwise wrong format # or non-conformant (JFIF or SPIFF header) if src[0:2] != b"\xFF\xD8": return info # Skip to the SOF0 to SOF15 (JPEG) or SOF55 (JPEG-LS) marker # We skip through any other segments except APP as they sometimes # contain color space information (such as Adobe's APP14) offset = 2 app_markers = {} while (marker := src[offset : offset + 2]) not in _SOF: length = _UNPACK_SHORT(src[offset + 2 : offset + 4])[0] if marker in _APP: # `length` counts from the first byte of the APP length app_markers[marker] = src[offset + 4 : offset + 2 + length] offset += length + 2 # at the start of the next marker if app_markers: info["app"] = app_markers # SOF segment layout is identical for JPEG and JPEG-LS # 2 byte SOF marker # 2 bytes header length # 1 byte precision (bits stored) # 2 bytes rows # 2 bytes columns # 1 byte number of components in frame (samples per pixel) # for _ in range(number of components): # 1 byte component ID # 4/4 bits horizontal/vertical sampling factors # 1 byte table selector offset += 2 # at the start of the SOF length info["precision"] = src[offset + 2] info["height"] = _UNPACK_SHORT(src[offset + 3 : offset + 5])[0] info["width"] = _UNPACK_SHORT(src[offset + 5 : offset + 7])[0] info["components"] = src[offset + 7] # Parse the component IDs - these are sometimes used to denote the color # space of the input by using ASCII codes for the IDs (such as R G B) offset += 8 # start of the component IDs info["component_ids"] = [] for _ in range(info["components"]): info["component_ids"].append(src[offset]) offset += 3 # `offset` is at the start of the next marker # If JPEG then return if marker != b"\xFF\xF7": return info # Skip to the SOS marker while src[offset : offset + 2] != b"\xFF\xDA": offset += _UNPACK_SHORT(src[offset + 2 : offset + 4])[0] + 2 # `offset` is at the start of the SOS marker # SOS segment layout is the same for JPEG and JPEG-LS # 2 byte SOS marker # 2 bytes header length # 1 byte number of components in scan # for _ in range(number of components): # 1 byte scan component ID selector # 4/4 bits DC/AC entropy table selectors # 1 byte start spectral selector (JPEG) or NEAR (JPEG-LS) # 1 byte end spectral selector (JPEG) or ILV (JPEG-LS) # 4/4 bits approx bit high/low offset += 5 + src[offset + 4] * 2 info["lossy_error"] = src[offset] info["interleave_mode"] = src[offset + 1] except Exception: return {} return info def get_nr_frames(ds: "Dataset", warn: bool = True) -> int: """Return NumberOfFrames or 1 if NumberOfFrames is None or 0. Parameters ---------- ds : dataset.Dataset The :class:`~pydicom.dataset.Dataset` containing the Image Pixel module corresponding to the data in `arr`. warn : bool If ``True`` (the default), a warning is issued if NumberOfFrames has an invalid value. Returns ------- int An integer for the NumberOfFrames or 1 if NumberOfFrames is None or 0 """ nr_frames: int | None = getattr(ds, "NumberOfFrames", 1) # 'NumberOfFrames' may exist in the DICOM file but have value equal to None if not nr_frames: # None or 0 if warn: warn_and_log( f"A value of {nr_frames} for (0028,0008) 'Number of Frames' is " "non-conformant. It's recommended that this value be " "changed to 1" ) nr_frames = 1 return nr_frames def iter_pixels( src: "str | PathLike[str] | BinaryIO | Dataset", *, ds_out: "Dataset | None" = None, specific_tags: list[BaseTag | int] | None = None, indices: Iterable[int] | None = None, raw: bool = False, decoding_plugin: str = "", **kwargs: Any, ) -> Iterator["np.ndarray"]: """Yield decoded pixel data frames from `src` as :class:`~numpy.ndarray`. .. versionadded:: 3.0 .. warning:: This function requires `NumPy `_ and may require the installation of additional packages to perform the actual pixel data decompression. See the :doc:`pixel data decompression documentation ` for more information. **Memory Usage** To minimize memory usage `src` should be the path to the dataset or a `file-like object `_ containing the dataset. **Processing** The following processing operations on the raw pixel data are always performed: * Natively encoded bit-packed pixel data for a :ref:`bits allocated ` of ``1`` will be unpacked. * Natively encoded pixel data with a :ref:`photometric interpretation ` of ``"YBR_FULL_422"`` will have it's sub-sampling removed. * The output array will be reshaped to the specified dimensions. * JPEG-LS or JPEG 2000 encoded data whose signedness doesn't match the expected :ref:`pixel representation` will be converted to match. If ``raw = False`` (the default) then the following processing operation will also be performed: * Pixel data with a :ref:`photometric interpretation ` of ``"YBR_FULL"`` or ``"YBR_FULL_422"`` will be converted to ``"RGB"``. Examples -------- Read a DICOM dataset then iterate through all the pixel data frames:: from pydicom import dcmread from pydicom.pixels import iter_pixels ds = dcmread("path/to/dataset.dcm") for arr in iter_pixels(ds): print(arr.shape) Iterate through all the pixel data frames in a dataset while minimizing memory usage:: from pydicom.pixels import iter_pixels for arr in iter_pixels("path/to/dataset.dcm"): print(arr.shape) Iterate through the even frames for a dataset with 10 frames:: from pydicom.pixels import iter_pixels with open("path/to/dataset.dcm", "rb") as f: for arr in iter_pixels(f, indices=range(0, 10, 2)): print(arr.shape) Parameters ---------- src : str | PathLike[str] | file-like | pydicom.dataset.Dataset * :class:`str` | :class:`os.PathLike`: the path to a DICOM dataset containing pixel data, or * file-like: a `file-like object `_ in 'rb' mode containing the dataset. * :class:`~pydicom.dataset.Dataset`: a dataset instance ds_out : pydicom.dataset.Dataset, optional A :class:`~pydicom.dataset.Dataset` that will be updated with the non-retired group ``0x0028`` image pixel module elements and the group ``0x0002`` file meta information elements from the dataset in `src`. **Only available when `src` is a path or file-like.** specific_tags : list[int | pydicom.tag.BaseTag], optional A list of additional tags from the dataset in `src` to be added to the `ds_out` dataset. indices : Iterable[int] | None, optional If ``None`` (default) then iterate through the entire pixel data, otherwise only iterate through the frames specified by `indices`. raw : bool, optional If ``True`` then yield the decoded pixel data after only minimal processing (see the processing section above). If ``False`` (default) then additional processing may be applied to convert the pixel data to it's most commonly used form (such as converting from YCbCr to RGB). decoding_plugin : str, optional The name of the decoding plugin to use when decoding compressed pixel data. If no `decoding_plugin` is specified (default) then all available plugins will be tried and the result from the first successful one yielded. For information on the available plugins for each decoder see the :doc:`API documentation`. **kwargs Optional keyword parameters for controlling decoding are also available, please see the :doc:`decoding options documentation ` for more information. Yields ------- numpy.ndarray A single frame of decoded pixel data with shape: * (rows, columns) for single sample data * (rows, columns, samples) for multi-sample data A writeable :class:`~numpy.ndarray` is yielded by default. For native transfer syntaxes with ``view_only=True`` a read-only :class:`~numpy.ndarray` will be yielded. """ from pydicom.dataset import Dataset from pydicom.pixels import get_decoder if isinstance(src, Dataset): ds: Dataset = src file_meta = getattr(ds, "file_meta", {}) if not (tsyntax := file_meta.get("TransferSyntaxUID", None)): raise AttributeError( "Unable to decode the pixel data as the dataset's 'file_meta' " "has no (0002,0010) 'Transfer Syntax UID' element" ) try: decoder = get_decoder(tsyntax) except NotImplementedError: raise NotImplementedError( "Unable to decode the pixel data as a (0002,0010) 'Transfer Syntax " f"UID' value of '{tsyntax.name}' is not supported" ) opts = as_pixel_options(ds, **kwargs) iterator = decoder.iter_array( ds, indices=indices, validate=True, raw=raw, decoding_plugin=decoding_plugin, **opts, ) for arr, _ in iterator: yield arr return f: BinaryIO if not hasattr(src, "read"): path = Path(src).resolve(strict=True) f = path.open("rb") else: f = cast(BinaryIO, src) file_offset = f.tell() f.seek(0) tags = _DEFAULT_TAGS if ds_out is not None: tags = set(specific_tags) if specific_tags else set() tags = tags | _GROUP_0028 | {0x7FE00001, 0x7FE00002} try: ds, opts = _array_common(f, list(tags), **kwargs) if isinstance(ds_out, Dataset): ds_out.file_meta = ds.file_meta ds_out.set_original_encoding(*ds.original_encoding) ds_out._dict.update(ds._dict) tsyntax = opts["transfer_syntax_uid"] try: decoder = get_decoder(tsyntax) except NotImplementedError: raise NotImplementedError( "Unable to decode the pixel data as a (0002,0010) 'Transfer Syntax " f"UID' value of '{tsyntax.name}' is not supported" ) iterator = decoder.iter_array( f, indices=indices, validate=True, raw=raw, decoding_plugin=decoding_plugin, **opts, ) for arr, _ in iterator: yield arr finally: # Close the open file only if we were the ones that opened it if not hasattr(src, "read"): f.close() else: f.seek(file_offset) def pack_bits(arr: "np.ndarray", pad: bool = True) -> bytes: """Pack a binary :class:`numpy.ndarray` for use with *Pixel Data*. Should be used in conjunction with (0028,0100) *Bits Allocated* = 1. .. versionchanged:: 2.1 Added the `pad` keyword parameter and changed to allow `arr` to be 2 or 3D. Parameters ---------- arr : numpy.ndarray The :class:`numpy.ndarray` containing 1-bit data as ints. `arr` must only contain integer values of 0 and 1 and must have an 'uint' or 'int' :class:`numpy.dtype`. For the sake of efficiency it's recommended that the length of `arr` be a multiple of 8 (i.e. that any empty bit-padding to round out the byte has already been added). The input `arr` should either be shaped as (rows, columns) or (frames, rows, columns) or the equivalent 1D array used to ensure that the packed data is in the correct order. pad : bool, optional If ``True`` (default) then add a null byte to the end of the packed data to ensure even length, otherwise no padding will be added. Returns ------- bytes The bit packed data. Raises ------ ValueError If `arr` contains anything other than 0 or 1. References ---------- DICOM Standard, Part 5, :dcm:`Section 8.1.1` and :dcm:`Annex D` """ if arr.shape == (0,): return b"" # Test array if not np.array_equal(arr, arr.astype(bool)): raise ValueError( "Only binary arrays (containing ones or zeroes) can be packed." ) if len(arr.shape) > 1: arr = arr.ravel() # The array length must be a multiple of 8, pad the end if arr.shape[0] % 8: arr = np.append(arr, np.zeros(8 - arr.shape[0] % 8)) arr = np.packbits(arr.astype("u1"), bitorder="little") packed: bytes = arr.tobytes() if pad: return packed + b"\x00" if len(packed) % 2 else packed return packed def _passes_version_check(package_name: str, minimum_version: tuple[int, ...]) -> bool: """Return True if `package_name` is available and its version is greater or equal to `minimum_version` """ try: module = importlib.import_module(package_name, "__version__") return tuple(int(x) for x in module.__version__.split(".")) >= minimum_version except Exception as exc: LOGGER.debug(exc) return False def pixel_array( src: "str | PathLike[str] | BinaryIO | Dataset", *, ds_out: "Dataset | None" = None, specific_tags: list[int] | None = None, index: int | None = None, raw: bool = False, decoding_plugin: str = "", **kwargs: Any, ) -> "np.ndarray": """Return decoded pixel data from `src` as :class:`~numpy.ndarray`. .. versionadded:: 3.0 .. warning:: This function requires `NumPy `_ and may require the installation of additional packages to perform the actual pixel data decompression. See the :doc:`pixel data decompression documentation ` for more information. **Memory Usage** To minimize memory usage `src` should be the path to the dataset or a `file-like object `_ containing the dataset. **Processing** The following processing operations on the raw pixel data are always performed: * Natively encoded bit-packed pixel data for a :ref:`bits allocated ` of ``1`` will be unpacked. * Natively encoded pixel data with a :ref:`photometric interpretation ` of ``"YBR_FULL_422"`` will have it's sub-sampling removed. * The output array will be reshaped to the specified dimensions. * JPEG-LS or JPEG 2000 encoded data whose signedness doesn't match the expected :ref:`pixel representation` will be converted to match. If ``raw = False`` (the default) then the following processing operation will also be performed: * Pixel data with a :ref:`photometric interpretation ` of ``"YBR_FULL"`` or ``"YBR_FULL_422"`` will be converted to RGB. Examples -------- Read a DICOM dataset and return the entire pixel data:: from pydicom import dcmread from pydicom.pixels import pixel_array ds = dcmread("path/to/dataset.dcm") arr = pixel_array(ds) Return the entire pixel data from a dataset while minimizing memory usage:: from pydicom.pixels import pixel_array arr = pixel_array("path/to/dataset.dcm") Return the 3rd frame of a dataset containing at least 3 frames while minimizing memory usage:: from pydicom.pixels import pixel_array with open("path/to/dataset.dcm", "rb") as f: arr = pixel_array(f, index=2) # 'index' starts at 0 Parameters ---------- src : str | PathLike[str] | file-like | pydicom.dataset.Dataset * :class:`str` | :class:`os.PathLike`: the path to a DICOM dataset containing pixel data, or * file-like: a `file-like object `_ in 'rb' mode containing the dataset. * :class:`~pydicom.dataset.Dataset`: a dataset instance ds_out : pydicom.dataset.Dataset, optional A :class:`~pydicom.dataset.Dataset` that will be updated with the non-retired group ``0x0028`` image pixel module elements and the group ``0x0002`` file meta information elements from the dataset in `src`. **Only available when `src` is a path or file-like.** specific_tags : list[int | pydicom.tag.BaseTag], optional A list of additional tags from the dataset in `src` to be added to the `ds_out` dataset. index : int | None, optional If ``None`` (default) then return an array containing all the frames in the pixel data, otherwise return only the frame from the specified `index`, which starts at 0 for the first frame. raw : bool, optional If ``True`` then return the decoded pixel data after only minimal processing (see the processing section above). If ``False`` (default) then additional processing may be applied to convert the pixel data to it's most commonly used form (such as converting from YCbCr to RGB). decoding_plugin : str, optional The name of the decoding plugin to use when decoding compressed pixel data. If no `decoding_plugin` is specified (default) then all available plugins will be tried and the result from the first successful one returned. For information on the available plugins for each decoder see the :doc:`API documentation`. **kwargs Optional keyword parameters for controlling decoding, please see the :doc:`decoding options documentation` for more information. Returns ------- numpy.ndarray One or more frames of decoded pixel data with shape: * (rows, columns) for single frame, single sample data * (rows, columns, samples) for single frame, multi-sample data * (frames, rows, columns) for multi-frame, single sample data * (frames, rows, columns, samples) for multi-frame, multi-sample data A writeable :class:`~numpy.ndarray` is returned by default. For native transfer syntaxes with ``view_only=True`` a read-only :class:`~numpy.ndarray` will be returned. """ from pydicom.dataset import Dataset from pydicom.pixels import get_decoder if isinstance(src, Dataset): ds: Dataset = src file_meta = getattr(ds, "file_meta", {}) if not (tsyntax := file_meta.get("TransferSyntaxUID", None)): raise AttributeError( "Unable to decode the pixel data as the dataset's 'file_meta' " "has no (0002,0010) 'Transfer Syntax UID' element" ) try: decoder = get_decoder(tsyntax) except NotImplementedError: raise NotImplementedError( "Unable to decode the pixel data as a (0002,0010) 'Transfer Syntax " f"UID' value of '{tsyntax.name}' is not supported" ) opts = as_pixel_options(ds, **kwargs) return decoder.as_array( ds, index=index, validate=True, raw=raw, decoding_plugin=decoding_plugin, **opts, )[0] f: BinaryIO if not hasattr(src, "read"): path = Path(src).resolve(strict=True) f = path.open("rb") else: f = cast(BinaryIO, src) file_offset = f.tell() f.seek(0) tags = _DEFAULT_TAGS if ds_out is not None: tags = set(specific_tags) if specific_tags else set() tags = tags | _GROUP_0028 | {0x7FE00001, 0x7FE00002} try: ds, opts = _array_common(f, list(tags), **kwargs) tsyntax = opts["transfer_syntax_uid"] try: decoder = get_decoder(tsyntax) except NotImplementedError: raise NotImplementedError( "Unable to decode the pixel data as a (0002,0010) 'Transfer Syntax " f"UID' value of '{tsyntax.name}' is not supported" ) arr, _ = decoder.as_array( f, index=index, validate=True, raw=raw, decoding_plugin=decoding_plugin, **opts, # type: ignore[arg-type] ) finally: # Close the open file only if we were the ones that opened it if not hasattr(src, "read"): f.close() else: f.seek(file_offset) if isinstance(ds_out, Dataset): ds_out.file_meta = ds.file_meta ds_out.set_original_encoding(*ds.original_encoding) ds_out._dict.update(ds._dict) return arr def pixel_dtype(ds: "Dataset", as_float: bool = False) -> "np.dtype": """Return a :class:`numpy.dtype` for the pixel data in `ds`. Suitable for use with IODs containing the Image Pixel module (with ``as_float=False``) and the Floating Point Image Pixel and Double Floating Point Image Pixel modules (with ``as_float=True``). +------------------------------------------+------------------+ | Element | Supported | +-------------+---------------------+------+ values | | Tag | Keyword | Type | | +=============+=====================+======+==================+ | (0028,0101) | BitsAllocated | 1 | 1, 8, 16, 32, 64 | +-------------+---------------------+------+------------------+ | (0028,0103) | PixelRepresentation | 1 | 0, 1 | +-------------+---------------------+------+------------------+ Parameters ---------- ds : Dataset The :class:`~pydicom.dataset.Dataset` containing the pixel data you wish to get the data type for. as_float : bool, optional If ``True`` then return a float dtype, otherwise return an integer dtype (default ``False``). Float dtypes are only supported when (0028,0101) *Bits Allocated* is 32 or 64. Returns ------- numpy.dtype A :class:`numpy.dtype` suitable for containing the pixel data. Raises ------ NotImplementedError If the pixel data is of a type that isn't supported by either numpy or *pydicom*. """ if not HAVE_NP: raise ImportError("Numpy is required to determine the dtype.") # Prefer Transfer Syntax UID, fall back to the original encoding if hasattr(ds, "file_meta"): is_little_endian = ds.file_meta._tsyntax_encoding[1] else: is_little_endian = ds.original_encoding[1] if is_little_endian is None: raise AttributeError( "Unable to determine the endianness of the dataset, please set " "an appropriate Transfer Syntax UID in " f"'{type(ds).__name__}.file_meta'" ) if not as_float: # (0028,0103) Pixel Representation, US, 1 # Data representation of the pixel samples # 0x0000 - unsigned int # 0x0001 - 2's complement (signed int) pixel_repr = cast(int, ds.PixelRepresentation) if pixel_repr == 0: dtype_str = "uint" elif pixel_repr == 1: dtype_str = "int" else: raise ValueError( "Unable to determine the data type to use to contain the " f"Pixel Data as a value of '{pixel_repr}' for '(0028,0103) " "Pixel Representation' is invalid" ) else: dtype_str = "float" # (0028,0100) Bits Allocated, US, 1 # The number of bits allocated for each pixel sample # PS3.5 8.1.1: Bits Allocated shall either be 1 or a multiple of 8 # For bit packed data we use uint8 bits_allocated = cast(int, ds.BitsAllocated) if bits_allocated == 1: dtype_str = "uint8" elif bits_allocated > 0 and bits_allocated % 8 == 0: dtype_str += str(bits_allocated) else: raise ValueError( "Unable to determine the data type to use to contain the " f"Pixel Data as a value of '{bits_allocated}' for '(0028,0100) " "Bits Allocated' is invalid" ) # Check to see if the dtype is valid for numpy try: dtype = np.dtype(dtype_str) except TypeError: raise NotImplementedError( f"The data type '{dtype_str}' needed to contain the Pixel Data " "is not supported by numpy" ) # Correct for endianness of the system vs endianness of the dataset if is_little_endian != (byteorder == "little"): # 'S' swap from current to opposite dtype = dtype.newbyteorder("S") return dtype def reshape_pixel_array(ds: "Dataset", arr: "np.ndarray") -> "np.ndarray": """Return a reshaped :class:`numpy.ndarray` `arr`. +------------------------------------------+-----------+----------+ | Element | Supported | | +-------------+---------------------+------+ values | | | Tag | Keyword | Type | | | +=============+=====================+======+===========+==========+ | (0028,0002) | SamplesPerPixel | 1 | N > 0 | Required | +-------------+---------------------+------+-----------+----------+ | (0028,0006) | PlanarConfiguration | 1C | 0, 1 | Optional | +-------------+---------------------+------+-----------+----------+ | (0028,0008) | NumberOfFrames | 1C | N > 0 | Optional | +-------------+---------------------+------+-----------+----------+ | (0028,0010) | Rows | 1 | N > 0 | Required | +-------------+---------------------+------+-----------+----------+ | (0028,0011) | Columns | 1 | N > 0 | Required | +-------------+---------------------+------+-----------+----------+ (0028,0008) *Number of Frames* is required when *Pixel Data* contains more than 1 frame. (0028,0006) *Planar Configuration* is required when (0028,0002) *Samples per Pixel* is greater than 1. For certain compressed transfer syntaxes it is always taken to be either 0 or 1 as shown in the table below. +---------------------------------------------+-----------------------+ | Transfer Syntax | Planar Configuration | +------------------------+--------------------+ | | UID | Name | | +========================+====================+=======================+ | 1.2.840.10008.1.2.4.50 | JPEG Baseline | 0 | +------------------------+--------------------+-----------------------+ | 1.2.840.10008.1.2.4.57 | JPEG Lossless, | 0 | | | Non-hierarchical | | +------------------------+--------------------+-----------------------+ | 1.2.840.10008.1.2.4.70 | JPEG Lossless, | 0 | | | Non-hierarchical, | | | | SV1 | | +------------------------+--------------------+-----------------------+ | 1.2.840.10008.1.2.4.80 | JPEG-LS Lossless | 0 | +------------------------+--------------------+-----------------------+ | 1.2.840.10008.1.2.4.81 | JPEG-LS Lossy | 0 | +------------------------+--------------------+-----------------------+ | 1.2.840.10008.1.2.4.90 | JPEG 2000 Lossless | 0 | +------------------------+--------------------+-----------------------+ | 1.2.840.10008.1.2.4.91 | JPEG 2000 Lossy | 0 | +------------------------+--------------------+-----------------------+ | 1.2.840.10008.1.2.5 | RLE Lossless | 1 | +------------------------+--------------------+-----------------------+ .. versionchanged:: 2.1 JPEG-LS transfer syntaxes changed to *Planar Configuration* of 0 Parameters ---------- ds : dataset.Dataset The :class:`~pydicom.dataset.Dataset` containing the Image Pixel module corresponding to the data in `arr`. arr : numpy.ndarray The 1D array containing the pixel data. Returns ------- numpy.ndarray A reshaped array containing the pixel data. The shape of the array depends on the contents of the dataset: * For single frame, single sample data (rows, columns) * For single frame, multi-sample data (rows, columns, samples) * For multi-frame, single sample data (frames, rows, columns) * For multi-frame, multi-sample data (frames, rows, columns, samples) References ---------- * DICOM Standard, Part 3, :dcm:`Annex C.7.6.3.1` * DICOM Standard, Part 5, :dcm:`Section 8.2` """ if not HAVE_NP: raise ImportError("Numpy is required to reshape the pixel array.") nr_frames = get_nr_frames(ds) nr_samples = cast(int, ds.SamplesPerPixel) if nr_samples < 1: raise ValueError( f"Unable to reshape the pixel array as a value of {nr_samples} " "for (0028,0002) 'Samples per Pixel' is invalid." ) # Valid values for Planar Configuration are dependent on transfer syntax if nr_samples > 1: transfer_syntax = ds.file_meta.TransferSyntaxUID if transfer_syntax in [ "1.2.840.10008.1.2.4.50", "1.2.840.10008.1.2.4.57", "1.2.840.10008.1.2.4.70", "1.2.840.10008.1.2.4.80", "1.2.840.10008.1.2.4.81", "1.2.840.10008.1.2.4.90", "1.2.840.10008.1.2.4.91", ]: planar_configuration = 0 elif transfer_syntax in ["1.2.840.10008.1.2.5"]: planar_configuration = 1 else: planar_configuration = ds.PlanarConfiguration if planar_configuration not in [0, 1]: raise ValueError( "Unable to reshape the pixel array as a value of " f"{planar_configuration} for (0028,0006) 'Planar " "Configuration' is invalid." ) rows = cast(int, ds.Rows) columns = cast(int, ds.Columns) if nr_frames > 1: # Multi-frame if nr_samples == 1: # Single sample arr = arr.reshape(nr_frames, rows, columns) else: # Multiple samples, usually 3 if planar_configuration == 0: arr = arr.reshape(nr_frames, rows, columns, nr_samples) else: arr = arr.reshape(nr_frames, nr_samples, rows, columns) arr = arr.transpose(0, 2, 3, 1) else: # Single frame if nr_samples == 1: # Single sample arr = arr.reshape(rows, columns) else: # Multiple samples, usually 3 if planar_configuration == 0: arr = arr.reshape(rows, columns, nr_samples) else: arr = arr.reshape(nr_samples, rows, columns) arr = arr.transpose(1, 2, 0) return arr def set_pixel_data( ds: "Dataset", arr: "np.ndarray", photometric_interpretation: str, bits_stored: int, *, generate_instance_uid: bool = True, ) -> None: """Use an :class:`~numpy.ndarray` to set a dataset's *Pixel Data* and related Image Pixel module elements. .. versionadded:: 3.0 The following :dcm:`Image Pixel` module elements values will be added, updated or removed as necessary: * (0028,0002) *Samples per Pixel* using a value corresponding to `photometric_interpretation`. * (0028,0004) *Photometric Interpretation* from `photometric_interpretation`. * (0028,0006) *Planar Configuration* will be added and set to ``0`` if *Samples per Pixel* is > 1, otherwise it will be removed. * (0028,0008) *Number of Frames* from the array :attr:`~numpy.ndarray.shape`, however it will be removed if `arr` only contains a single frame. * (0028,0010) *Rows* and (0028,0011) *Columns* from the array :attr:`~numpy.ndarray.shape`. * (0028,0100) *Bits Allocated* from the array :class:`~numpy.dtype`. * (0028,0101) *Bits Stored* and (0028,0102) *High Bit* from `bits_stored`. * (0028,0103) *Pixel Representation* from the array :class:`~numpy.dtype`. In addition: * The *Transfer Syntax UID* will be set to *Explicit VR Little Endian* if it doesn't already exist or uses a compressed (encapsulated) transfer syntax. * If `generate_instance_uid` is ``True`` (default) then the *SOP Instance UID* will be added or updated. Parameters ---------- ds : pydicom.dataset.Dataset The little endian encoded dataset to be modified. arr : np.ndarray An array with :class:`~numpy.dtype` uint8, uint16, int8 or int16. The array must be shaped as one of the following: * (rows, columns) for a single frame of grayscale data. * (frames, rows, columns) for multi-frame grayscale data. * (rows, columns, samples) for a single frame of multi-sample data such as RGB. * (frames, rows, columns, samples) for multi-frame, multi-sample data. photometric_interpretation : str The value to use for (0028,0004) *Photometric Interpretation*. Valid values are ``"MONOCHROME1"``, ``"MONOCHROME2"``, ``"PALETTE COLOR"``, ``"RGB"``, ``"YBR_FULL"``, ``"YBR_FULL_422"``. bits_stored : int The value to use for (0028,0101) *Bits Stored*. Must be no greater than the number of bits used by the :attr:`~numpy.dtype.itemsize` of `arr`. generate_instance_uid : bool, optional If ``True`` (default) then add or update the (0008,0018) *SOP Instance UID* element with a value generated using :func:`~pydicom.uid.generate_uid`. """ from pydicom.dataset import FileMetaDataset from pydicom.pixels.common import PhotometricInterpretation as PI if (elem := ds.get(0x7FE00008, None)) or (elem := ds.get(0x7FE00009, None)): raise AttributeError( f"The dataset has an existing {elem.tag} '{elem.name}' element which " "indicates the (0008,0016) 'SOP Class UID' value is not suitable for a " f"dataset with 'Pixel Data'. The '{elem.name}' element should be deleted " "and the 'SOP Class UID' changed." ) if not hasattr(ds, "file_meta"): ds.file_meta = FileMetaDataset() tsyntax = ds.file_meta.get("TransferSyntaxUID", None) if tsyntax and not tsyntax.is_little_endian: raise NotImplementedError( f"The dataset's transfer syntax '{tsyntax.name}' is big-endian, " "which is not supported" ) # Make no changes to the dataset until after validation checks have passed! changes: dict[str, tuple[str, Any]] = {} shape, ndim, dtype = arr.shape, arr.ndim, arr.dtype if dtype.kind not in ("u", "i") or dtype.itemsize not in (1, 2): raise ValueError( f"Unsupported ndarray dtype '{dtype}', must be int8, int16, uint8 or " "uint16" ) # Use `photometric_interpretation` to determine *Samples Per Pixel* # Don't support retired (such as CMYK) or inappropriate values (such as YBR_RCT) interpretations: dict[str, int] = { PI.MONOCHROME1: 1, PI.MONOCHROME2: 1, PI.PALETTE_COLOR: 1, PI.RGB: 3, PI.YBR_FULL: 3, PI.YBR_FULL_422: 3, } try: nr_samples = interpretations[photometric_interpretation] except KeyError: raise ValueError( "Unsupported 'photometric_interpretation' value " f"'{photometric_interpretation}'" ) if nr_samples == 1: if ndim not in (2, 3): raise ValueError( f"An ndarray with '{photometric_interpretation}' data must have 2 or 3 " f"dimensions, not {ndim}" ) # ndim = 3 is (frames, rows, columns), else (rows, columns) changes["NumberOfFrames"] = ("+", shape[0]) if ndim == 3 else ("-", None) changes["Rows"] = ("+", shape[1] if ndim == 3 else shape[0]) changes["Columns"] = ("+", shape[2] if ndim == 3 else shape[1]) else: if ndim not in (3, 4): raise ValueError( f"An ndarray with '{photometric_interpretation}' data must have 3 or 4 " f"dimensions, not {ndim}" ) if shape[-1] != nr_samples: raise ValueError( f"An ndarray with '{photometric_interpretation}' data must have shape " f"(rows, columns, 3) or (frames, rows, columns, 3), not {shape}" ) # ndim = 3 is (rows, columns, samples), else (frames, rows, columns, samples) changes["NumberOfFrames"] = ("-", None) if ndim == 3 else ("+", shape[0]) changes["Rows"] = ("+", shape[0] if ndim == 3 else shape[1]) changes["Columns"] = ("+", shape[1] if ndim == 3 else shape[2]) if not 0 < bits_stored <= dtype.itemsize * 8: raise ValueError( f"Invalid 'bits_stored' value '{bits_stored}', must be greater than 0 and " "less than or equal to the number of bits for the ndarray's itemsize " f"'{arr.dtype.itemsize * 8}'" ) # Check values in `arr` are in the range allowed by `bits_stored` actual_min, actual_max = arr.min(), arr.max() allowed_min = 0 if dtype.kind == "u" else -(2 ** (bits_stored - 1)) allowed_max = ( 2**bits_stored - 1 if dtype.kind == "u" else 2 ** (bits_stored - 1) - 1 ) if actual_min < allowed_min or actual_max > allowed_max: raise ValueError( f"The range of values in the ndarray [{actual_min}, {actual_max}] is " f"greater than that allowed by the 'bits_stored' value [{allowed_min}, " f"{allowed_max}]" ) changes["SamplesPerPixel"] = ("+", nr_samples) changes["PlanarConfiguration"] = ("+", 0) if nr_samples > 1 else ("-", None) changes["PhotometricInterpretation"] = ("+", photometric_interpretation) changes["BitsAllocated"] = ("+", dtype.itemsize * 8) changes["BitsStored"] = ("+", bits_stored) changes["HighBit"] = ("+", bits_stored - 1) changes["PixelRepresentation"] = ("+", 0 if dtype.kind == "u" else 1) # Update the Image Pixel module elements for keyword, (operation, value) in changes.items(): if operation == "+": setattr(ds, keyword, value) elif operation == "-" and keyword in ds: del ds[keyword] # Part 3, C.7.6.3.1.2: YBR_FULL_422 data needs to be downsampled if photometric_interpretation == PI.YBR_FULL_422: # Y1 B1 R1 Y2 B1 R1 -> Y1 Y2 B1 R1 arr = arr.ravel() out = np.empty(arr.size // 3 * 2, dtype=dtype) out[::4] = arr[::6] # Y1 out[1::4] = arr[3::6] # Y2 out[2::4] = arr[1::6] # B out[3::4] = arr[2::6] # R arr = out # Update the Pixel Data data = arr.tobytes() ds.PixelData = data if len(data) % 2 == 0 else b"".join((data, b"\x00")) elem = ds["PixelData"] elem.VR = VR.OB if ds.BitsAllocated <= 8 else VR.OW elem.is_undefined_length = False ds._pixel_array = None ds._pixel_id = {} if not tsyntax or tsyntax.is_compressed: ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian if generate_instance_uid: instance_uid = generate_uid() ds.SOPInstanceUID = instance_uid ds.file_meta.MediaStorageSOPInstanceUID = instance_uid def unpack_bits(src: bytes, as_array: bool = True) -> "np.ndarray | bytes": """Unpack the bit-packed data in `src`. Suitable for use when (0028,0011) *Bits Allocated* or (60xx,0100) *Overlay Bits Allocated* is 1. If `NumPy `_ is available then it will be used to unpack the data, otherwise only the standard library will be used, which is about 20 times slower. .. versionchanged:: 2.3 Added the `as_array` keyword parameter, support for unpacking without NumPy, and added :class:`bytes` as a possible return type Parameters ---------- src : bytes The bit-packed data. as_array : bool, optional If ``False`` then return the unpacked data as :class:`bytes`, otherwise return a :class:`numpy.ndarray` (default, requires NumPy). Returns ------- bytes or numpy.ndarray The unpacked data as an :class:`numpy.ndarray` (if NumPy is available and ``as_array == True``) or :class:`bytes` otherwise. Raises ------ ValueError If `as_array` is ``True`` and NumPy is not available. References ---------- DICOM Standard, Part 5, :dcm:`Section 8.1.1` and :dcm:`Annex D` """ if HAVE_NP: arr = np.frombuffer(src, dtype="u1") arr = np.unpackbits(arr, bitorder="little") return arr if as_array else arr.tobytes() if as_array: raise ValueError("unpack_bits() requires NumPy if 'as_array = True'") return b"".join(map(_UNPACK_LUT.__getitem__, src))