diff --git a/.github/workflows/pytest-builds.yml b/.github/workflows/pytest-builds.yml index 4c01776..886c225 100644 --- a/.github/workflows/pytest-builds.yml +++ b/.github/workflows/pytest-builds.yml @@ -25,6 +25,7 @@ jobs: with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.arch }} + allow-prereleases: true - name: Install package and dependencies run: | @@ -59,6 +60,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install package and dependencies run: | @@ -82,7 +84,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 @@ -93,6 +95,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install package and dependencies run: | @@ -106,13 +109,14 @@ jobs: pytest --cov openjpeg openjpeg/tests - name: Install pydicom dev and rerun pytest (3.10+) - if: ${{ contains('3.10 3.11 3.12 3.13', matrix.python-version) }} + if: ${{ contains('3.10 3.11 3.12 3.13 3.14', matrix.python-version) }} run: | pip install pylibjpeg pip install git+https://github.com/pydicom/pydicom pytest --cov openjpeg openjpeg/tests - name: Switch to current pydicom release and rerun pytest + if: ${{ contains('3.10 3.11 3.12 3.13', matrix.python-version) }} run: | pip uninstall -y pydicom pip install pydicom pylibjpeg diff --git a/.github/workflows/release-wheels.yml b/.github/workflows/release-wheels.yml index 2dd8ad2..ae2f627 100644 --- a/.github/workflows/release-wheels.yml +++ b/.github/workflows/release-wheels.yml @@ -57,6 +57,9 @@ jobs: - os: windows-latest python: 313 platform_id: win32 + - os: windows-latest + python: 314 + platform_id: win32 # Windows 64 bit - os: windows-latest @@ -74,6 +77,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 @@ -96,6 +102,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 @@ -113,6 +123,9 @@ jobs: - os: ubuntu-latest python: 313 platform_id: manylinux_aarch64 + - os: ubuntu-latest + python: 314 + platform_id: manylinux_aarch64 # MacOS 13 x86_64 - os: macos-13 @@ -130,6 +143,9 @@ jobs: - os: macos-13 python: 313 platform_id: macosx_x86_64 + - os: macos-13 + python: 314 + platform_id: macosx_x86_64 steps: - uses: actions/checkout@v4 @@ -194,6 +210,9 @@ jobs: - os: macos-14 python: 313 platform_id: macosx_arm64 + - os: macos-14 + python: 314 + platform_id: macosx_arm64 steps: - uses: actions/checkout@v4 diff --git a/docs/changes/v2.5.0.rst b/docs/changes/v2.5.0.rst new file mode 100644 index 0000000..0ad1201 --- /dev/null +++ b/docs/changes/v2.5.0.rst @@ -0,0 +1,10 @@ +.. _v2.5.0: + +2.5.0 +===== + +Changes +....... + +* Bits above the precision are now ignored when encoding (:issue:`104`) +* Supported Python versions are 3.9 to 3.14. diff --git a/lib/interface/encode.c b/lib/interface/encode.c index ac7d607..edbd3c9 100644 --- a/lib/interface/encode.c +++ b/lib/interface/encode.c @@ -781,20 +781,41 @@ extern int EncodeBuffer( OPJ_UINT64 nr_pixels = rows * columns; char *data = PyBytes_AsString(src); if (bytes_per_pixel == 1) { + unsigned char value; + unsigned char unsigned_mask = 0xFF >> (8 - bits_stored); + unsigned char signed_mask = 0xFF << bits_stored; + unsigned char bit_flag = 1 << (bits_stored - 1); + unsigned short do_masking = bits_stored < 8; for (OPJ_UINT64 ii = 0; ii < nr_pixels; ii++) { for (p = 0; p < samples_per_pixel; p++) { // comps[...].data[...] is OPJ_INT32 -> int32_t - image->comps[p].data[ii] = is_signed ? (signed char) *data : (unsigned char) *data; + value = (unsigned char) *data; data++; + + if (do_masking) { + // Unsigned: zero out bits above `precision` + // Signed: zero out bits above `precision` if value >= 0, otherwise + // set them to one + if (is_signed && (bit_flag & value)) { + value = value | signed_mask; + } else { + value = value & unsigned_mask; + } + } + + image->comps[p].data[ii] = is_signed ? (signed char) value : value; } } } else if (bytes_per_pixel == 2) { unsigned short value; unsigned char temp1; unsigned char temp2; - + unsigned short unsigned_mask = 0xFFFF >> (16 - bits_stored); + unsigned short signed_mask = 0xFFFF << bits_stored; + unsigned short bit_flag = 1 << (bits_stored - 1); + unsigned short do_masking = bits_stored < 16; for (OPJ_UINT64 ii = 0; ii < nr_pixels; ii++) { for (p = 0; p < samples_per_pixel; p++) @@ -803,7 +824,16 @@ extern int EncodeBuffer( data++; temp2 = (unsigned char) *data; data++; + value = (unsigned short) ((temp2 << 8) + temp1); + if (do_masking) { + if (is_signed && (bit_flag & value)) { + value = value | signed_mask; + } else { + value = value & unsigned_mask; + } + } + image->comps[p].data[ii] = is_signed ? (signed short) value : value; } } @@ -813,7 +843,10 @@ extern int EncodeBuffer( unsigned char temp2; unsigned char temp3; unsigned char temp4; - + unsigned long unsigned_mask = 0xFFFFFFFF >> (32 - bits_stored); + unsigned long signed_mask = 0xFFFFFFFF << bits_stored; + unsigned long bit_flag = 1 << (bits_stored - 1); + unsigned short do_masking = bits_stored < 32; for (OPJ_UINT64 ii = 0; ii < nr_pixels; ii++) { for (p = 0; p < samples_per_pixel; p++) @@ -827,13 +860,16 @@ extern int EncodeBuffer( temp4 = (unsigned char) * data; data++; - value = (unsigned long)((temp4 << 24) + (temp3 << 16) + (temp2 << 8) + temp1); - - if (is_signed) { - image->comps[p].data[ii] = (long) value; - } else { - image->comps[p].data[ii] = value; + value = (unsigned long) ((temp4 << 24) + (temp3 << 16) + (temp2 << 8) + temp1); + if (do_masking) { + if (is_signed && (bit_flag & value)) { + value = value | signed_mask; + } else { + value = value & unsigned_mask; + } } + + image->comps[p].data[ii] = is_signed ? (long) value : value; } } } diff --git a/openjpeg/tests/test_decode.py b/openjpeg/tests/test_decode.py index ace7346..5706a37 100644 --- a/openjpeg/tests/test_decode.py +++ b/openjpeg/tests/test_decode.py @@ -3,8 +3,8 @@ from io import BytesIO try: - from pydicom.encaps import generate_pixel_data_frame - from pydicom.pixel_data_handlers.util import ( + from pydicom.encaps import generate_frames + from pydicom.pixels.utils import ( reshape_pixel_array, pixel_dtype, ) @@ -72,10 +72,10 @@ def test_version(): assert 5 == version[1] -def generate_frames(ds): +def get_frame_generator(ds): """Return a frame generator for DICOM datasets.""" nr_frames = ds.get("NumberOfFrames", 1) - return generate_pixel_data_frame(ds.PixelData, nr_frames) + return generate_frames(ds.PixelData, number_of_frames=nr_frames) def test_get_format_raises(): @@ -91,7 +91,7 @@ def test_bad_decode(): """Test trying to decode bad data.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["966.dcm"]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) msg = r"Error decoding the J2K data: failed to decode image" with pytest.raises(RuntimeError, match=msg): decode(frame) @@ -108,7 +108,7 @@ def test_decode_bytes(self): """Test decoding using bytes.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["MR_small_jp2klossless.dcm"]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) assert isinstance(frame, bytes) arr = decode(frame) assert arr.flags.writeable @@ -126,7 +126,7 @@ def test_decode_filelike(self): """Test decoding using file-like.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["MR_small_jp2klossless.dcm"]["ds"] - frame = BytesIO(next(generate_frames(ds))) + frame = BytesIO(next(get_frame_generator(ds))) assert isinstance(frame, BytesIO) arr = decode(frame) assert arr.flags.writeable @@ -144,7 +144,7 @@ def test_decode_bad_type_raises(self): """Test decoding using invalid type raises.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["MR_small_jp2klossless.dcm"]["ds"] - frame = tuple(next(generate_frames(ds))) + frame = tuple(next(get_frame_generator(ds))) assert not hasattr(frame, "tell") and not isinstance(frame, bytes) msg = ( @@ -159,7 +159,7 @@ def test_decode_bad_format_raises(self): """Test decoding using invalid jpeg format raises.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["MR_small_jp2klossless.dcm"]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) msg = r"Unsupported 'j2k_format' value: 3" with pytest.raises(ValueError, match=msg): @@ -170,7 +170,7 @@ def test_decode_reshape_true(self): """Test decoding using invalid jpeg format raises.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["US1_J2KR.dcm"]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) arr = decode(frame) assert arr.flags.writeable @@ -205,7 +205,7 @@ def test_decode_reshape_false(self): """Test decoding using invalid jpeg format raises.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["US1_J2KR.dcm"]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) arr = decode(frame, reshape=False) assert arr.flags.writeable @@ -216,7 +216,7 @@ def test_signed_error(self): """Regression test for #30.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["693_J2KR.dcm"]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) arr = decode(frame) assert -2000 == arr[0, 0] @@ -372,7 +372,7 @@ def test_jpeg2000r(self, fname, info): # info: (rows, columns, spp, bps) index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index[fname]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) arr = decode(BytesIO(frame), reshape=False) assert arr.flags.writeable @@ -406,7 +406,7 @@ def test_jpeg2000i(self, fname, info): index = get_indexed_datasets("1.2.840.10008.1.2.4.91") ds = index[fname]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) arr = decode(BytesIO(frame), reshape=False) assert arr.flags.writeable diff --git a/openjpeg/tests/test_encode.py b/openjpeg/tests/test_encode.py index d5460c2..6afd39a 100644 --- a/openjpeg/tests/test_encode.py +++ b/openjpeg/tests/test_encode.py @@ -11,6 +11,7 @@ encode_buffer, encode_pixel_data, decode, + decode_pixel_data, PhotometricInterpretation as PI, _get_bits_stored, ) @@ -1056,7 +1057,7 @@ def test_lossless_bool(self): assert out.dtype.kind == "u" assert np.array_equal(arr, out) - def test_lossless_unsigned_u1(self): + def test_lossless_u1(self): """Test encoding unsigned data for bit-depth 1-8""" rows = 123 cols = 234 @@ -1128,7 +1129,35 @@ def test_lossless_unsigned_u1(self): assert out.dtype.kind == "u" assert np.array_equal(arr, out) - def test_lossless_unsigned_u2(self): + def test_lossless_u1_overflow(self): + """Test encoding result when data exists in bits above the precision.""" + arr = np.ones((64 * 64 - 4), dtype="u1") + src = [ + # 8-bit value -> 6-bit value + # |---> + 0b1110_0000, # 0b0010_0000 + 0b1111_1111, # 0b0011_1111 + 0b0000_0000, # 0b0000_0000 + 0b0000_0001, # 0b0000_0001 + ] + src = b"".join([v.to_bytes(length=1, byteorder="little") for v in src]) + src += arr.tobytes() + buffer = encode_buffer( + src, + rows=64, + columns=64, + samples_per_pixel=1, + bits_stored=6, + is_signed=False, + photometric_interpretation=PI.MONOCHROME2, + ) + out = decode_pixel_data(buffer, version=2) + assert out[0] == 0b0010_0000 + assert out[1] == 0b0011_1111 + assert out[2] == 0 + assert out[3] == 1 + + def test_lossless_u2(self): """Test encoding unsigned data for bit-depth 9-16""" jpg = DIR_15444 / "2KLS" / "693.j2k" with open(jpg, "rb") as f: @@ -1216,7 +1245,39 @@ def test_lossless_unsigned_u2(self): assert out.dtype.kind == "u" assert np.array_equal(arr, out) - def test_lossless_unsigned_u4(self): + def test_lossless_u2_overflow(self): + """Test encoding result when data exists in bits above the precision.""" + arr = np.ones((64 * 64 - 6), dtype="u2") + src = [ + # 16-bit value -> 12-bit value + # |---> + 0b1111_0000_0000_0000, # 0b0000_0000_0000_0000 + 0b1111_1111_1111_1111, # 0b0000_1111_1111_1111 + 0b0000_1111_1111_1111, # 0b0000_0011_1111_1111 + 0b0000_0000_0000_0000, # 0b0000_0000_0000_0000 + 0b0000_0000_0000_0001, # 0b0000_0000_0000_0001 + 0b1111_0000_1111_0000, # 0b0000_0000_1111_0000 + ] + src = b"".join([v.to_bytes(length=2, byteorder="little") for v in src]) + src += arr.tobytes() + buffer = encode_buffer( + src, + rows=64, + columns=64, + samples_per_pixel=1, + bits_stored=12, + is_signed=False, + photometric_interpretation=PI.MONOCHROME2, + ) + out = decode_pixel_data(buffer, version=2) + assert int.from_bytes(out[0:2], byteorder="little") ==0 + assert int.from_bytes(out[2:4], byteorder="little") == 0b0000_1111_1111_1111 + assert int.from_bytes(out[4:6], byteorder="little") == 0b0000_1111_1111_1111 + assert int.from_bytes(out[6:8], byteorder="little") == 0 + assert int.from_bytes(out[8:10], byteorder="little") == 1 + assert int.from_bytes(out[10:12], byteorder="little") == 0b0000_0000_1111_0000 + + def test_lossless_u4(self): """Test encoding unsigned data for bit-depth 17-24""" rows = 123 cols = 234 @@ -1274,10 +1335,48 @@ def test_lossless_unsigned_u4(self): assert out.dtype.kind == "u" assert np.array_equal(arr, out) - def test_lossless_signed_i1(self): + def test_lossless_u4_overflow(self): + """Test encoding result when data exists in bits above the precision.""" + arr = np.ones((64 * 64 - 6), dtype="u4") + src = [ + # 32-bit value -> 24-bit value + # |---> + 0b1111_1111_0000_0000_0000_0000_0000_0000, + 0b1111_1111_1111_1111_1111_1111_1111_1111, + 0b0000_1111_1111_1111_1111_1111_1111_1111, + 0b0000_0000_0000_0000_0000_0000_0000_0000, + 0b0000_0000_0000_0000_0000_0000_0000_0001, + 0b1111_0000_1111_0000_1111_0000_1111_0000, + ] + src = b"".join([v.to_bytes(length=4, byteorder="little") for v in src]) + src += arr.tobytes() + buffer = encode_buffer( + src, + rows=64, + columns=64, + samples_per_pixel=1, + bits_stored=24, + is_signed=False, + photometric_interpretation=PI.MONOCHROME2, + ) + out = decode_pixel_data(buffer, version=2) + assert int.from_bytes(out[0:4], byteorder="little") == 0 + assert int.from_bytes(out[4:8], byteorder="little") == ( + 0b0000_0000_1111_1111_1111_1111_1111_1111 + ) + assert int.from_bytes(out[8:12], byteorder="little") == ( + 0b0000_0000_1111_1111_1111_1111_1111_1111 + ) + assert int.from_bytes(out[12:16], byteorder="little") == 0 + assert int.from_bytes(out[16:20], byteorder="little") == 1 + assert int.from_bytes(out[20:24], byteorder="little") == ( + 0b0000_0000_1111_0000_1111_0000_1111_0000 + ) + + def test_lossless_i1(self): """Test encoding signed data for bit-depth 1-8""" rows = 123 - cols = 543 + cols = 234 for bit_depth in range(2, 9): maximum = 2 ** (bit_depth - 1) - 1 minimum = -(2 ** (bit_depth - 1)) @@ -1354,10 +1453,50 @@ def test_lossless_signed_i1(self): assert out.dtype.kind == "i" assert np.array_equal(arr, out) - def test_lossless_signed_i2(self): + def test_lossless_i1_overflow(self): + """Test encoding result when data exists in bits above the precision.""" + arr = np.ones((64 * 64 - 10), dtype="i1") + src = [ + # 8-bit value -> 6-bit value + # |---> + 0b1110_0000, # 0b1110_0000 + 0b1111_1111, # 0b1111_1111 + 0b0000_0000, # 0b0000_0000 + 0b0000_0001, # 0b0000_0001 + 0b0001_1111, # 0b0001_1111 + 0b1011_0100, # 0b1111_0100 + 0b1111_0100, # 0b1111_0100 + 0b1101_0100, # 0b0001_0100 + 0b0110_1100, # 0b1110_1100 + 0b0010_0011, # 0b1110_0011 + ] + src = b"".join([v.to_bytes(length=1, byteorder="little") for v in src]) + src += arr.tobytes() + buffer = encode_buffer( + src, + rows=64, + columns=64, + samples_per_pixel=1, + bits_stored=6, + is_signed=True, + photometric_interpretation=PI.MONOCHROME2, + ) + out = decode_pixel_data(buffer, version=2) + assert out[0] == 0b1110_0000 + assert out[1] == 0b1111_1111 + assert out[2] == 0b0000_0000 + assert out[3] == 0b0000_0001 + assert out[4] == 0b0001_1111 + assert out[5] == 0b1111_0100 + assert out[6] == 0b1111_0100 + assert out[7] == 0b0001_0100 + assert out[8] == 0b1110_1100 + assert out[9] == 0b1110_0011 + + def test_lossless_i2(self): """Test encoding signed data for bit-depth 9-16""" rows = 123 - cols = 543 + cols = 234 for bit_depth in range(9, 17): maximum = 2 ** (bit_depth - 1) - 1 minimum = -(2 ** (bit_depth - 1)) @@ -1443,7 +1582,47 @@ def test_lossless_signed_i2(self): assert out.dtype.kind == "i" assert np.array_equal(arr, out) - def test_lossless_signed_i4(self): + def test_lossless_i2_overflow(self): + """Test encoding result when data exists in bits above the precision.""" + arr = np.ones((64 * 64 - 10), dtype="i2") + src = [ + # 16-bit value -> 12-bit value + # |---> + 0b1111_0000_0000_0000, # 0b0000_0000_0000_0000 + 0b1111_1111_1111_1111, # 0b1111_1111_1111_1111 + 0b0000_1111_1111_1111, # 0b1111_1111_1111_1111 + 0b0000_0000_0000_0000, # 0b0000_0000_0000_0000 + 0b0000_0000_0000_0001, # 0b0000_0000_0000_0001 + 0b1111_0000_1111_0000, # 0b0000_0000_1111_0000 + 0b0000_0111_1111_1111, # 0b0000_0111_1111_1111 + 0b1010_1001_1001_0110, # 0b1111_1001_1001_0110 + 0b1010_0101_1001_0110, # 0b0000_0101_1001_0110 + 0b0001_1101_1001_0110, # 0b1111_1101_1001_0110 + ] + src = b"".join([v.to_bytes(length=2, byteorder="little") for v in src]) + src += arr.tobytes() + buffer = encode_buffer( + src, + rows=64, + columns=64, + samples_per_pixel=1, + bits_stored=12, + is_signed=True, + photometric_interpretation=PI.MONOCHROME2, + ) + out = decode_pixel_data(buffer, version=2) + assert int.from_bytes(out[0:2], byteorder="little") == 0 + assert int.from_bytes(out[2:4], byteorder="little") == 0b1111_1111_1111_1111 + assert int.from_bytes(out[4:6], byteorder="little") == 0b1111_1111_1111_1111 + assert int.from_bytes(out[6:8], byteorder="little") == 0 + assert int.from_bytes(out[8:10], byteorder="little") == 1 + assert int.from_bytes(out[10:12], byteorder="little") == 0b0000_0000_1111_0000 + assert int.from_bytes(out[12:14], byteorder="little") == 0b0000_0111_1111_1111 + assert int.from_bytes(out[14:16], byteorder="little") == 0b1111_1001_1001_0110 + assert int.from_bytes(out[16:18], byteorder="little") == 0b0000_0101_1001_0110 + assert int.from_bytes(out[18:20], byteorder="little") == 0b1111_1101_1001_0110 + + def test_lossless_i4(self): """Test encoding signed data for bit-depth 17-24""" rows = 123 cols = 234 @@ -1505,6 +1684,60 @@ def test_lossless_signed_i4(self): assert out.dtype.kind == "i" assert np.array_equal(arr, out) + def test_lossless_i4_overflow(self): + """Test encoding result when data exists in bits above the precision.""" + arr = np.ones((64 * 64 - 10), dtype="i4") + src = [ + # 32-bit value -> 24-bit value + # |---> + 0b1111_1111_0000_0000_0000_0000_0000_0000, + 0b1111_1111_1111_1111_1111_1111_1111_1111, + 0b0000_0000_1111_1111_1111_1111_1111_1111, + 0b0000_0000_0000_0000_0000_0000_0000_0000, + 0b0000_0000_0000_0000_0000_0000_0000_0001, + 0b1111_0000_1111_0000_1111_0000_1111_0000, + 0b0000_0000_0111_1111_1111_1111_1111_1111, + 0b1100_1100_1010_1001_1001_0110_0111_1110, + 0b1100_1100_0010_1001_1001_0110_0111_1110, + 0b0000_0001_1101_1001_0110_0111_1110_1111, + ] + src = b"".join([v.to_bytes(length=4, byteorder="little") for v in src]) + src += arr.tobytes() + buffer = encode_buffer( + src, + rows=64, + columns=64, + samples_per_pixel=1, + bits_stored=24, + is_signed=True, + photometric_interpretation=PI.MONOCHROME2, + ) + out = decode_pixel_data(buffer, version=2) + assert int.from_bytes(out[0:4], byteorder="little") == 0 + assert int.from_bytes(out[4:8], byteorder="little") == ( + 0b1111_1111_1111_1111_1111_1111_1111_1111 + ) + assert int.from_bytes(out[8:12], byteorder="little") == ( + 0b1111_1111_1111_1111_1111_1111_1111_1111 + ) + assert int.from_bytes(out[12:16], byteorder="little") == 0 + assert int.from_bytes(out[16:20], byteorder="little") == 1 + assert int.from_bytes(out[20:24], byteorder="little") == ( + 0b1111_1111_1111_0000_1111_0000_1111_0000 + ) + assert int.from_bytes(out[24:28], byteorder="little") == ( + 0b0000_0000_0111_1111_1111_1111_1111_1111 + ) + assert int.from_bytes(out[28:32], byteorder="little") == ( + 0b1111_1111_1010_1001_1001_0110_0111_1110 + ) + assert int.from_bytes(out[32:36], byteorder="little") == ( + 0b0000_0000_0010_1001_1001_0110_0111_1110 + ) + assert int.from_bytes(out[36:40], byteorder="little") == ( + 0b1111_1111_1101_1001_0110_0111_1110_1111 + ) + def test_lossy_unsigned(self): """Test lossy encoding with unsigned data""" rows = 123 @@ -1728,6 +1961,79 @@ def test_jp2(self): buffer = encode_buffer(arr.tobytes(), **kwargs) assert buffer.startswith(b"\x00\x00\x00\x0c\x6a\x50\x20\x20\x0d\x0a\x87\x0a") + @pytest.mark.skip() + def test_unused_bits_u1(self): + """Test that bits above precision are ignored.""" + rows = 123 + cols = 234 + for bit_depth in range(2, 9): + maximum = 2**bit_depth - 1 + arr = np.random.randint(0, high=maximum + 1, size=(rows, cols), dtype="u1") + buffer = encode_buffer( + arr.tobytes(), + cols, + rows, + 1, + bit_depth, + False, + photometric_interpretation=PI.MONOCHROME2, + ) + out = decode(buffer) + param = parse_j2k(buffer) + assert param["precision"] == bit_depth + assert param["is_signed"] is False + assert param["layers"] == 1 + assert param["components"] == 1 + + assert out.dtype.kind == "u" + assert np.array_equal(arr, out) + + arr = np.random.randint( + 0, high=maximum + 1, size=(rows, cols, 3), dtype="u1" + ) + buffer = encode_buffer( + arr.tobytes(), + cols, + rows, + 3, + bit_depth, + False, + photometric_interpretation=PI.RGB, + use_mct=False, + ) + out = decode(buffer) + param = parse_j2k(buffer) + assert param["precision"] == bit_depth + assert param["is_signed"] is False + assert param["layers"] == 1 + assert param["components"] == 3 + + assert out.dtype.kind == "u" + assert np.array_equal(arr, out) + + arr = np.random.randint( + 0, high=maximum + 1, size=(rows, cols, 4), dtype="u1" + ) + buffer = encode_buffer( + arr.tobytes(), + cols, + rows, + 4, + bit_depth, + False, + photometric_interpretation=5, + use_mct=False, + ) + out = decode(buffer) + param = parse_j2k(buffer) + assert param["precision"] == bit_depth + assert param["is_signed"] is False + assert param["layers"] == 1 + assert param["components"] == 4 + + assert out.dtype.kind == "u" + assert np.array_equal(arr, out) + class TestEncodePixelData: """Tests for encode_pixel_data()""" diff --git a/openjpeg/tests/test_handler.py b/openjpeg/tests/test_handler.py index fe213d9..e32301d 100644 --- a/openjpeg/tests/test_handler.py +++ b/openjpeg/tests/test_handler.py @@ -4,8 +4,8 @@ try: from pydicom import __version__ - from pydicom.encaps import generate_pixel_data_frame - from pydicom.pixel_data_handlers.util import ( + from pydicom.encaps import generate_frames + from pydicom.pixels.utils import ( reshape_pixel_array, pixel_dtype, ) @@ -21,10 +21,10 @@ PYD_VERSION = int(__version__.split(".")[0]) -def generate_frames(ds): +def get_frame_generator(ds): """Return a frame generator for DICOM datasets.""" nr_frames = ds.get("NumberOfFrames", 1) - return generate_pixel_data_frame(ds.PixelData, nr_frames) + return generate_frames(ds.PixelData, number_of_frames=nr_frames) @pytest.mark.skipif(not HAS_PYDICOM, reason="pydicom unavailable") @@ -35,7 +35,7 @@ def test_invalid_type_raises(self): """Test decoding using invalid type raises.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["MR_small_jp2klossless.dcm"]["ds"] - frame = tuple(next(generate_frames(ds))) + frame = tuple(next(get_frame_generator(ds))) assert not hasattr(frame, "tell") and not isinstance(frame, bytes) msg = "a bytes-like object is required, not 'tuple'" @@ -45,7 +45,7 @@ def test_invalid_type_raises(self): def test_no_dataset(self): index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["MR_small_jp2klossless.dcm"]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) arr = decode_pixel_data(frame) assert arr.flags.writeable assert "uint8" == arr.dtype @@ -636,7 +636,7 @@ def test_data_unsigned_pr_1(self): # Note: if PR is 1 but the JPEG data is unsigned then it should # probably be converted to signed using 2s complement ds.pixel_array - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) params = get_parameters(frame) assert params["is_signed"] is False @@ -656,7 +656,7 @@ def test_data_signed_pr_0(self): # Note: if PR is 0 but the JPEG data is signed then... ? ds.pixel_array - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) params = get_parameters(frame) assert params["is_signed"] is True diff --git a/openjpeg/tests/test_parameters.py b/openjpeg/tests/test_parameters.py index b7a2dc4..020d726 100644 --- a/openjpeg/tests/test_parameters.py +++ b/openjpeg/tests/test_parameters.py @@ -3,7 +3,7 @@ import pytest try: - from pydicom.encaps import generate_pixel_data_frame + from pydicom.encaps import generate_frames HAS_PYDICOM = True except ImportError: @@ -50,10 +50,10 @@ } -def generate_frames(ds): +def get_frame_generator(ds): """Return a frame generator for DICOM datasets.""" nr_frames = ds.get("NumberOfFrames", 1) - return generate_pixel_data_frame(ds.PixelData, nr_frames) + return generate_frames(ds.PixelData, number_of_frames=nr_frames) def test_bad_decode(): @@ -87,7 +87,7 @@ def test_jpeg2000r(self, fname, info): index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index[fname]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) params = get_parameters(frame) assert (info[0], info[1]) == (params["rows"], params["columns"]) @@ -101,7 +101,7 @@ def test_jpeg2000i(self, fname, info): index = get_indexed_datasets("1.2.840.10008.1.2.4.91") ds = index[fname]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) params = get_parameters(frame) assert (info[0], info[1]) == (params["rows"], params["columns"]) @@ -114,7 +114,7 @@ def test_decode_bad_type_raises(self): """Test decoding using invalid type raises.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["MR_small_jp2klossless.dcm"]["ds"] - frame = tuple(next(generate_frames(ds))) + frame = tuple(next(get_frame_generator(ds))) assert not hasattr(frame, "tell") and not isinstance(frame, bytes) msg = ( @@ -129,7 +129,7 @@ def test_decode_format_raises(self): """Test decoding using invalid format raises.""" index = get_indexed_datasets("1.2.840.10008.1.2.4.90") ds = index["693_J2KR.dcm"]["ds"] - frame = next(generate_frames(ds)) + frame = next(get_frame_generator(ds)) msg = r"Unsupported 'j2k_format' value: 3" with pytest.raises(ValueError, match=msg): get_parameters(frame, j2k_format=3) diff --git a/pyproject.toml b/pyproject.toml index f1de238..fb2a899 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers=[ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Topic :: Scientific/Engineering :: Medical Science Apps.", "Topic :: Software Development :: Libraries", @@ -48,7 +49,7 @@ packages = [ {include = "openjpeg" }, ] readme = "README.md" -version = "2.4.0" +version = "2.5.0" [tool.poetry.dependencies]