Skip to content

Commit 6e612df

Browse files
Compress/resize DICOM pixel data before storing to PACS (#10)
Compress and resize pixel data when storing DICOM image
1 parent 52bfa28 commit 6e612df

11 files changed

Lines changed: 569 additions & 35 deletions

File tree

.gitleaksignore

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@
88

99

1010
src/services/storage.py:ipv4:74
11+
tests/services/dicom/test_c_store.py:ipv4:15
12+
tests/services/dicom/test_c_store.py:ipv4:19
13+
tests/services/dicom/test_c_store.py:ipv4:48
14+
tests/services/dicom/test_c_store.py:ipv4:62
1115
tests/integration/test_c_store_saves_metadata.py:ipv4:21
16+
tests/integration/test_c_store_saves_metadata.py:ipv4:23
17+
tests/integration/test_c_store_saves_metadata.py:ipv4:38
1218
tests/integration/test_c_store_saves_metadata.py:ipv4:41
1319
tests/integration/test_c_store_saves_metadata.py:ipv4:52
20+
tests/integration/test_c_store_saves_metadata.py:ipv4:55
1421
tests/integration/test_c_store_saves_metadata.py:ipv4:65
15-
tests/services/dicom/test_c_store.py:ipv4:15
16-
tests/services/dicom/test_c_store.py:ipv4:48
22+
tests/integration/test_c_store_saves_metadata.py:ipv4:73
23+
tests/integration/test_c_store_saves_metadata.py:ipv4:91
24+
tests/integration/test_c_store_saves_metadata.py:ipv4:108
1725
tests/services/test_storage.py:ipv4:37
1826
tests/services/test_storage.py:ipv4:40
1927
tests/services/test_storage.py:ipv4:52

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ license = { text = "MIT" }
88
dependencies = [
99
"pydicom>=3.0.1",
1010
"pynetdicom>=3.0.4",
11+
"numpy>=2.4.0,<3",
12+
"pylibjpeg>=2.0.0",
13+
"pylibjpeg-libjpeg>=2.0.0",
14+
"pylibjpeg-openjpeg>=2.2.0",
15+
"pillow>=11.0.0",
1116
]
1217

1318
[dependency-groups]
@@ -17,7 +22,6 @@ dev = [
1722
"ruff>=0.14.1,<0.15",
1823
"pyright>=1.1.390",
1924
"ipdb>=0.13.13,<0.14",
20-
"numpy>=2.4.0,<3",
2125
]
2226

2327
[tool.uv]

src/services/dicom/c_store.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
)
1111

1212
from services.dicom import FAILURE, SUCCESS
13+
from services.dicom.image_compressor import ImageCompressor
1314
from services.storage import InstanceExistsError, PACSStorage
1415

1516
logger = logging.getLogger(__name__)
@@ -21,8 +22,9 @@ class CStore:
2122
DigitalMammographyXRayImageStorageForProcessing,
2223
]
2324

24-
def __init__(self, storage: PACSStorage):
25+
def __init__(self, storage: PACSStorage, compressor: ImageCompressor | None = None):
2526
self.storage = storage
27+
self.compressor = compressor or ImageCompressor()
2628

2729
def call(self, event: Event) -> int:
2830
try:
@@ -46,9 +48,12 @@ def call(self, event: Event) -> int:
4648
accession_number = ds.get("AccessionNumber", "")
4749
patient_name = str(ds.get("PatientName", ""))
4850

