Initial commit

This commit is contained in:
René Mathieu
2026-01-17 13:49:51 +01:00
commit 0fef8d96c5
1897 changed files with 396119 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
from pydicom.pixels.decoders.base import get_decoder
from pydicom.pixels.encoders.base import get_encoder
from pydicom.pixels.processing import (
apply_color_lut,
apply_icc_profile,
apply_modality_lut,
apply_presentation_lut,
apply_rescale,
apply_voi_lut,
apply_voi,
apply_windowing,
convert_color_space,
create_icc_transform,
)
from pydicom.pixels.utils import (
as_pixel_options,
compress,
decompress,
iter_pixels,
pack_bits,
pixel_array,
set_pixel_data,
unpack_bits,
)

View File

@@ -0,0 +1,689 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
"""Common objects for pixel data handling."""
from enum import Enum, unique
from importlib import import_module
from typing import TYPE_CHECKING, Any, TypedDict
from pydicom.misc import warn_and_log
from pydicom.pixels.utils import as_pixel_options
from pydicom.uid import UID
if TYPE_CHECKING: # pragma: no cover
from collections.abc import Callable
from pydicom.dataset import Dataset
from pydicom.pixels.decoders.base import DecodeOptions, DecodeFunction
from pydicom.pixels.encoders.base import EncodeOptions, EncodeFunction
Buffer = bytes | bytearray | memoryview
class CoderBase:
"""Base class for Decoder and Encoder."""
def __init__(self, uid: UID, decoder: bool) -> None:
"""Create a new data decoder or encoder.
Parameters
----------
uid : pydicom.uid.UID
The supported *Transfer Syntax UID*.
decoder : bool
``True`` for a decoder subclass, ``False`` for an encoder subclass.
"""
# The *Transfer Syntax UID* data will be encoded to
self._uid = uid
# Available plugins
self._available: dict[str, Callable] = {}
# Unavailable plugins - missing dependencies or other reason
self._unavailable: dict[str, tuple[str, ...]] = {}
# True for a Decoder class, False for an Encoder class
self._decoder = decoder
def add_plugin(self, label: str, import_path: tuple[str, str]) -> None:
"""Add a plugin to the class instance.
.. warning::
This method is not thread-safe.
The requirements for encoding plugins are available
:doc:`here</guides/encoding/encoder_plugins>`, while the requirements
for decoding plugins are available :doc:`here
</guides/decoding/decoder_plugins>`.
Only encoding plugins should be added to
:class:`~pydicom.pixels.encoders.base.Encoder` class instances
and only decoding plugins should be added to
:class:`~pydicom.pixels.decoders.base.Decoder` class instances.
Parameters
----------
label : str
The label to use for the plugin, should be unique.
import_path : Tuple[str, str]
The module import path and the function's name (e.g.
``('pydicom.pixels.encoders.pylibjpeg', 'encode_pixel_data')`` or
``('pydicom.pixels.decoders.pylibjpeg', 'decode_pixel_data')``).
Raises
------
ModuleNotFoundError
If the module import path is incorrect or unavailable.
AttributeError
If the plugin's required functions and attributes aren't found in
the module.
"""
if label in self._available or label in self._unavailable:
raise ValueError(
f"'{type(self).__name__}' already has a plugin named '{label}'"
)
module = import_module(import_path[0])
# `is_available(UID)` is required for plugins
if module.is_available(self.UID):
self._available[label] = getattr(module, import_path[1])
else:
# `DE/ENCODER_DEPENDENCIES[UID]` is required for plugins
if self._decoder:
deps = module.DECODER_DEPENDENCIES
else:
deps = module.ENCODER_DEPENDENCIES
if self.UID not in deps:
raise ValueError(
f"The '{label}' plugin doesn't support '{self.UID.name}'"
)
self._unavailable[label] = deps[self.UID]
def add_plugins(self, plugins: list[tuple[str, tuple[str, str]]]) -> None:
"""Add multiple plugins to the class instance.
.. warning::
This method is not thread-safe.
The requirements for encoding plugins are available
:doc:`here</guides/encoding/encoder_plugins>`, while the requirements
for decoding plugins are available :doc:`here
</guides/decoding/decoder_plugins>`.
Only encoding plugins should be added to
:class:`~pydicom.pixels.encoders.base.Encoder` class instances
and only decoding plugins should be added to
:class:`~pydicom.pixels.decoders.base.Decoder` class instances.
Parameters
----------
plugins : list[tuple[str, tuple[str, str]]]
A list of [label, import path] for the plugins, where:
* `label` is the label to use for the plugin, which should be unique.
* `import path` is the module import path and the function's
name (e.g. ``('pydicom.pixels.encoders.pylibjpeg', 'encode_pixel_data')``
or ``('pydicom.pixels.decoders.pylibjpeg', 'decode_pixel_data')``).
"""
for label, import_path in plugins:
self.add_plugin(label, import_path)
@property
def available_plugins(self) -> tuple[str, ...]:
"""Return a tuple containing available plugins."""
return tuple(sorted(self._available.keys()))
@property
def is_available(self) -> bool:
"""Return ``True`` if plugins are available that can be used to encode or
decode data, ``False`` otherwise.
"""
if self._decoder and not self.UID.is_encapsulated:
return True
return bool(self._available)
@property
def is_encapsulated(self) -> bool:
"""Return ``True`` if the decoder is for an encapsulated transfer
syntax, ``False`` otherwise.
"""
return self.UID.is_encapsulated
@property
def is_native(self) -> bool:
"""Return ``True`` if the decoder is for an native transfer
syntax, ``False`` otherwise.
"""
return not self.is_encapsulated
@property
def missing_dependencies(self) -> list[str]:
"""Return nice strings for plugins with missing dependencies."""
s = []
for label, deps in self._unavailable.items():
if not deps:
# A plugin might have no dependencies and be unavailable for
# other reasons
s.append(f"{label} - plugin indicating it is unavailable")
elif len(deps) > 1:
s.append(f"{label} - requires {', '.join(deps[:-1])} and {deps[-1]}")
else:
s.append(f"{label} - requires {deps[0]}")
return s
def remove_plugin(self, label: str) -> None:
"""Remove a plugin.
.. warning::
This method is not thread-safe.
Parameters
----------
label : str
The label of the plugin to remove.
"""
if label in self._available:
del self._available[label]
elif label in self._unavailable:
del self._unavailable[label]
else:
raise ValueError(f"Unable to remove '{label}', no such plugin'")
@property
def UID(self) -> UID:
"""Return the corresponding *Transfer Syntax UID* as :class:`~pydicom.uid.UID`."""
return self._uid
def _validate_plugins(
self, plugin: str = ""
) -> dict[str, "DecodeFunction"] | dict[str, "EncodeFunction"]:
"""Return available plugins.
Parameters
----------
plugin : str, optional
If not used (default) then return all available plugins, otherwise
only return the plugin with a matching name (if it's available).
Returns
-------
dict[str, DecodeFunction] | dict[str, EncodeFunction]
A dict of available {plugin name: decode/encode function} that can
be used to decode/encode the corresponding pixel data.
"""
if self._decoder and not self.UID.is_encapsulated:
return {} # type: ignore[return-value]
if plugin:
if plugin in self.available_plugins:
return {plugin: self._available[plugin]}
if deps := self._unavailable.get(plugin, None):
missing = deps[0]
if len(deps) > 1:
missing = f"{', '.join(deps[:-1])} and {deps[-1]}"
if self._decoder:
raise RuntimeError(
f"Unable to decompress '{self.UID.name}' pixel data because "
f"the specified plugin is missing dependencies:\n\t{plugin} "
f"- requires {missing}"
)
raise RuntimeError(
f"Unable to compress the pixel data using '{self.UID.name}' because "
f"the specified plugin is missing dependencies:\n\t{plugin} "
f"- requires {missing}"
)
msg = (
f"No plugin named '{plugin}' has been added to '{self.UID.keyword}"
f"{type(self).__name__}'"
)
if self._available:
msg += f", available plugins are: {', '.join(self.available_plugins)}"
raise ValueError(msg)
if self._available:
return self._available.copy()
missing = "\n".join([f"\t{s}" for s in self.missing_dependencies])
if self._decoder:
raise RuntimeError(
f"Unable to decompress '{self.UID.name}' pixel data because all "
f"plugins are missing dependencies:\n{missing}"
)
raise RuntimeError(
f"Unable to compress the pixel data using '{self.UID.name}' because all "
f"plugins are missing dependencies:\n{missing}"
)
# TODO: Python 3.11 switch to StrEnum
@unique
class PhotometricInterpretation(str, Enum):
"""Values for (0028,0004) *Photometric Interpretation*"""
# Standard Photometric Interpretations from C.7.6.3.1.2 in Part 3
MONOCHROME1 = "MONOCHROME1"
MONOCHROME2 = "MONOCHROME2"
PALETTE_COLOR = "PALETTE COLOR"
RGB = "RGB"
YBR_FULL = "YBR_FULL"
YBR_FULL_422 = "YBR_FULL_422"
YBR_ICT = "YBR_ICT"
YBR_RCT = "YBR_RCT"
HSV = "HSV" # Retired
ARGB = "ARGB" # Retired
CMYK = "CMYK" # Retired
YBR_PARTIAL_422 = "YBR_PARTIAL_422" # Retired
YBR_PARTIAL_420 = "YBR_PARTIAL_420" # Retired
# TODO: no longer needed if StrEnum
def __str__(self) -> str:
return str.__str__(self)
class RunnerBase:
"""Base class for the pixel data decoding/encoding process managers."""
def __init__(self, tsyntax: UID) -> None:
"""Create a new runner for encoding/decoding data.
Parameters
----------
tsyntax : pydicom.uid.UID
The transfer syntax UID corresponding to the pixel data to be
decoded.
"""
# Runner options
self._opts: DecodeOptions | EncodeOptions = {}
self.set_option("transfer_syntax_uid", tsyntax)
# Runner options that cannot be deleted, only modified
self._undeletable: tuple[str, ...] = ("transfer_syntax_uid",)
# The source type, one of "Dataset", "Buffer", "Array" or "BinaryIO"
self._src_type = "UNDEFINED"
@property
def bits_allocated(self) -> int:
"""Return the expected number of bits allocated used by the data."""
if (value := self._opts.get("bits_allocated", None)) is not None:
return value
raise AttributeError("No value for 'bits_allocated' has been set")
@property
def bits_stored(self) -> int:
"""Return the expected number of bits stored used by the data."""
if (value := self._opts.get("bits_stored", None)) is not None:
return value
raise AttributeError("No value for 'bits_stored' has been set")
@property
def columns(self) -> int:
"""Return the expected number of columns in the data."""
if (value := self._opts.get("columns", None)) is not None:
return value
raise AttributeError("No value for 'columns' has been set")
def del_option(self, name: str) -> None:
"""Delete option `name` from the runner."""
if name in self._undeletable:
raise ValueError(f"Deleting '{name}' is not allowed")
self._opts.pop(name, None) # type: ignore[misc]
@property
def extended_offsets(
self,
) -> tuple[list[int], list[int]] | tuple[bytes, bytes] | None:
"""Return the extended offsets table and lengths
Returns
-------
tuple[list[int], list[int]] | tuple[bytes, bytes] | None
Returns the extended offsets and lengths as either lists of int
or their equivalent encoded values, or ``None`` if no extended
offsets have been set.
"""
return self._opts.get("extended_offsets", None)
def frame_length(self, unit: str = "bytes") -> int | float:
"""Return the expected length (in number of bytes or pixels) of each
frame of pixel data.
Parameters
----------
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 | float
The expected length of a single frame of pixel data in either whole
bytes or pixels, excluding the NULL trailing padding byte for odd
length data. For "pixels", an integer will always be returned. For
"bytes", a float will be returned for images with BitsAllocated of
1 whose frames do not consist of a whole number of bytes.
"""
length: int | float = self.rows * self.columns * self.samples_per_pixel
if unit == "pixels":
return length
# Correct for the number of bytes per pixel
if self.bits_allocated == 1:
if self.transfer_syntax.is_encapsulated:
# 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:
# For native, "bit-packed" pixel data, frames are not padded so
# this may not be a whole number of bytes e.g. 10x10 = 100
# pixels images are packed into 12.5 bytes
length = length / 8
if length.is_integer():
length = int(length)
else:
length *= self.bits_allocated // 8
# DICOM Standard, Part 4, Annex C.7.6.3.1.2 - native only
if (
self.photometric_interpretation == PhotometricInterpretation.YBR_FULL_422
and not self.transfer_syntax.is_encapsulated
):
length = length // 3 * 2
return length
def get_option(self, name: str, default: Any = None) -> Any:
"""Return the value of the option `name`."""
return self._opts.get(name, default)
@property
def is_array(self) -> bool:
"""Return ``True`` if the pixel data source is an :class:`~numpy.ndarray`"""
return self._src_type == "Array"
@property
def is_binary(self) -> bool:
"""Return ``True`` if the pixel data source is BinaryIO"""
return self._src_type == "BinaryIO"
@property
def is_buffer(self) -> bool:
"""Return ``True`` if the pixel data source is a buffer-like"""
return self._src_type == "Buffer"
@property
def is_dataset(self) -> bool:
"""Return ``True`` if the pixel data source is a :class:`~pydicom.dataset.Dataset`"""
return self._src_type == "Dataset"
@property
def number_of_frames(self) -> int:
"""Return the expected number of frames in the data."""
if (value := self._opts.get("number_of_frames", None)) is not None:
return value
raise AttributeError("No value for 'number_of_frames' has been set")
@property
def options(self) -> "DecodeOptions | EncodeOptions":
"""Return a reference to the runner's options dict."""
return self._opts
@property
def photometric_interpretation(self) -> str:
"""Return the expected photometric interpretation of the data."""
if (value := self._opts.get("photometric_interpretation", None)) is not None:
return value
raise AttributeError("No value for 'photometric_interpretation' has been set")
@property
def pixel_keyword(self) -> str:
"""Return the expected pixel keyword of the data.
Returns
-------
str
One of ``"PixelData"``, ``"FloatPixelData"``, ``"DoubleFloatPixelData"``
"""
if (value := self._opts.get("pixel_keyword", None)) is not None:
return value
raise AttributeError("No value for 'pixel_keyword' has been set")
@property
def pixel_representation(self) -> int:
"""Return the expected pixel representation of the data."""
if (value := self._opts.get("pixel_representation", None)) is not None:
return value
raise AttributeError("No value for 'pixel_representation' has been set")
@property
def planar_configuration(self) -> int:
"""Return the expected planar configuration of the data."""
# Only required when number of samples is more than 1
# Uncompressed may be either 0 or 1
if (value := self._opts.get("planar_configuration", None)) is not None:
return value
# Planar configuration is not relevant for compressed syntaxes
if self.transfer_syntax.is_compressed:
return 0
raise AttributeError("No value for 'planar_configuration' has been set")
@property
def rows(self) -> int:
"""Return the expected number of rows in the data."""
if (value := self._opts.get("rows", None)) is not None:
return value
raise AttributeError("No value for 'rows' has been set")
@property
def samples_per_pixel(self) -> int:
"""Return the expected number of samples per pixel in the data."""
if (value := self._opts.get("samples_per_pixel", None)) is not None:
return value
raise AttributeError("No value for 'samples_per_pixel' has been set")
def set_option(self, name: str, value: Any) -> None:
"""Set a runner option.
Parameters
----------
name : str
The name of the option to be set.
value : Any
The value of the option.
"""
if name == "number_of_frames":
value = int(value) if isinstance(value, str) else value
if value in (None, 0):
warn_and_log(
f"A value of '{value}' for (0028,0008) 'Number of Frames' is "
"invalid, assuming 1 frame"
)
value = 1
elif name == "photometric_interpretation":
if value == "PALETTE COLOR":
value = PhotometricInterpretation.PALETTE_COLOR
try:
value = PhotometricInterpretation[value]
except KeyError:
pass
self._opts[name] = value # type: ignore[literal-required]
def set_options(self, **kwargs: "DecodeOptions | EncodeOptions") -> None:
"""Set multiple runner options.
Parameters
----------
kwargs : dict[str, Any]
A dictionary containing the options as ``{name: value}``, where
`name` is the name of the option and `value` is it's value.
"""
for name, value in kwargs.items():
self.set_option(name, value)
def _set_options_ds(self, ds: "Dataset") -> None:
"""Set options using a dataset.
Parameters
----------
ds : pydicom.dataset.Dataset
The dataset to use.
"""
self.set_options(**as_pixel_options(ds))
@property
def transfer_syntax(self) -> UID:
"""Return the expected transfer syntax corresponding to the data."""
return self._opts["transfer_syntax_uid"]
def validate(self) -> None:
"""Validate the runner options and source data (if any)."""
raise NotImplementedError(
f"{type(self).__name__}.validate() has not been implemented"
)
def _validate_options(self) -> None:
"""Validate the supplied options to ensure they meet requirements."""
prefix = "Missing required element: (0028"
if self._opts.get("bits_allocated") is None:
raise AttributeError(f"{prefix},0100) 'Bits Allocated'")
if not 1 <= self.bits_allocated <= 64 or (
self.bits_allocated != 1 and self.bits_allocated % 8
):
raise ValueError(
f"A (0028,0100) 'Bits Allocated' value of '{self.bits_allocated}' "
"is invalid, it must be 1 or a multiple of 8 and in the range (1, 64)"
)
if "Float" not in self.pixel_keyword:
if self._opts.get("bits_stored") is None:
raise AttributeError(f"{prefix},0101) 'Bits Stored'")
if not 1 <= self.bits_stored <= self.bits_allocated <= 64:
raise ValueError(
f"A (0028,0101) 'Bits Stored' value of '{self.bits_stored}' is "
"invalid, it must be in the range (1, 64) and no greater than "
"the (0028,0100) 'Bits Allocated' value of "
f"'{self.bits_allocated}'"
)
if self._opts.get("columns") is None:
raise AttributeError(f"{prefix},0011) 'Columns'")
if not 0 < self.columns <= 2**16 - 1:
raise ValueError(
f"A (0028,0011) 'Columns' value of '{self.columns}' is invalid, "
"it must be in the range (1, 65535)"
)
# Number of Frames is conditionally required
if self._opts.get("number_of_frames") is not None and self.number_of_frames < 1:
raise ValueError(
f"A (0028,0008) 'Number of Frames' value of '{self.number_of_frames}' "
"is invalid, it must be greater than or equal to 1"
)
if self._opts.get("photometric_interpretation") is None:
raise AttributeError(f"{prefix},0004) 'Photometric Interpretation'")
try:
PhotometricInterpretation[self.photometric_interpretation]
except KeyError:
if self.photometric_interpretation != "PALETTE COLOR":
raise ValueError(
"Unknown (0028,0004) 'Photometric Interpretation' value "
f"'{self.photometric_interpretation}'"
)
kw = ("PixelData", "FloatPixelData", "DoubleFloatPixelData")
if self.pixel_keyword not in kw:
raise ValueError(f"Unknown 'pixel_keyword' value '{self.pixel_keyword}'")
if self.pixel_keyword == "PixelData":
if self._opts.get("pixel_representation") is None:
raise AttributeError(f"{prefix},0103) 'Pixel Representation'")
if self.pixel_representation not in (0, 1):
raise ValueError(
"A (0028,0103) 'Pixel Representation' value of "
f"'{self.pixel_representation}' is invalid, it must be 0 or 1"
)
if self._opts.get("rows") is None:
raise AttributeError(f"{prefix},0010) 'Rows'")
if not 0 < self.rows <= 2**16 - 1:
raise ValueError(
f"A (0028,0010) 'Rows' value of '{self.rows}' is invalid, it "
"must be in the range (1, 65535)"
)
if self._opts.get("samples_per_pixel") is None:
raise AttributeError(f"{prefix},0002) 'Samples per Pixel'")
if self.samples_per_pixel not in (1, 3):
raise ValueError(
f"A (0028,0002) 'Samples per Pixel' value of '{self.samples_per_pixel}' "
"is invalid, it must be 1 or 3"
)
if self.samples_per_pixel == 3:
if self._opts.get("planar_configuration") is None:
raise AttributeError(f"{prefix},0006) 'Planar Configuration'")
if self.planar_configuration not in (0, 1):
raise ValueError(
"A (0028,0006) 'Planar Configuration' value of "
f"'{self.planar_configuration}' is invalid, it must be 0 or 1"
)
class RunnerOptions(TypedDict, total=False):
"""Options accepted by RunnerBase"""
## Pixel data description options
# Required
bits_allocated: int
bits_stored: int
columns: int
number_of_frames: int
photometric_interpretation: str
pixel_keyword: str
pixel_representation: int
rows: int
samples_per_pixel: int
transfer_syntax_uid: UID
# Conditionally required if samples_per_pixel > 1
planar_configuration: int
# Optional
# The Extended Offset Table values
extended_offsets: tuple[bytes, bytes] | tuple[list[int], list[int]]

