diff --git a/.github/workflows/pytest-builds.yml b/.github/workflows/pytest-builds.yml index 09a0157..90d1e22 100644 --- a/.github/workflows/pytest-builds.yml +++ b/.github/workflows/pytest-builds.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] os: [ubuntu-latest, windows-latest, macos-latest] steps: @@ -24,18 +24,29 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install Rust (stable) run: curl https://sh.rustup.rs -sSf | sh -s -- -y - - name: Install dependencies + - name: Install dependencies (!= 3.14) + if: ${{ matrix.python-version != '3.14' }} run: | pip install --upgrade pip pip install setuptools-rust pytest pydicom coverage pytest-cov pip install git+https://github.com/pydicom/pylibjpeg-data pip install -e . + - name: Install dependencies (== 3.14) + if: ${{ matrix.python-version == '3.14' }} + run: | + pip install --upgrade pip + pip install setuptools-rust pytest coverage pytest-cov + pip install git+https://github.com/pydicom/pylibjpeg-data + pip install git+https://github.com/pydicom/pydicom + pip install -e . + - name: Test with pytest env: PYTHON_VERSION: ${{ matrix.python-version }} diff --git a/.github/workflows/release-wheels.yml b/.github/workflows/release-wheels.yml index 80f3c0d..01c5851 100644 --- a/.github/workflows/release-wheels.yml +++ b/.github/workflows/release-wheels.yml @@ -58,6 +58,9 @@ jobs: - os: windows-latest python: 313 platform_id: win32 + - os: windows-latest + python: 314 + platform_id: win32 # Windows 64 bit - os: windows-latest @@ -72,6 +75,9 @@ jobs: - os: windows-latest python: 313 platform_id: win_amd64 + - os: windows-latest + python: 314 + platform_id: win_amd64 # Linux 64 bit manylinux2014 - os: ubuntu-latest @@ -90,6 +96,10 @@ jobs: python: 313 platform_id: manylinux_x86_64 manylinux_image: manylinux2014 + - os: ubuntu-latest + python: 314 + platform_id: manylinux_x86_64 + manylinux_image: manylinux2014 # Linux aarch64 - os: ubuntu-latest @@ -104,6 +114,9 @@ jobs: - os: ubuntu-latest python: 313 platform_id: manylinux_aarch64 + - os: ubuntu-latest + python: 314 + platform_id: manylinux_aarch64 # MacOS x86_64 - os: macos-latest @@ -118,6 +131,9 @@ jobs: - os: macos-latest python: 313 platform_id: macosx_x86_64 + - os: macos-latest + python: 314 + platform_id: macosx_x86_64 # MacOS arm64 - os: macos-latest @@ -132,6 +148,9 @@ jobs: - os: macos-latest python: 313 platform_id: macosx_arm64 + - os: macos-latest + python: 314 + platform_id: macosx_arm64 steps: - uses: actions/checkout@v5 @@ -157,7 +176,7 @@ jobs: run: | python -m pip install -U pip python -m pip install -U setuptools-rust - python -m pip install cibuildwheel>=2.23 + python -m pip install cibuildwheel>=3.1.3 - name: Build wheels env: @@ -179,63 +198,63 @@ jobs: name: wheel-${{ matrix.python }}-${{ matrix.platform_id }} path: ./dist - test-package: - name: Test built package - needs: [ build-wheels, build-sdist ] - runs-on: ubuntu-latest - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - python-version: ['3.10', '3.11', '3.12', '3.13'] - - steps: - - name: Install Rust (stable) - run: - curl https://sh.rustup.rs -sSf | sh -s -- -y - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Download the wheels - uses: actions/download-artifact@v5 - with: - path: dist/ - merge-multiple: true - - - name: Install from package wheels and test - run: | - python -m venv testwhl - source testwhl/bin/activate - python -m pip install -U pip - python -m pip install -U pytest pydicom pylibjpeg - python -m pip uninstall -y pylibjpeg-rle - python -m pip install git+https://github.com/pydicom/pylibjpeg-data - python -m pip install -U --pre --find-links dist/ pylibjpeg-rle - python -m pytest --pyargs rle.tests - deactivate - - - name: Install from package tarball and test - run: | - python -m venv testsrc - source testsrc/bin/activate - python -m pip install -U pip - python -m pip install -U pytest pydicom pylibjpeg - python -m pip uninstall -y pylibjpeg-rle - python -m pip install git+https://github.com/pydicom/pylibjpeg-data - export PATH="$PATH:$HOME/.cargo/bin" - python -m pip install -U dist/pylibjpeg*rle-*.tar.gz - python -m pytest --pyargs rle.tests - deactivate + # test-package: + # name: Test built package + # needs: [ build-wheels, build-sdist ] + # runs-on: ubuntu-latest + # timeout-minutes: 30 + # strategy: + # fail-fast: false + # matrix: + # python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + # + # steps: + # - name: Install Rust (stable) + # run: + # curl https://sh.rustup.rs -sSf | sh -s -- -y + # + # - name: Set up Python ${{ matrix.python-version }} + # uses: actions/setup-python@v5 + # with: + # python-version: ${{ matrix.python-version }} + # + # - name: Download the wheels + # uses: actions/download-artifact@v4 + # with: + # path: dist/ + # merge-multiple: true + # + # - name: Install from package wheels and test + # run: | + # python -m venv testwhl + # source testwhl/bin/activate + # python -m pip install -U pip + # python -m pip install -U pytest pydicom pylibjpeg + # python -m pip uninstall -y pylibjpeg-rle + # python -m pip install git+https://github.com/pydicom/pylibjpeg-data + # python -m pip install -U --pre --find-links dist/ pylibjpeg-rle + # python -m pytest --pyargs rle.tests + # deactivate + # + # - name: Install from package tarball and test + # run: | + # python -m venv testsrc + # source testsrc/bin/activate + # python -m pip install -U pip + # python -m pip install -U pytest pydicom pylibjpeg + # python -m pip uninstall -y pylibjpeg-rle + # python -m pip install git+https://github.com/pydicom/pylibjpeg-data + # export PATH="$PATH:$HOME/.cargo/bin" + # python -m pip install -U dist/pylibjpeg*rle-*.tar.gz + # python -m pytest --pyargs rle.tests + # deactivate # The pypi upload fails with non-linux containers, so grab the uploaded # artifacts and run using those # See: https://github.com/pypa/gh-action-pypi-publish/discussions/15 deploy: name: Upload wheels to PyPI - needs: [ test-package ] + needs: [ build-wheels ] runs-on: ubuntu-latest environment: name: pypi diff --git a/Cargo.lock b/Cargo.lock index 77d35d4..354d199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,15 +4,9 @@ version = 4 [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "heck" @@ -28,9 +22,9 @@ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "memoffset" @@ -49,33 +43,32 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "portable-atomic" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] [[package]] name = "pylibjpeg-rle" -version = "1.1.0" +version = "1.2.0" dependencies = [ "pyo3", ] [[package]] name = "pyo3" -version = "0.24.2" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +checksum = "7ba0117f4212101ee6544044dae45abe1083d30ce7b29c4b5cbdfa2354e07383" dependencies = [ - "cfg-if", "indoc", "libc", "memoffset", @@ -89,19 +82,18 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.24.2" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +checksum = "4fc6ddaf24947d12a9aa31ac65431fb1b851b8f4365426e182901eabfb87df5f" dependencies = [ - "once_cell", "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.24.2" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +checksum = "025474d3928738efb38ac36d4744a74a400c901c7596199e20e45d98eb194105" dependencies = [ "libc", "pyo3-build-config", @@ -109,9 +101,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.24.2" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +checksum = "2e64eb489f22fe1c95911b77c44cc41e7c19f3082fc81cce90f657cdc42ffded" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -121,9 +113,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.24.2" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +checksum = "100246c0ecf400b475341b8455a9213344569af29a3c841d29270e53102e0fcf" dependencies = [ "heck", "proc-macro2", @@ -143,9 +135,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.101" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index a78c8e7..144a8ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pylibjpeg-rle" -version = "1.1.0" +version = "1.2.0" authors = ["scaramallion "] edition = "2024" exclude = [".github", "docs", ".codecov.yml", "asv.*", ".gitignore", ".coveragerc"] @@ -11,4 +11,4 @@ crate-type = ["cdylib"] [dependencies] -pyo3 = { version = "0.24.2", features = ["extension-module"] } +pyo3 = { version = "0.26.0", features = ["extension-module"] } diff --git a/LICENSE b/LICENSE index 62f3da2..133562c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020-2021 scaramallion +Copyright (c) 2020-2025 scaramallion Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6f0f08a..4782f7b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## pylibjpeg-rle -A fast DICOM ([PackBits](https://en.wikipedia.org/wiki/PackBits)) RLE plugin for [pylibjpeg](https://github.com/pydicom/pylibjpeg), written in Rust with a Python 3.7+ wrapper. +A fast DICOM ([PackBits](https://en.wikipedia.org/wiki/PackBits)) RLE plugin for [pylibjpeg](https://github.com/pydicom/pylibjpeg), written in Rust with a Python wrapper. Linux, MacOS and Windows are all supported. @@ -85,32 +85,32 @@ ds.save_as('as_rle.dcm') ### Benchmarks #### Decoding -Time per 1000 decodes, pydicom's default RLE handler vs. pylibjpeg-rle +Time per 1000 decodes, pydicom's default RLE decoder vs. pylibjpeg-rle: | Dataset | Pixels | Bytes | pydicom | pylibjpeg-rle | | --- | --- | --- | --- | --- | -| OBXXXX1A_rle.dcm | 480,000 | 480,000 | 4.89 s | 0.79 s | -| OBXXXX1A_rle_2frame.dcm | 960,000 | 960,000 | 9.89 s | 1.65 s | -| SC_rgb_rle.dcm | 10,000 | 30,000 | 0.20 s | 0.15 s | -| SC_rgb_rle_2frame.dcm | 20,000 | 60,000 | 0.32 s | 0.18 s | -| MR_small_RLE.dcm | 4,096 | 8,192 | 0.35 s | 0.13 s | -| emri_small_RLE.dcm | 40,960 | 81,920 | 1.13 s | 0.28 s | -| SC_rgb_rle_16bit.dcm | 10,000 | 60,000 | 0.33 s | 0.17 s | -| SC_rgb_rle_16bit_2frame.dcm | 20,000 | 120,000 | 0.56 s | 0.21 s | -| rtdose_rle_1frame.dcm | 100 | 400 | 0.12 s | 0.13 s | -| rtdose_rle.dcm | 1,500 | 6,000 | 0.53 s | 0.26 s | -| SC_rgb_rle_32bit.dcm | 10,000 | 120,000 | 0.56 s | 0.19 s | -| SC_rgb_rle_32bit_2frame.dcm | 20,000 | 240,000 | 1.03 s | 0.28 s | +| OBXXXX1A_rle.dcm | 480,000 | 480,000 | 5.7 s | 1.1 s | +| OBXXXX1A_rle_2frame.dcm | 960,000 | 960,000 | 11.5 s | 2.1 s | +| SC_rgb_rle.dcm | 10,000 | 30,000 | 0.28 s | 0.19 s | +| SC_rgb_rle_2frame.dcm | 20,000 | 60,000 | 0.45 s | 0.28 s | +| MR_small_RLE.dcm | 4,096 | 8,192 | 0.46 s | 0.15 s | +| emri_small_RLE.dcm | 40,960 | 81,920 | 1.8 s | 0.67 s | +| SC_rgb_rle_16bit.dcm | 10,000 | 60,000 | 0.48 s | 0.25 s | +| SC_rgb_rle_16bit_2frame.dcm | 20,000 | 120,000 | 0.86 s | 0.39 s | +| rtdose_rle_1frame.dcm | 100 | 400 | 0.16 s | 0.13 s | +| rtdose_rle.dcm | 1,500 | 6,000 | 1.0 s | 0.64 s | +| SC_rgb_rle_32bit.dcm | 10,000 | 120,000 | 0.82 s | 0.35 s | +| SC_rgb_rle_32bit_2frame.dcm | 20,000 | 240,000 | 1.5 s | 0.60 s | #### Encoding -Time per 1000 encodes, pydicom's default RLE handler vs. pylibjpeg-rle +Time per 1000 encodes, pydicom's default RLE encoder vs. pylibjpeg-rle and [python-gdcm](https://github.com/tfmoraes/python-gdcm): -| Dataset | Pixels | Bytes | pydicom | pylibjpeg-rle | -| --- | --- | --- | --- | --- | -| OBXXXX1A.dcm | 480,000 | 480,000 | 30.7 s | 1.36 s | -| SC_rgb.dcm | 10,000 | 30,000 | 1.80 s | 0.09 s | -| MR_small.dcm | 4,096 | 8,192 | 2.29 s | 0.04 s | -| SC_rgb_16bit.dcm | 10,000 | 60,000 | 3.57 s | 0.17 s | -| rtdose_1frame.dcm | 100 | 400 | 0.19 s | 0.003 s | -| SC_rgb_32bit.dcm | 10,000 | 120,000 | 7.20 s | 0.33 s | +| Dataset | Pixels | Bytes | pydicom | pylibjpeg-rle | python-gdcm | +| --- | --- | --- | --- | --- | --- | +| OBXXXX1A.dcm | 480,000 | 480,000 | 30.6 s | 1.4 s | 1.5 s | +| SC_rgb.dcm | 10,000 | 30,000 | 1.9 s | 0.11 s | 0.21 s | +| MR_small.dcm | 4,096 | 8,192 | 3.0 s | 0.11 s | 0.29 s | +| SC_rgb_16bit.dcm | 10,000 | 60,000 | 3.6 s | 0.18 s | 0.28 s | +| rtdose_1frame.dcm | 100 | 400 | 0.28 s | 0.04 s | 0.14 s | +| SC_rgb_32bit.dcm | 10,000 | 120,000 | 7.1 s | 0.32 s | 0.43 s | diff --git a/docs/release_notes/v2.2.0.rst b/docs/release_notes/v2.2.0.rst new file mode 100644 index 0000000..1a81007 --- /dev/null +++ b/docs/release_notes/v2.2.0.rst @@ -0,0 +1,9 @@ +.. _v2.2.0: + +2.2.0 +===== + +Enhancements +............ + +* Added support for encoding and decoding RLE data with a *Bits Allocated* of 1 diff --git a/pyproject.toml b/pyproject.toml index 859c0af..00a049c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: MacOS :: MacOS X", "Operating System :: POSIX :: Linux", "Operating System :: Microsoft :: Windows", @@ -32,7 +33,7 @@ keywords = ["dicom pydicom python rle pylibjpeg rust"] license = "MIT" name = "pylibjpeg-rle" readme = "README.md" -version = "2.1.0" +version = "2.2.0" requires-python = ">=3.10" dependencies = [ "numpy>=2.0", @@ -53,13 +54,17 @@ homepage = "https://github.com/pydicom/pylibjpeg-rle" [project.entry-points."pylibjpeg.pixel_data_encoders"] "1.2.840.10008.1.2.5" = "rle:encode_pixel_data" +[project.entry-points."pylibjpeg.utils"] +"pack_bits" = "rle:pack_bits" +"unpack_bits" = "rle:unpack_bits" + [tool.coverage.run] omit = [ - "libjpeg/tests/*", + "rle/tests/*", ] [tool.mypy] -python_version = "3.8" +python_version = "3.10" files = "rle" exclude = ["rle/tests", "rle/benchmarks"] show_error_codes = true diff --git a/rle/__init__.py b/rle/__init__.py index 8a79be0..2b21cf7 100644 --- a/rle/__init__.py +++ b/rle/__init__.py @@ -1,4 +1,11 @@ """Set package shortcuts.""" from rle._version import __version__ -from rle.utils import pixel_array, generate_frames, decode_pixel_data, encode_pixel_data +from rle.utils import ( + pixel_array, + generate_frames, + decode_pixel_data, + encode_pixel_data, + pack_bits, + unpack_bits, +) diff --git a/rle/tests/test_decode.py b/rle/tests/test_decode.py index 36df11e..2f7368b 100644 --- a/rle/tests/test_decode.py +++ b/rle/tests/test_decode.py @@ -69,19 +69,19 @@ def as_bytes(self, offsets): def test_bits_allocated_zero_raises(self): """Test exception raised for BitsAllocated 0.""" - msg = r"The \(0028,0100\) 'Bits Allocated' value must be 8, 16, 32 or 64" + msg = r"The \(0028,0100\) 'Bits Allocated' value must be 1, 8, 16, 32 or 64" with pytest.raises(ValueError, match=msg): decode_frame(b"\x00\x00\x00\x00", 1, 0, "<") def test_bits_allocated_not_octal_raises(self): - """Test exception raised for BitsAllocated not a multiple of 8.""" - msg = r"The \(0028,0100\) 'Bits Allocated' value must be 8, 16, 32 or 64" + """Test exception raised for BitsAllocated not 1 or a multiple of 8.""" + msg = r"The \(0028,0100\) 'Bits Allocated' value must be 1, 8, 16, 32 or 64" with pytest.raises(ValueError, match=msg): decode_frame(b"\x00\x00\x00\x00", 1, 12, "<") def test_bits_allocated_large_raises(self): """Test exception raised for BitsAllocated greater than 64.""" - msg = r"The \(0028,0100\) 'Bits Allocated' value must be 8, 16, 32 or 64" + msg = r"The \(0028,0100\) 'Bits Allocated' value must be 1, 8, 16, 32 or 64" with pytest.raises(ValueError, match=msg): decode_frame(b"\x00\x00\x00\x00", 1, 72, "<") @@ -126,6 +126,28 @@ def test_invalid_samples_px_raises(self): with pytest.raises(ValueError, match=msg): decode_frame(d + b"\x00" * 8, 1, 8, "<") + # Bits Allocated 1 must be Samples per Pixel 1 + header = ( + b"\x03\x00\x00\x00" # 3 segments + b"\x40\x00\x00\x00" # 64 + b"\x47\x00\x00\x00" # 71 + b"\x4E\x00\x00\x00" # 78 + ) + header += (64 - len(header)) * b"\x00" + # 2 x 3 data + # 0, 64, 128, 160, 192, 255 + data = ( + b"\x05\x00\x40\x80\xA0\xC0\xFF" # R + b"\x05\xFF\xC0\x80\x40\x00\xFF" # B + b"\x05\x01\x40\x80\xA0\xC0\xFE" # G + ) + msg = ( + r"The \(0028,0002\) 'Samples per Pixel' must be 1 if \(0028,0100\) 'Bits " + r"Allocated' is 1" + ) + with pytest.raises(ValueError, match=msg): + decoded = decode_frame(header + data, 2 * 3, 1, "<") + def test_insufficient_frame_literal(self): """Test segment with excess padding on lit.""" d = self.as_bytes([64]) @@ -357,6 +379,24 @@ def test_u32_3s(self): assert [4294967295, 16777216, 65536, 256, 1, 0] == arr[6:12].tolist() assert [1, 16777216, 65536, 256, 1, 4294967294] == arr[12:].tolist() + def test_u8_1s_bs1(self): + """Test decoding bit packed 1 sample/px.""" + header = b"\x01\x00\x00\x00\x40\x00\x00\x00" + header += (64 - len(header)) * b"\x00" + # 0 0 0 0 0 1 0 1 0 1 1 0 1 1 1 1 + data = b"\xFC\x00\x07\x01\x00\x01\x00\x01\x01\x00\x01\xFD\x01\x00" + decoded = decode_frame(header + data, 16, 1, ">") + arr = np.frombuffer(decoded, np.dtype("uint8")) + assert [0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1] == arr.tolist() + decoded = decode_frame(header + data, 16, 1, "<") + assert [0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1] == arr.tolist() + + # 0 0 0 0 0 1 0 1 0 1 1 0 1 1 1 + data = b"\xFC\x00\x07\x01\x00\x01\x00\x01\x01\x00\x01\xFE\x01\x00" + decoded = decode_frame(header + data, 15, 1, ">") + arr = np.frombuffer(decoded, np.dtype("uint8")) + assert [0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1] == arr.tolist() + @pytest.mark.skipif(not HAVE_PYDICOM, reason="No pydicom") class TestDecodeFrame_Datasets: diff --git a/rle/tests/test_encode.py b/rle/tests/test_encode.py index 4548c10..d4b10eb 100644 --- a/rle/tests/test_encode.py +++ b/rle/tests/test_encode.py @@ -386,15 +386,19 @@ def test_invalid_samples_per_pixel_raises(self): with pytest.raises(ValueError, match=msg): encode_frame(b"", 1, 1, 4, 1, "<") + msg = ( + r"The \(0028,0002\) 'Samples per Pixel' must be 1 if \(0028,0100\) 'Bits " + "Allocated' is 1" + ) + with pytest.raises(ValueError, match=msg): + encode_frame(b"", 1, 1, 3, 1, "<") + def test_invalid_bits_per_pixel_raises(self): """Test exception raised if bits per pixel not valid.""" - msg = r"The \(0028,0100\) 'Bits Allocated' value must be 8, 16, 32 or 64" + msg = r"The \(0028,0100\) 'Bits Allocated' value must be 1, 8, 16, 32 or 64" with pytest.raises(ValueError, match=msg): encode_frame(b"", 1, 1, 1, 0, "<") - with pytest.raises(ValueError, match=msg): - encode_frame(b"", 1, 1, 1, 1, "<") - with pytest.raises(ValueError, match=msg): encode_frame(b"", 1, 1, 1, 7, "<") diff --git a/rle/tests/test_utils.py b/rle/tests/test_utils.py index 5371d88..371d865 100644 --- a/rle/tests/test_utils.py +++ b/rle/tests/test_utils.py @@ -1,5 +1,7 @@ """Tests for the utils module.""" +from struct import pack, unpack + import numpy as np import pytest @@ -17,12 +19,46 @@ from pydicom.pixels.decoders.rle import _rle_decode_frame from rle.data import get_indexed_datasets -from rle.utils import encode_pixel_data, encode_array, pixel_data, pixel_array +from rle.utils import ( + decode_pixel_data, + encode_pixel_data, + encode_array, + pixel_data, + pixel_array, + pack_bits, + unpack_bits, +) +from rle.rle import pack_bits as _pack_bits, unpack_bits as _unpack_bits INDEX_LEE = get_indexed_datasets("1.2.840.10008.1.2.1") +class TestDecodePixelData: + """Tests for decode_pixel_data()""" + + def test_u8_1s_ba1(self): + """Tests bits allocated 1""" + header = b"\x01\x00\x00\x00\x40\x00\x00\x00" + header += (64 - len(header)) * b"\x00" + # 0 0 0 0 0 1 0 1 0 1 1 0 1 1 1 1 + data = b"\xFC\x00\x07\x01\x00\x01\x00\x01\x01\x00\x01\xFD\x01\x00" + src = header + data + opts = { + "rows": 1, + "columns": 16, + "bits_allocated": 1, + } + + frame = decode_pixel_data(src, version=2, **opts) + assert frame == ( + b"\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x01\x00\x01\x01\x01\x01" + ) + opts["pack_bits"] = True + frame = decode_pixel_data(src, version=2, **opts) + assert frame == b"\xA0\xF6" + + @pytest.mark.skipif(not HAVE_PYDICOM, reason="no pydicom") class TestEncodeArray: """Tests for utils.encode_array().""" @@ -199,7 +235,7 @@ def test_bad_bits_allocated_raises(self): "byteorder": "<", } - msg = r"'bits_allocated' must be 8, 16, 32 or 64" + msg = r"'bits_allocated' must be 1, 8, 16, 32 or 64" with pytest.raises(ValueError, match=msg): encode_pixel_data(b"", **kwargs) @@ -234,6 +270,42 @@ def test_too_many_segments_raises(self): with pytest.raises(ValueError, match=msg): encode_pixel_data(b"", **kwargs) + def test_u8_1s_ba1(self): + """Tests bits allocated 1""" + opts = { + "rows": 1, + "columns": 16, + "bits_allocated": 1, + "samples_per_pixel": 1, + } + # 0 0 0 0 0 1 0 1 0 1 1 0 1 1 1 1 + enc = encode_pixel_data(b"\xA0\xF6", **opts) + + header = b"\x01\x00\x00\x00\x40\x00\x00\x00" + header += (64 - len(header)) * b"\x00" + data = b"\xFC\x00\x03\x01\x00\x01\x00\xFF\x01\x00\x00\xFD\x01\x00" + assert enc == header + data + assert decode_pixel_data(enc, version=2, **opts) == ( + b"\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x01\x00\x01\x01\x01\x01" + ) + + opts = { + "rows": 1, + "columns": 12, + "bits_allocated": 1, + "samples_per_pixel": 1, + } + # 0 0 0 0 0 1 0 1 0 1 1 0 + enc = encode_pixel_data(b"\xA0\xF6", **opts) + + header = b"\x01\x00\x00\x00\x40\x00\x00\x00" + header += (64 - len(header)) * b"\x00" + data = b"\xFC\x00\x03\x01\x00\x01\x00\xFF\x01\x00\x00\x00" + assert enc == header + data + assert decode_pixel_data(enc, version=2, **opts) == ( + b"\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x01\x00" + ) + @pytest.mark.skipif(not HAVE_PYDICOM, reason="no pydicom") class TestPixelData: @@ -249,3 +321,277 @@ def test_pixel_data(self): ds.PixelData = data assert np.array_equal(ref, pixel_array(ds)) + + +REFERENCE_PACK_UNPACK = [ + # src, little, big + (b"", [], []), + (b"\x00", [0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0]), + (b"\x01", [1, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x02", [0, 1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 1, 0]), + (b"\x04", [0, 0, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 1, 0, 0]), + (b"\x08", [0, 0, 0, 1, 0, 0, 0, 0], [0, 0, 0, 0, 1, 0, 0, 0]), + (b"\x10", [0, 0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0, 0]), + (b"\x20", [0, 0, 0, 0, 0, 1, 0, 0], [0, 0, 1, 0, 0, 0, 0, 0]), + (b"\x40", [0, 0, 0, 0, 0, 0, 1, 0], [0, 1, 0, 0, 0, 0, 0, 0]), + (b"\x80", [0, 0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0]), + (b"\xaa", [0, 1, 0, 1, 0, 1, 0, 1], [1, 0, 1, 0, 1, 0, 1, 0]), + (b"\xf0", [0, 0, 0, 0, 1, 1, 1, 1], [1, 1, 1, 1, 0, 0, 0, 0]), + (b"\x0f", [1, 1, 1, 1, 0, 0, 0, 0], [0, 0, 0, 0, 1, 1, 1, 1]), + (b"\xff", [1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1]), + ( + b"\x00\x00", + #| 1st byte | 2nd byte + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + ), + ( + b"\x00\x01", + #| 1st byte | 2nd byte + [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + ), + ( + b"\x00\x80", + #| 1st byte | 2nd byte + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], + ), + ( + b"\x00\xff", + #| 1st byte | 2nd byte + [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1], + ), + ( + b"\x01\x80", + #| 1st byte | 2nd byte + [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0], + ), + ( + b"\x80\x80", + #| 1st byte | 2nd byte + [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], + ), + ( + b"\xff\x80", + #| 1st byte | 2nd byte + [1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], + ), +] + + +class TestUnpackBits: + """Tests for _unpack_bits().""" + + @pytest.mark.parametrize("src, little, big", REFERENCE_PACK_UNPACK) + def test_unpack_bytes(self, src, little, big): + """Test unpacking data without numpy.""" + as_bytes = pack(f"{len(little)}B", *little) + assert _unpack_bits(src, 0, "<") == as_bytes + assert _unpack_bits(src, 32, "<") == as_bytes + as_bytes = pack(f"{len(big)}B", *big) + assert _unpack_bits(src, 0, ">") == as_bytes + assert _unpack_bits(src, 32, ">") == as_bytes + + @pytest.mark.parametrize("src, little, big", REFERENCE_PACK_UNPACK) + def test_unpack_bytes_util(self, src, little, big): + """Test unpacking data without numpy.""" + as_bytes = pack(f"{len(little)}B", *little) + assert unpack_bits(src, 0, "<") == as_bytes + assert unpack_bits(src, 32, "<") == as_bytes + as_bytes = pack(f"{len(big)}B", *big) + assert unpack_bits(src, 0, ">") == as_bytes + assert unpack_bits(src, 32, ">") == as_bytes + + def test_count_little(self): + """Test the `count` parameter for little endian unpacking.""" + assert _unpack_bits(b"\x00", 1, "<") == b"\x00" + assert _unpack_bits(b"\xff", 1, "<") == b"\x01" + assert _unpack_bits(b"\xff", 2, "<") == b"\x01" * 2 + assert _unpack_bits(b"\xff", 3, "<") == b"\x01" * 3 + assert _unpack_bits(b"\xff", 4, "<") == b"\x01" * 4 + assert _unpack_bits(b"\xff", 5, "<") == b"\x01" * 5 + assert _unpack_bits(b"\xff", 6, "<") == b"\x01" * 6 + assert _unpack_bits(b"\xff", 7, "<") == b"\x01" * 7 + assert _unpack_bits(b"\xff", 8, "<") == b"\x01" * 8 + assert _unpack_bits(b"\xff\xAA", 9, "<") == b"\x01" * 8 + b"\x00" + assert _unpack_bits(b"\xff\xAA", 10, "<") == b"\x01" * 8 + b"\x00\x01" + assert _unpack_bits(b"\xff\xAA", 11, "<") == b"\x01" * 8 + b"\x00\x01\x00" + assert _unpack_bits(b"\xff\xAA", 12, "<") == b"\x01" * 8 + b"\x00\x01" * 2 + assert _unpack_bits(b"\xff\xAA", 13, "<") == b"\x01" * 8 + b"\x00\x01" * 2 + b"\x00" + assert _unpack_bits(b"\xff\xAA", 14, "<") == b"\x01" * 8 + b"\x00\x01" * 3 + assert _unpack_bits(b"\xff\xAA", 15, "<") == b"\x01" * 8 + b"\x00\x01" * 3 + b"\x00" + assert _unpack_bits(b"\xff\xAA", 16, "<") == b"\x01" * 8 + b"\x00\x01" * 4 + + def test_count_little_util(self): + """Test the `count` parameter for little endian unpacking.""" + assert unpack_bits(b"\x00", -1, "<") == b"\x00" * 8 + assert unpack_bits(b"\x00", 10, "<") == b"\x00" * 8 + assert unpack_bits(b"\x00", None, "<") == b"\x00" * 8 + assert unpack_bits(b"\x00", 0, "<") == b"\x00" * 8 + + assert unpack_bits(b"\x00", 1, "<") == b"\x00" + assert unpack_bits(b"\xff", 1, "<") == b"\x01" + assert unpack_bits(b"\xff", 2, "<") == b"\x01" * 2 + assert unpack_bits(b"\xff", 3, "<") == b"\x01" * 3 + assert unpack_bits(b"\xff", 4, "<") == b"\x01" * 4 + assert unpack_bits(b"\xff", 5, "<") == b"\x01" * 5 + assert unpack_bits(b"\xff", 6, "<") == b"\x01" * 6 + assert unpack_bits(b"\xff", 7, "<") == b"\x01" * 7 + assert unpack_bits(b"\xff", 8, "<") == b"\x01" * 8 + assert unpack_bits(b"\xff\xAA", 9, "<") == b"\x01" * 8 + b"\x00" + assert unpack_bits(b"\xff\xAA", 10, "<") == b"\x01" * 8 + b"\x00\x01" + assert unpack_bits(b"\xff\xAA", 11, "<") == b"\x01" * 8 + b"\x00\x01\x00" + assert unpack_bits(b"\xff\xAA", 12, "<") == b"\x01" * 8 + b"\x00\x01" * 2 + assert unpack_bits(b"\xff\xAA", 13, "<") == b"\x01" * 8 + b"\x00\x01" * 2 + b"\x00" + assert unpack_bits(b"\xff\xAA", 14, "<") == b"\x01" * 8 + b"\x00\x01" * 3 + assert unpack_bits(b"\xff\xAA", 15, "<") == b"\x01" * 8 + b"\x00\x01" * 3 + b"\x00" + assert unpack_bits(b"\xff\xAA", 16, "<") == b"\x01" * 8 + b"\x00\x01" * 4 + + def test_count_big(self): + """Test the `count` parameter for big endian unpacking.""" + assert _unpack_bits(b"\x00", 1, ">") == b"\x00" + assert _unpack_bits(b"\xff", 1, ">") == b"\x01" + assert _unpack_bits(b"\xff", 2, ">") == b"\x01" * 2 + assert _unpack_bits(b"\xff", 3, ">") == b"\x01" * 3 + assert _unpack_bits(b"\xff", 4, ">") == b"\x01" * 4 + assert _unpack_bits(b"\xff", 5, ">") == b"\x01" * 5 + assert _unpack_bits(b"\xff", 6, ">") == b"\x01" * 6 + assert _unpack_bits(b"\xff", 7, ">") == b"\x01" * 7 + assert _unpack_bits(b"\xff", 8, ">") == b"\x01" * 8 + assert _unpack_bits(b"\xff\xAA", 9, ">") == b"\x01" * 8 + b"\x01" + assert _unpack_bits(b"\xff\xAA", 10, ">") == b"\x01" * 8 + b"\x01\x00" + assert _unpack_bits(b"\xff\xAA", 11, ">") == b"\x01" * 8 + b"\x01\x00\x01" + assert _unpack_bits(b"\xff\xAA", 12, ">") == b"\x01" * 8 + b"\x01\x00" * 2 + assert _unpack_bits(b"\xff\xAA", 13, ">") == b"\x01" * 8 + b"\x01\x00" * 2 + b"\x01" + assert _unpack_bits(b"\xff\xAA", 14, ">") == b"\x01" * 8 + b"\x01\x00" * 3 + assert _unpack_bits(b"\xff\xAA", 15, ">") == b"\x01" * 8 + b"\x01\x00" * 3 + b"\x01" + assert _unpack_bits(b"\xff\xAA", 16, ">") == b"\x01" * 8 + b"\x01\x00" * 4 + + @pytest.mark.parametrize("src, little, big", REFERENCE_PACK_UNPACK) + def test_unpack_bytearray(self, src, little, big): + """Test unpacking data without numpy.""" + as_bytes = pack(f"{len(little)}B", *little) + assert _unpack_bits(bytearray(src), 0, "<") == as_bytes + as_bytes = pack(f"{len(big)}B", *big) + assert _unpack_bits(bytearray(src), 0, ">") == as_bytes + + +REFERENCE_PACK_PARTIAL_LITTLE = [ + # | 1st byte | 2nd byte + (b"\x00\x40", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), # 15-bits + (b"\x00\x20", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x00\x10", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x00\x08", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x00\x04", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x00\x02", [0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x00\x01", [0, 0, 0, 0, 0, 0, 0, 0, 1]), # 9-bits + (b"\x80", [0, 0, 0, 0, 0, 0, 0, 1]), # 8-bits + (b"\x40", [0, 0, 0, 0, 0, 0, 1]), + (b"\x20", [0, 0, 0, 0, 0, 1]), + (b"\x10", [0, 0, 0, 0, 1]), + (b"\x08", [0, 0, 0, 1]), + (b"\x04", [0, 0, 1]), + (b"\x02", [0, 1]), + (b"\x01", [1]), + (b"", []), +] +REFERENCE_PACK_PARTIAL_BIG = [ + # | 1st byte | 2nd byte + (b"\x00\x02", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), # 15-bits + (b"\x00\x04", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x00\x08", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x00\x10", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x00\x20", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x00\x40", [0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + (b"\x00\x80", [0, 0, 0, 0, 0, 0, 0, 0, 1]), # 9-bits + (b"\x01", [0, 0, 0, 0, 0, 0, 0, 1]), # 8-bits + (b"\x02", [0, 0, 0, 0, 0, 0, 1]), + (b"\x04", [0, 0, 0, 0, 0, 1]), + (b"\x08", [0, 0, 0, 0, 1]), + (b"\x10", [0, 0, 0, 1]), + (b"\x20", [0, 0, 1]), + (b"\x40", [0, 1]), + (b"\x80", [1]), + (b"", []), +] + + +class TestPackBits: + """Tests for pack_bits().""" + + @pytest.mark.parametrize("output, little, big", REFERENCE_PACK_UNPACK) + def test_pack_bytes(self, output, little, big): + """Test packing data.""" + assert output == _pack_bits(bytes(little), "<") + assert output == _pack_bits(bytes(big), ">") + + @pytest.mark.parametrize("output, little, big", REFERENCE_PACK_UNPACK) + def test_pack_bytes_utils(self, output, little, big): + """Test packing data.""" + assert output == pack_bits(bytes(little), "<") + assert output == pack_bits(bytes(big), ">") + + @pytest.mark.parametrize("output, little, big", REFERENCE_PACK_UNPACK) + def test_pack_bytearray(self, output, little, big): + """Test packing data.""" + assert output == _pack_bits(bytearray(little), "<") + assert output == _pack_bits(bytearray(big), ">") + + def test_non_binary_input(self): + """Test non-binary input raises exception.""" + msg = r"Only binary input \(containing zeros or ones\) can be packed" + with pytest.raises(ValueError, match=msg): + _pack_bits(b"\x00\x00\x02\x00\x00\x00\x00\x00", "<") + + def test_bytes_input(self): + """Repeat above test with bytes input.""" + # fmt: off + src = bytes( + [ + 0, 0, 0, 0, 0, 0, 0, 0, + 1, 0, 1, 0, 1, 0, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, + ] + ) + # fmt: on + assert b"\x00\x55\xff" == _pack_bits(src, "<") + assert b"\x00\xAA\xff" == _pack_bits(src, ">") + + def test_bytearry_input(self): + """Repeat above test with bytearray input.""" + # fmt: off + src = bytearray( + [ + 0, 0, 0, 0, 0, 0, 0, 0, + 1, 0, 1, 0, 1, 0, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, + ] + ) + # fmt: on + assert b"\x00\x55\xff" == _pack_bits(src, "<") + assert b"\x00\xAA\xff" == _pack_bits(src, ">") + + @pytest.mark.parametrize("output, src", REFERENCE_PACK_PARTIAL_LITTLE) + def test_pack_partial_bytes(self, src, output): + """Test packing data that isn't a full byte long.""" + assert output == _pack_bits(bytes(src), "<") + + @pytest.mark.parametrize("output, src", REFERENCE_PACK_PARTIAL_LITTLE) + def test_pack_partial_bytearray(self, src, output): + """Test packing data that isn't a full byte long.""" + assert output == _pack_bits(bytearray(src), "<") + + @pytest.mark.parametrize("output, src", REFERENCE_PACK_PARTIAL_BIG) + def test_pack_partial_bytes_big(self, src, output): + """Test packing data that isn't a full byte long.""" + assert output == _pack_bits(bytes(src), ">") + + @pytest.mark.parametrize("output, src", REFERENCE_PACK_PARTIAL_BIG) + def test_pack_partial_bytearray_big(self, src, output): + """Test packing data that isn't a full byte long.""" + assert output == _pack_bits(bytearray(src), ">") diff --git a/rle/utils.py b/rle/utils.py index 63c78ef..439a828 100644 --- a/rle/utils.py +++ b/rle/utils.py @@ -1,12 +1,19 @@ """Utility functions.""" import enum +import math import sys -from typing import Iterator, Optional, Any, TYPE_CHECKING, cast, Union +from typing import Iterator, Any, TYPE_CHECKING, cast import numpy as np -from rle.rle import decode_frame, decode_segment, encode_frame, encode_segment +from rle.rle import ( + decode_frame, + decode_segment, + encode_frame, + encode_segment, +) +from rle.rle import pack_bits as _pack_bits, unpack_bits as _unpack_bits if TYPE_CHECKING: # pragma: no cover @@ -20,13 +27,16 @@ class Version(enum.IntEnum): def decode_pixel_data( src: bytes, - ds: Optional["Dataset"] = None, + ds: "Dataset | None" = None, version: int = Version.v1, **kwargs: Any, -) -> Union[np.ndarray, bytearray]: - """Return the decoded RLE Lossless data as a :class:`numpy.ndarray`. +) -> np.ndarray | bytearray: + """Return the decoded RLE Lossless data as a :class:`numpy.ndarray` or + :class:`bytearray`. - Intended for use with *pydicom* ``Dataset`` objects. + .. versionchanged:: 2.2 + + Added the ``"pack_bits"`` decoding option and support for *Bits Allocated* 1. Parameters ---------- @@ -35,7 +45,7 @@ def decode_pixel_data( ds : pydicom.dataset.Dataset, optional A :class:`~pydicom.dataset.Dataset` containing the group ``0x0028`` elements corresponding to the image frame. If not used then `kwargs` - must be supplied. Only used with version ``1``. + must be supplied. Only used with `version` ``1``. version : int, optional * If ``1`` (default) then return the image data as an :class:`numpy.ndarray` @@ -43,22 +53,26 @@ def decode_pixel_data( **kwargs Required keys if `ds` is not supplied or if version is ``2``: - * ``"rows"``: :class:`int` - the number of rows in the decoded image + * ``"rows"``: :class:`int` - the number of rows in the decoded image. * ``"columns"``: :class:`int` - the number of columns in the decoded - image + image. * ``"bits_allocated"``: :class:`int` - the number of bits allocated - to each pixel + to each pixel. Current decoding options are: - * ``{'byteorder': str}`` specify the byte ordering for the decoded data - when more than 8 bits per pixel are used, should be '<' for little - endian ordering (default) or '>' for big-endian ordering. + * ``"byteorder"``: :class:`str` - specify the byte ordering for the decoded + data when more than 8 bits per pixel are used, should be ``'<'`` for little + endian ordering (default) or ``'>'`` for big-endian ordering. + * ``"pack_bits"``: :class:`bool` - (`version` 2 only) if ``True`` and + ``"bits_allocated"`` is ``1`` then return the decoded data in its packed + form (8 pixels per byte), otherwise return the decoded data in its unpacked + form (1 pixel per byte). Default ``False``. Returns ------- bytearray | numpy.ndarray - The image data as either a bytearray or ndarray. + The image data as either a bytearray (`version` 2) or ndarray (`version` 1). Raises ------ @@ -95,17 +109,27 @@ def decode_pixel_data( rows = cast(int, kwargs.get("rows")) bits_allocated = cast(int, kwargs.get("bits_allocated")) byteorder = kwargs.get("byteorder", "<") + as_packed = kwargs.get("pack_bits", False) + + frame: bytearray = decode_frame(src, rows * columns, bits_allocated, byteorder) - return cast(bytearray, decode_frame(src, rows * columns, bits_allocated, byteorder)) + if bits_allocated != 1 or as_packed is False: + return frame + + return _pack_bits(frame, "<") def encode_array( - arr: "np.ndarray", ds: Optional["Dataset"] = None, **kwargs: Any + arr: "np.ndarray", ds: "Dataset | None" = None, **kwargs: Any ) -> Iterator[bytes]: """Yield RLE encoded frames from `arr`. .. versionadded:: 1.1 + .. versionchanged:: 2.2 + + Added support for 'bits_allocated' 1. + Parameters ---------- arr : numpy.ndarray @@ -124,7 +148,7 @@ def encode_array( * ``samples_per_px': int`` the number of samples per pixel, either 1 for monochrome or 3 for RGB or similar data. * ``'bits_per_px': int`` the number of bits needed to contain each - pixel, either 8, 16, 32 or 64. + pixel, either 1, 8, 16, 32 or 64. * ``'nr_frames': int`` the number of frames in `arr`, required if more than one frame is present. @@ -155,8 +179,8 @@ def encode_array( def encode_pixel_data( src: bytes, - ds: Optional["Dataset"] = None, - byteorder: Optional[str] = None, + ds: "Dataset | None" = None, + byteorder: str | None = None, **kwargs: Any, ) -> bytes: """Return `src` encoded using the DICOM RLE (PackBits) algorithm. @@ -169,10 +193,16 @@ def encode_pixel_data( to 15 in order to meet the requirements of the *RLE Lossless* transfer syntax. + .. versionchanged:: 2.2 + + Added support for 'bits_allocated' 1. + Parameters ---------- src : bytes - The data for a single image frame data to be RLE encoded. + The data for a single image frame data to be RLE encoded. For *Bits Allocated* + 1 if `src` contains bit-packed data then it will be automatically unpacked + prior to encoding. ds : pydicom.dataset.Dataset, optional The dataset corresponding to `src` with matching values for *Rows*, *Columns*, *Samples per Pixel* and *Bits Allocated*. Required if @@ -184,12 +214,12 @@ def encode_pixel_data( **kwargs If `ds` is not used then the following are required: - * ``'rows': int`` the number of rows contained in `src` - * ``'columns': int`` the number of columns contained in `src` - * ``samples_per_pixel': int`` the number of samples per pixel, either + * ``'rows'``: :class:`int` the number of rows contained in `src` + * ``'columns'``: :class:`int` the number of columns contained in `src` + * ``'samples_per_pixel'``: :class:`int` the number of samples per pixel, either 1 for monochrome or 3 for RGB or similar data. - * ``'bits_allocated': int`` the number of bits needed to contain each - pixel, either 8, 16, 32 or 64. + * ``'bits_allocated'``: :class:`int` the number of bits needed to contain each + pixel, must be 1, 8, 16, 32 or 64. Returns ------- @@ -208,28 +238,32 @@ def encode_pixel_data( spp = kwargs["samples_per_pixel"] # Validate input - if spp not in [1, 3]: + if spp not in (1, 3): msg = "(0028,0002) 'Samples per Pixel'" if ds else "'samples_per_pixel'" raise ValueError(f"{msg} must be 1 or 3") - if bpp not in [8, 16, 32, 64]: + if bpp not in (1, 8, 16, 32, 64): msg = "(0028,0100) 'Bits Allocated'" if ds else "'bits_allocated'" - raise ValueError(f"{msg} must be 8, 16, 32 or 64") + raise ValueError(f"{msg} must be 1, 8, 16, 32 or 64") - if bpp / 8 * spp > 15: + if bpp != 1 and bpp / 8 * spp > 15: raise ValueError( "Unable to encode the data as the RLE format used by the DICOM " "Standard only allows a maximum of 15 segments" ) - byteorder = "<" if bpp == 8 else byteorder + byteorder = "<" if bpp <= 8 else byteorder if byteorder not in ("<", ">"): raise ValueError( "A valid 'byteorder' is required when the number of bits per " "pixel is greater than 8" ) - if len(src) != (r * c * bpp / 8 * spp): + src_length = len(src) + if bpp == 1 and src_length == math.ceil((r * c) / 8): + # src is bit packed, so unpack + src = _unpack_bits(src, r * c, "<") + elif src_length != (r * c * bpp / 8 * spp): raise ValueError("The length of the data doesn't match the image parameters") return cast(bytes, encode_frame(src, r, c, spp, bpp, byteorder)) @@ -368,3 +402,57 @@ def pixel_data(arr: "np.ndarray", ds: "Dataset") -> bytes: from pydicom.encaps import encapsulate return encapsulate([ii for ii in encode_array(arr, ds)]) + + +def pack_bits(src: bytes | bytearray, bitorder: str = "<") -> bytearray: + """Bit pack `src` + + .. versionadded: 2.2 + + Parameters + ---------- + src : bytes | bytearray + The data to be bit-packed, should only contain zeros and ones. + bitorder : str, optional + The bit ordering to use for the packed data, should be '<' for little endian + ordering (default) or '>' for big-endian ordering. For example, if `src` is + ``b"\x00\x01\x00\x00"`` then ``0x02`` (``0b00000010``) will be returned for + little endian ordering and ``0x40`` (``0b01000000``) for big endian ordering. + + Returns + ------- + bytearray + The bit-packed data, one byte per 8 bytes of the original with padding bits + added if the length of `src` is not a multiple of 8. + """ + return _pack_bits(src, bitorder) + + +def unpack_bits( + src: bytes | bytearray, count: int | None = None, bitorder: str = "<" + ) -> bytearray: + """Bit unpack `src` + + .. versionadded: 2.2 + + Parameters + ---------- + src : bytes | bytearray + The data to be unpacked. + count : int | None, optional + If ``None`` (default) then return the entire unpacked `src`, otherwise return + the first `count` number of bytes from the unpacked `src`. + bitorder : str, optional + The bit ordering to used by the packed data, should be '<' for little endian + ordering (default) or '>' for big-endian ordering. + + Returns + ------- + bytearray + The unpacked data, 8 bytes per byte of `src`. + """ + maximum_nr_bits = len(src) * 8 + if count is None or not (0 < count <= maximum_nr_bits): + count = maximum_nr_bits + + return _unpack_bits(src, count, bitorder) diff --git a/src/lib.rs b/src/lib.rs index ecd05d6..435a6a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,10 +19,132 @@ fn rle(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(encode_segment, m)?); m.add_function(wrap_pyfunction!(encode_frame, m)?); + m.add_function(wrap_pyfunction!(pack_bits, m)?); + m.add_function(wrap_pyfunction!(unpack_bits, m)?); + Ok(()) } +// Utilities +// --------- + +#[pyfunction] +fn pack_bits<'py>( + src: Vec, bitorder: char, py: Python<'py> +) -> PyResult> { + + match bitorder { + '>' | '<' => {}, + _ => { return Err(PyValueError::new_err("'bitorder' must be '>' or '<'")) } + } + + // Check values are in (0, 1) + if src.iter().max() > Some(&1u8) { + return Err( + PyValueError::new_err("Only binary input (containing zeros or ones) can be packed") + ) + } + + let mut dst: Vec = Vec::new(); + + if bitorder == '<' { + // Bits use little endian ordering + for chunk in src.chunks_exact(8) { + let mut packed = 0u8; + for idx in 0..8 { + packed |= chunk[idx] << idx; + } + dst.push(packed); + } + } else { + // Bits use big endian ordering + for chunk in src.chunks_exact(8) { + let mut packed = 0u8; + for idx in 0..8 { + packed |= chunk[idx] << (7 - idx); + } + dst.push(packed); + } + } + + let remainder = src.len() % 8; + if remainder > 0 { + let mut last_byte = 0u8; + // ..., 0, 1, 0, 0 iterates as 0, 0, 1, 0 + if bitorder == '<' { + for (idx, bit) in src.iter().rev().take(remainder).enumerate() { + // 0 0 0 0 0 0 1 0 + last_byte |= bit << (remainder - idx - 1); + } + } else { + for (idx, bit) in src.iter().rev().take(remainder).enumerate() { + // 0 1 0 0 0 0 0 0 + last_byte |= bit << (8 - remainder - idx); + } + } + dst.push(last_byte); + } + + Ok(PyByteArray::new(py, &dst)) +} + + +#[pyfunction] +fn unpack_bits<'py>( + src: Vec, count: u128, bitorder: char, py: Python<'py> +) -> PyResult> { + match bitorder { + '>' | '<' => {}, + _ => { return Err(PyValueError::new_err("'bitorder' must be '>' or '<'")) } + } + + // The maximum value of `count` should be 2^65 + let nr_bits: u128; + let nr_bytes = u128::try_from(src.len()).unwrap(); + if count == 0 || count > nr_bytes * 8 { + nr_bits = nr_bytes * 8; + } else { + nr_bits = count; + } + + let mut dst: Vec = Vec::new(); + // Shouldn't be more than 2^64 + let nr_whole_bytes = usize::try_from(nr_bits / 8).unwrap(); + // Shouldn't be more than 7 + let nr_remainder_bits = usize::try_from(nr_bits % 8).unwrap(); + + if bitorder == '<' { + // Unpack the whole bytes + for offset in 0..nr_whole_bytes { + for idx in 0..8 { + dst.push((src[offset] >> idx) & 1u8); + } + } + // Do the final (partial) byte, if required + if nr_remainder_bits != 0 { + for idx in 0..nr_remainder_bits { + dst.push((src[nr_whole_bytes] >> idx) & 1u8); + } + } + } else { + for offset in 0..nr_whole_bytes { + for idx in 0..8 { + dst.push((src[offset] >> (7 - idx)) & 1u8); + } + } + if nr_remainder_bits != 0 { + for idx in 0..nr_remainder_bits { + dst.push((src[nr_whole_bytes] >> (7 - idx)) & 1u8); + } + } + + } + + Ok(PyByteArray::new(py, &dst)) +} + + // RLE Decoding // ------------ @@ -58,7 +180,7 @@ fn _parse_header(src: &[u8; 64]) -> [u32; 15] { Parameters ---------- src - The 64 byte RLE header. + The 64 byte RLE header containing 15 little-endian ordered offset values. */ return [ u32::from_le_bytes([ src[4], src[5], src[6], src[7]]), @@ -94,7 +216,7 @@ fn decode_frame<'py>( The total number of pixels in the frame (rows x columns), maximum (2^32 - 1). bpp : int - The number of bits per pixel, supported values 8, 16, 32, 64. + The number of bits per pixel, supported values 1, 8, 16, 32, 64. byteorder : str The byte order of the returned data, '<' for little endian, '>' for big endian. @@ -123,7 +245,7 @@ fn _decode_frame( nr_pixels The total number of pixels in the frame (rows x columns). bpp - The number of bits per pixel, should be a multiple of 8 and no larger + The number of bits per pixel, should be 1 or a multiple of 8 and no larger than 64. byteorder The byte order of the decoded data, '<' for little endian, '>' for @@ -133,7 +255,7 @@ fn _decode_frame( // Pre-define our errors for neatness let err_invalid_bits_allocated = Err( String::from( - "The (0028,0100) 'Bits Allocated' value must be 8, 16, 32 or 64" + "The (0028,0100) 'Bits Allocated' value must be 1, 8, 16, 32 or 64" ).into() ); let err_invalid_offset = Err( @@ -145,6 +267,11 @@ fn _decode_frame( let err_invalid_nr_samples = Err( String::from("The (0028,0002) 'Samples per Pixel' must be 1 or 3").into() ); + let err_invalid_nr_samples_ba1 = Err( + String::from( + "The (0028,0002) 'Samples per Pixel' must be 1 if (0028,0100) 'Bits Allocated' is 1" + ).into() + ); let err_segment_length = Err( String::from( "The decoded segment length does not match the expected length" @@ -154,17 +281,23 @@ fn _decode_frame( String::from("'byteorder' must be '>' or '<'").into() ); - // Ensure we have a valid bits/px value + // Check 'Bits Allocated' is 1 or a multiple of 8 + let bytes_per_pixel: u8; // Valid values are 1 | 2 | 4 | 8 match bpp { 0 => return err_invalid_bits_allocated, + 1 => { + bytes_per_pixel = 1; + }, _ => match bpp % 8 { - 0 => {}, + 0 => { + bytes_per_pixel = bpp / 8; + }, _ => return err_invalid_bits_allocated } } - // Ensure `bytes_per_pixel` is in [1, 2, 4, 8] - let bytes_per_pixel: u8 = bpp / 8; + // Check `byteorder` is a valid character + // Check *Bits Allocated* is in [1, 8, 16, 32, 64] match bytes_per_pixel { 1 => {}, 2 | 4 | 8 => match byteorder { @@ -180,7 +313,10 @@ fn _decode_frame( let encoded_length = src.len(); if encoded_length < 64 { return err_insufficient_data } + // Don't need to check the unwrap as we just checked + // there's enough data in `src` let header = <&[u8; 64]>::try_from(&src[0..64]).unwrap(); + // All 15 header offsets, however no guarantee they will be non-zero let all_offsets: [u32; 15] = _parse_header(header); // First offset must always be 64 @@ -209,21 +345,19 @@ fn _decode_frame( // Check the samples per pixel is conformant let spp: u8 = nr_segments / bytes_per_pixel; match spp { - 1 | 3 => {}, + 1 => {}, + 3 => { + // Bits allocated 1 must be 1 sample per pixel + match bpp { + 1 => { return err_invalid_nr_samples_ba1 }, + _ => {} + } + }, _ => return err_invalid_nr_samples } - // Watch for overflow here; u32 * u32 -> u64 - let expected_length = usize::try_from( - nr_pixels * u32::from(bytes_per_pixel * spp) - ).unwrap(); - - // Pre-allocate a vector for the decoded frame - let mut frame = vec![0u8; expected_length]; - - /* - Example - ------- + /* Example + ---------- RLE encoded data is ordered like this (for 16-bit, 3 sample): Segment: 1 | 2 | 3 | 4 | 5 | 6 R MSB | R LSB | G MSB | G LSB | B MSB | B LSB @@ -240,8 +374,18 @@ fn _decode_frame( // Decode each segment and place it into the vector // ------------------------------------------------ + // TODO: handle unwrap let pps = usize::try_from(nr_pixels).unwrap(); // Concatenate sample planes into a frame + // Watch for overflow here; u32 * u32 -> u64 + // Actual values are (u16 * u16) * u8 + let expected_length = usize::try_from( + nr_pixels * u32::from(nr_segments) + ).unwrap(); + + // Pre-allocate a vector for the decoded frame + let mut frame = vec![0u8; expected_length]; + for sample in 0..spp { // 0 or (0, 1, 2) // Sample offset let so = usize::from(sample * bytes_per_pixel) * pps; @@ -271,11 +415,12 @@ fn _decode_frame( usize::from(bytes_per_pixel), usize::from(byte_offset) + so )?; + if len != pps { return err_segment_length } } } - Ok(frame) + return Ok(frame) } @@ -462,7 +607,7 @@ fn _decode_segment(src: &[u8], dst: &mut Vec) -> Result<(), Box> #[pyfunction] fn encode_frame<'py>( - src: &[u8], rows: u16, cols: u16, spp: u8, bpp: u8, byteorder: char, py: Python<'py> + src: Vec, rows: u16, cols: u16, spp: u8, bpp: u8, byteorder: char, py: Python<'py> ) -> PyResult> { /* Return RLE encoded `src` as bytes. @@ -478,7 +623,7 @@ fn encode_frame<'py>( spp : int The number of samples per pixel, supported values are 1 or 3. bpp : int - The number of bits per pixel, supported values are 8, 16, 32 and 64. + The number of bits per pixel, supported values are 1, 8, 16, 32 and 64. byteorder : str Required if `bpp` is greater than 1, '>' if `src` is in big endian byte order, '<' if little endian. @@ -497,15 +642,15 @@ fn encode_frame<'py>( fn _encode_frame( - src: &[u8], dst: &mut Vec, rows: u16, cols: u16, spp: u8, bpp: u8, byteorder: char + src: Vec, dst: &mut Vec, rows: u16, cols: u16, spp: u8, bpp: u8, byteorder: char ) -> Result<(), Box> { /* Parameters ---------- src - The data to be RLE encoded, ordered as R1, G1, B1, R2, G2, B2, ..., - Rn, Gn, Bn (i.e. Planar Configuration 0). + The data to be RLE encoded, with multi-sample data ordered as R1, G1, B1, + R2, G2, B2, ..., Rn, Gn, Bn (i.e. Planar Configuration 0). dst The vector storing the encoded data. rows @@ -513,19 +658,27 @@ fn _encode_frame( cols The number of columns in the data. spp - The number of samples per pixel, supported values are 1 or 3. + The number of samples per pixel, supported values are 1 or 3. May only be 1 if `bpp` + is 1. bpp - The number of bits per pixel, supported values are 8, 16, 32 and 64. + The number of bits per pixel, supported values are 1, 8, 16, 32 and 64. byteorder Required if bpp is greater than 1, '>' if `src` is in big endian byte order, '<' if little endian. */ + + // Pre-define our errors for neatness let err_invalid_nr_samples = Err( String::from("The (0028,0002) 'Samples per Pixel' must be 1 or 3").into() ); + let err_invalid_nr_samples_ba1 = Err( + String::from( + "The (0028,0002) 'Samples per Pixel' must be 1 if (0028,0100) 'Bits Allocated' is 1" + ).into() + ); let err_invalid_bits_allocated = Err( String::from( - "The (0028,0100) 'Bits Allocated' value must be 8, 16, 32 or 64" + "The (0028,0100) 'Bits Allocated' value must be 1, 8, 16, 32 or 64" ).into() ); let err_invalid_nr_segments = Err( @@ -546,22 +699,30 @@ fn _encode_frame( String::from("'byteorder' must be '>' or '<'").into() ); - // Check 'Samples per Pixel' + // Check 'Samples per Pixel' is either 1 or 3 + // Check 'Bits Allocated' is 1 or a multiple of 8 + // Check 'Samples per Pixel' is 1 if 'Bits Allocated' is 1 + let bytes_per_pixel: u8; match spp { - 1 | 3 => {}, + 1 => { + match bpp { + 1 => { bytes_per_pixel = 1; }, + 8 | 16 | 32 | 64 => { bytes_per_pixel = bpp / 8; }, + _ => { return err_invalid_bits_allocated } + } + } + 3 => { + match bpp { + 1 => { return err_invalid_nr_samples_ba1 }, + 8 | 16 | 32 | 64 => { bytes_per_pixel = bpp / 8; }, + _ => { return err_invalid_bits_allocated } + } + }, _ => return err_invalid_nr_samples } - // Check 'Bits Allocated' - match bpp { - 0 => return err_invalid_bits_allocated, - _ => match bpp % 8 { - 0 => {}, - _ => return err_invalid_bits_allocated - } - } - // Ensure `bytes_per_pixel` is in [1, 2, 4, 8] - let bytes_per_pixel: u8 = bpp / 8; + // Check `byteorder` is a valid character + // Check *Bits Allocated* is in [1, 8, 16, 32, 64] match bytes_per_pixel { 1 => {}, 2 | 4 | 8 => match byteorder { @@ -572,10 +733,13 @@ fn _encode_frame( } // Ensure parameters are consistent + // TODO: handle unwrap let r = usize::try_from(rows).unwrap(); let c = usize::try_from(cols).unwrap(); - if src.len() != r * c * usize::from(spp * bytes_per_pixel) { + let total_pixels = r * c * usize::from(spp); + let total_length = total_pixels * usize::from(bytes_per_pixel); + if src.len() != total_length { return err_invalid_parameters } @@ -587,10 +751,11 @@ fn _encode_frame( dst.extend(u32::from(nr_segments).to_le_bytes().to_vec()); dst.extend([0u8; 60].to_vec()); - // A vector of the start indexes used when segmenting - default big endian + // A vector of the start indexes used when segmenting + // Start with big-endian ordered pixel sample values let mut start_indices: Vec = (0..usize::from(nr_segments)).collect(); if byteorder != '>' { - // `src` has little endian byte ordering + // Typically `src` uses little endian byte ordering for idx in 0..spp { let s = usize::from(idx * bytes_per_pixel); let e = usize::from((idx + 1) * bytes_per_pixel); @@ -599,6 +764,8 @@ fn _encode_frame( } // Encode the data and update the RLE header segment offsets + // Segments are ordered from most significant byte to least significant for + // multi-byte values for idx in 0..usize::from(nr_segments) { // Update RLE header: convert current offset to 4x le ordered u8s let current_offset = (u32::try_from(dst.len()).unwrap()).to_le_bytes();