51+
# Compress dataset before storing
52+
compressed_ds = self.compressor.compress(ds)
53+
4954
self.storage.store_instance(
5055
sop_instance_uid,
51-
self.dataset_to_bytes(ds),
56+
self.dataset_to_bytes(compressed_ds),
5257
{
5358
"accession_number": accession_number,
5459
"patient_id": patient_id,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""DICOM Image Compression Service
2+
3+
Provides compression functionality for DICOM images using JPEG 2000 Lossy compression.
4+
"""
5+
6+
import logging
7+
import os
8+
9+
from pydicom import Dataset
10+
from pydicom.pixels.utils import compress
11+
from pydicom.uid import JPEG2000, ExplicitVRBigEndian, ExplicitVRLittleEndian, ImplicitVRLittleEndian
12+
13+
from services.dicom.image_resizer import ImageResizer
14+
15+
# Uncompressed DICOM transfer syntaxes
16+
UNCOMPRESSED_TRANSFER_SYNTAXES = {ExplicitVRLittleEndian, ImplicitVRLittleEndian, ExplicitVRBigEndian}
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class ImageCompressor:
22+
def __init__(self, compression_ratio: int | None = None, resizer: ImageResizer | None = None):
23+
self.compression_ratio = (
24+
compression_ratio if compression_ratio is not None else int(os.getenv("DICOM_COMPRESSION_RATIO", "15"))
25+
)
26+
self.resizer = resizer or ImageResizer()
27+
28+
def compress(self, ds: Dataset) -> Dataset:
29+
"""
30+
Resize and compress DICOM image.
31+
32+
Args:
33+
ds: DICOM dataset
34+
35+
Returns:
36+
Best possible version of dataset given any failures
37+
"""
38+
if not hasattr(ds, "PixelData") or ds.PixelData is None:
39+
logger.info("No pixel data found, skipping compression")
40+
return ds
41+
42+
# Decompress if already compressed
43+
if ds.file_meta.TransferSyntaxUID not in UNCOMPRESSED_TRANSFER_SYNTAXES:
44+
try:
45+
logger.info(f"Decompressing from {ds.file_meta.TransferSyntaxUID.name}")
46+
ds.decompress()
47+
except Exception as e:
48+
logger.error(f"Decompression failed: {e}", exc_info=True)
49+
logger.warning("Continuing with compressed dataset")
50+
51+
try:
52+
ds = self.resizer.resize(ds)
53+
logger.debug(f"Resized to {ds.Columns}×{ds.Rows}")
54+
except Exception as e:
55+
logger.error(f"Resizing failed: {e}", exc_info=True)
56+
logger.warning("Continuing with original size")
57+
58+
try:
59+
compressed_ds = compress(
60+
ds, transfer_syntax_uid=JPEG2000, encoding_plugin="pylibjpeg", j2k_cr=[self.compression_ratio]
61+
)
62+
logger.info(
63+
f"Compressed to {compressed_ds.file_meta.TransferSyntaxUID.name} "
64+
f"({self.compression_ratio}:1, {compressed_ds.Columns}×{compressed_ds.Rows})"
65+
)
66+
return compressed_ds
67+
68+
except Exception as e:
69+
logger.error(f"Compression failed: {e}", exc_info=True)
70+
logger.warning(
71+
f"Returning uncompressed dataset ({ds.Columns}×{ds.Rows}, ~{(ds.Columns * ds.Rows * 2) / 1024:.0f} KB)"
72+
)
73+
return ds
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""DICOM Image Resizing Service
2+
3+
Provides resizing functionality for DICOM images while maintaining aspect ratio.
4+
"""
5+
6+
import logging
7+
import os
8+
9+
import numpy as np
10+
from PIL import Image
11+
from pydicom import Dataset
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class ImageResizer:
17+
def __init__(self, thumbnail_size: int | None = None):
18+
self.thumbnail_size = (
19+
thumbnail_size if thumbnail_size is not None else int(os.getenv("DICOM_THUMBNAIL_SIZE", "400"))
20+
)
21+
22+
def _calculate_thumbnail_dimensions(self, original_cols: int, original_rows: int) -> tuple[int, int]:
23+
aspect_ratio = original_cols / original_rows
24+
if original_cols > original_rows:
25+
new_cols = self.thumbnail_size
26+
new_rows = int(self.thumbnail_size / aspect_ratio)
27+
else:
28+
new_rows = self.thumbnail_size
29+
new_cols = int(self.thumbnail_size * aspect_ratio)
30+
return new_cols, new_rows
31+
32+
def _to_pil_image(self, pixel_array: np.ndarray, bits_allocated: int) -> tuple[Image.Image, dict]:
33+
normalization_info = {}
34+
35+
if bits_allocated == 16:
36+
pixel_min = pixel_array.min()
37+
pixel_max = pixel_array.max()
38+
normalization_info = {"pixel_min": pixel_min, "pixel_max": pixel_max}
39+
40+
if pixel_max > pixel_min:
41+
pixel_array_8bit = ((pixel_array - pixel_min) / (pixel_max - pixel_min) * 255).astype(np.uint8)
42+
else:
43+
# Handle uniform images (all same value)
44+
pixel_array_8bit = np.zeros_like(pixel_array, dtype=np.uint8)
45+
img = Image.fromarray(pixel_array_8bit, mode="L")
46+
else:
47+
img = Image.fromarray(pixel_array, mode="L")
48+
49+
return img, normalization_info
50+
51+
def _from_pil_image(self, img: Image.Image, bits_allocated: int, normalization_info: dict) -> np.ndarray:
52+
resized_array = np.array(img)
53+
54+
# For 16-bit images, scale back to 16-bit range
55+
if bits_allocated == 16:
56+
pixel_min = normalization_info.get("pixel_min", 0)
57+
pixel_max = normalization_info.get("pixel_max", 0)
58+
59+
if pixel_max > pixel_min:
60+
resized_array = (resized_array.astype(np.float32) / 255 * (pixel_max - pixel_min) + pixel_min).astype(
61+
np.uint16
62+
)
63+
else:
64+
# Keep uniform image as is
65+
resized_array = resized_array.astype(np.uint16)
66+
67+
return resized_array
68+
69+
def resize(self, ds: Dataset) -> Dataset:
70+
original_rows = ds.Rows
71+
original_cols = ds.Columns
72+
73+
# Skip if already smaller than thumbnail size
74+
if original_rows <= self.thumbnail_size and original_cols <= self.thumbnail_size:
75+
logger.info(
76+
f"Image {original_cols}x{original_rows} already smaller than {self.thumbnail_size}, skipping resize"
77+
)
78+
return ds
79+
80+
# Calculate new dimensions
81+
new_cols, new_rows = self._calculate_thumbnail_dimensions(original_cols, original_rows)
82+
logger.info(f"Resizing from {original_cols}x{original_rows} to {new_cols}x{new_rows}")
83+
84+
# Convert DICOM to PIL Image
85+
pixel_array = ds.pixel_array
86+
img, normalization_info = self._to_pil_image(pixel_array, ds.BitsAllocated)
87+
88+
# Resize
89+
img_resized = img.resize((new_cols, new_rows), Image.Resampling.LANCZOS)
90+
91+
# Convert back to DICOM pixel data
92+
resized_array = self._from_pil_image(img_resized, ds.BitsAllocated, normalization_info)
93+
94+
# Update dataset
95+
ds.PixelData = resized_array.tobytes()
96+
ds.Rows = new_rows
97+
ds.Columns = new_cols
98+
99+
return ds

tests/conftest.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
import sys
33
from pathlib import Path
44

5+
import numpy as np
56
import pytest
7+
from pydicom import Dataset, FileMetaDataset
8+
from pydicom.uid import ExplicitVRLittleEndian
9+
from pynetdicom.sop_class import (
10+
DigitalMammographyXRayImageStorageForPresentation,
11+
)
612

713
sys.path.append(f"{Path(__file__).parent.parent}/src")
814

@@ -27,3 +33,40 @@ def normalize(sql: str) -> str:
2733
assert normalize(expected) == normalize(actual), (
2834
f"SQL statements do not match. Expected:\n{normalize(expected)}\n\nActual:\n{normalize(actual)}"
2935
)
36+
37+
38+
# DICOM test fixtures
39+
@pytest.fixture
40+
def dicom_file_meta():
41+
"""Create standard DICOM file meta information."""
42+
file_meta = FileMetaDataset()
43+
file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
44+
file_meta.MediaStorageSOPClassUID = DigitalMammographyXRayImageStorageForPresentation
45+
return file_meta
46+
47+
48+
@pytest.fixture
49+
def dataset_with_pixels(dicom_file_meta):
50+
"""Create a DICOM dataset with pixel data (256x256, 16-bit)."""
51+
ds = Dataset()
52+
ds.Rows = 256
53+
ds.Columns = 256
54+
ds.BitsAllocated = 16
55+
ds.BitsStored = 16
56+
ds.HighBit = 15
57+
ds.PixelRepresentation = 0
58+
ds.SamplesPerPixel = 1
59+
ds.PhotometricInterpretation = "MONOCHROME2"
60+
ds.PixelData = np.zeros((256, 256), dtype=np.uint16).tobytes()
61+
ds.file_meta = dicom_file_meta
62+
return ds
63+
64+
65+
@pytest.fixture
66+
def dataset_without_pixels(dicom_file_meta):
67+
"""Create a DICOM dataset without pixel data."""
68+
ds = Dataset()
69+
ds.PatientID = "123456"
70+
ds.PatientName = "TEST^PATIENT"
71+
ds.file_meta = dicom_file_meta
72+
return ds

tests/integration/test_c_store_saves_metadata.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from pathlib import Path
22
from unittest.mock import PropertyMock
33

4+
import pydicom
45
import pytest
56
from pydicom import Dataset, FileMetaDataset
6-
from pydicom.uid import ExplicitVRLittleEndian
7+
from pydicom.uid import JPEG2000, ExplicitVRLittleEndian
78
from pynetdicom.sop_class import (
89
DigitalMammographyXRayImageStorageForProcessing,
910
)
@@ -81,3 +82,39 @@ def test_valid_event_is_stored(self, storage, mock_event):
8182
assert source_aet == "ae-title"
8283
assert storage_path == "ff/af/ffaff041ab509297.dcm"
8384
assert Path(f"{storage.storage_root}/{storage_path}").is_file()
85+
86+
def test_compressed_image_stored_on_filesystem(self, storage, dataset_with_pixels):
87+
"""Verify compressed images are stored with JPEG 2000 transfer syntax."""
88+
# Customize the shared dataset for this test
89+
dataset_with_pixels.AccessionNumber = "DEF456"
90+
dataset_with_pixels.PatientID = "9990002223"
91+
dataset_with_pixels.SOPInstanceUID = "1.2.3.4.5.7"
92+
93+
# Wrap in PropertyMock to simulate DICOM event
94+
event = PropertyMock()
95+
event.file_meta = dataset_with_pixels.file_meta
96+
event.dataset = dataset_with_pixels
97+
event.assoc.requestor.ae_title = "test-ae"
98+
99+
subject = CStore(storage)
100+
assert subject.call(event) == SUCCESS
101+
102+
# Verify stored file has JPEG 2000 compression
103+
with storage._get_connection() as conn:
104+
cursor = conn.execute(
105+
"""
106+
SELECT storage_path
107+
FROM stored_instances
108+
WHERE sop_instance_uid = '1.2.3.4.5.7'
109+
"""
110+
)
111+
result = cursor.fetchone()
112+
113+
assert result is not None
114+
storage_path = result[0]
115+
stored_file = Path(storage.storage_root) / storage_path
116+
assert stored_file.exists()
117+
118+
# Read with force=True since DicomFileLike doesn't write preamble
119+
stored_ds = pydicom.dcmread(stored_file, force=True)
120+
assert stored_ds.file_meta.TransferSyntaxUID == JPEG2000

0 commit comments

Comments
 (0)