View File

@@ -0,0 +1,20 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
from pydicom.pixels.decoders.base import (
ImplicitVRLittleEndianDecoder,
ExplicitVRLittleEndianDecoder,
ExplicitVRBigEndianDecoder,
DeflatedExplicitVRLittleEndianDecoder,
JPEGBaseline8BitDecoder,
JPEGExtended12BitDecoder,
JPEGLosslessDecoder,
JPEGLosslessSV1Decoder,
JPEGLSLosslessDecoder,
JPEGLSNearLosslessDecoder,
JPEG2000LosslessDecoder,
JPEG2000Decoder,
HTJ2KLosslessDecoder,
HTJ2KLosslessRPCLDecoder,
HTJ2KDecoder,
RLELosslessDecoder,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,144 @@
# Copyright 2024 pydicom authors. See LICENSE file for details.
"""Use GDCM <https://github.com/malaterre/GDCM> to decompress encoded
*Pixel Data*.
This module is not intended to be used directly.
"""
from typing import cast
from pydicom import uid
from pydicom.pixels.decoders.base import DecodeRunner
from pydicom.pixels.common import PhotometricInterpretation as PI
try:
import gdcm
GDCM_VERSION = tuple(int(x) for x in gdcm.Version.GetVersion().split("."))
HAVE_GDCM = True
except ImportError:
HAVE_GDCM = False
DECODER_DEPENDENCIES = {
uid.JPEGBaseline8Bit: ("gdcm>=3.0.10",),
uid.JPEGExtended12Bit: ("gdcm>=3.0.10",),
uid.JPEGLossless: ("gdcm>=3.0.10",),
uid.JPEGLosslessSV1: ("gdcm>=3.0.10",),
uid.JPEGLSLossless: ("gdcm>=3.0.10",),
uid.JPEGLSNearLossless: ("gdcm>=3.0.10",),
uid.JPEG2000Lossless: ("gdcm>=3.0.10",),
uid.JPEG2000: ("gdcm>=3.0.10",),
}
def is_available(uid: str) -> bool:
"""Return ``True`` if a pixel data decoder for `uid` is available for use,
``False`` otherwise.
"""
if not HAVE_GDCM or GDCM_VERSION < (3, 0):
return False
return uid in DECODER_DEPENDENCIES
def _decode_frame(src: bytes, runner: DecodeRunner) -> bytes:
"""Return the decoded `src` as :class:`bytes`.
Parameters
----------
src : bytes
An encoded pixel data frame.
runner : pydicom.pixels.decoders.base.DecodeRunner
The runner managing the decoding.
Returns
-------
bytes
The decoded pixel data frame.
"""
tsyntax = runner.transfer_syntax
photometric_interpretation = runner.photometric_interpretation
bits_stored = runner.bits_stored
if tsyntax == uid.JPEGExtended12Bit and bits_stored != 8:
raise NotImplementedError(
"GDCM does not support 'JPEG Extended' for samples with 12-bit precision"
)
if (
tsyntax == uid.JPEGLSNearLossless
and runner.pixel_representation == 1
and bits_stored < 8
):
raise ValueError(
"Unable to decode signed lossy JPEG-LS pixel data with a sample "
"precision less than 8 bits"
)
if tsyntax in uid.JPEGLSTransferSyntaxes and bits_stored in (6, 7):
raise ValueError(
"Unable to decode unsigned JPEG-LS pixel data with a sample "
"precision of 6 or 7 bits"
)
fragment = gdcm.Fragment()
fragment.SetByteStringValue(src)
fragments = gdcm.SequenceOfFragments.New()
fragments.AddFragment(fragment)
elem = gdcm.DataElement(gdcm.Tag(0x7FE0, 0x0010))
elem.SetValue(fragments.__ref__())
img = gdcm.Image()
img.SetNumberOfDimensions(2)
img.SetDimensions((runner.columns, runner.rows, 1))
img.SetDataElement(elem)
pi_type = gdcm.PhotometricInterpretation.GetPIType(photometric_interpretation)
img.SetPhotometricInterpretation(gdcm.PhotometricInterpretation(pi_type))
if runner.samples_per_pixel > 1:
img.SetPlanarConfiguration(runner.planar_configuration)
ts_type = gdcm.TransferSyntax.GetTSType(str.__str__(tsyntax))
img.SetTransferSyntax(gdcm.TransferSyntax(ts_type))
if tsyntax in uid.JPEGLSTransferSyntaxes:
# GDCM always returns JPEG-LS data as color-by-pixel
runner.set_option("planar_configuration", 0)
bits_stored = runner.get_option("jls_precision", bits_stored)
if 0 < bits_stored <= 8:
runner.set_option("bits_allocated", 8)
elif 8 < bits_stored <= 16:
runner.set_option("bits_allocated", 16)
if tsyntax in uid.JPEG2000TransferSyntaxes:
# GDCM pixel container size is based on precision
bits_stored = runner.get_option("j2k_precision", bits_stored)
if 0 < bits_stored <= 8:
runner.set_option("bits_allocated", 8)
elif 8 < bits_stored <= 16:
runner.set_option("bits_allocated", 16)
elif 16 < bits_stored <= 32:
runner.set_option("bits_allocated", 32)
pixel_format = gdcm.PixelFormat(
runner.samples_per_pixel,
runner.bits_allocated,
bits_stored,
bits_stored - 1,
runner.pixel_representation,
)
img.SetPixelFormat(pixel_format)
# GDCM returns char* as str, so re-encode it to bytes
frame = img.GetBuffer().encode("utf-8", "surrogateescape")
# GDCM returns YBR_ICT and YBR_RCT as RGB
if tsyntax in uid.JPEG2000TransferSyntaxes and photometric_interpretation in (
PI.YBR_ICT,
PI.YBR_RCT,
):
runner.set_option("photometric_interpretation", PI.RGB)
return cast(bytes, frame)

View File

@@ -0,0 +1,127 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
"""Use Pillow <https://github.com/python-pillow/Pillow> to decompress encoded
*Pixel Data*.
This module is not intended to be used directly.
"""
from io import BytesIO
from typing import cast
from pydicom import uid
from pydicom.pixels.utils import _passes_version_check
from pydicom.pixels.common import PhotometricInterpretation as PI
from pydicom.pixels.decoders.base import DecodeRunner
try:
from PIL import Image, features
except ImportError:
pass
try:
import numpy as np
HAVE_NP = True
except ImportError:
HAVE_NP = False
DECODER_DEPENDENCIES = {
uid.JPEGBaseline8Bit: ("pillow>=10.0",),
uid.JPEGExtended12Bit: ("pillow>=10.0",),
uid.JPEG2000Lossless: ("numpy", "pillow>=10.0"),
uid.JPEG2000: ("numpy", "pillow>=10.0"),
}
_LIBJPEG_SYNTAXES = [uid.JPEGBaseline8Bit, uid.JPEGExtended12Bit]
_OPENJPEG_SYNTAXES = [uid.JPEG2000Lossless, uid.JPEG2000]
def is_available(uid: str) -> bool:
"""Return ``True`` if a pixel data decoder for `uid` is available for use,
``False`` otherwise.
"""
if not _passes_version_check("PIL", (10, 0)):
return False
if uid in _LIBJPEG_SYNTAXES:
return bool(features.check_codec("jpg")) # type: ignore[no-untyped-call]
if uid in _OPENJPEG_SYNTAXES:
return bool(features.check_codec("jpg_2000")) and HAVE_NP # type: ignore[no-untyped-call]
return False
def _decode_frame(src: bytes, runner: DecodeRunner) -> bytes:
"""Return the decoded image data in `src` as a :class:`bytes`."""
tsyntax = runner.transfer_syntax
# libjpeg only supports 8-bit JPEG Extended (can be 8 or 12 in the JPEG standard)
if tsyntax == uid.JPEGExtended12Bit and runner.bits_stored != 8:
raise NotImplementedError(
"Pillow does not support 'JPEG Extended' for samples with 12-bit precision"
)
image = Image.open(BytesIO(src), formats=("JPEG", "JPEG2000"))
if tsyntax in _LIBJPEG_SYNTAXES:
if runner.samples_per_pixel != 1:
# If the Adobe APP14 marker is not present then Pillow assumes
# that JPEG images were transformed into YCbCr color space prior
# to compression, so setting the image mode to YCbCr signals we
# don't want any color transformations.
# Any color transformations would be inconsistent with the
# behavior required by the `raw` flag
if "adobe_transform" not in image.info:
image.draft("YCbCr", image.size) # type: ignore[no-untyped-call]
return cast(bytes, image.tobytes())
# JPEG 2000
# The precision from the J2K codestream is more appropriate because the
# decoder will use it to create the output integers
precision = runner.get_option("j2k_precision", runner.bits_stored)
# pillow's pixel container size is based on precision
if 0 < precision <= 8:
runner.set_option("bits_allocated", 8)
elif 8 < precision <= 16:
# Pillow converts >= 9-bit RGB/YCbCr data to 8-bit
if runner.samples_per_pixel > 1:
raise ValueError(
f"Pillow cannot decode {precision}-bit multi-sample data correctly"
)
runner.set_option("bits_allocated", 16)
else:
raise ValueError(
"only (0028,0101) 'Bits Stored' values of up to 16 are supported"
)
# Pillow converts N-bit signed/unsigned data to 8- or 16-bit unsigned data
# See Pillow src/libImaging/Jpeg2KDecode.c::j2ku_gray_i
buffer = bytearray(image.tobytes()) # so the array is writeable
del image
dtype = runner.pixel_dtype
arr = np.frombuffer(buffer, dtype=f"u{dtype.itemsize}")
is_signed = runner.pixel_representation
if runner.get_option("apply_j2k_sign_correction", False):
is_signed = runner.get_option("j2k_is_signed", is_signed)
if is_signed and runner.pixel_representation == 1:
# Re-view the unsigned integers as signed
# e.g. [0, 127, 128, 255] -> [0, 127, -128, -1]
arr = arr.view(dtype)
# Level-shift to match the unsigned integers range
# e.g. [0, 127, -128, -1] -> [-128, -1, 0, 127]
arr -= np.int32(2 ** (runner.bits_allocated - 1))
if bit_shift := (runner.bits_allocated - precision):
# Bit shift to undo the upscaling of N-bit to 8- or 16-bit
np.right_shift(arr, bit_shift, out=arr)
# pillow returns YBR_ICT and YBR_RCT as RGB
if runner.photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT):
runner.set_option("photometric_interpretation", PI.RGB)
return cast(bytes, arr.tobytes())

