Skip to content

Commit 98fdf31

Browse files
committed
Added unit tests and updated dependencies for compressed DICOM support
Signed-off-by: M Q <[email protected]>
1 parent 480ffc9 commit 98fdf31

File tree

4 files changed

+205
-4
lines changed

4 files changed

+205
-4
lines changed

monai/deploy/operators/decoder_nvimgcodec.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes:
212212

213213
# Double check if the transfer syntax is supported although the runner should be correct.
214214
tsyntax = runner.transfer_syntax
215-
_logger.info(f"transfer_syntax: {tsyntax}")
215+
_logger.debug(f"transfer_syntax: {tsyntax}")
216216

217217
if not is_available(tsyntax):
218218
raise ValueError(f"Transfer syntax {tsyntax} not supported; see details in the debug log.")

requirements-dev.txt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ pytest-lazy-fixture==0.6.3
2323
cucim~=21.06; platform_system == "Linux"
2424
monai>=1.3.0
2525
docker>=5.0.0
26-
pydicom>=2.3.0
26+
pydicom>=3.0.0
2727
PyPDF2>=2.11.1
2828
highdicom>=0.18.2
2929
SimpleITK>=2.0.0
@@ -34,3 +34,9 @@ nibabel>=3.2.1
3434
numpy-stl>=2.12.0
3535
trimesh>=3.8.11
3636
torch>=2.6.0
37+
nvidia-nvimgcodec-cu12>=0.6.1
38+
nvidia-nvjpeg2k-cu12>=0.9.1
39+
python-gdcm>=3.0.10
40+
pylibjpeg>=2.0
41+
pylibjpeg-libjpeg>=2.1
42+
pylibjpeg-openjpeg>=2.0

