diff --git a/doc/reference/examples.rst b/doc/reference/examples.rst index d414403bc0..fd86b8e4d5 100644 --- a/doc/reference/examples.rst +++ b/doc/reference/examples.rst @@ -21,17 +21,17 @@ The ``examples`` module contains the following DICOM datasets: +-------------------+---------------------------------------+----------------------+ | ``rt_ss`` | ``rtstruct.dcm`` | RT Structure Set | +-------------------+---------------------------------------+----------------------+ -| ``overlay`` | ``MR-SIEMENS-DICOM-WithOverlays.dcm`` | MR Image | +| ``overlay`` | ``examples_overlay.dcm`` | MR Image | +-------------------+---------------------------------------+----------------------+ | ``waveform`` | ``waveform_ecg.dcm`` | 12 Lead ECG | +-------------------+---------------------------------------+----------------------+ -| ``rgb_color`` | ``US1_UNCR.dcm`` | US Image | +| ``rgb_color`` | ``examples_rgb_color.dcm`` | US Image | +-------------------+---------------------------------------+----------------------+ | ``palette_color`` | ``OBXXXX1A.dcm`` | US Image | +-------------------+---------------------------------------+----------------------+ -| ``ybr_color`` | ``color3d_jpeg_baseline.dcm`` | US Multi-frame Image | +| ``ybr_color`` | ``examples_ybr_color.dcm`` | US Multi-frame Image | +-------------------+---------------------------------------+----------------------+ -| ``jpeg2k`` | ``US1_J2KR.dcm`` | US Image | +| ``jpeg2k`` | ``examples_jpeg2k.dcm`` | US Image | +-------------------+---------------------------------------+----------------------+ | ``dicomdir`` | ``DICOMDIR`` | Media Storage | +-------------------+---------------------------------------+----------------------+ diff --git a/doc/release_notes/index.rst b/doc/release_notes/index.rst index 112a77b830..ad0ebf4819 100644 --- a/doc/release_notes/index.rst +++ b/doc/release_notes/index.rst @@ -2,6 +2,7 @@ Release notes ============= +.. include:: v3.0.1.rst .. include:: v3.0.0.rst .. include:: v2.4.0.rst .. include:: v2.3.0.rst diff --git a/doc/release_notes/v3.0.1.rst b/doc/release_notes/v3.0.1.rst new file mode 100644 index 0000000000..30924ead84 --- /dev/null +++ b/doc/release_notes/v3.0.1.rst @@ -0,0 +1,12 @@ +Version 3.0.1 +============= + +Fixes +----- + +* Changed logging of missing plugin imports to use :attr:`logging.DEBUG` (:issue:`2128`). +* Include all :mod:`~pydicom.examples` module datasets with the package (:issue:`2128`, :issue:`2131`) +* Fixed an invalid VR value in the private data dictionary (:issue:`2132`). +* Fixed checking for *Bits Stored* when converting *Float Pixel Data* and *Double Float + Pixel Data* using the :mod:`~pydicom.pixels` backend (:issue:`2135`). +* Fixed decoding of pixel data for images with *Bits Allocated* of 1 when frame boundaries are not aligned with byte boundaries (:issue:`2134`). diff --git a/pyproject.toml b/pyproject.toml index f5103e57f2..f4a77ea5d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ license = {text = "MIT"} name = "pydicom" readme = "README.md" requires-python = ">=3.10" -version = "3.0.0" +version = "3.0.1" [project.optional-dependencies] diff --git a/src/pydicom/_private_dict.py b/src/pydicom/_private_dict.py index 7a78fdc03b..5261ba9463 100644 --- a/src/pydicom/_private_dict.py +++ b/src/pydicom/_private_dict.py @@ -1834,7 +1834,7 @@ '0027xxA0': ('IS', '1', 'No. of Data', ''), '0027xxA1': ('CS', '2', 'Data Input Type', ''), '0027xxA2': ('CS', '2', 'Data Output Type', ''), - '0027xxA3': ('US_SS', '1-n', 'Raw Data', ''), + '0027xxA3': ('US or SS', '1-n', 'Raw Data', ''), '0029xx20': ('CS', '1', 'Image Scanning Direction', ''), '0029xx25': ('CS', '1', 'Image Rotation/Reversal Information', ''), '0029xx30': ('CS', '1', 'Extended Reading Size Value', ''), diff --git a/src/pydicom/config.py b/src/pydicom/config.py index b8b59a96c6..b2a5d58950 100644 --- a/src/pydicom/config.py +++ b/src/pydicom/config.py @@ -30,6 +30,50 @@ def __call__( _use_future = False _use_future_env = os.getenv("PYDICOM_FUTURE") + +# Logging system and debug function to change logging level +logger = logging.getLogger("pydicom") +logger.addHandler(logging.NullHandler()) + +debugging: bool + + +def debug(debug_on: bool = True, default_handler: bool = True) -> None: + """Turn on/off debugging of DICOM file reading and writing. + + When debugging is on, file location and details about the elements read at + that location are logged to the 'pydicom' logger using Python's + :mod:`logging` + module. + + Parameters + ---------- + debug_on : bool, optional + If ``True`` (default) then turn on debugging, ``False`` to turn off. + default_handler : bool, optional + If ``True`` (default) then use :class:`logging.StreamHandler` as the + handler for log messages. + """ + global logger, debugging + + if default_handler: + handler = logging.StreamHandler() + formatter = logging.Formatter("%(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + + if debug_on: + logger.setLevel(logging.DEBUG) + debugging = True + else: + logger.setLevel(logging.WARNING) + debugging = False + + +# force level=WARNING, in case logging default is set differently (issue 103) +debug(False, False) + + # Set the type used to hold DS values # default False; was decimal-based in pydicom 0.9.7 use_DS_decimal: bool = False @@ -360,10 +404,6 @@ def strict_reading() -> Generator: .. versionadded:: 2.0 """ -# Logging system and debug function to change logging level -logger = logging.getLogger("pydicom") -logger.addHandler(logging.NullHandler()) - import pydicom.pixel_data_handlers.numpy_handler as np_handler # noqa import pydicom.pixel_data_handlers.rle_handler as rle_handler # noqa import pydicom.pixel_data_handlers.pillow_handler as pillow_handler # noqa @@ -496,43 +536,6 @@ def needs_to_convert_to_RGB(ds): element tag as a 2-tuple or int, or an element keyword """ -debugging: bool - - -def debug(debug_on: bool = True, default_handler: bool = True) -> None: - """Turn on/off debugging of DICOM file reading and writing. - - When debugging is on, file location and details about the elements read at - that location are logged to the 'pydicom' logger using Python's - :mod:`logging` - module. - - Parameters - ---------- - debug_on : bool, optional - If ``True`` (default) then turn on debugging, ``False`` to turn off. - default_handler : bool, optional - If ``True`` (default) then use :class:`logging.StreamHandler` as the - handler for log messages. - """ - global logger, debugging - - if default_handler: - handler = logging.StreamHandler() - formatter = logging.Formatter("%(message)s") - handler.setFormatter(formatter) - logger.addHandler(handler) - - if debug_on: - logger.setLevel(logging.DEBUG) - debugging = True - else: - logger.setLevel(logging.WARNING) - debugging = False - - -# force level=WARNING, in case logging default is set differently (issue 103) -debug(False, False) if _use_future_env: if _use_future_env.lower() in ["true", "yes", "on", "1"]: diff --git a/src/pydicom/data/hashes.json b/src/pydicom/data/hashes.json index 958dc18326..ed402257e6 100644 --- a/src/pydicom/data/hashes.json +++ b/src/pydicom/data/hashes.json @@ -74,5 +74,8 @@ "JLSL_RGB_ILV2.dcm": "f8d670e9988cbca207d3367d916aff3cb508c076495211a0d132692266e9546d", "JLSN_RGB_ILV0.dcm": "a377750d24bd3413d21faa343662dfff997db9acf65c0b095c5d8a95beb866fa", "JLSL_08_07_0_1F.dcm": "308fb028c8fbdd1e9a93e731978ea4da6b15cb55b40451cf6f21e7c9ba35dd8a", - "JLSL_16_15_1_1F.dcm": "61f38f250a7dc82c44529c0face2eeab3ffd02ca8b9dfc756dd818eb252104b6" + "JLSL_16_15_1_1F.dcm": "61f38f250a7dc82c44529c0face2eeab3ffd02ca8b9dfc756dd818eb252104b6", + "parametric_map_float.dcm": "957f34397c26d82f7a90cad7a653ce0f7238f4be6aa9dfa9a33bae5dc2ce7e23", + "parametric_map_double_float.dcm": "a41e0b78b05e543a2448e22435858f9ca8d5f94807d7b391b93b4bca80e23a22", + "liver_nonbyte_aligned.dcm": "530c6af2a2a0caa6033d99ad407fe1f6e3942c64a8fcfc5649d4d06c26473862" } diff --git a/src/pydicom/data/test_files/README.txt b/src/pydicom/data/test_files/README.txt index 2f84348062..733dbe07a4 100644 --- a/src/pydicom/data/test_files/README.txt +++ b/src/pydicom/data/test_files/README.txt @@ -259,6 +259,15 @@ explicit_VR-UN.dcm * image was compressed using "gdcmconv --j2k " * almost all tags have VR "UN" due to gdcmconv issue +== Examples Datasets == + +* examples_jpeg2k.dcm: identical to US1_J2KR.dcm +* examples_overlay.dcm: MR-SIEMENS-DICOM-WithOverlays.dcm with cropped Pixel Data and Overlay Data +* examples_palette.dcm: OBXXXX1A.dcm with cropped Pixel Data +* examples_rgb_color.dcm: US1_UNCR.dcm with cropped Pixel Data +* examples_ybr_color.dcm: color3d_jpeg_baseline.dcm with reduced frames and rescaled Pixel Data + + == DICOMDIR tests == dicomdirtests files were from https://www.pcir.org, freely available image sets. diff --git a/src/pydicom/data/test_files/examples_jpeg2k.dcm b/src/pydicom/data/test_files/examples_jpeg2k.dcm new file mode 100644 index 0000000000..212ce83bb8 Binary files /dev/null and b/src/pydicom/data/test_files/examples_jpeg2k.dcm differ diff --git a/src/pydicom/data/test_files/examples_overlay.dcm b/src/pydicom/data/test_files/examples_overlay.dcm new file mode 100644 index 0000000000..fdb7273f24 Binary files /dev/null and b/src/pydicom/data/test_files/examples_overlay.dcm differ diff --git a/src/pydicom/data/test_files/examples_palette.dcm b/src/pydicom/data/test_files/examples_palette.dcm new file mode 100644 index 0000000000..3b21e5376c Binary files /dev/null and b/src/pydicom/data/test_files/examples_palette.dcm differ diff --git a/src/pydicom/data/test_files/examples_rgb_color.dcm b/src/pydicom/data/test_files/examples_rgb_color.dcm new file mode 100644 index 0000000000..4eb984f4cd Binary files /dev/null and b/src/pydicom/data/test_files/examples_rgb_color.dcm differ diff --git a/src/pydicom/data/test_files/examples_ybr_color.dcm b/src/pydicom/data/test_files/examples_ybr_color.dcm new file mode 100644 index 0000000000..8c74068e63 Binary files /dev/null and b/src/pydicom/data/test_files/examples_ybr_color.dcm differ diff --git a/src/pydicom/data/urls.json b/src/pydicom/data/urls.json index 548a8916ce..7f75efd155 100644 --- a/src/pydicom/data/urls.json +++ b/src/pydicom/data/urls.json @@ -21,6 +21,7 @@ "JPGLosslessP14SV1_1s_1f_8b.dcm": "https://github.com/pydicom/pydicom-data/raw/39a2eb31815eec435dc26c322c27aec5cfcbddb6/data/JPGLosslessP14SV1_1s_1f_8b.dcm", "liver.dcm": "https://github.com/pydicom/pydicom-data/raw/39a2eb31815eec435dc26c322c27aec5cfcbddb6/data/liver.dcm", "liver_expb.dcm": "https://github.com/pydicom/pydicom-data/raw/39a2eb31815eec435dc26c322c27aec5cfcbddb6/data/liver_expb.dcm", + "liver_nonbyte_aligned.dcm": "https://github.com/pydicom/pydicom-data/raw/8da482f208401d63cd63f3f4efc41b6856ef36c7/data_store/data/liver_nonbyte_aligned.dcm", "mlut_18.dcm": "https://github.com/pydicom/pydicom-data/raw/39a2eb31815eec435dc26c322c27aec5cfcbddb6/data/mlut_18.dcm", "MR-SIEMENS-DICOM-WithOverlays.dcm": "https://github.com/pydicom/pydicom-data/raw/39a2eb31815eec435dc26c322c27aec5cfcbddb6/data/MR-SIEMENS-DICOM-WithOverlays.dcm", "MR2_J2KI.dcm": "https://github.com/pydicom/pydicom-data/raw/39a2eb31815eec435dc26c322c27aec5cfcbddb6/data/MR2_J2KI.dcm", @@ -74,5 +75,7 @@ "JLSL_RGB_ILV2.dcm": "https://github.com/pydicom/pydicom-data/raw/7358d21f75fa8cdc8ae4e0bb02f7612c52a140ec/data_store/data/JLSL_RGB_ILV2.dcm", "JLSN_RGB_ILV0.dcm": "https://github.com/pydicom/pydicom-data/raw/7358d21f75fa8cdc8ae4e0bb02f7612c52a140ec/data_store/data/JLSN_RGB_ILV0.dcm", "JLSL_08_07_0_1F.dcm": "https://github.com/pydicom/pydicom-data/raw/831d25636b0aff13916975c493b20d6fadc01d2e/data_store/data/JLSL_08_07_0_1F.dcm", - "JLSL_16_15_1_1F.dcm": "https://github.com/pydicom/pydicom-data/raw/eaaec0d930f5e578db5e56892da57a8e63af71d7/data_store/data/JLSL_16_15_1_1F.dcm" + "JLSL_16_15_1_1F.dcm": "https://github.com/pydicom/pydicom-data/raw/eaaec0d930f5e578db5e56892da57a8e63af71d7/data_store/data/JLSL_16_15_1_1F.dcm", + "parametric_map_float.dcm": "https://github.com/pydicom/pydicom-data/raw/812f8edacbb6a1f3606ff4a9c16c54b831e9fd3b/data_store/data/parametric_map_float.dcm", + "parametric_map_double_float.dcm": "https://github.com/pydicom/pydicom-data/raw/812f8edacbb6a1f3606ff4a9c16c54b831e9fd3b/data_store/data/parametric_map_double_float.dcm" } diff --git a/src/pydicom/examples/__init__.py b/src/pydicom/examples/__init__.py index d20e2ccebb..3b6744f1b4 100644 --- a/src/pydicom/examples/__init__.py +++ b/src/pydicom/examples/__init__.py @@ -7,20 +7,22 @@ from pydicom.filereader import dcmread +# All datasets included here must be available in the package itself +# NOT via the pydicom-data download method _DATASETS: dict[str, str] = { "ct": cast(str, get_testdata_file("CT_small.dcm")), "dicomdir": cast(str, get_testdata_file("DICOMDIR")), - "jpeg2k": cast(str, get_testdata_file("US1_J2KR.dcm")), + "jpeg2k": cast(str, get_testdata_file("examples_jpeg2k.dcm")), "mr": cast(str, get_testdata_file("MR_small.dcm")), "no_meta": cast(str, get_testdata_file("no_meta.dcm")), - "overlay": cast(str, get_testdata_file("MR-SIEMENS-DICOM-WithOverlays.dcm")), - "palette_color": cast(str, get_testdata_file("OBXXXX1A.dcm")), - "rgb_color": cast(str, get_testdata_file("US1_UNCR.dcm")), + "overlay": cast(str, get_testdata_file("examples_overlay.dcm")), + "palette_color": cast(str, get_testdata_file("examples_palette.dcm")), + "rgb_color": cast(str, get_testdata_file("examples_rgb_color.dcm")), "rt_dose": cast(str, get_testdata_file("rtdose.dcm")), "rt_plan": cast(str, get_testdata_file("rtplan.dcm")), "rt_ss": cast(str, get_testdata_file("rtstruct.dcm")), "waveform": cast(str, get_testdata_file("waveform_ecg.dcm")), - "ybr_color": cast(str, get_testdata_file("color3d_jpeg_baseline.dcm")), + "ybr_color": cast(str, get_testdata_file("examples_ybr_color.dcm")), } diff --git a/src/pydicom/pixels/common.py b/src/pydicom/pixels/common.py index 89852aabe3..10a4a930ac 100644 --- a/src/pydicom/pixels/common.py +++ b/src/pydicom/pixels/common.py @@ -357,7 +357,7 @@ def extended_offsets( """ return self._opts.get("extended_offsets", None) - def frame_length(self, unit: str = "bytes") -> int: + 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. @@ -372,22 +372,32 @@ def frame_length(self, unit: str = "bytes") -> int: Returns ------- - int - 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. + 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 = self.rows * self.columns * self.samples_per_pixel + 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: - # 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) + 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 @@ -572,15 +582,17 @@ def _validate_options(self) -> None: "is invalid, it must be 1 or a multiple of 8 and in the range (1, 64)" ) - if self._opts.get("bits_stored") is None: - raise AttributeError(f"{prefix},0101) 'Bits Stored'") + 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 " - f"the (0028,0100) 'Bits Allocated' value of '{self.bits_allocated}'" - ) + 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'") diff --git a/src/pydicom/pixels/decoders/base.py b/src/pydicom/pixels/decoders/base.py index e3b3c7ef69..ec6a138cc6 100644 --- a/src/pydicom/pixels/decoders/base.py +++ b/src/pydicom/pixels/decoders/base.py @@ -4,6 +4,7 @@ from collections.abc import Callable, Iterator, Iterable import logging from io import BufferedIOBase +from math import ceil, floor import sys from typing import Any, BinaryIO, cast, TYPE_CHECKING @@ -537,7 +538,6 @@ def pixel_properties(self, as_frame: bool = False) -> dict[str, str | int]: """ d = { "bits_allocated": self.bits_allocated, - "bits_stored": self.bits_stored, "columns": self.columns, "number_of_frames": self.number_of_frames if not as_frame else 1, "photometric_interpretation": str(self.photometric_interpretation), @@ -549,6 +549,7 @@ def pixel_properties(self, as_frame: bool = False) -> dict[str, str | int]: d["planar_configuration"] = self.planar_configuration if self.pixel_keyword == "PixelData": + d["bits_stored"] = self.bits_stored d["pixel_representation"] = self.pixel_representation return cast(dict[str, str | int], d) @@ -757,7 +758,7 @@ def _validate_buffer(self) -> None: """Validate the supplied buffer data.""" # Check that the actual length of the pixel data is as expected frame_length = self.frame_length(unit="bytes") - expected = frame_length * self.number_of_frames + expected = ceil(frame_length * self.number_of_frames) actual = len(cast(Buffer, self._src)) if self.transfer_syntax.is_encapsulated: @@ -1045,7 +1046,7 @@ def _as_array_encapsulated(runner: DecodeRunner, index: int | None) -> "np.ndarr # inserting it into the preallocated array, then resetting at the end # so the returned image pixel dict matches the array original_bits_allocated = runner.bits_allocated - pixels_per_frame = runner.frame_length(unit="pixels") + pixels_per_frame = cast(int, runner.frame_length(unit="pixels")) number_of_frames = 1 if index is not None else runner.number_of_frames # Preallocate output array @@ -1114,13 +1115,14 @@ def _as_array_native(runner: DecodeRunner, index: int | None) -> "np.ndarray": A 1D array containing the pixel data. """ length_bytes = runner.frame_length(unit="bytes") + length_pixels = int(runner.frame_length(unit="pixels")) dtype = runner.pixel_dtype src: memoryview | BinaryIO if runner.is_dataset or runner.is_buffer: src = memoryview(cast(Buffer, runner.src)) file_offset = 0 - length_source = len(src) + length_source: int | float = len(src) else: src = cast(BinaryIO, runner.src) # Should be the start of the pixel data element's value @@ -1141,6 +1143,11 @@ def _as_array_native(runner: DecodeRunner, index: int | None) -> "np.ndarray": "'Explicit VR Big Endian'" ) + # Since we are using 8 bit images, frames will always be an integer + # number of bytes + length_bytes = cast(int, length_bytes) + length_source = cast(int, length_source) + # ndarray.byteswap() creates a new memory object if index is not None: # Return specified frame only @@ -1177,17 +1184,18 @@ def _as_array_native(runner: DecodeRunner, index: int | None) -> "np.ndarray": arr = arr.view(dtype)[:length_bytes] else: if index is not None: - start_offset = file_offset + index * length_bytes + start_offset = floor(file_offset + index * length_bytes) if (start_offset + length_bytes) > file_offset + length_source: raise ValueError( f"There is insufficient pixel data to contain {index + 1} frames" ) - frame = runner.get_data(src, start_offset, length_bytes) + frame = runner.get_data(src, start_offset, ceil(length_bytes)) arr = np.frombuffer(frame, dtype=dtype) else: length_bytes *= runner.number_of_frames - buffer = runner.get_data(src, file_offset, length_bytes) + + buffer = runner.get_data(src, file_offset, ceil(length_bytes)) arr = np.frombuffer(buffer, dtype=dtype) # Unpack bit-packed data (if required) @@ -1198,11 +1206,23 @@ def _as_array_native(runner: DecodeRunner, index: int | None) -> "np.ndarray": "original buffer for bit-packed pixel data" ) - length_pixels = runner.frame_length(unit="pixels") + # Number of bits to remove from the start after unpacking in + bit_offset_start = 0 + if index is None: length_pixels *= runner.number_of_frames + else: + bit_offset_start = (index * length_pixels) % 8 + + unpacked = np.unpackbits( + arr, bitorder="little", count=ceil(length_bytes) * 8 + ) + + # May need to remove bits from the beginning or end if frame + # boundaries are not byte-aligned + unpacked = unpacked[bit_offset_start : bit_offset_start + length_pixels] - return np.unpackbits(arr, bitorder="little", count=length_pixels) + return unpacked if runner.photometric_interpretation != PI.YBR_FULL_422: return arr @@ -1441,8 +1461,20 @@ def _as_buffer_native(runner: DecodeRunner, index: int | None) -> Buffer: same type as in the buffer containing the pixel data unless `view_only` is ``True`` in which case a :class:`memoryview` of the original buffer will be returned instead. + + Notes + ----- + For certain images, those with BitsAllocated=1, multiple frames and + number of pixels per frame that is not a multiple of 8, it is not + possible to isolate a buffer to a single frame because frame boundaries + may occur within the middle a byte. If a single frame is requested (via + ``index``) for these cases, the buffer returned will consist of the + smallest set of bytes required to entirely contain the requested frame. + However, the first and last byte may also contain information on pixel + values in neighboring frames. """ length_bytes = runner.frame_length(unit="bytes") + src: Buffer | BinaryIO if runner.is_dataset or runner.is_buffer: if runner.get_option("view_only", False): @@ -1451,13 +1483,19 @@ def _as_buffer_native(runner: DecodeRunner, index: int | None) -> Buffer: src = cast(Buffer, runner.src) file_offset = 0 - length_source = len(src) + length_source: int | float = len(src) else: src = cast(BinaryIO, runner.src) file_offset = src.tell() length_source = length_bytes * runner.number_of_frames if runner._test_for("be_swap_ow"): + + # Since we are using 8 bit images, frames will always be an integer + # number of bytes + length_bytes = cast(int, length_bytes) + length_source = cast(int, length_source) + # Big endian 8-bit data encoded as OW if index is not None: # Return specified frame only @@ -1484,17 +1522,17 @@ def _as_buffer_native(runner: DecodeRunner, index: int | None) -> Buffer: if index is not None: # Return specified frame only - start_offset = file_offset + index * length_bytes + start_offset = floor(file_offset + index * length_bytes) if start_offset + length_bytes > file_offset + length_source: raise ValueError( f"There is insufficient pixel data to contain {index + 1} frames" ) - return runner.get_data(src, start_offset, length_bytes) + return runner.get_data(src, start_offset, ceil(length_bytes)) # Return all frames length_bytes *= runner.number_of_frames - return runner.get_data(src, file_offset, length_bytes) + return runner.get_data(src, file_offset, ceil(length_bytes)) def iter_array( self, diff --git a/src/pydicom/pixels/encoders/base.py b/src/pydicom/pixels/encoders/base.py index 652ad65f05..2143807db4 100644 --- a/src/pydicom/pixels/encoders/base.py +++ b/src/pydicom/pixels/encoders/base.py @@ -175,12 +175,12 @@ def _get_frame_buffer(self, index: int | None) -> bytes | bytearray: # 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 = self.frame_length(unit="bytes") + 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 = self.frame_length(unit="pixels") + expected_length = cast(int, self.frame_length(unit="pixels")) bytes_per_pixel = len(src) // expected_length # 1 byte/px actual diff --git a/src/pydicom/pixels/utils.py b/src/pydicom/pixels/utils.py index b279a3e032..1fbca7334c 100644 --- a/src/pydicom/pixels/utils.py +++ b/src/pydicom/pixels/utils.py @@ -1274,7 +1274,7 @@ def _passes_version_check(package_name: str, minimum_version: tuple[int, ...]) - module = importlib.import_module(package_name, "__version__") return tuple(int(x) for x in module.__version__.split(".")) >= minimum_version except Exception as exc: - LOGGER.exception(exc) + LOGGER.debug(exc) return False diff --git a/tests/pixels/pixels_reference.py b/tests/pixels/pixels_reference.py index 16f24e0bfe..949ea296cd 100644 --- a/tests/pixels/pixels_reference.py +++ b/tests/pixels/pixels_reference.py @@ -187,7 +187,7 @@ def test(ref, arr, **kwargs): # Frame 3 if index in (None, 2): frame = arr if index == 2 else arr[2] - assert 0 == frame[511][511] + assert 0 == frame[-1][-1] assert 0 == frame[147, :249].max() assert (0, 1, 0, 1, 1, 1) == tuple(frame[147, 248:254]) assert (1, 0, 1, 0, 1, 1) == tuple(frame[147, 260:266]) @@ -201,6 +201,11 @@ def test(ref, arr, **kwargs): EXPL_1_1_3F = PixelReference("liver.dcm", "u1", test) +# Same image cropped from 512 x 512 to 510 x 511 such that frame boundaries are +# no longer aligned with byte boundaries +EXPL_1_1_3F_NONALIGNED = PixelReference("liver_nonbyte_aligned.dcm", "u1", test) + + # DEFL, (8, 8), (1, 512, 512, 1), OB, MONOCHROME2, 0 def test(ref, arr, **kwargs): assert 41 == arr[10].min() @@ -604,9 +609,32 @@ def test(ref, arr, **kwargs): EXPL_32_3_2F = PixelReference("SC_rgb_32bit_2frame.dcm", " 1 bit depth - ((0, 0, 0), 1, (0, 0, None)), - ((1, 1, 1), 1, (1, 1, None)), # 1 bit -> 1 byte - ((1, 1, 3), 1, (1, 3, None)), # 3 bits -> 1 byte - ((1, 3, 3), 1, (2, 9, None)), # 9 bits -> 2 bytes - ((2, 2, 1), 1, (1, 4, None)), # 4 bits -> 1 byte - ((2, 4, 1), 1, (1, 8, None)), # 8 bits -> 1 byte - ((3, 3, 1), 1, (2, 9, None)), # 9 bits -> 2 bytes - ((512, 512, 1), 1, (32768, 262144, None)), # Typical length - ((512, 512, 3), 1, (98304, 786432, None)), - ((0, 0, 0), 8, (0, 0, None)), - ((1, 1, 1), 8, (1, 1, None)), # Odd length - ((9, 1, 1), 8, (9, 9, None)), # Odd length - ((1, 2, 1), 8, (2, 2, None)), # Even length - ((512, 512, 1), 8, (262144, 262144, None)), - ((512, 512, 3), 8, (786432, 786432, 524288)), - ((0, 0, 0), 16, (0, 0, None)), - ((1, 1, 1), 16, (2, 1, None)), # 16 bit data can't be odd length - ((1, 2, 1), 16, (4, 2, None)), - ((512, 512, 1), 16, (524288, 262144, None)), - ((512, 512, 3), 16, (1572864, 786432, 1048576)), - ((0, 0, 0), 32, (0, 0, None)), - ((1, 1, 1), 32, (4, 1, None)), # 32 bit data can't be odd length - ((1, 2, 1), 32, (8, 2, None)), - ((512, 512, 1), 32, (1048576, 262144, None)), - ((512, 512, 3), 32, (3145728, 786432, 2097152)), + ((0, 0, 0), 1, (0, 0, 0, None)), + ((1, 1, 1), 1, (0.125, 1, 1, None)), # 1 bit -> 1/8 byte + ((1, 1, 3), 1, (0.375, 1, 3, None)), # 3 bits -> 3/8 byte + ((1, 3, 3), 1, (1.125, 2, 9, None)), # 9 bits -> 1 1/8 bytes + ((2, 2, 1), 1, (0.5, 1, 4, None)), # 4 bits -> 1/2 byte + ((2, 4, 1), 1, (1, 1, 8, None)), # 8 bits -> 1 byte + ((3, 3, 1), 1, (1.125, 2, 9, None)), # 9 bits -> 1 1/8 bytes + ((512, 512, 1), 1, (32768, 32768, 262144, None)), # Typical length + ((512, 512, 3), 1, (98304, 98304, 786432, None)), + ((0, 0, 0), 8, (0, 0, 0, None)), + ((1, 1, 1), 8, (1, 1, 1, None)), # Odd length + ((9, 1, 1), 8, (9, 9, 9, None)), # Odd length + ((1, 2, 1), 8, (2, 2, 2, None)), # Even length + ((512, 512, 1), 8, (262144, 262144, 262144, None)), + ((512, 512, 3), 8, (786432, 786432, 786432, 524288)), + ((0, 0, 0), 16, (0, 0, 0, None)), + ((1, 1, 1), 16, (2, 2, 1, None)), # 16 bit data can't be odd length + ((1, 2, 1), 16, (4, 4, 2, None)), + ((512, 512, 1), 16, (524288, 524288, 262144, None)), + ((512, 512, 3), 16, (1572864, 1572864, 786432, 1048576)), + ((0, 0, 0), 32, (0, 0, 0, None)), + ((1, 1, 1), 32, (4, 4, 1, None)), # 32 bit data can't be odd length + ((1, 2, 1), 32, (8, 8, 2, None)), + ((512, 512, 1), 32, (1048576, 1048576, 262144, None)), + ((512, 512, 3), 32, (3145728, 3145728, 786432, 2097152)), ] @@ -214,6 +214,7 @@ def test_validate_options(self): """Tests for validate_options()""" # Generic option validation runner = RunnerBase(ExplicitVRLittleEndian) + runner.set_option("pixel_keyword", "PixelData") msg = r"Missing required element: \(0028,0100\) 'Bits Allocated'" with pytest.raises(AttributeError, match=msg): @@ -281,13 +282,15 @@ def test_validate_options(self): with pytest.raises(ValueError, match=msg): runner._validate_options() + runner.del_option("pixel_keyword") + runner.set_option("photometric_interpretation", PI.RGB) msg = "No value for 'pixel_keyword' has been set" with pytest.raises(AttributeError, match=msg): runner._validate_options() - runner.set_option("pixel_keyword", -1) - msg = "Unknown 'pixel_keyword' value '-1'" + runner.set_option("pixel_keyword", "foo") + msg = "Unknown 'pixel_keyword' value 'foo'" with pytest.raises(ValueError, match=msg): runner._validate_options() @@ -440,15 +443,15 @@ def test_frame_length(self, shape, bits, length): encaps_runner.set_options(**opts) assert length[0] == native_runner.frame_length(unit="bytes") - assert length[1] == native_runner.frame_length(unit="pixels") - assert length[0] == encaps_runner.frame_length(unit="bytes") - assert length[1] == encaps_runner.frame_length(unit="pixels") + assert length[2] == native_runner.frame_length(unit="pixels") + assert length[1] == encaps_runner.frame_length(unit="bytes") + assert length[2] == encaps_runner.frame_length(unit="pixels") if shape[2] == 3 and bits != 1: native_runner.set_option("photometric_interpretation", PI.YBR_FULL_422) encaps_runner.set_option("photometric_interpretation", PI.YBR_FULL_422) - assert length[2] == native_runner.frame_length(unit="bytes") - assert length[0] == encaps_runner.frame_length(unit="bytes") + assert length[3] == native_runner.frame_length(unit="bytes") + assert length[1] == encaps_runner.frame_length(unit="bytes") class TestCoderBase: diff --git a/tests/pixels/test_decoder_base.py b/tests/pixels/test_decoder_base.py index b53a299323..db8b428d55 100644 --- a/tests/pixels/test_decoder_base.py +++ b/tests/pixels/test_decoder_base.py @@ -2,6 +2,7 @@ from io import BytesIO import logging +from math import ceil from struct import pack, unpack from sys import byteorder @@ -34,6 +35,7 @@ HAVE_NP = False from .pixels_reference import ( + EXPL_1_1_3F_NONALIGNED, PIXEL_REFERENCE, RLE_16_1_1F, RLE_16_1_10F, @@ -356,6 +358,14 @@ def test_validate_options(self): runner.set_option("extended_offsets", ([0], [10])) runner._validate_options() + # Float/Double Float Pixel Data + runner.del_option("bits_stored") + runner.set_option("pixel_keyword", "FloatPixelData") + runner._validate_options() + + runner.set_option("pixel_keyword", "DoubleFloatPixelData") + runner._validate_options() + @pytest.mark.skipif(not HAVE_NP, reason="Numpy is not available") def test_decode(self): """Test decode()""" @@ -1621,6 +1631,37 @@ def test_native_view_only(self): assert arr.tobytes() == buffer assert buffer.obj is src + def test_native_single_bit_nonaligned(self): + """Test `as_buffer` with a single bit image whose frame boundaries are + not aligned with byte boundaries.""" + decoder = get_decoder(ExplicitVRLittleEndian) + + assert decoder.is_available + + reference = EXPL_1_1_3F_NONALIGNED + full_buffer, _ = decoder.as_buffer(reference.ds) + + full_len = ceil( + ( + reference.ds.Rows + * reference.ds.Columns + * reference.ds.SamplesPerPixel + * reference.ds.NumberOfFrames + ) + / 8 + ) + assert len(full_buffer) == full_len + + # When requesting a single frame, the returned buffer will contain some + # pixels from neighnoring frames + frame_1_buffer, _ = decoder.as_buffer(reference.ds, index=1) + + frame_length_pixels = reference.ds.Rows * reference.ds.Columns + assert ( + frame_1_buffer + == full_buffer[frame_length_pixels // 8 : ceil(2 * frame_length_pixels / 8)] + ) + def test_encapsulated_index(self): """Test `index` with an encapsulated pixel data.""" decoder = get_decoder(RLELossless) diff --git a/tests/pixels/test_decoder_gdcm.py b/tests/pixels/test_decoder_gdcm.py index 1fee917c9a..85dab4c0e8 100644 --- a/tests/pixels/test_decoder_gdcm.py +++ b/tests/pixels/test_decoder_gdcm.py @@ -12,7 +12,7 @@ except ImportError: HAVE_NP = False -from pydicom import dcmread +from pydicom import dcmread, config from pydicom.pixels import get_decoder from pydicom.pixels.utils import _passes_version_check from pydicom.uid import ( @@ -239,10 +239,18 @@ def test_jfif(self): assert meta["photometric_interpretation"] == "YBR_FULL_422" +@pytest.fixture() +def enable_debugging(): + original = config.debugging + config.debugging = True + yield + config.debugging = original + + @pytest.mark.skipif(SKIP_TEST, reason="Test is missing dependencies") -def test_version_check(caplog): +def test_version_check(enable_debugging, caplog): """Test _passes_version_check() when the package has no __version__""" # GDCM doesn't have a __version__ attribute - with caplog.at_level(logging.ERROR, logger="pydicom"): + with caplog.at_level(logging.DEBUG, logger="pydicom"): assert _passes_version_check("gdcm", (3, 0)) is False assert "module 'gdcm' has no attribute '__version__'" in caplog.text diff --git a/tests/pixels/test_decoder_native.py b/tests/pixels/test_decoder_native.py index c731590b3f..e43c523ada 100644 --- a/tests/pixels/test_decoder_native.py +++ b/tests/pixels/test_decoder_native.py @@ -25,13 +25,31 @@ except ImportError: HAVE_NP = False -from .pixels_reference import PIXEL_REFERENCE, EXPL_16_1_1F_PAD, IMPL_32_1_1F +from .pixels_reference import ( + PIXEL_REFERENCE, + EXPL_16_1_1F_PAD, + IMPL_32_1_1F, + EXPL_64_1F_DOUBLE_FLOAT, +) def name(ref): return f"{ref.name}" +def get_pixel_keyword(ds): + if "PixelData" in ds: + return "PixelData" + + if "FloatPixelData" in ds: + return "FloatPixelData" + + if "DoubleFloatPixelData" in ds: + return "DoubleFloatPixelData" + + raise ValueError("No pixel data element found") + + @pytest.mark.skipif(not HAVE_NP, reason="NumPy is not available") class TestAsArray: """Tests for decoder.as_array() with native transfer syntaxes""" @@ -82,21 +100,25 @@ def test_reference_expl_binary(self, reference): """Test against the reference data for explicit little for binary IO.""" decoder = get_decoder(ExplicitVRLittleEndian) ds = reference.ds + pixel_keyword = get_pixel_keyword(ds) + opts = { "rows": ds.Rows, "columns": ds.Columns, "samples_per_pixel": ds.SamplesPerPixel, "photometric_interpretation": ds.PhotometricInterpretation, - "pixel_representation": ds.PixelRepresentation, "bits_allocated": ds.BitsAllocated, - "bits_stored": ds.BitsStored, "number_of_frames": ds.get("NumberOfFrames", 1), "planar_configuration": ds.get("PlanarConfiguration", 0), - "pixel_keyword": "PixelData", + "pixel_keyword": pixel_keyword, } + if pixel_keyword == "PixelData": + opts["bits_stored"] = ds.BitsStored + opts["pixel_representation"] = ds.PixelRepresentation + with open(reference.path, "rb") as f: - file_offset = reference.ds["PixelData"].file_tell + file_offset = reference.ds[pixel_keyword].file_tell f.seek(file_offset) arr, _ = decoder.as_array(f, raw=True, **opts) assert f.tell() == file_offset @@ -229,35 +251,6 @@ def test_reference_expb_binary(self, reference): else: assert arr.shape == reference.shape[1:] - def test_float_pixel_data(self): - """Test Float Pixel Data.""" - # Only 1 sample per pixel allowed - ds = dcmread(IMPL_32_1_1F.path) - ds.FloatPixelData = ds.PixelData - del ds.PixelData - assert 32 == ds.BitsAllocated - decoder = get_decoder(ds.file_meta.TransferSyntaxUID) - arr, _ = decoder.as_array(ds, raw=True) - assert "float32" == arr.dtype - - ref, _ = decoder.as_array(IMPL_32_1_1F.ds, raw=True) - assert np.array_equal(arr, ref.view("float32")) - - def test_double_float_pixel_data(self): - """Test Double Float Pixel Data.""" - # Only 1 sample per pixel allowed - ds = dcmread(IMPL_32_1_1F.path) - ds.DoubleFloatPixelData = ds.PixelData + ds.PixelData - del ds.PixelData - ds.BitsAllocated = 64 - decoder = get_decoder(ds.file_meta.TransferSyntaxUID) - arr, _ = decoder.as_array(ds, raw=True) - assert "float64" == arr.dtype - - ref, _ = decoder.as_array(IMPL_32_1_1F.ds, raw=True) - assert np.array_equal(arr.ravel()[:50], ref.view("float64").ravel()) - assert np.array_equal(arr.ravel()[50:], ref.view("float64").ravel()) - @pytest.mark.skipif(not HAVE_NP, reason="NumPy is not available") class TestIterArray: @@ -306,21 +299,25 @@ def test_reference_expl_binary(self, reference): """Test against the reference data for explicit little for binary IO.""" decoder = get_decoder(ExplicitVRLittleEndian) ds = reference.ds + pixel_keyword = get_pixel_keyword(ds) + opts = { "rows": ds.Rows, "columns": ds.Columns, "samples_per_pixel": ds.SamplesPerPixel, "photometric_interpretation": ds.PhotometricInterpretation, - "pixel_representation": ds.PixelRepresentation, "bits_allocated": ds.BitsAllocated, - "bits_stored": ds.BitsStored, "number_of_frames": ds.get("NumberOfFrames", 1), "planar_configuration": ds.get("PlanarConfiguration", 0), - "pixel_keyword": "PixelData", + "pixel_keyword": pixel_keyword, } + if pixel_keyword == "PixelData": + opts["bits_stored"] = ds.BitsStored + opts["pixel_representation"] = ds.PixelRepresentation + with open(reference.path, "rb") as f: - file_offset = reference.ds["PixelData"].file_tell + file_offset = reference.ds[pixel_keyword].file_tell f.seek(file_offset) frame_generator = decoder.iter_array(f, raw=True, **opts) @@ -492,21 +489,25 @@ def test_reference_expl_binary(self, reference): return ds = reference.ds + pixel_keyword = get_pixel_keyword(ds) + opts = { "rows": ds.Rows, "columns": ds.Columns, "samples_per_pixel": ds.SamplesPerPixel, "photometric_interpretation": ds.PhotometricInterpretation, - "pixel_representation": ds.PixelRepresentation, "bits_allocated": ds.BitsAllocated, - "bits_stored": ds.BitsStored, "number_of_frames": ds.get("NumberOfFrames", 1), "planar_configuration": ds.get("PlanarConfiguration", 0), - "pixel_keyword": "PixelData", + "pixel_keyword": pixel_keyword, } + if pixel_keyword == "PixelData": + opts["bits_stored"] = ds.BitsStored + opts["pixel_representation"] = ds.PixelRepresentation + with open(reference.path, "rb") as f: - file_offset = reference.ds["PixelData"].file_tell + file_offset = reference.ds[pixel_keyword].file_tell f.seek(file_offset) arr, _ = decoder.as_array(f, raw=True, **opts) buffer, _ = decoder.as_buffer(f, **opts) @@ -705,28 +706,6 @@ def test_expb_8bit_ow_binary(self): out[:27] = arr.ravel() assert out.view(">u2").byteswap().tobytes() == buffer - def test_float_pixel_data(self): - """Test Float Pixel Data.""" - ds = dcmread(IMPL_32_1_1F.path) - ref = ds.PixelData - ds.FloatPixelData = ref - del ds.PixelData - assert 32 == ds.BitsAllocated - decoder = get_decoder(ds.file_meta.TransferSyntaxUID) - buffer, _ = decoder.as_buffer(ds, raw=True) - assert buffer == ref - - def test_double_float_pixel_data(self): - """Test Double Float Pixel Data.""" - ds = dcmread(IMPL_32_1_1F.path) - ref = ds.PixelData + ds.PixelData - ds.DoubleFloatPixelData = ref - del ds.PixelData - ds.BitsAllocated = 64 - decoder = get_decoder(ds.file_meta.TransferSyntaxUID) - buffer, _ = decoder.as_buffer(ds, raw=True) - assert buffer == ref - @pytest.mark.skipif(not HAVE_NP, reason="NumPy is not available") class TestIterBuffer: @@ -776,21 +755,25 @@ def test_reference_expl_binary(self, reference): return ds = reference.ds + pixel_keyword = get_pixel_keyword(ds) + opts = { "rows": ds.Rows, "columns": ds.Columns, "samples_per_pixel": ds.SamplesPerPixel, "photometric_interpretation": ds.PhotometricInterpretation, - "pixel_representation": ds.PixelRepresentation, "bits_allocated": ds.BitsAllocated, - "bits_stored": ds.BitsStored, "number_of_frames": ds.get("NumberOfFrames", 1), "planar_configuration": ds.get("PlanarConfiguration", 0), - "pixel_keyword": "PixelData", + "pixel_keyword": pixel_keyword, } + if pixel_keyword == "PixelData": + opts["bits_stored"] = ds.BitsStored + opts["pixel_representation"] = ds.PixelRepresentation + with open(reference.path, "rb") as f: - file_offset = reference.ds["PixelData"].file_tell + file_offset = reference.ds[pixel_keyword].file_tell f.seek(file_offset) arr_gen = decoder.iter_array(f, raw=True, **opts) buf_gen = decoder.iter_buffer(f, **opts) diff --git a/tests/pixels/test_encoder_pylibjpeg.py b/tests/pixels/test_encoder_pylibjpeg.py index db19a59877..82e729503a 100644 --- a/tests/pixels/test_encoder_pylibjpeg.py +++ b/tests/pixels/test_encoder_pylibjpeg.py @@ -40,6 +40,7 @@ IMPL = get_testdata_file("MR_small_implicit.dcm") EXPL = get_testdata_file("OBXXXX1A.dcm") +RGB = get_testdata_file("US1_UNCR.dcm") @pytest.mark.skipif(SKIP_RLE, reason="no -rle plugin") @@ -121,7 +122,7 @@ def setup_method(self): arr /= arr.max() self.ref = arr - ds = examples.rgb_color + ds = dcmread(RGB) arr = ds.pixel_array arr = arr.astype("float32") @@ -241,7 +242,7 @@ def test_arr_u4_spp1(self): def test_arr_u1_spp3(self): """Test unsigned bits allocated 8, bits stored (1, 8), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -278,7 +279,7 @@ def test_arr_u1_spp3(self): def test_arr_u2_spp3(self): """Test unsigned bits allocated 16, bits stored (1, 16), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -313,7 +314,7 @@ def test_arr_u2_spp3(self): def test_arr_u4_spp3(self): """Test unsigned bits allocated 32, bits stored (1, 24), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -571,7 +572,7 @@ def test_buffer_u4_spp1(self): def test_buffer_u1_spp3(self): """Test unsigned bits allocated 8, bits stored (1, 8), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -611,7 +612,7 @@ def test_buffer_u1_spp3(self): def test_buffer_u2_spp3(self): """Test unsigned bits allocated 16, bits stored (1, 16), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -649,7 +650,7 @@ def test_buffer_u2_spp3(self): def test_buffer_u4_spp3(self): """Test unsigned bits allocated 32, bits stored (1, 24), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -801,7 +802,7 @@ def test_buffer_i4_spp1(self): def test_mct(self): """Test that MCT is used correctly""" # If RGB then no MCT - ds = examples.rgb_color + ds = dcmread(RGB) arr = ds.pixel_array opts = { "rows": ds.Rows, @@ -827,7 +828,7 @@ def test_mct(self): def test_lossy_kwargs_raise(self): """Test that lossy kwargs raise an exception""" - ds = examples.rgb_color + ds = dcmread(RGB) arr = ds.pixel_array opts = { "rows": ds.Rows, @@ -855,7 +856,7 @@ def test_lossy_kwargs_raise(self): def test_bits_stored_25_raises(self): """Test that bits stored > 24 raises an exception.""" - ds = examples.rgb_color + ds = dcmread(RGB) arr = ds.pixel_array opts = { "rows": ds.Rows, @@ -893,7 +894,7 @@ def setup_method(self): arr /= arr.max() self.ref = arr - ds = examples.rgb_color + ds = dcmread(RGB) arr = ds.pixel_array arr = arr.astype("float32") @@ -1011,7 +1012,7 @@ def test_arr_u4_spp1(self): def test_arr_u1_spp3(self): """Test unsigned bits allocated 8, bits stored (1, 8), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -1048,7 +1049,7 @@ def test_arr_u1_spp3(self): def test_arr_u2_spp3(self): """Test unsigned bits allocated 16, bits stored (1, 16), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -1083,7 +1084,7 @@ def test_arr_u2_spp3(self): def test_arr_u4_spp3(self): """Test unsigned bits allocated 32, bits stored (1, 24), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -1351,7 +1352,7 @@ def test_buffer_u4_spp1(self): def test_buffer_u1_spp3(self): """Test unsigned bits allocated 8, bits stored (1, 8), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -1391,7 +1392,7 @@ def test_buffer_u1_spp3(self): def test_buffer_u2_spp3(self): """Test unsigned bits allocated 16, bits stored (1, 16), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -1429,7 +1430,7 @@ def test_buffer_u2_spp3(self): def test_buffer_u4_spp3(self): """Test unsigned bits allocated 32, bits stored (1, 24), samples per pixel 3""" - ds = examples.rgb_color + ds = dcmread(RGB) opts = { "rows": ds.Rows, "columns": ds.Columns, @@ -1593,7 +1594,7 @@ def test_buffer_i4_spp1(self): def test_j2k_psnr(self): """Test compression using j2k_psnr""" - ds = examples.rgb_color + ds = dcmread(RGB) arr = ds.pixel_array opts = { "rows": ds.Rows, @@ -1620,7 +1621,7 @@ def test_j2k_psnr(self): def test_mct(self): """Test that MCT is used correctly""" # If RGB then no MCT - ds = examples.rgb_color + ds = dcmread(RGB) arr = ds.pixel_array opts = { "rows": ds.Rows, @@ -1647,7 +1648,7 @@ def test_mct(self): def test_both_lossy_kwargs_raises(self): """Test that having both lossy kwargs raises an exception""" - ds = examples.rgb_color + ds = dcmread(RGB) arr = ds.pixel_array opts = { "rows": ds.Rows, @@ -1674,7 +1675,7 @@ def test_both_lossy_kwargs_raises(self): def test_neither_lossy_kwargs_raises(self): """Test that having neither lossy kwarg raises an exception""" - ds = examples.rgb_color + ds = dcmread(RGB) arr = ds.pixel_array opts = { "rows": ds.Rows, @@ -1698,7 +1699,7 @@ def test_neither_lossy_kwargs_raises(self): def test_bits_stored_25_raises(self): """Test that bits stored > 24 raises an exception.""" - ds = examples.rgb_color + ds = dcmread(RGB) arr = ds.pixel_array opts = { "rows": ds.Rows, diff --git a/tests/pixels/test_utils.py b/tests/pixels/test_utils.py index 9841ebbb7c..d1050c0277 100644 --- a/tests/pixels/test_utils.py +++ b/tests/pixels/test_utils.py @@ -77,6 +77,7 @@ JLSN_08_01_1_0_1F, J2KR_08_08_3_0_1F_YBR_RCT, EXPL_1_1_3F, + EXPL_1_1_3F_NONALIGNED, ) from ..test_helpers import assert_no_warning @@ -479,9 +480,9 @@ def test_no_matching_decoder_raises(self): next(iter_pixels(b)) -def test_version_check(caplog): - """Test _passes_version_check() when the package is absent""" - with caplog.at_level(logging.ERROR, logger="pydicom"): +def test_version_check_debugging(caplog): + """Test _passes_version_check() when the package is absent and debugging on""" + with caplog.at_level(logging.DEBUG, logger="pydicom"): assert _passes_version_check("foo", (3, 0)) is False assert "No module named 'foo'" in caplog.text @@ -1385,6 +1386,13 @@ def test_functional(self): arr = arr.ravel() assert ds.PixelData == pack_bits(arr) + def test_functional_nonaligned(self): + """Test against a real dataset.""" + ds = EXPL_1_1_3F_NONALIGNED.ds + arr = ds.pixel_array + arr = arr.ravel() + assert ds.PixelData == pack_bits(arr) + @pytest.mark.skipif(not HAVE_NP, reason="Numpy is not available") class TestExpandYBR422: diff --git a/util/generate_dict/generate_private_dict.py b/util/generate_dict/generate_private_dict.py index 1d86c56287..2fb5cea361 100644 --- a/util/generate_dict/generate_private_dict.py +++ b/util/generate_dict/generate_private_dict.py @@ -4,6 +4,9 @@ from urllib.request import urlopen from pathlib import Path from collections import defaultdict +import sys + +from pydicom.valuerep import VR GDCM_PRIVATE_DICT = ( @@ -94,6 +97,18 @@ def parse_private_docbook(doc_root): vm = entry.attrib["vm"] name = entry.attrib["name"].replace("\\", "\\\\") # escape backslashes + # Check VR for conformance + try: + VR(vr) + except Exception: + print(f"Invalid VR found for {owner} {tag}: {vr}") + + if "_" in vr: + vr = vr.replace("_", " or ") + print(f" Replacing VR with {vr}") + else: + sys.exit() + # Convert unknown element names to 'Unknown' if name == "?": name = "Unknown"