View File

@@ -0,0 +1,47 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
"""Use pyjpegls <https://github.com/pydicom/pyjpegls> to decompress encoded
*Pixel Data*.
This module is not intended to be used directly.
"""
from typing import cast
from pydicom import uid
from pydicom.pixels.utils import _passes_version_check
from pydicom.pixels.decoders.base import DecodeRunner
try:
import jpeg_ls
except ImportError:
pass
DECODER_DEPENDENCIES = {
uid.JPEGLSLossless: ("numpy", "pyjpegls>=1.2"),
uid.JPEGLSNearLossless: ("numpy", "pyjpegls>=1.2"),
}
def is_available(uid: str) -> bool:
"""Return ``True`` if the decoder has its dependencies met, ``False`` otherwise"""
return _passes_version_check("jpeg_ls", (1, 2))
def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray:
"""Return the decoded image data in `src` as a :class:`bytearray`."""
buffer, info = jpeg_ls.decode_pixel_data(src)
# Interleave mode 0 is colour-by-plane, 1 and 2 are colour-by-pixel
if info["components"] > 1:
if info["interleave_mode"] == 0:
runner.set_option("planar_configuration", 1)
else:
runner.set_option("planar_configuration", 0)
precision = info["bits_per_sample"]
if 0 < precision <= 8:
runner.set_option("bits_allocated", 8)
elif 8 < precision <= 16:
runner.set_option("bits_allocated", 16)
return cast(bytearray, buffer)

View File