requirements-examples.txt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
scikit-image>=0.17.2
2-
pydicom>=2.3.0
2+
pydicom>=3.0.0
33
PyPDF2>=2.11.1
44
types-pytz>=2024.1.0.20240203
55
highdicom>=0.18.2
@@ -9,4 +9,10 @@ nibabel>=3.2.1
99
numpy-stl>=2.12.0
1010
trimesh>=3.8.11
1111
torch>=2.6.0
12-
monai>=1.3.0
12+
monai>=1.3.0
13+
nvidia-nvimgcodec-cu12>=0.6.1
14+
nvidia-nvjpeg2k-cu12>=0.9.1
15+
python-gdcm>=3.0.10
16+
pylibjpeg>=2.0
17+
pylibjpeg-libjpeg>=2.1
18+
pylibjpeg-openjpeg>=2.0
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import time
2+
from pathlib import Path
3+
4+
import numpy as np
5+
import pytest
6+
from pydicom import dcmread
7+
from pydicom.data import get_testdata_files
8+
9+
from monai.deploy.operators.decoder_nvimgcodec import (
10+
SUPPORTED_TRANSFER_SYNTAXES,
11+
_is_nvimgcodec_available,
12+
register_as_decoder_plugin,
13+
unregister_as_decoder_plugin,
14+
)
15+
16+
17+
# The JPEG 8-bit standard allows a maximum 1 bit of difference for each pixel component.
18+
# It is normal for slight differences to exist due to varying internal precision in the
19+
# decoders' inverse Discrete Cosine Transform (IDCT) implementations
20+
# So, need to more closely inspect the pixel values for these transfer syntaxes.
21+
TRANSFER_SYNTAXES_WITH_UNEQUAL_PIXEL_VALUES = [
22+
"1.2.840.10008.1.2.4.50",
23+
"1.2.840.10008.1.2.4.91",
24+
"1.2.840.10008.1.2.4.203",
25+
]
26+
27+
# Files that cannot be decoded with the default decoders
28+
SKIPPING_DEFAULT_ERRORED_FILES = {
29+
'UN_sequence.dcm': '1.2.840.10008.1.2.4.70',
30+
'JPEG-lossy.dcm': '1.2.840.10008.1.2.4.51',
31+
'JPEG2000-embedded-sequence-delimiter.dcm': '1.2.840.10008.1.2.4.91',
32+
'emri_small_jpeg_2k_lossless_too_short.dcm': '1.2.840.10008.1.2.4.90',
33+
}
34+
35+
# Files that have unequal pixel values between default and nvimgcodec decoders
36+
SKIPPING_UNEQUAL_PIXEL_FILES = {
37+
'SC_rgb_jpeg_lossy_gdcm.dcm': '1.2.840.10008.1.2.4.50',
38+
'SC_rgb_dcmtk_+eb+cr.dcm': '1.2.840.10008.1.2.4.50',
39+
'SC_rgb_jpeg_dcmtk.dcm': '1.2.840.10008.1.2.4.50',
40+
'SC_rgb_dcmtk_+eb+cy+n1.dcm': '1.2.840.10008.1.2.4.50',
41+
'SC_rgb_dcmtk_+eb+cy+s2.dcm': '1.2.840.10008.1.2.4.50',
42+
'examples_ybr_color.dcm': '1.2.840.10008.1.2.4.50',
43+
'SC_rgb_dcmtk_+eb+cy+n2.dcm': '1.2.840.10008.1.2.4.50',
44+
'SC_rgb_dcmtk_+eb+cy+s4.dcm': '1.2.840.10008.1.2.4.50',
45+
'SC_rgb_jpeg.dcm': '1.2.840.10008.1.2.4.50',
46+
'SC_rgb_jpeg_app14_dcmd.dcm': '1.2.840.10008.1.2.4.50',
47+
'SC_jpeg_no_color_transform.dcm': '1.2.840.10008.1.2.4.50',
48+
'SC_jpeg_no_color_transform_2.dcm': '1.2.840.10008.1.2.4.50',
49+
'SC_rgb_small_odd_jpeg.dcm': '1.2.840.10008.1.2.4.50',
50+
'SC_rgb_dcmtk_+eb+cy+np.dcm': '1.2.840.10008.1.2.4.50',
51+
'color3d_jpeg_baseline.dcm': '1.2.840.10008.1.2.4.50',
52+
'MR2_J2KI.dcm': '1.2.840.10008.1.2.4.91',
53+
'RG3_J2KI.dcm': '1.2.840.10008.1.2.4.91',
54+
'US1_J2KI.dcm': '1.2.840.10008.1.2.4.91'
55+
}
56+
57+
CONFIRMED_EQUAL_PIXEL_FILES = {
58+
'JPGExtended.dcm': '1.2.840.10008.1.2.4.51',
59+
'examples_jpeg2k.dcm': '1.2.840.10008.1.2.4.90',
60+
'J2K_pixelrep_mismatch.dcm': '1.2.840.10008.1.2.4.90',
61+
'SC_rgb_gdcm_KY.dcm': '1.2.840.10008.1.2.4.91',
62+
'GDCMJ2K_TextGBR.dcm': '1.2.840.10008.1.2.4.90',
63+
'SC_rgb_jpeg_gdcm.dcm': '1.2.840.10008.1.2.4.70',
64+
'MR_small_jp2klossless.dcm': '1.2.840.10008.1.2.4.90',
65+
'JPEG2000.dcm': '1.2.840.10008.1.2.4.91',
66+
'693_J2KI.dcm': '1.2.840.10008.1.2.4.91',
67+
'693_J2KR.dcm': '1.2.840.10008.1.2.4.90',
68+
'bad_sequence.dcm': '1.2.840.10008.1.2.4.70',
69+
'emri_small_jpeg_2k_lossless.dcm': '1.2.840.10008.1.2.4.90',
70+
'explicit_VR-UN.dcm': '1.2.840.10008.1.2.4.90',
71+
'JPEG-LL.dcm': '1.2.840.10008.1.2.4.70',
72+
'JPGLosslessP14SV1_1s_1f_8b.dcm': '1.2.840.10008.1.2.4.70',
73+
'MR2_J2KR.dcm': '1.2.840.10008.1.2.4.90',
74+
'RG1_J2KI.dcm': '1.2.840.10008.1.2.4.91',
75+
'RG1_J2KR.dcm': '1.2.840.10008.1.2.4.90',
76+
'RG3_J2KR.dcm': '1.2.840.10008.1.2.4.90',
77+
'US1_J2KR.dcm': '1.2.840.10008.1.2.4.90'
78+
}
79+
80+
81+
@pytest.mark.skipif(not _is_nvimgcodec_available(), reason="nvimgcodec dependencies unavailable")
82+
def test_nvimgcodec_decoder_matches_default():
83+
"""Ensure nvimgcodec decoder matches default decoding for supported transfer syntaxes."""
84+
85+
test_files = get_testdata_files("*.dcm")
86+
baseline_total = 0.0
87+
nvimgcodec_total = 0.0
88+
compared = 0
89+
_rtol = 0.01 # The relative tolerance parameter
90+
_atol = 1.00 # The absolute tolerance parameter
91+
92+
default_errored_files = dict()
93+
nvimgcodec_errored_files = dict()
94+
unequal_pixel_files = dict()
95+
inspected_unequal_files = dict()
96+
confirmed_equal_pixel_files = dict()
97+
98+
for path in test_files:
99+
default_errored = False
100+
nvimgcodec_errored = False
101+
102+
try:
103+
dataset = dcmread(path, stop_before_pixels=True, force=True)
104+
transfer_syntax = dataset.file_meta.TransferSyntaxUID
105+
except Exception as e:
106+
print(f"Skipping: error reading DICOM file {path}: {e}")
107+
continue
108+
109+
if transfer_syntax not in SUPPORTED_TRANSFER_SYNTAXES:
110+
print(f"Skipping: unsupported transfer syntax DICOM file {path}: {transfer_syntax}")
111+
continue
112+
113+
try:
114+
ds_default = dcmread(path, force=True)
115+
start = time.perf_counter()
116+
baseline_pixels = ds_default.pixel_array
117+
baseline_total += time.perf_counter() - start
118+
except Exception as e:
119+
# Skip files that cannot be decoded with the default backend
120+
print(f"Skipping: default backends cannot decode DICOM file {path}: {e}")
121+
default_errored_files[Path(path).name] = transfer_syntax
122+
default_errored = True
123+
# Let's see if nvimgcodec can decode it.
124+
125+
# Register the nvimgcodec decoder plugin and unregister it after each use.
126+
register_as_decoder_plugin()
127+
try:
128+
ds_custom = dcmread(path, force=True)
129+
start = time.perf_counter()
130+
nv_pixels = ds_custom.pixel_array
131+
nvimgcodec_total += time.perf_counter() - start
132+
except Exception as e:
133+
print(f"Skipping: nvimgcodec cannot decode DICOM file {path}: {e}")
134+
nvimgcodec_errored_files[Path(path).name] = transfer_syntax
135+
nvimgcodec_errored = True
136+
finally:
137+
unregister_as_decoder_plugin()
138+
139+
if default_errored or nvimgcodec_errored:
140+
print(f"Skipping: either default or nvimgcodec decoder or both failed to decode DICOM file {path}")
141+
continue
142+
143+
assert baseline_pixels.shape == nv_pixels.shape, f"Shape mismatch for {Path(path).name}"
144+
145+
if baseline_pixels.dtype != nv_pixels.dtype:
146+
baseline_compare = baseline_pixels.astype(np.float32)
147+
nv_compare = nv_pixels.astype(np.float32)
148+
else:
149+
baseline_compare = baseline_pixels
150+
nv_compare = nv_pixels
151+
152+
if not np.allclose(
153+
baseline_compare,
154+
nv_compare,
155+
rtol=_rtol,
156+
atol=_atol,
157+
):
158+
if transfer_syntax in TRANSFER_SYNTAXES_WITH_UNEQUAL_PIXEL_VALUES:
159+
diff = baseline_compare.astype(np.float32) - nv_compare.astype(np.float32)
160+
peak_absolute_error = float(np.max(np.abs(diff)))
161+
mean_squared_error = float(np.mean(diff ** 2))
162+
inspected_unequal_files[Path(path).name] = {
163+
"transfer_syntax": transfer_syntax,
164+
"peak_absolute_error": peak_absolute_error,
165+
"mean_squared_error": mean_squared_error,
166+
}
167+
else:
168+
unequal_pixel_files[Path(path).name] = {"transfer_syntax": transfer_syntax}
169+
else:
170+
confirmed_equal_pixel_files[Path(path).name] = transfer_syntax
171+
172+
compared += 1
173+
174+
print(f"Default decoder total time: {baseline_total:.4f}s")
175+
print(f"nvimgcodec decoder total time: {nvimgcodec_total:.4f}s")
176+
print(f"Total tested DICOM files: {compared}")
177+
print(f"Default errored files: {default_errored_files}")
178+
print(f"nvimgcodec errored files: {nvimgcodec_errored_files}")
179+
print(f"Unequal files (tolerance: {_rtol}, {_atol}): {unequal_pixel_files}")
180+
print(f"Inspected unequal files (tolerance: {_rtol}, {_atol}): {inspected_unequal_files}")
181+
print(f"Confirmed tested files: {confirmed_equal_pixel_files}")
182+
183+
assert compared > 0, "No compatible DICOM files found for nvimgcodec decoder test."
184+
assert (x in default_errored_files.keys() for x in (nvimgcodec_errored_files.keys())), "nvimgcodec decoder errored files found."
185+
assert len(unequal_pixel_files) == 0, "Unequal files found."
186+
assert len(confirmed_equal_pixel_files) > 0, "No files with equal pixel values after decoding with both decoders."
187+
188+
if __name__ == "__main__":
189+
test_nvimgcodec_decoder_matches_default()

0 commit comments

Comments
 (0)