Skip to content

Commit d2b755e

Browse files
Validate DICOM images when saving to PACS (#15)
If modalities are sending invalid DICOM to the gateway, or if our processing (compression/resizing) is causing the DICOM to be invalid, we prefer to know about it sooner rather than later. This is a somewhat naive but fast implementation. If we find that we need something more comprehensive, we could use pydicom's [dicom-validator](https://pydicom.github.io/dicom-validator).
1 parent e28482b commit d2b755e

5 files changed

Lines changed: 226 additions & 2 deletions

File tree

src/services/dicom/c_store.py

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

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

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

24-
def __init__(self, storage: PACSStorage, compressor: ImageCompressor | None = None):
25+
def __init__(
26+
self,
27+
storage: PACSStorage,
28+
compressor: ImageCompressor | None = None,
29+
validator: DicomValidator | None = None,
30+
):
2531
self.storage = storage
2632
self.compressor = compressor or ImageCompressor()
33+
self.validator = validator or DicomValidator()
2734

2835
def call(self, event: Event) -> int:
2936
try:
@@ -47,12 +54,28 @@ def call(self, event: Event) -> int:
4754
accession_number = ds.get("AccessionNumber", "")
4855
patient_name = str(ds.get("PatientName", ""))
4956

57+
# Validate dataset before compression
58+
try:
59+
self.validator.validate_dataset(ds)
60+
self.validator.validate_pixel_data(ds)
61+
except DicomValidationError as e:
62+
logger.error(f"DICOM validation failed: {e}")
63+
return FAILURE
64+
5065
# Compress dataset before storing
5166
compressed_ds = self.compressor.compress(ds)
5267

68+
# Serialize and validate output
69+
dicom_bytes = self.dataset_to_bytes(compressed_ds)
70+
try:
71+
self.validator.validate_bytes(dicom_bytes)
72+
except DicomValidationError as e:
73+
logger.error(f"Serialized DICOM invalid: {e}")
74+
return FAILURE
75+
5376
self.storage.store_instance(
5477
sop_instance_uid,
55-
self.dataset_to_bytes(compressed_ds),
78+
dicom_bytes,
5679
{
5780
"accession_number": accession_number,
5881
"patient_id": patient_id,

src/services/dicom/validator.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""DICOM Validation utilities."""
2+
3+
import logging
4+
5+
from pydicom import Dataset
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class DicomValidationError(Exception):
11+
"""Raised when DICOM validation fails."""
12+
13+
pass
14+
15+
16+
class DicomValidator:
17+
REQUIRED_TAGS = ["SOPInstanceUID", "PatientID", "StudyInstanceUID", "SOPClassUID"]
18+
DICOM_PREFIX = b"DICM"
19+
PREAMBLE_LENGTH = 128
20+
21+
def validate_dataset(self, ds: Dataset) -> None:
22+
"""Validate dataset has required DICOM tags."""
23+
for tag in self.REQUIRED_TAGS:
24+
value = ds.get(tag)
25+
if not value:
26+
raise DicomValidationError(f"Missing required tag: {tag}")
27+
28+
def validate_bytes(self, data: bytes) -> None:
29+
"""Validate serialized DICOM bytes have valid preamble."""
30+
min_size = self.PREAMBLE_LENGTH + len(self.DICOM_PREFIX)
31+
if len(data) < min_size:
32+
raise DicomValidationError(f"DICOM too small ({len(data)} bytes), missing preamble")
33+
34+
preamble = data[self.PREAMBLE_LENGTH : self.PREAMBLE_LENGTH + 4]
35+
if preamble != self.DICOM_PREFIX:
36+
raise DicomValidationError(f"Invalid DICOM prefix: {preamble!r}, expected {self.DICOM_PREFIX!r}")
37+
38+
def validate_pixel_data(self, ds: Dataset) -> None:
39+
"""Validate pixel data consistency if present."""
40+
if not hasattr(ds, "PixelData") or ds.PixelData is None:
41+
return # No pixel data to validate
42+
43+
required_image_tags = ["Rows", "Columns", "BitsAllocated"]
44+
for tag in required_image_tags:
45+
if not hasattr(ds, tag):
46+
raise DicomValidationError(f"Image has PixelData but missing {tag}")

tests/integration/test_c_store_saves_metadata.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ def mock_event(self):
2121
dataset.AccessionNumber = "ABC123"
2222
dataset.PatientID = "9990001112"
2323
dataset.SOPInstanceUID = "1.2.3.4.5.6" # gitleaks:allow
24+
dataset.StudyInstanceUID = "1.2.3.4.5.6.7" # gitleaks:allow
25+
dataset.SOPClassUID = "1.2.840.10008.5.1.4.1.1.1.2" # gitleaks:allow
2426
file_meta = FileMetaDataset()
2527
file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
2628
file_meta.MediaStorageSOPClassUID = DigitalMammographyXRayImageStorageForProcessing
@@ -89,6 +91,8 @@ def test_compressed_image_stored_on_filesystem(self, storage, dataset_with_pixel
8991
dataset_with_pixels.AccessionNumber = "DEF456"
9092
dataset_with_pixels.PatientID = "9990002223"
9193
dataset_with_pixels.SOPInstanceUID = "1.2.3.4.5.7" # gitleaks:allow
94+
dataset_with_pixels.StudyInstanceUID = "1.2.3.4.5.7.8" # gitleaks:allow
95+
dataset_with_pixels.SOPClassUID = "1.2.840.10008.5.1.4.1.1.1.2" # gitleaks:allow
9296

9397
# Wrap in PropertyMock to simulate DICOM event
9498
event = PropertyMock()

tests/services/dicom/test_c_store.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ def mock_event(self, dataset_with_pixels):
1919
dataset_with_pixels.SOPInstanceUID = "1.2.3.4.5.6" # gitleaks:allow
2020
dataset_with_pixels.PatientID = "9990001112"
2121
dataset_with_pixels.PatientName = "JANE^SMITH"
22+
dataset_with_pixels.StudyInstanceUID = "1.2.3.4.5.6.7" # gitleaks:allow
23+
dataset_with_pixels.SOPClassUID = "1.2.840.10008.5.1.4.1.1.1.2" # gitleaks:allow
2224

2325
# Wrap in PropertyMock to simulate DICOM event
2426
event = PropertyMock()
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import pytest
2+
from pydicom import Dataset
3+
4+
from services.dicom.validator import DicomValidationError, DicomValidator
5+
6+
# Test UIDs for DICOM validation tests
7+
TEST_SOP_INSTANCE_UID = "1.2.3.4.5" # gitleaks:allow
8+
TEST_STUDY_INSTANCE_UID = "1.2.3.4.5.6" # gitleaks:allow
9+
TEST_SOP_CLASS_UID = "1.2.840.10008.5.1.4.1.1.1.2" # gitleaks:allow
10+
TEST_PATIENT_ID = "123456"
11+
12+
13+
class TestDicomValidator:
14+
@pytest.fixture
15+
def valid_dataset(self):
16+
ds = Dataset()
17+
ds.SOPInstanceUID = TEST_SOP_INSTANCE_UID
18+
ds.PatientID = TEST_PATIENT_ID
19+
ds.StudyInstanceUID = TEST_STUDY_INSTANCE_UID
20+
ds.SOPClassUID = TEST_SOP_CLASS_UID
21+
return ds
22+
23+
@pytest.fixture
24+
def valid_image_dataset(self, valid_dataset):
25+
valid_dataset.PixelData = b"\x00" * 100
26+
valid_dataset.Rows = 10
27+
valid_dataset.Columns = 10
28+
valid_dataset.BitsAllocated = 8
29+
return valid_dataset
30+
31+
def test_validate_dataset_success(self, valid_dataset):
32+
validator = DicomValidator()
33+
validator.validate_dataset(valid_dataset) # Should not raise
34+
35+
def test_validate_dataset_missing_sop_instance_uid(self):
36+
ds = Dataset()
37+
ds.PatientID = TEST_PATIENT_ID
38+
ds.StudyInstanceUID = TEST_STUDY_INSTANCE_UID
39+
ds.SOPClassUID = TEST_SOP_CLASS_UID
40+
41+
validator = DicomValidator()
42+
with pytest.raises(DicomValidationError, match="Missing required tag: SOPInstanceUID"):
43+
validator.validate_dataset(ds)
44+
45+
def test_validate_dataset_missing_patient_id(self):
46+
ds = Dataset()
47+
ds.SOPInstanceUID = TEST_SOP_INSTANCE_UID
48+
ds.StudyInstanceUID = TEST_STUDY_INSTANCE_UID
49+
ds.SOPClassUID = TEST_SOP_CLASS_UID
50+
51+
validator = DicomValidator()
52+
with pytest.raises(DicomValidationError, match="Missing required tag: PatientID"):
53+
validator.validate_dataset(ds)
54+
55+
def test_validate_dataset_missing_study_instance_uid(self):
56+
ds = Dataset()
57+
ds.SOPInstanceUID = TEST_SOP_INSTANCE_UID
58+
ds.PatientID = TEST_PATIENT_ID
59+
ds.SOPClassUID = TEST_SOP_CLASS_UID
60+
61+
validator = DicomValidator()
62+
with pytest.raises(DicomValidationError, match="Missing required tag: StudyInstanceUID"):
63+
validator.validate_dataset(ds)
64+
65+
def test_validate_dataset_missing_sop_class_uid(self):
66+
ds = Dataset()
67+
ds.SOPInstanceUID = TEST_SOP_INSTANCE_UID
68+
ds.PatientID = TEST_PATIENT_ID
69+
ds.StudyInstanceUID = TEST_STUDY_INSTANCE_UID
70+
71+
validator = DicomValidator()
72+
with pytest.raises(DicomValidationError, match="Missing required tag: SOPClassUID"):
73+
validator.validate_dataset(ds)
74+
75+
def test_validate_bytes_valid_preamble(self):
76+
# 128 bytes preamble + DICM + minimal content
77+
data = b"\x00" * 128 + b"DICM" + b"\x00" * 100
78+
79+
validator = DicomValidator()
80+
validator.validate_bytes(data) # Should not raise
81+
82+
def test_validate_bytes_missing_preamble(self):
83+
# DICM at wrong position (no 128-byte preamble before it)
84+
data = b"DICM" + b"\x00" * 200
85+
86+
validator = DicomValidator()
87+
with pytest.raises(DicomValidationError, match="Invalid DICOM prefix"):
88+
validator.validate_bytes(data)
89+
90+
def test_validate_bytes_too_small(self):
91+
data = b"\x00" * 50
92+
93+
validator = DicomValidator()
94+
with pytest.raises(DicomValidationError, match="too small"):
95+
validator.validate_bytes(data)
96+
97+
def test_validate_bytes_wrong_magic(self):
98+
data = b"\x00" * 128 + b"XXXX" + b"\x00" * 100
99+
100+
validator = DicomValidator()
101+
with pytest.raises(DicomValidationError, match="Invalid DICOM prefix"):
102+
validator.validate_bytes(data)
103+
104+
def test_validate_pixel_data_valid(self, valid_image_dataset):
105+
validator = DicomValidator()
106+
validator.validate_pixel_data(valid_image_dataset) # Should not raise
107+
108+
def test_validate_pixel_data_missing_rows(self):
109+
ds = Dataset()
110+
ds.PixelData = b"\x00" * 100
111+
ds.Columns = 10
112+
ds.BitsAllocated = 8
113+
114+
validator = DicomValidator()
115+
with pytest.raises(DicomValidationError, match="missing Rows"):
116+
validator.validate_pixel_data(ds)
117+
118+
def test_validate_pixel_data_missing_columns(self):
119+
ds = Dataset()
120+
ds.PixelData = b"\x00" * 100
121+
ds.Rows = 10
122+
ds.BitsAllocated = 8
123+
124+
validator = DicomValidator()
125+
with pytest.raises(DicomValidationError, match="missing Columns"):
126+
validator.validate_pixel_data(ds)
127+
128+
def test_validate_pixel_data_missing_bits_allocated(self):
129+
ds = Dataset()
130+
ds.PixelData = b"\x00" * 100
131+
ds.Rows = 10
132+
ds.Columns = 10
133+
134+
validator = DicomValidator()
135+
with pytest.raises(DicomValidationError, match="missing BitsAllocated"):
136+
validator.validate_pixel_data(ds)
137+
138+
def test_validate_pixel_data_no_pixel_data(self):
139+
ds = Dataset() # No PixelData
140+
141+
validator = DicomValidator()
142+
validator.validate_pixel_data(ds) # Should not raise
143+
144+
def test_validate_pixel_data_none_pixel_data(self):
145+
ds = Dataset()
146+
ds.PixelData = None
147+
148+
validator = DicomValidator()
149+
validator.validate_pixel_data(ds) # Should not raise

0 commit comments

Comments
 (0)