@@ -0,0 +1,115 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
"""Use pylibjpeg <https://github.com/pydicom/pylibjpeg> to decompress encoded
*Pixel Data*.
This module is not intended to be used directly.
"""
from typing import cast
from pydicom import uid
from pydicom.pixels.decoders.base import DecodeRunner
from pydicom.pixels.utils import _passes_version_check
from pydicom.pixels.common import PhotometricInterpretation as PI
try:
from pylibjpeg.utils import get_pixel_data_decoders, Decoder
# {UID: {plugin name: function}}
_DECODERS = cast(
dict[uid.UID, dict[str, "Decoder"]], get_pixel_data_decoders(version=2)
)
except ImportError:
_DECODERS = {}
DECODER_DEPENDENCIES = {
uid.JPEGBaseline8Bit: ("pylibjpeg>=2.0", "pylibjpeg-libjpeg>=2.1"),
uid.JPEGExtended12Bit: ("pylibjpeg>=2.0", "pylibjpeg-libjpeg>=2.1"),
uid.JPEGLossless: ("pylibjpeg>=2.0", "pylibjpeg-libjpeg>=2.1"),
uid.JPEGLosslessSV1: ("pylibjpeg>=2.0", "pylibjpeg-libjpeg>=2.1"),
uid.JPEGLSLossless: ("pylibjpeg>=2.0", "pylibjpeg-libjpeg>=2.1"),
uid.JPEGLSNearLossless: ("pylibjpeg>=2.0", "pylibjpeg-libjpeg>=2.1"),
uid.JPEG2000Lossless: ("pylibjpeg>=2.0", "pylibjpeg-openjpeg>=2.0"),
uid.JPEG2000: ("pylibjpeg>=2.0", "pylibjpeg-openjpeg>=2.0"),
uid.HTJ2KLossless: ("pylibjpeg>=2.0", "pylibjpeg-openjpeg>=2.0"),
uid.HTJ2KLosslessRPCL: ("pylibjpeg>=2.0", "pylibjpeg-openjpeg>=2.0"),
uid.HTJ2K: ("pylibjpeg>=2.0", "pylibjpeg-openjpeg>=2.0"),
uid.RLELossless: ("pylibjpeg>=2.0", "pylibjpeg-rle>=2.0"),
}
_LIBJPEG_SYNTAXES = [
uid.JPEGBaseline8Bit,
uid.JPEGExtended12Bit,
uid.JPEGLossless,
uid.JPEGLosslessSV1,
uid.JPEGLSLossless,
uid.JPEGLSNearLossless,
]
_OPENJPEG_SYNTAXES = [
uid.JPEG2000Lossless,
uid.JPEG2000,
uid.HTJ2KLossless,
uid.HTJ2KLosslessRPCL,
uid.HTJ2K,
]
_RLE_SYNTAXES = [uid.RLELossless]
def is_available(uid: str) -> bool:
"""Return ``True`` if the decoder has its dependencies met, ``False`` otherwise"""
if not _passes_version_check("pylibjpeg", (2, 0)):
return False
if uid in _LIBJPEG_SYNTAXES:
return _passes_version_check("libjpeg", (2, 0, 2))
if uid in _OPENJPEG_SYNTAXES:
return _passes_version_check("openjpeg", (2, 0))
if uid in _RLE_SYNTAXES:
return _passes_version_check("rle", (2, 0))
return False
def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray: # type: ignore[return]
"""Return the decoded image data in `src` as a :class:`bytearray`."""
tsyntax = runner.transfer_syntax
# Currently only one pylibjpeg plugin is available per UID
# so decode using the first available decoder
for _, func in sorted(_DECODERS[tsyntax].items()):
# `version=2` to return frame as bytearray
frame = cast(bytearray, func(src, version=2, **runner.options))
# pylibjpeg-rle returns decoded data as planar configuration 1
if tsyntax == uid.RLELossless:
runner.set_option("planar_configuration", 1)
if tsyntax in _OPENJPEG_SYNTAXES:
# pylibjpeg-openjpeg returns YBR_ICT and YBR_RCT as RGB
if runner.photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT):
runner.set_option("photometric_interpretation", PI.RGB)
# pylibjpeg-openjpeg pixel container size is based on J2K precision
precision = runner.get_option("j2k_precision", runner.bits_stored)
if 0 < precision <= 8:
runner.set_option("bits_allocated", 8)
elif 8 < precision <= 16:
runner.set_option("bits_allocated", 16)
elif 16 < precision <= 32:
runner.set_option("bits_allocated", 32)
if tsyntax in uid.JPEGLSTransferSyntaxes:
# pylibjpeg-libjpeg always returns JPEG-LS data as color-by-pixel
runner.set_option("planar_configuration", 0)
# pylibjpeg-libjpeg pixel container size is based on JPEG-LS precision
precision = runner.get_option("jls_precision", runner.bits_stored)
if 0 < precision <= 8:
runner.set_option("bits_allocated", 8)
elif 8 < precision <= 16:
runner.set_option("bits_allocated", 16)
return frame

View File

@@ -0,0 +1,278 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
"""Use Python to decode RLE Lossless encoded *Pixel Data*.
This module is not intended to be used directly.
"""
from struct import unpack
from pydicom.misc import warn_and_log
from pydicom.pixels.decoders.base import DecodeRunner
from pydicom.uid import RLELossless
DECODER_DEPENDENCIES = {RLELossless: ()}
def is_available(uid: str) -> bool:
"""Return ``True`` if a pixel data decoder for `uid` is available for use,
``False`` otherwise.
"""
return uid in DECODER_DEPENDENCIES
def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray:
"""Wrapper for use with the decoder interface.
Parameters
----------
src : bytes
A single frame of RLE encoded data.
runner : pydicom.pixels.decoders.base.DecodeRunner
Required parameters:
* `rows`: int
* `columns`: int
* `samples_per_pixel`: int
* `bits_allocated`: int
Optional parameters:
* `rle_segment_order`: str, "<" for little endian segment order, or
">" for big endian (default)
Returns
-------
bytearray
The decoded frame, ordered as planar configuration 1.
"""
frame = _rle_decode_frame(
src,
runner.rows,
runner.columns,
runner.samples_per_pixel,
runner.bits_allocated,
runner.get_option("rle_segment_order", ">"),
)
# Update the runner options to ensure the reshaping is correct
# Only do this if we successfully decoded the frame
runner.set_option("planar_configuration", 1)
return frame
def _rle_decode_frame(
src: bytes,
rows: int,
columns: int,
nr_samples: int,
nr_bits: int,
segment_order: str = ">",
) -> bytearray:
"""Decodes a single frame of RLE encoded data.
Each frame may contain up to 15 segments of encoded data.
Parameters
----------
src : bytes
The RLE frame data
rows : int
The number of output rows
columns : int
The number of output columns
nr_samples : int
Number of samples per pixel (e.g. 3 for RGB data).
nr_bits : int
Number of bits per sample - must be a multiple of 8
segment_order : str
The segment order of the `data`, '>' for big endian (default),
'<' for little endian (non-conformant).
Returns
-------
bytearray
The frame's decoded data in little endian and planar configuration 1
byte ordering (i.e. for RGB data this is all red pixels then all
green then all blue, with the bytes for each pixel ordered from
MSB to LSB when reading left to right).
"""
if nr_bits % 8:
raise NotImplementedError(
f"Unable to decode RLE encoded pixel data with {nr_bits} bits allocated"
)
# Parse the RLE Header
offsets = _rle_parse_header(src[:64])
nr_segments = len(offsets)
# Check that the actual number of segments is as expected
bytes_per_sample = nr_bits // 8
if nr_segments != nr_samples * bytes_per_sample:
raise ValueError(
"The number of RLE segments in the pixel data doesn't match the "
f"expected amount ({nr_segments} vs. {nr_samples * bytes_per_sample} "
"segments)"
)
# Ensure the last segment gets decoded
offsets.append(len(src))
# Preallocate with null bytes
decoded = bytearray(rows * columns * nr_samples * bytes_per_sample)
# Example:
# RLE encoded data is ordered like this (for 16-bit, 3 sample):
# Segment: 0 | 1 | 2 | 3 | 4 | 5
# R MSB | R LSB | G MSB | G LSB | B MSB | B LSB
# A segment contains only the MSB or LSB parts of all the sample pixels
# To minimise the amount of array manipulation later, and to make things
# faster we interleave each segment in a manner consistent with a planar
# configuration of 1 (and use little endian byte ordering):
# All red samples | All green samples | All blue
# Pxl 1 Pxl 2 ... Pxl N | Pxl 1 Pxl 2 ... Pxl N | ...
# LSB MSB LSB MSB ... LSB MSB | LSB MSB LSB MSB ... LSB MSB | ...
# `stride` is the total number of bytes of each sample plane
stride = bytes_per_sample * rows * columns
for sample_number in range(nr_samples):
le_gen = range(bytes_per_sample)
byte_offsets = le_gen if segment_order == "<" else reversed(le_gen)
for byte_offset in byte_offsets:
# Decode the segment
ii = sample_number * bytes_per_sample + byte_offset
# ii is 1, 0, 3, 2, 5, 4 for the example above
# This is where the segment order correction occurs
segment = _rle_decode_segment(src[offsets[ii] : offsets[ii + 1]])
# Check that the number of decoded bytes is correct
actual_length = len(segment)
if actual_length < rows * columns:
raise ValueError(
"The amount of decoded RLE segment data doesn't match the "
f"expected amount ({actual_length} vs. {rows * columns} bytes)"
)
elif actual_length != rows * columns:
warn_and_log(
"The decoded RLE segment contains non-conformant padding "
f"- {actual_length} vs. {rows * columns} bytes expected"
)
if segment_order == ">":
byte_offset = bytes_per_sample - byte_offset - 1
# For 100 pixel/plane, 32-bit, 3 sample data, `start` will be
# 0, 1, 2, 3, 400, 401, 402, 403, 800, 801, 802, 803
start = byte_offset + (sample_number * stride)
decoded[start : start + stride : bytes_per_sample] = segment[
: rows * columns
]
return decoded
def _rle_decode_segment(src: bytes) -> bytearray:
"""Return a single segment of decoded RLE data as bytearray.
Parameters
----------
buffer : bytes
The segment data to be decoded.
Returns
-------
bytearray
The decoded segment.
"""
result = bytearray()
pos = 0
result_extend = result.extend
try:
while True:
# header_byte is N + 1
header_byte = src[pos] + 1
pos += 1
if header_byte > 129:
# Extend by copying the next byte (-N + 1) times
# however since using uint8 instead of int8 this will be
# (256 - N + 1) times
result_extend(src[pos : pos + 1] * (258 - header_byte))
pos += 1
elif header_byte < 129:
# Extend by literally copying the next (N + 1) bytes
result_extend(src[pos : pos + header_byte])
pos += header_byte
except IndexError:
pass
return result
def _rle_parse_header(header: bytes) -> list[int]:
"""Return a list of byte offsets for the segments in RLE data.
**RLE Header Format**
The RLE Header contains the number of segments for the image and the
starting offset of each segment. Each of these numbers is represented as
an unsigned long stored in little-endian. The RLE Header is 16 long words
in length (i.e. 64 bytes) which allows it to describe a compressed image
with up to 15 segments. All unused segment offsets shall be set to zero.
As an example, the table below describes an RLE Header with 3 segments as
would typically be used with 8-bit RGB or YCbCr data (with 1 segment per
channel).
+--------------+---------------------------------+------------+
| Byte offset | Description | Value |
+==============+=================================+============+
| 0 | Number of segments | 3 |
+--------------+---------------------------------+------------+
| 4 | Offset of segment 1, N bytes | 64 |
+--------------+---------------------------------+------------+
| 8 | Offset of segment 2, M bytes | 64 + N |
+--------------+---------------------------------+------------+
| 12 | Offset of segment 3 | 64 + N + M |
+--------------+---------------------------------+------------+
| 16 | Offset of segment 4 (not used) | 0 |
+--------------+---------------------------------+------------+
| ... | ... | 0 |
+--------------+---------------------------------+------------+
| 60 | Offset of segment 15 (not used) | 0 |
+--------------+---------------------------------+------------+
Parameters
----------
header : bytes
The RLE header data (i.e. the first 64 bytes of an RLE frame).
Returns
-------
list of int
The byte offsets for each segment in the RLE data.
Raises
------
ValueError
If there are more than 15 segments or if the header is not 64 bytes
long.
References
----------
DICOM Standard, Part 5, :dcm:`Annex G<part05/chapter_G.html>`
"""
if len(header) != 64:
raise ValueError("The RLE header can only be 64 bytes long")
nr_segments = unpack("<L", header[:4])[0]
if nr_segments > 15:
raise ValueError(
f"The RLE header specifies an invalid number of segments ({nr_segments})"
)
return list(unpack(f"<{nr_segments}L", header[4 : 4 * (nr_segments + 1)]))

View File

@@ -0,0 +1,9 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
from pydicom.pixels.encoders.base import (
RLELosslessEncoder,
JPEGLSLosslessEncoder,
JPEGLSNearLosslessEncoder,
JPEG2000LosslessEncoder,
JPEG2000Encoder,
)

View File

