Files
René Mathieu 0fef8d96c5 Initial commit
2026-01-17 13:49:51 +01:00

1995 lines
74 KiB
Python
Executable File

# 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<part05/sect_A.4.html>` codestream.
.. versionadded:: 3.0
The dataset `ds` must already have the following
:dcm:`Image Pixel<part03/sect_C.7.6.3.html>` 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
<part03/sect_C.7.6.3.html>` 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 |
+------------------------+----------------------+ | </guides/encoding/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 |
+------------------------+----------------------+ | </guides/encoding/jpeg_2k>` |
| *JPEG 2000* |1.2.840.10008.1.2.4.91| | |
+------------------------+----------------------+-----------+----------------------------------+
| *RLE Lossless* | 1.2.840.10008.1.2.5 | pydicom, | :doc:`RLE Lossless |
| | | pylibjpeg,| </guides/encoding/rle_lossless>` |
| | | 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<part05/chapter_10.html>` 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 </guides/user/image_data_compression>` 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
</guides/encoding/encoder_plugin_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 <https://numpy.org/>`_ and may require
the installation of additional packages to perform the actual pixel
data decoding. See the :doc:`pixel data decompression documentation
</guides/user/image_data_handlers>` 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
<pydicom.dataelem.DataElement.is_undefined_length>` attribute for the
*Pixel Data* element will be set to ``False``.
* Any :dcm:`image pixel<part03/sect_C.7.6.3.html>` 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<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</reference/pixels.decoders>`.
kwargs : dict[str, Any], optional
Optional keyword parameters for the decoding plugin may also be
present. See the :doc:`decoding plugins options
</guides/decoding/decoder_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 <https://numpy.org/>`_ and may require
the installation of additional packages to perform the actual pixel
data decompression. See the :doc:`pixel data decompression documentation
</guides/user/image_data_handlers>` for more information.
**Memory Usage**
To minimize memory usage `src` should be the path to the dataset
or a `file-like object <https://docs.python.org/3/glossary.html#term-file-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
<bits_allocated>` of ``1`` will be unpacked.
* Natively encoded pixel data with a :ref:`photometric interpretation
<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<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
<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
<https://docs.python.org/3/glossary.html#term-file-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</reference/pixels.decoders>`.
**kwargs
Optional keyword parameters for controlling decoding are also
available, please see the :doc:`decoding options documentation
</guides/decoding/decoder_options>` 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<part05/chapter_8.html#sect_8.1.1>` and
:dcm:`Annex D<part05/chapter_D.html>`
"""
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 <https://numpy.org/>`_ and may require
the installation of additional packages to perform the actual pixel
data decompression. See the :doc:`pixel data decompression documentation
</guides/user/image_data_handlers>` for more information.
**Memory Usage**
To minimize memory usage `src` should be the path to the dataset
or a `file-like object <https://docs.python.org/3/glossary.html#term-file-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
<bits_allocated>` of ``1`` will be unpacked.
* Natively encoded pixel data with a :ref:`photometric interpretation
<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<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
<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
<https://docs.python.org/3/glossary.html#term-file-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</reference/pixels.decoders>`.
**kwargs
Optional keyword parameters for controlling decoding, please see the
:doc:`decoding options documentation</guides/decoding/decoder_options>`
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<part03/sect_C.7.6.3.html#sect_C.7.6.3.1>`
* DICOM Standard, Part 5, :dcm:`Section 8.2<part05/sect_8.2.html>`
"""
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<part03/sect_C.7.6.3.3.html#table_C.7-11c>`
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 <https://numpy.org/>`_ 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<part05/chapter_8.html#sect_8.1.1>` and
:dcm:`Annex D<part05/chapter_D.html>`
"""
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))