@@ -0,0 +1,874 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
"""Pixel data encoding."""
from collections.abc import Callable, Iterator, Iterable
import logging
import math
import sys
from typing import Any, cast, TYPE_CHECKING
try:
import numpy as np
except ImportError:
pass
from pydicom import config
from pydicom.pixels.common import Buffer, RunnerBase, CoderBase, RunnerOptions
from pydicom.uid import (
UID,
JPEGBaseline8Bit,
JPEGExtended12Bit,
JPEGLossless,
JPEGLosslessSV1,
JPEGLSLossless,
JPEGLSNearLossless,
JPEG2000Lossless,
JPEG2000,
RLELossless,
JPEGLSTransferSyntaxes,
)
if TYPE_CHECKING: # pragma: no cover
from pydicom.dataset import Dataset
LOGGER = logging.getLogger(__name__)
EncodeFunction = Callable[[bytes, "EncodeRunner"], bytes | bytearray]
class EncodeOptions(RunnerOptions, total=False):
"""Options accepted by EncodeRunner and encoding plugins"""
# The byte order used by the raw pixel data sent to the encoder
# ">" for big endian, "<" for little endian (default)
byteorder: str
## Transfer Syntax specific options
# JPEG-LS Near Lossless
# The maximum allowable error in *unsigned* pixel intensity
jls_error: int
# JPEG 2000
# Either j2k_cr or j2k_psnr is required
# The compression ratio for each quality layer, should be in decreasing order
j2k_cr: list[float]
# The peak signal-to-noise ratio for each layer, should be in increasing order
j2k_psnr: list[float]
class EncodeRunner(RunnerBase):
"""Class for managing the pixel data encoding process.
.. versionadded:: 3.0
This class is not intended to be used directly. For encoding pixel data
use the :class:`~pydicom.pixels.encoders.base.Encoder` instance
corresponding to the transfer syntax of the pixel data.
"""
def __init__(self, tsyntax: UID) -> None:
"""Create a new runner for encoding data as `tsyntax`.
Parameters
----------
tsyntax : pydicom.uid.UID
The transfer syntax UID corresponding to the pixel data to be
encoded.
"""
self._src: Buffer | np.ndarray
self._src_type: str
self._opts: EncodeOptions = {
"transfer_syntax_uid": tsyntax,
"byteorder": "<",
"pixel_keyword": "PixelData",
}
self._undeletable = ("transfer_syntax_uid", "pixel_keyword", "byteorder")
self._encoders: dict[str, EncodeFunction] = {}
def encode(self, index: int | None) -> bytes:
"""Return an encoded frame of pixel data as :class:`bytes`.
Parameters
----------
index : int | None
If `index` is ``None`` then the pixel data only contains one frame,
otherwise `index` is the frame number to be encoded.
Returns
------
bytes
The encoded pixel data frame.
"""
failure_messages = []
for name, func in self._encoders.items():
try:
return func(self.get_frame(index), self)
except Exception as exc:
LOGGER.exception(exc)
failure_messages.append(f"{name}: {exc}")
messages = "\n ".join(failure_messages)
raise RuntimeError(
"Unable to encode as exceptions were raised by all available "
f"plugins:\n {messages}"
)
def get_frame(self, index: int | None) -> bytes:
"""Return a frame's worth of uncompressed pixel data as :class:`bytes`.
Parameters
----------
index : int | None
If the pixel data only has one from then use ``None``, otherwise
`index` is the index of the frame to be returned.
"""
if self.is_array:
return self._get_frame_array(index)
frame = self._get_frame_buffer(index)
return bytes(frame) if not isinstance(frame, bytes) else frame
def _get_frame_array(self, index: int | None) -> bytes:
"""Return a frame's worth of uncompressed pixel data from an ndarray."""
# Grab the frame so that subsequent array manipulations minimize
# the memory usage
arr = cast(np.ndarray, self.src[index] if index is not None else self.src)
# The ndarray containing the pixel data may use a larger container
# than is strictly needed: e.g. 32 bits allocated with 7 bits stored.
# However the encoders expect data to be sized appropriately
# for the sample precision, so we may need to downscale
if self.bits_stored <= 8:
itemsize = 1
elif 8 < self.bits_stored <= 16:
itemsize = 2
elif 16 < self.bits_stored <= 32:
itemsize = 4
elif 32 < self.bits_stored <= 64:
itemsize = 8
if arr.dtype.itemsize != itemsize:
arr = arr.astype(f"{arr.dtype.kind}{itemsize}")
# JPEG-LS allows different ordering of the input image data via the
# interleave mode (ILV) parameter. ILV 0 matches a planar configuration
# of 1 and requires shape (samples, rows, columns). ILV 1 and 2 match
# planar configuration 0 so no further action is needed
if (
self.transfer_syntax in JPEGLSTransferSyntaxes
and self.samples_per_pixel == 3
and self.planar_configuration == 1
):
arr = arr.transpose(2, 0, 1)
return cast(bytes, arr.tobytes())
def _get_frame_buffer(self, index: int | None) -> bytes | bytearray:
"""Return a frame's worth of uncompressed pixel data from buffer-like."""
# The encoded pixel data may use a larger container than is strictly
# needed: e.g. 32 bits allocated with 7 bits stored
# However the encoders typically expect data to be sized appropriately
# for the sample precision, so we need to downscale to:
# 0 < precision <= 8: an 8-bit container (char)
# 8 < precision <= 16: a 16-bit container (short)
# 16 < precision <= 32: a 32-bit container (int/long)
# 32 < precision <= 64: a 64-bit container (long long)
bytes_per_frame = cast(int, self.frame_length(unit="bytes"))
start = 0 if index is None else index * bytes_per_frame
src = cast(bytes, self.src[start : start + bytes_per_frame])
# Resize the data to fit the appropriate container
expected_length = cast(int, self.frame_length(unit="pixels"))
bytes_per_pixel = len(src) // expected_length
# 1 byte/px actual
if self.bits_stored <= 8:
# If not 1 byte/px then must be 2, 3, 4, 5, 6, 7 or 8
# but only the first byte is relevant
return src if bytes_per_pixel == 1 else src[::bytes_per_pixel]
# 2 bytes/px actual
if 8 < self.bits_stored <= 16:
if bytes_per_pixel == 2:
return src
# If not 2 bytes/px then must be 3, 4, 5, 6, 7 or 8
# but only the first 2 bytes are relevant
out = bytearray(expected_length * 2)
out[::2] = src[::bytes_per_pixel]
out[1::2] = src[1::bytes_per_pixel]
return out
# 3 or 4 bytes/px actual
if 16 < self.bits_stored <= 32:
if bytes_per_pixel == 4:
return src
# If not 4 bytes/px then must be 3, 5, 6, 7 or 8
# but only the first 3 or 4 bytes are relevant
out = bytearray(expected_length * 4)
out[::4] = src[::bytes_per_pixel]
out[1::4] = src[1::bytes_per_pixel]
out[2::4] = src[2::bytes_per_pixel]
if bytes_per_pixel > 3:
out[3::4] = src[3::bytes_per_pixel]
return out
# 32 < bits_stored <= 64 (maximum allowed)
# 5, 6, 7 or 8 bytes/px actual
if bytes_per_pixel == 8:
return src
# If not 8 bytes/px then must be 5, 6 or 7
out = bytearray(expected_length * 8)
out[::8] = src[::bytes_per_pixel]
out[1::8] = src[1::bytes_per_pixel]
out[2::8] = src[2::bytes_per_pixel]
out[3::8] = src[3::bytes_per_pixel]
out[4::8] = src[4::bytes_per_pixel]
if bytes_per_pixel == 5:
return out
out[5::8] = src[5::bytes_per_pixel]
if bytes_per_pixel == 6:
return out
# 7 bytes/px
out[6::8] = src[6::bytes_per_pixel]
return out
def set_encoders(self, encoders: dict[str, EncodeFunction]) -> None:
"""Set the encoders use for encoding compressed pixel data.
Parameters
----------
encoders : dict[str, EncodeFunction]
A dict of {name: encoder function}.
"""
self._encoders = encoders
def set_source(self, src: "np.ndarray | Dataset | Buffer") -> None:
"""Set the pixel data to be encoded.
Parameters
----------
src : bytes | bytearray | memoryview | pydicom.dataset.Dataset | numpy.ndarray
* If a buffer-like then the encoded pixel data
* If a :class:`~pydicom.dataset.Dataset` then a dataset containing
the pixel data and associated group ``0x0028`` elements.
* If a :class:`numpy.ndarray` then an array containing the image data.
"""
from pydicom.dataset import Dataset
if isinstance(src, Dataset):
self._set_options_ds(src)
self._src = src.PixelData
self._src_type = "Dataset"
elif isinstance(src, (bytes | bytearray | memoryview)):
self._src = src
self._src_type = "Buffer"
elif isinstance(src, np.ndarray):
# Ensure the array is in the required byte order (little-endian)
sys_endianness = "<" if sys.byteorder == "little" else ">"
# `byteorder` may be
# '|': none available, such as for 8 bit -> ignore
# '=': native system endianness -> change to '<' or '>'
# '<' or '>': little or big
byteorder = src.dtype.byteorder
byteorder = sys_endianness if byteorder == "=" else byteorder
if byteorder == ">":
src = src.astype(src.dtype.newbyteorder("<"))
self._src = src
self._src_type = "Array"
else:
raise TypeError(
"'src' must be bytes, numpy.ndarray or pydicom.dataset.Dataset, "
f"not '{src.__class__.__name__}'"
)
@property
def src(self) -> "Buffer | np.ndarray":
"""Return the buffer-like or :class:`numpy.ndarray` containing the pixel data."""
return self._src
def __str__(self) -> str:
"""Return nice string output for the runner."""
s = [f"EncodeRunner for '{self.transfer_syntax.name}'"]
s.append("Options")
s.extend([f" {name}: {value}" for name, value in self.options.items()])
if self._encoders:
s.append("Encoders")
s.extend([f" {name}" for name in self._encoders])
return "\n".join(s)
def validate(self) -> None:
"""Validate the encoding options and source pixel data."""
self._validate_options()
if self.is_dataset or self.is_buffer:
self._validate_buffer()
else:
self._validate_array()
# UID specific validation based on Section 8 of Part 5
self._validate_encoding_profile()
def _validate_array(self) -> None:
"""Check that the ndarray matches the supplied options."""
arr = cast(np.ndarray, self.src)
shape = arr.shape
dtype = arr.dtype
if len(shape) not in (2, 3, 4):
raise ValueError(f"Unable to encode {len(shape)}D ndarrays")
# `arr` may be (for planar configuration 0):
# (rows, columns)
# (rows, columns, planes)
# (frames, rows, columns)
# (frames, rows, columns, planes)
expected = [
self.number_of_frames,
self.rows,
self.columns,
self.samples_per_pixel,
]
expected = expected[1:] if expected[0] == 1 else expected
expected = expected[:-1] if expected[-1] in (None, 1) else expected
if shape != tuple(expected):
raise ValueError(
f"Mismatch between the expected ndarray shape {tuple(expected)} "
f"and the actual shape {shape}"
)
# Check dtype is int or uint
ui = [
np.issubdtype(dtype, np.unsignedinteger),
np.issubdtype(dtype, np.signedinteger),
]
if not any(ui):
raise ValueError(
f"The ndarray's dtype '{dtype}' is not supported, must be a "
"signed or unsigned integer type"
)
# Check dtype is consistent with the *Pixel Representation*
is_signed = self.pixel_representation == 1
if not ui[is_signed]:
s = ["unsigned", "signed"][is_signed]
raise ValueError(
f"The ndarray's dtype '{dtype}' is not consistent with a (0028,0103) "
f"'Pixel Representation' of '{self.pixel_representation}' ({s} integers)"
)
# Check the dtype's itemsize is at least as large as *Bits Allocated*
if dtype.itemsize < math.ceil(self.bits_allocated / 8):
raise ValueError(
f"The ndarray's dtype '{dtype}' is not consistent with a (0028,0100) "
f"'Bits Allocated' value of '{self.bits_allocated}'"
)
# Check the pixel data values are consistent with *Bits Stored*
amax, amin = arr.max(), arr.min()
if is_signed:
minimum = -(2 ** (self.bits_stored - 1))
maximum = 2 ** (self.bits_stored - 1) - 1
else:
minimum, maximum = 0, 2**self.bits_stored - 1
if amax > maximum or amin < minimum:
raise ValueError(
"The ndarray contains values that are outside the allowable "
f"range of ({minimum}, {maximum}) for a (0028,0101) 'Bits "
f"Stored' value of '{self.bits_stored}'"
)
if self.transfer_syntax == JPEGLSNearLossless and is_signed:
# JPEG-LS doesn't track signedness, so with lossy encoding of
# signed data it's possible to flip from a negative to a positive
# value (or vice versa) due to the introduced error.
# The only way to avoid this is to limit pixel values to the
# range (minimum + jls_error, maximum - jls_error), where
# `jls_error` is the JPEG-LS 'NEAR' parameter and `minimum`
# and `maximum` are the min/max possible values for a given
# sample precision
error = self.get_option("jls_error", 0)
within = amax <= (maximum - error) and amin >= (minimum + error)
if error and not within:
raise ValueError(
"The supported range of pixel values when performing lossy "
"JPEG-LS encoding of signed integers with a (0028,0103) 'Bits "
f"Stored' value of '{self.bits_stored}' and a 'jls_error' "
f"of '{error}' is ({minimum + error}, {maximum - error})"
)
def _validate_buffer(self) -> None:
"""Validate the supplied pixel data buffer."""
# Check the length is at least as long as required
length_bytes = self.frame_length(unit="bytes")
expected = length_bytes * self.number_of_frames
if (actual := len(self.src)) < expected:
raise ValueError(
"The length of the uncompressed pixel data doesn't match the "
f"expected length - {actual} bytes actual vs. {expected} expected"
)
def _validate_encoding_profile(self) -> None:
"""Perform UID specific validation of encoding parameters based on
Part 5, Section 8 of the DICOM Standard.
Encoding profiles should be:
Tuple[str, int, Iterable[int], Iterable[int], Iterable[int]] as
(
PhotometricInterpretation, SamplesPerPixel, PixelRepresentation,
BitsAllocated, BitsStored
)
"""
if self.transfer_syntax not in ENCODING_PROFILES:
return
# Test each profile and see if it matches source parameters
profile = ENCODING_PROFILES[self.transfer_syntax]
for pi, spp, px_repr, bits_a, bits_s in profile:
try:
assert self.photometric_interpretation == pi
assert self.samples_per_pixel == spp
assert self.pixel_representation in px_repr
assert self.bits_allocated in bits_a
assert self.bits_stored in bits_s
except AssertionError:
continue
return
raise ValueError(
"One or more of the following values is not valid for pixel data "
f"encoded with '{self.transfer_syntax.name}':\n"
f" (0028,0002) Samples per Pixel: {self.samples_per_pixel}\n"
" (0028,0006) Photometric Interpretation: "
f"{self.photometric_interpretation}\n"
f" (0028,0100) Bits Allocated: {self.bits_allocated}\n"
f" (0028,0101) Bits Stored: {self.bits_stored}\n"
f" (0028,0103) Pixel Representation: {self.pixel_representation}\n"
"See Part 5, Section 8.2 of the DICOM Standard for more information"
)
class Encoder(CoderBase):
"""Factory class for data encoders.
Every available ``Encoder`` instance in *pydicom* corresponds directly
to a single DICOM *Transfer Syntax UID*, and provides a mechanism for
converting raw unencoded source data to meet the requirements of that
transfer syntax using one or more :doc:`encoding plugins
</guides/encoding/encoder_plugins>`.
.. versionadded:: 2.2
"""
def __init__(self, uid: UID) -> None:
"""Create a new data encoder.
Parameters
----------
uid : pydicom.uid.UID
The *Transfer Syntax UID* that the encoder supports.
"""
super().__init__(uid, decoder=False)
def encode(
self,
src: "bytes | np.ndarray | Dataset",
*,
index: int | None = None,
validate: bool = True,
encoding_plugin: str = "",
**kwargs: Any,
) -> bytes:
"""Return an encoded frame of the pixel data in `src` as
:class:`bytes`.
.. warning::
With the exception of *RLE Lossless*, this method requires the
installation of additional packages to perform the actual pixel
data encoding. See the :doc:`encoding documentation
</guides/user/image_data_compression>` for more information.
Parameters
----------
src : bytes, numpy.ndarray or pydicom.dataset.Dataset
Single or multi-frame pixel data as one of the following:
* :class:`~numpy.ndarray`: the uncompressed pixel data, should be
:attr:`shaped<numpy.ndarray.shape>` as:
* (rows, columns) for single frame, single sample data.
* (rows, columns, planes) for single frame, multi-sample data.
* (frames, rows, columns) for multi-frame, single sample data.
* (frames, rows, columns, planes) for multi-frame and
multi-sample data.
* :class:`~pydicom.dataset.Dataset`: the dataset containing
the uncompressed *Pixel Data* to be encoded.
* :class:`bytes`: the uncompressed little-endian ordered pixel
data. `src` should use 1, 2, 4 or 8 bytes per pixel, whichever
of these is sufficient for the (0028,0103) *Bits Stored* value.
index : int, optional
Required when `src` contains multiple frames, this is the index
of the frame to be encoded.
validate : bool, optional
If ``True`` (default) then validate the supplied encoding options
and pixel data prior to encoding, otherwise if ``False`` no
validation will be performed.
encoding_plugin : str, optional
The name of the pixel data encoding plugin to use. If
`encoding_plugin` is not specified then all available
plugins will be tried (default). For information on the available
plugins for each encoder see the
:mod:`API documentation<pydicom.pixels.encoders>`.
**kwargs
The following keyword parameters are required when `src` is
:class:`bytes` or :class:`~numpy.ndarray`:
* ``'rows'``: :class:`int` - the number of rows of pixels in `src`,
maximum 65535.
* ``'columns'``: :class:`int` - the number of columns of pixels in
`src`, maximum 65535.
* ``'number_of_frames'``: :class:`int` - the number of frames
in `src`.
* ``'samples_per_pixel'``: :class:`int` - the number of samples
per pixel in `src`, should be 1 or 3.
* ``'bits_allocated'``: :class:`int` - the number of bits used
to contain each pixel, should be a multiple of 8.
* ``'bits_stored'``: :class:`int` - the number of bits actually
used per pixel. For example, an ``ndarray`` `src` might have a
:class:`~numpy.dtype` of ``'uint16'`` (range 0 to 65535) but
only contain 12-bit pixel values (range 0 to 4095).
* ``'pixel_representation'``: :class:`int` - the type of data
being encoded, ``0`` for unsigned, ``1`` for 2's complement
(signed)
* ``'photometric_interpretation'``: :class:`str` - the intended
color space of the *encoded* pixel data, such as ``'YBR_FULL'``.
Optional keyword parameters for the encoding plugin may also be
present. See the :doc:`encoding plugin options
</guides/encoding/encoder_plugin_options>` for more information.
Returns
-------
bytes
The encoded pixel data.
"""
if index is not None and index < 0:
raise ValueError("'index' must be greater than or equal to 0")
runner = EncodeRunner(self.UID)
runner.set_source(src)
runner.set_options(**kwargs)
runner.set_encoders(
cast(
dict[str, "EncodeFunction"],
self._validate_plugins(encoding_plugin),
),
)
if config.debugging:
LOGGER.debug(runner)
if validate:
runner.validate()
if runner.number_of_frames > 1 and index is None:
raise ValueError(
"The 'index' of the frame to be encoded is required for "
"multi-frame pixel data"
)
return runner.encode(index)
def iter_encode(
self,
src: "bytes | np.ndarray | Dataset",
*,
validate: bool = True,
encoding_plugin: str = "",
**kwargs: Any,
) -> Iterator[bytes]:
"""Yield encoded frames of the pixel data in `src` as :class:`bytes`.
.. warning::
With the exception of *RLE Lossless*, this method requires the
installation of additional packages to perform the actual pixel
data encoding. See the :doc:`encoding documentation
</guides/user/image_data_compression>` for more information.
Parameters
----------
src : bytes, numpy.ndarray or pydicom.dataset.Dataset
Single or multi-frame pixel data as one of the following:
* :class:`~numpy.ndarray`: the uncompressed pixel data, should be
:attr:`shaped<numpy.ndarray.shape>` as:
* (rows, columns) for single frame, single sample data.
* (rows, columns, planes) for single frame, multi-sample data.
* (frames, rows, columns) for multi-frame, single sample data.
* (frames, rows, columns, planes) for multi-frame and
multi-sample data.
* :class:`~pydicom.dataset.Dataset`: the dataset containing
the uncompressed *Pixel Data* to be encoded.
* :class:`bytes`: the uncompressed little-endian ordered pixel
data. `src` should use 1, 2, 4 or 8 bytes per pixel, whichever
of these is sufficient for the (0028,0103) *Bits Stored* value.
validate : bool, optional
If ``True`` (default) then validate the supplied encoding options
and pixel data prior to encoding, otherwise if ``False`` no
validation will be performed.
encoding_plugin : str, optional
The name of the pixel data encoding plugin to use. If
`encoding_plugin` is not specified then all available
plugins will be tried (default). For information on the available
plugins for each encoder see the
:mod:`API documentation<pydicom.pixels.encoders>`.
**kwargs
The following keyword parameters are required when `src` is
:class:`bytes` or :class:`~numpy.ndarray`:
* ``'rows'``: :class:`int` - the number of rows of pixels in `src`,
maximum 65535.
* ``'columns'``: :class:`int` - the number of columns of pixels in
`src`, maximum 65535.
* ``'number_of_frames'``: :class:`int` - the number of frames
in `src`.
* ``'samples_per_pixel'``: :class:`int` - the number of samples
per pixel in `src`, should be 1 or 3.
* ``'bits_allocated'``: :class:`int` - the number of bits used
to contain each pixel, should be a multiple of 8.
* ``'bits_stored'``: :class:`int` - the number of bits actually
used per pixel. For example, an ``ndarray`` `src` might have a
:class:`~numpy.dtype` of ``'uint16'`` (range 0 to 65535) but
only contain 12-bit pixel values (range 0 to 4095).
* ``'pixel_representation'``: :class:`int` - the type of data
being encoded, ``0`` for unsigned, ``1`` for 2's complement
(signed)
* ``'photometric_interpretation'``: :class:`str` - the intended
color space of the encoded pixel data, such as ``'YBR_FULL'``.
Optional keyword parameters for the encoding plugin may also be
present. See the :doc:`encoding plugin options
</guides/encoding/encoder_plugin_options>` for more information.
Yields
------
bytes
An encoded frame of pixel data.
"""
runner = EncodeRunner(self.UID)
runner.set_source(src)
runner.set_options(**kwargs)
runner.set_encoders(
cast(
dict[str, "EncodeFunction"],
self._validate_plugins(encoding_plugin),
),
)
if config.debugging:
LOGGER.debug(runner)
if validate:
runner.validate()
if runner.number_of_frames == 1:
yield runner.encode(None)
return
for index in range(runner.number_of_frames):
yield runner.encode(index)
# UID: [
# Photometric Interpretation (the intended value *after* encoding),
# Samples per Pixel,
# Pixel Representation,
# Bits Allocated,
# Bits Stored,
# ]
ProfileType = tuple[str, int, Iterable[int], Iterable[int], Iterable[int]]
ENCODING_PROFILES: dict[UID, list[ProfileType]] = {
JPEGBaseline8Bit: [ # 1.2.840.10008.1.2.4.50: Table 8.2.1-1 in PS3.5
("MONOCHROME1", 1, (0,), (8,), (8,)),
("MONOCHROME2", 1, (0,), (8,), (8,)),
("YBR_FULL_422", 3, (0,), (8,), (8,)),
("RGB", 3, (0,), (8,), (8,)),
],
JPEGExtended12Bit: [ # 1.2.840.10008.1.2.4.51: Table 8.2.1-1 in PS3.5
("MONOCHROME1", 1, (0,), (8,), (8,)),
("MONOCHROME1", 1, (0,), (16,), (12,)),
("MONOCHROME2", 1, (0,), (8,), (8,)),
("MONOCHROME2", 1, (0,), (16,), (12,)),
],
JPEGLossless: [ # 1.2.840.10008.1.2.4.57: Table 8.2.1-2 in PS3.5
("MONOCHROME1", 1, (0, 1), (8, 16), range(1, 17)),
("MONOCHROME2", 1, (0, 1), (8, 16), range(1, 17)),
("PALETTE COLOR", 1, (0,), (8, 16), range(1, 17)),
("YBR_FULL", 3, (0,), (8, 16), range(1, 17)),
("RGB", 3, (0,), (8, 16), range(1, 17)),
],
JPEGLosslessSV1: [ # 1.2.840.10008.1.2.4.70: Table 8.2.1-2 in PS3.5
("MONOCHROME1", 1, (0, 1), (8, 16), range(1, 17)),
("MONOCHROME2", 1, (0, 1), (8, 16), range(1, 17)),
("PALETTE COLOR", 1, (0,), (8, 16), range(1, 17)),
("YBR_FULL", 3, (0,), (8, 16), range(1, 17)),
("RGB", 3, (0,), (8, 16), range(1, 17)),
],
JPEGLSLossless: [ # 1.2.840.10008.1.2.4.80: Table 8.2.3-1 in PS3.5
("MONOCHROME1", 1, (0, 1), (8, 16), range(2, 17)),
("MONOCHROME2", 1, (0, 1), (8, 16), range(2, 17)),
("PALETTE COLOR", 1, (0,), (8, 16), range(2, 17)),
("YBR_FULL", 3, (0,), (8,), range(2, 9)),
("RGB", 3, (0,), (8, 16), range(2, 17)),
],
JPEGLSNearLossless: [ # 1.2.840.10008.1.2.4.81: Table 8.2.3-1 in PS3.5
("MONOCHROME1", 1, (0, 1), (8, 16), range(2, 17)),
("MONOCHROME2", 1, (0, 1), (8, 16), range(2, 17)),
("YBR_FULL", 3, (0,), (8,), range(2, 9)),
("RGB", 3, (0,), (8, 16), range(2, 17)),
],
JPEG2000Lossless: [ # 1.2.840.10008.1.2.4.90: Table 8.2.4-1 in PS3.5
("MONOCHROME1", 1, (0, 1), (8, 16, 24, 32, 40), range(1, 39)),
("MONOCHROME2", 1, (0, 1), (8, 16, 24, 32, 40), range(1, 39)),
("PALETTE COLOR", 1, (0,), (8, 16), range(1, 17)),
("YBR_RCT", 3, (0,), (8, 16, 24, 32, 40), range(1, 39)),
("RGB", 3, (0,), (8, 16, 24, 32, 40), range(1, 39)),
("YBR_FULL", 3, (0,), (8, 16, 24, 32, 40), range(1, 39)),
],
JPEG2000: [ # 1.2.840.10008.1.2.4.91: Table 8.2.4-1 in PS3.5
("MONOCHROME1", 1, (0, 1), (8, 16, 24, 32, 40), range(1, 39)),
("MONOCHROME2", 1, (0, 1), (8, 16, 24, 32, 40), range(1, 39)),
("YBR_ICT", 3, (0,), (8, 16, 24, 32, 40), range(1, 39)),
("RGB", 3, (0,), (8, 16, 24, 32, 40), range(1, 39)),
("YBR_FULL", 3, (0,), (8, 16, 24, 32, 40), range(1, 39)),
],
RLELossless: [ # 1.2.840.10008.1.2.5: Table 8.2.2-1 in PS3.5
("MONOCHROME1", 1, (0, 1), (8, 16), range(1, 17)),
("MONOCHROME2", 1, (0, 1), (8, 16), range(1, 17)),
("PALETTE COLOR", 1, (0,), (8, 16), range(1, 17)),
("YBR_FULL", 3, (0,), (8,), range(1, 9)),
("RGB", 3, (0,), (8, 16), range(1, 17)),
],
}
# Encoder names should be f"{UID.keyword}Encoder"
RLELosslessEncoder = Encoder(RLELossless)
RLELosslessEncoder.add_plugins(
[
("gdcm", ("pydicom.pixels.encoders.gdcm", "encode_pixel_data")),
("pylibjpeg", ("pydicom.pixels.encoders.pylibjpeg", "_encode_frame")),
("pydicom", ("pydicom.pixels.encoders.native", "_encode_frame")),
],
)
JPEGLSLosslessEncoder = Encoder(JPEGLSLossless)
JPEGLSLosslessEncoder.add_plugin(
"pyjpegls", ("pydicom.pixels.encoders.pyjpegls", "_encode_frame")
)
JPEGLSNearLosslessEncoder = Encoder(JPEGLSNearLossless)
JPEGLSNearLosslessEncoder.add_plugin(
"pyjpegls", ("pydicom.pixels.encoders.pyjpegls", "_encode_frame")
)
JPEG2000LosslessEncoder = Encoder(JPEG2000Lossless)
JPEG2000LosslessEncoder.add_plugin(
"pylibjpeg", ("pydicom.pixels.encoders.pylibjpeg", "_encode_frame")
)
JPEG2000Encoder = Encoder(JPEG2000)
JPEG2000Encoder.add_plugin(
"pylibjpeg", ("pydicom.pixels.encoders.pylibjpeg", "_encode_frame")
)
# Available pixel data encoders
_PIXEL_DATA_ENCODERS = {
# UID: (encoder, 'versionadded')
RLELossless: (RLELosslessEncoder, "2.2"),
JPEGLSLossless: (JPEGLSLosslessEncoder, "3.0"),
JPEGLSNearLossless: (JPEGLSNearLosslessEncoder, "3.0"),
JPEG2000Lossless: (JPEG2000LosslessEncoder, "3.0"),
JPEG2000: (JPEG2000Encoder, "3.0"),
}
def _build_encoder_docstrings() -> None:
"""Override the default Encoder docstring."""
plugin_doc_links = {
"pydicom": ":ref:`pydicom <encoder_plugin_pydicom>`",
"pylibjpeg": ":ref:`pylibjpeg <encoder_plugin_pylibjpeg>`",
"gdcm": ":ref:`gdcm <encoder_plugin_gdcm>`",
"pyjpegls": ":ref:`pyjpegls <encoder_plugin_pyjpegls>`",
}
for enc, versionadded in _PIXEL_DATA_ENCODERS.values():
uid = enc.UID
available = enc._available.keys()
unavailable = enc._unavailable.keys()
plugins = list(available) + list(unavailable)
plugins = [plugin_doc_links[name] for name in sorted(plugins)]
s = [f"A *Pixel Data* encoder for *{uid.name}* - ``{uid}``"]
s.append("")
s.append(f".. versionadded:: {versionadded}")
s.append("")
s.append(f"Encoding plugins: {', '.join(plugins)}")
s.append("")
s.append(
"See the :class:`~pydicom.pixels.encoders.base.Encoder` "
"reference for instance methods and attributes."
)
enc.__doc__ = "\n".join(s)
_build_encoder_docstrings()
def get_encoder(uid: str) -> Encoder:
"""Return the pixel data encoder corresponding to `uid`.
.. versionadded:: 2.2
+--------------------------------------------------+----------------+
| Transfer Syntax | Version added |
+-------------------------+------------------------+ +
| Name | UID | |
+=========================+========================+================+
| *JPEG-LS Lossless* | 1.2.840.10008.1.2.4.80 | 3.0 |
+-------------------------+------------------------+----------------+
| *JPEG-LS Near Lossless* | 1.2.840.10008.1.2.4.81 | 3.0 |
+-------------------------+------------------------+----------------+
| *JPEG 2000 Lossless* | 1.2.840.10008.1.2.4.90 | 3.0 |
+-------------------------+------------------------+----------------+
| *JPEG 2000* | 1.2.840.10008.1.2.4.91 | 3.0 |
+-------------------------+------------------------+----------------+
| *RLE Lossless* | 1.2.840.10008.1.2.5 | 2.2 |
+-------------------------+------------------------+----------------+
"""
uid = UID(uid)
try:
return _PIXEL_DATA_ENCODERS[uid][0]
except KeyError:
raise NotImplementedError(
f"No pixel data encoders have been implemented for '{uid.name}'"
)

View File

@@ -0,0 +1,143 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
"""Interface for *Pixel Data* encoding, not intended to be used directly."""
from typing import cast
from pydicom.pixels.encoders.base import EncodeRunner
from pydicom.uid import RLELossless
try:
import gdcm
GDCM_VERSION = tuple(int(x) for x in gdcm.Version.GetVersion().split("."))
HAVE_GDCM = True
except ImportError:
HAVE_GDCM = False
ENCODER_DEPENDENCIES = {
RLELossless: ("gdcm>=3.0.10",),
}
def is_available(uid: str) -> bool:
"""Return ``True`` if a pixel data encoder for `uid` is available for use,
``False`` otherwise.
"""
if not HAVE_GDCM or GDCM_VERSION < (3, 0, 10):
return False
return uid in ENCODER_DEPENDENCIES
def encode_pixel_data(src: bytes, runner: EncodeRunner) -> bytes:
"""Return the encoded image data in `src`.
Parameters
----------
src : bytes
The raw image frame data to be encoded.
runner : pydicom.pixels.encoders.base.EncodeRunner
The runner managing the encoding process.
Returns
-------
bytes
The encoded image data.
"""
byteorder = runner.get_option("byteorder", "<")
if byteorder == ">":
raise ValueError("Unsupported option \"byteorder = '>'\"")
return _ENCODERS[runner.transfer_syntax](src, runner)
def _rle_encode(src: bytes, runner: EncodeRunner) -> bytes:
"""Return RLE encoded image data from `src`.
Parameters
----------
src : bytes
The raw image frame data to be encoded.
runner : pydicom.pixels.encoders.base.EncodeRunner
The runner managing the encoding process.
Returns
-------
bytes
The encoded image data.
"""
if runner.bits_allocated > 32:
raise ValueError("Unable to encode more than 32-bit data")
# Create a gdcm.Image with the uncompressed `src` data
pi = gdcm.PhotometricInterpretation.GetPIType(runner.photometric_interpretation)
# GDCM's null photometric interpretation gets used for invalid values
if pi == gdcm.PhotometricInterpretation.PI_END:
raise ValueError(
f"Invalid photometric interpretation '{runner.photometric_interpretation}'"
)
# `src` uses little-endian byte ordering
ts = gdcm.TransferSyntax.ImplicitVRLittleEndian
# Must use ImageWriter().GetImage() to create a gdcmImage
# also have to make sure `writer` doesn't go out of scope
writer = gdcm.ImageWriter()
image = writer.GetImage()
image.SetNumberOfDimensions(2)
image.SetDimensions((runner.columns, runner.rows, 1))
image.SetPhotometricInterpretation(gdcm.PhotometricInterpretation(pi))
image.SetTransferSyntax(gdcm.TransferSyntax(ts))
pixel_format = gdcm.PixelFormat(
runner.samples_per_pixel,
runner.bits_allocated,
runner.bits_stored,
runner.bits_stored - 1,
runner.pixel_representation,
)
image.SetPixelFormat(pixel_format)
if runner.samples_per_pixel > 1:
# Default `src` is planar configuration 0 (i.e. R1 G1 B1 R2 G2 B2)
image.SetPlanarConfiguration(0)
# Add the Pixel Data element and set the value to `src`
elem = gdcm.DataElement(gdcm.Tag(0x7FE0, 0x0010))
elem.SetByteStringValue(src)
image.SetDataElement(elem)
# Converts an image to match the set transfer syntax
converter = gdcm.ImageChangeTransferSyntax()
# Set up the converter with the intended transfer syntax...
rle = gdcm.TransferSyntax.GetTSType(runner.transfer_syntax)
converter.SetTransferSyntax(gdcm.TransferSyntax(rle))
# ...and image to be converted
converter.SetInput(image)
# Perform the conversion, returns bool
# 'PALETTE COLOR' and a lossy transfer syntax will return False
result = converter.Change()
if not result:
raise RuntimeError(
"ImageChangeTransferSyntax.Change() returned a failure result"
)
# A new gdcmImage with the converted pixel data element
image = converter.GetOutput()
# The element's value is the encapsulated encoded pixel data
seq = image.GetDataElement().GetSequenceOfFragments()
# RLECodec::Code() uses only 1 fragment per frame
if seq is None or seq.GetNumberOfFragments() != 1:
# Covers both no sequence and unexpected number of fragments
raise RuntimeError("Unexpected number of fragments found in the 'Pixel Data'")
fragment = seq.GetFragment(0).GetByteValue().GetBuffer()
return cast(bytes, fragment.encode("utf-8", "surrogateescape"))
_ENCODERS = {RLELossless: _rle_encode}

View File

@@ -0,0 +1,168 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
"""Interface for *Pixel Data* encoding, not intended to be used directly."""
from itertools import groupby
import math
from struct import pack
from pydicom.pixels.encoders.base import EncodeRunner
from pydicom.uid import RLELossless
ENCODER_DEPENDENCIES = {RLELossless: ()}
def is_available(uid: str) -> bool:
"""Return ``True`` if a pixel data encoder for `uid` is available for use,
``False`` otherwise.
"""
return True
def _encode_frame(src: bytes, runner: EncodeRunner) -> bytes:
"""Wrapper for use with the encoder interface.
Parameters
----------
src : bytes
A single frame of little-endian ordered image data to be RLE encoded.
runner : pydicom.pixels.encoders.base.EncodeRunner
The runner managing the encoding process.
Returns
-------
bytes
An RLE encoded frame.
"""
if runner.get_option("byteorder", "<") == ">":
raise ValueError("Unsupported option \"byteorder = '>'\"")
bytes_allocated = math.ceil(runner.bits_allocated / 8)
nr_segments = bytes_allocated * runner.samples_per_pixel
if nr_segments > 15:
raise ValueError(
"Unable to encode as the DICOM standard only allows "
"a maximum of 15 segments in RLE encoded data"
)
rle_data = bytearray()
seg_lengths = []
columns = runner.columns
for sample_nr in range(runner.samples_per_pixel):
for byte_offset in reversed(range(bytes_allocated)):
idx = byte_offset + bytes_allocated * sample_nr
segment = _encode_segment(src[idx::nr_segments], columns)
rle_data.extend(segment)
seg_lengths.append(len(segment))
# Add the number of segments to the header
rle_header = bytearray(pack("<L", len(seg_lengths)))
# Add the segment offsets, starting at 64 for the first segment
# We don't need an offset to any data at the end of the last segment
offsets = [64]
for ii, length in enumerate(seg_lengths[:-1]):
offsets.append(offsets[ii] + length)
rle_header.extend(pack(f"<{len(offsets)}L", *offsets))
# Add trailing padding to make up the rest of the header (if required)
rle_header.extend(b"\x00" * (64 - len(rle_header)))
return bytes(rle_header + rle_data)
def _encode_segment(src: bytes, columns: int) -> bytearray:
"""Return `src` as an RLE encoded bytearray.
Each row of the image is encoded separately as required by the DICOM
Standard.
Parameters
----------
src : bytes
The little-endian ordered data to be encoded, representing a Byte
Segment as in the DICOM Standard, Part 5,
:dcm:`Annex G.2<part05/sect_G.2.html>`.
columns : int
The number of columns in the image.
Returns
-------
bytearray
The RLE encoded segment, following the format specified by the DICOM
Standard. Odd length encoded segments are padded by a trailing ``0x00``
to be even length.
"""
out = bytearray()
for idx in range(0, len(src), columns):
out.extend(_encode_row(src[idx : idx + columns]))
# Pad odd length data with a trailing 0x00 byte
out.extend(b"\x00" * (len(out) % 2))
return out
def _encode_row(src: bytes) -> bytes:
"""Return `src` as RLE encoded bytes.
Parameters
----------
src : bytes
The little-endian ordered data to be encoded.
Returns
-------
bytes
The RLE encoded row, following the format specified by the DICOM
Standard, Part 5, :dcm:`Annex G<part05/chapter_G.html>`
Notes
-----
* 2-byte repeat runs are always encoded as Replicate Runs rather than
only when not preceded by a Literal Run as suggested by the Standard.
"""
out: list[int] = []
out_append = out.append
out_extend = out.extend
literal = []
for _, iter_group in groupby(src):
group = list(iter_group)
if len(group) == 1:
literal.append(group[0])
else:
if literal:
# Literal runs
nr_full_runs, len_partial_run = divmod(len(literal), 128)
for idx in range(nr_full_runs):
idx *= 128
out_append(127)
out_extend(literal[idx : idx + 128])
if len_partial_run:
out_append(len_partial_run - 1)
out_extend(literal[-len_partial_run:])
literal = []
# Replicate runs
nr_full_runs, len_partial_run = divmod(len(group), 128)
if nr_full_runs:
out_extend((129, group[0]) * nr_full_runs)
if len_partial_run > 1:
out_extend((257 - len_partial_run, group[0]))
elif len_partial_run == 1:
# Literal run - only if last replicate part is length 1
out_extend((0, group[0]))
# Final literal run if literal isn't followed by a replicate run
for ii in range(0, len(literal), 128):
_run = literal[ii : ii + 128]
out_append(len(_run) - 1)
out_extend(_run)
return pack(f"{len(out)}B", *out)

View File

@@ -0,0 +1,53 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
"""Use pyjpegls <https://github.com/pydicom/pyjpegls> to compress *Pixel Data*.
This module is not intended to be used directly.
"""
from typing import cast
from pydicom import uid
from pydicom.pixels.encoders.base import EncodeRunner
from pydicom.pixels.utils import _passes_version_check
try:
import jpeg_ls
except ImportError:
pass
ENCODER_DEPENDENCIES = {
uid.JPEGLSLossless: ("numpy", "pyjpegls>=1.3"),
uid.JPEGLSNearLossless: ("numpy", "pyjpegls>=1.3"),
}
def is_available(uid: str) -> bool:
"""Return ``True`` if the decoder has its dependencies met, ``False`` otherwise"""
return _passes_version_check("jpeg_ls", (1, 3))
def _encode_frame(src: bytes, runner: EncodeRunner) -> bytearray:
"""Return the image data in `src` as a JPEG-LS encoded codestream."""
lossy_error = runner.get_option("jls_error", 0)
if lossy_error and runner.transfer_syntax == uid.JPEGLSLossless:
raise ValueError(
f"A 'jls_error' value of '{lossy_error}' is being used with a "
"transfer syntax of 'JPEG-LS Lossless' - did you mean to use "
"'JPEG-LS Near Lossless' instead?"
)
opts = {
"rows": runner.rows,
"columns": runner.columns,
"samples_per_pixel": runner.samples_per_pixel,
"bits_stored": runner.bits_stored,
}
if runner.samples_per_pixel > 1:
opts["planar_configuration"] = runner.planar_configuration
return cast(
bytearray, jpeg_ls.encode_pixel_data(src, lossy_error=lossy_error, **opts)
)

View File

@@ -0,0 +1,82 @@
# Copyright 2008-2024 pydicom authors. See LICENSE file for details.
"""Interface for *Pixel Data* encoding, not intended to be used directly."""
from typing import cast
from pydicom.pixels.encoders.base import EncodeRunner
from pydicom.pixels.common import PhotometricInterpretation as PI
from pydicom.pixels.utils import _passes_version_check
from pydicom import uid
try:
from pylibjpeg.utils import get_pixel_data_encoders, Encoder
_ENCODERS = get_pixel_data_encoders()
except ImportError:
_ENCODERS = {}
ENCODER_DEPENDENCIES = {
uid.JPEG2000Lossless: ("numpy", "pylibjpeg>=2.0", "pylibjpeg-openjpeg>=2.2"),
uid.JPEG2000: ("numpy", "pylibjpeg>=2.0", "pylibjpeg-openjpeg>=2.2"),
uid.RLELossless: ("numpy", "pylibjpeg>=2.0", "pylibjpeg-rle>=2.0"),
}
_OPENJPEG_SYNTAXES = [uid.JPEG2000Lossless, uid.JPEG2000]
_RLE_SYNTAXES = [uid.RLELossless]
def is_available(uid: str) -> bool:
"""Return ``True`` if a pixel data encoder for `uid` is available for use,
``False`` otherwise.
"""
if not _passes_version_check("pylibjpeg", (2, 0)):
return False
if uid in _OPENJPEG_SYNTAXES:
return _passes_version_check("openjpeg", (2, 2))
if uid in _RLE_SYNTAXES:
return _passes_version_check("rle", (2, 0))
return False
def _encode_frame(src: bytes, runner: EncodeRunner) -> bytes | bytearray:
"""Return `src` as an encoded codestream."""
encoder = cast(Encoder, _ENCODERS[runner.transfer_syntax])
tsyntax = runner.transfer_syntax
if tsyntax == uid.RLELossless:
return cast(bytes, encoder(src, **runner.options))
opts = dict(runner.options)
if runner.photometric_interpretation == PI.RGB:
opts["use_mct"] = False
cr = opts.pop("compression_ratios", opts.get("j2k_cr", None))
psnr = opts.pop("signal_noise_ratios", opts.get("j2k_psnr", None))
if tsyntax == uid.JPEG2000Lossless:
if cr or psnr:
raise ValueError(
"A lossy configuration option is being used with a transfer "
"syntax of 'JPEG 2000 Lossless' - did you mean to use 'JPEG "
"2000' instead?"
)
return cast(bytes, encoder(src, **opts))
if not cr and not psnr:
raise ValueError(
"The 'JPEG 2000' transfer syntax requires a lossy configuration "
"option such as 'j2k_cr' or 'j2k_psnr'"
)
if cr and psnr:
raise ValueError(
"Multiple lossy configuration options are being used with the "
"'JPEG 2000' transfer syntax, please specify only one"
)
cs = encoder(src, **opts, compression_ratios=cr, signal_noise_ratios=psnr)
return cast(bytes, cs)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff