Skip to content

Commit fc21b8a

Browse files
committed
Add modality emulator
This standalone python script polls the worklist database every 5 seconds for new worklist items scheduled for today. It creates a DICOM dataset with the standard 4 images and the worklist patient info and then performs a C-STORE. The C-STORE will then be picked up by the dicom uploader and the sample images sent back to Manage.
1 parent 59f9b6f commit fc21b8a

6 files changed

Lines changed: 382 additions & 0 deletions

File tree

sample_images/LCC.jpg

512 KB
Loading

sample_images/LMLO.jpg

568 KB
Loading

sample_images/RCC.jpg

513 KB
Loading

sample_images/RMLO.jpg

569 KB
Loading

src/modality_emulator.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import datetime
2+
import logging
3+
import os
4+
import time
5+
from functools import cached_property
6+
7+
import numpy as np
8+
from PIL import Image
9+
from pydicom.dataset import Dataset, FileMetaDataset
10+
from pydicom.uid import ExplicitVRLittleEndian, generate_uid
11+
from pynetdicom import AE
12+
from pynetdicom.sop_class import DigitalMammographyXRayImageStorageForPresentation, ModalityWorklistInformationFind
13+
14+
from environment import Environment
15+
from services.dicom import PENDING, SUCCESS
16+
from services.mwl import MWLStatus
17+
from services.storage import MWLStorage, PACSStorage
18+
19+
logging.basicConfig(
20+
level=os.getenv("LOG_LEVEL", "INFO").upper(),
21+
format=os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
22+
)
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
MWL_DB_PATH = os.getenv("MWL_DB_PATH", "/var/lib/pacs/worklist.db")
28+
PACS_DB_PATH = os.getenv("PACS_DB_PATH", "/var/lib/pacs/pacs.db")
29+
PACS_STORAGE_PATH = os.getenv("PACS_STORAGE_PATH", "/var/lib/pacs/storage")
30+
MWL_AET = os.getenv("MWL_AET", "SCREENING_MWL")
31+
MWL_ADDRESS = os.getenv("MWL_ADDRESS", "localhost")
32+
MWL_PORT = int(os.getenv("MWL_PORT", "4243"))
33+
PACS_AET = os.getenv("PACS_AET", "SCREENING_PACS")
34+
PACS_ADDRESS = os.getenv("PACS_ADDRESS", "localhost")
35+
PACS_PORT = int(os.getenv("PACS_PORT", "4244"))
36+
DICOM_LATERALITIES = ["L", "R"]
37+
DICOM_VIEWS = ["CC", "MLO"]
38+
SAMPLE_IMAGES_PATH = os.getenv("SAMPLE_IMAGES_PATH", "/app/sample_images")
39+
EMULATED_PROCEDURE_DURATION_SECONDS = int(os.getenv("EMULATED_PROCEDURE_DURATION_SECONDS", "5"))
40+
41+
42+
class DicomExample:
43+
def __init__(self, dataset: Dataset, laterality: str, view: str, study_instance_uid: str, series_number: int):
44+
self.dataset = dataset
45+
self.laterality = laterality
46+
self.view = view
47+
self.study_instance_uid = study_instance_uid
48+
self.series_number = series_number
49+
self.data = self.generate_dicom()
50+
51+
def generate_dicom(self) -> Dataset:
52+
img_path = f"{SAMPLE_IMAGES_PATH}/{self.laterality}{self.view}.jpg"
53+
img = Image.open(img_path).convert("L")
54+
columns, rows = img.size
55+
pixel_array = np.array(img, dtype=np.uint8)
56+
pixel_bytes = pixel_array.tobytes()
57+
if len(pixel_bytes) % 2 != 0:
58+
pixel_bytes += b"\x00"
59+
60+
file_meta = FileMetaDataset()
61+
file_meta.MediaStorageSOPClassUID = DigitalMammographyXRayImageStorageForPresentation
62+
file_meta.MediaStorageSOPInstanceUID = generate_uid()
63+
file_meta.ImplementationClassUID = generate_uid()
64+
file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
65+
66+
ds = Dataset()
67+
ds.SOPClassUID = file_meta.MediaStorageSOPClassUID
68+
ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID
69+
ds.SamplesPerPixel = 1
70+
ds.PhotometricInterpretation = "MONOCHROME2"
71+
ds.Rows = rows
72+
ds.Columns = columns
73+
ds.BitsAllocated = 8
74+
ds.BitsStored = 8
75+
ds.HighBit = 7
76+
ds.PixelRepresentation = 0
77+
ds.PixelData = pixel_bytes
78+
ds.ImageLaterality = self.laterality
79+
ds.ViewPosition = self.view
80+
81+
ds.AccessionNumber = self.dataset.AccessionNumber
82+
ds.PatientID = self.dataset.PatientID
83+
ds.PatientName = self.dataset.PatientName
84+
ds.PatientBirthDate = self.dataset.PatientBirthDate
85+
ds.PatientSex = self.dataset.PatientSex
86+
scheduled_step = self.dataset.ScheduledProcedureStepSequence[0]
87+
ds.StudyDate = scheduled_step.ScheduledProcedureStepStartDate
88+
ds.StudyTime = scheduled_step.ScheduledProcedureStepStartTime
89+
ds.StudyInstanceUID = self.study_instance_uid
90+
ds.StudyID = f"STUDY{self.study_instance_uid[-8:]}"
91+
ds.SeriesInstanceUID = generate_uid()
92+
ds.SeriesNumber = self.series_number
93+
ds.Modality = "MG"
94+
ds.InstanceNumber = 1
95+
ds.file_meta = file_meta
96+
97+
logger.debug(f"Generated DICOM for worklist item {self.dataset.AccessionNumber} - {self.laterality}{self.view}")
98+
logger.debug(f"{ds}")
99+
100+
return ds
101+
102+
103+
class ModalityEmulator:
104+
def __init__(self, mwl_storage: MWLStorage, pacs_storage: PACSStorage):
105+
self.mwl_storage = mwl_storage
106+
self.pacs_storage = pacs_storage
107+
108+
def process_worklist_items(self, ae: AE):
109+
"""
110+
Queries the MWL for items scheduled for today and sends generated DICOM files to the PACS server for each item.
111+
"""
112+
mwl_assoc = ae.associate(MWL_ADDRESS, MWL_PORT, ae_title=MWL_AET)
113+
pacs_assoc = ae.associate(PACS_ADDRESS, PACS_PORT, ae_title=PACS_AET)
114+
115+
if mwl_assoc.is_established and pacs_assoc.is_established:
116+
logger.info(f"Connected to MWL server {MWL_ADDRESS}:{MWL_PORT} ({MWL_AET})")
117+
logger.info(f"Connected to PACS server {PACS_ADDRESS}:{PACS_PORT} ({PACS_AET})")
118+
119+
logger.info("Querying MWL for scheduled items...")
120+
responses = mwl_assoc.send_c_find(self.c_find_dataset, query_model=ModalityWorklistInformationFind)
121+
for status, ds in responses:
122+
if status == PENDING:
123+
accession_number = ds.AccessionNumber
124+
study_instance_uid = generate_uid()
125+
logger.info(f"Found worklist item: {accession_number} for patient {ds.PatientID}")
126+
series_number = 1
127+
for laterality in DICOM_LATERALITIES:
128+
for view in DICOM_VIEWS:
129+
logger.info(
130+
f"Processing worklist item {accession_number} - generating DICOM for {laterality}{view}"
131+
)
132+
dicom_example = DicomExample(ds, laterality, view, study_instance_uid, series_number)
133+
pacs_assoc.send_c_store(dicom_example.data)
134+
logger.info(
135+
f"Sent DICOM for {laterality}{view} of worklist item {accession_number}. Series# {series_number}"
136+
)
137+
series_number += 1
138+
139+
self.mwl_storage.update_status(accession_number, MWLStatus.COMPLETED.value)
140+
logger.info(f"Completed processing for worklist item {accession_number}")
141+
elif status == SUCCESS:
142+
logger.info("C-FIND query completed successfully")
143+
else:
144+
logger.error(f"C-FIND query failed with status: {status}")
145+
146+
else:
147+
logger.error("Failed to make MWL and PACS associations")
148+
149+
mwl_assoc.release()
150+
pacs_assoc.release()
151+
152+
@cached_property
153+
def c_find_dataset(self) -> Dataset:
154+
date_today = datetime.date.today()
155+
one_hour_ago = datetime.datetime.now() - datetime.timedelta(hours=1)
156+
ds = Dataset()
157+
sps_dataset = Dataset()
158+
sps_dataset.Modality = "MG"
159+
sps_dataset.ScheduledProcedureStepStartDate = date_today.strftime("%Y%m%d")
160+
sps_dataset.ScheduledProcedureStepStartTime = one_hour_ago.strftime("%H%M%S-")
161+
ds.ScheduledProcedureStepSequence = [sps_dataset]
162+
return ds
163+
164+
165+
def main():
166+
if Environment().production:
167+
raise RuntimeError("Modality Emulator should not be run in production environment")
168+
169+
logger.info("Modality Emulator Starting...")
170+
mwl_storage = MWLStorage(db_path=MWL_DB_PATH)
171+
pacs_storage = PACSStorage(db_path=PACS_DB_PATH)
172+
emulator = ModalityEmulator(mwl_storage, pacs_storage)
173+
ae = AE(ae_title="ModalityEmulator")
174+
ae.add_requested_context(DigitalMammographyXRayImageStorageForPresentation)
175+
ae.add_requested_context(ModalityWorklistInformationFind)
176+
177+
while True:
178+
try:
179+
emulator.process_worklist_items(ae)
180+
time.sleep(5)
181+
except KeyboardInterrupt:
182+
logger.warning("\n Modality Emulator shutting down...")
183+
break
184+
185+
186+
if __name__ == "__main__":
187+
main()

tests/test_modality_emulator.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
from pydicom.dataset import Dataset
5+
from pydicom.uid import DigitalMammographyXRayImageStorageForPresentation, ExplicitVRLittleEndian, generate_uid
6+
7+
from modality_emulator import (
8+
DICOM_LATERALITIES,
9+
DICOM_VIEWS,
10+
DicomExample,
11+
ModalityEmulator,
12+
main,
13+
)
14+
15+
16+
class TestDicomExample:
17+
@patch("modality_emulator.Image.open")
18+
@patch("modality_emulator.generate_uid")
19+
def test_generate_dicom_creates_valid_dataset(
20+
self,
21+
mock_generate_uid,
22+
mock_image_open,
23+
):
24+
study_instance_uid = generate_uid()
25+
sop_instance_uid = generate_uid()
26+
implementation_class_uid = generate_uid()
27+
series_instance_uid = generate_uid()
28+
29+
mock_generate_uid.side_effect = [
30+
sop_instance_uid,
31+
implementation_class_uid,
32+
series_instance_uid,
33+
]
34+
35+
mock_image = MagicMock()
36+
mock_image.convert.return_value = mock_image
37+
mock_image.size = (100, 200)
38+
39+
mock_image_open.return_value = mock_image
40+
41+
ds = Dataset()
42+
ds.AccessionNumber = "ACC123"
43+
ds.PatientID = "PAT001"
44+
ds.PatientName = "Jane^Doe"
45+
ds.PatientBirthDate = "19800101"
46+
ds.PatientSex = "F"
47+
sps = Dataset()
48+
sps.ScheduledProcedureStepStartDate = "20260514"
49+
sps.ScheduledProcedureStepStartTime = "090000"
50+
ds.ScheduledProcedureStepSequence = [sps]
51+
52+
with patch("modality_emulator.np.array") as mock_np_array:
53+
mock_pixel_array = MagicMock()
54+
mock_pixel_array.tobytes.return_value = b"\x01\x02"
55+
mock_np_array.return_value = mock_pixel_array
56+
57+
dicom = DicomExample(
58+
dataset=ds,
59+
laterality="L",
60+
view="CC",
61+
study_instance_uid=study_instance_uid,
62+
series_number=1,
63+
)
64+
65+
assert isinstance(dicom.data, Dataset)
66+
assert dicom.data.PatientID == "PAT001"
67+
assert dicom.data.PatientName == "Jane^Doe"
68+
assert dicom.data.ImageLaterality == "L"
69+
assert dicom.data.ViewPosition == "CC"
70+
assert dicom.data.Rows == 200
71+
assert dicom.data.Columns == 100
72+
assert dicom.data.StudyInstanceUID == study_instance_uid
73+
assert dicom.data.SOPInstanceUID == sop_instance_uid
74+
assert dicom.data.SeriesInstanceUID == series_instance_uid
75+
assert dicom.data.file_meta.TransferSyntaxUID == ExplicitVRLittleEndian
76+
assert dicom.data.file_meta.ImplementationClassUID == implementation_class_uid
77+
assert dicom.data.file_meta.MediaStorageSOPClassUID == DigitalMammographyXRayImageStorageForPresentation
78+
assert dicom.data.file_meta.MediaStorageSOPInstanceUID == sop_instance_uid
79+
assert dicom.data.StudyDate == "20260514"
80+
assert dicom.data.StudyTime == "090000"
81+
82+
83+
class TestModalityEmulator:
84+
@patch("modality_emulator.time.sleep")
85+
@patch("modality_emulator.generate_uid")
86+
@patch("modality_emulator.DicomExample")
87+
def test_process_worklist_items_sends_all_dicoms(
88+
self,
89+
mock_dicom_example,
90+
mock_generate_uid,
91+
_,
92+
):
93+
mock_generate_uid.return_value = "1.2.3.study" # gitleaks: ignore
94+
95+
mwl_storage = MagicMock()
96+
pacs_storage = MagicMock()
97+
98+
emulator = ModalityEmulator(mwl_storage, pacs_storage)
99+
100+
ds = Dataset()
101+
ds.AccessionNumber = "ACC123"
102+
ds.PatientID = "PAT001"
103+
ds.PatientName = "Jane^Doe"
104+
ds.PatientSex = "F"
105+
sps = Dataset()
106+
sps.ScheduledProcedureStepStartDate = "20260514"
107+
sps.ScheduledProcedureStepStartTime = "090000"
108+
ds.scheduled_procedure_step_sequence = [sps]
109+
110+
mock_dicom_example.return_value.data = ds
111+
112+
mwl_assoc = MagicMock()
113+
mwl_assoc.is_established = True
114+
mwl_assoc.send_c_find.return_value = [(0xFF00, ds), (0x0000, None)]
115+
116+
pacs_assoc = MagicMock()
117+
pacs_assoc.is_established = True
118+
119+
ae = MagicMock()
120+
ae.associate.side_effect = [mwl_assoc, pacs_assoc]
121+
122+
emulator.process_worklist_items(ae)
123+
124+
expected_send_count = len(DICOM_LATERALITIES) * len(DICOM_VIEWS)
125+
126+
assert mwl_assoc.send_c_find.call_count == 1
127+
assert pacs_assoc.send_c_store.call_count == expected_send_count
128+
129+
mwl_storage.update_status.assert_called_once_with(
130+
"ACC123",
131+
"COMPLETED",
132+
)
133+
134+
mwl_assoc.release.assert_called_once()
135+
pacs_assoc.release.assert_called_once()
136+
137+
@patch("modality_emulator.time.sleep")
138+
def test_process_worklist_items_returns_when_no_items(
139+
self,
140+
mock_sleep,
141+
):
142+
mwl_storage = MagicMock()
143+
pacs_storage = MagicMock()
144+
emulator = ModalityEmulator(mwl_storage, pacs_storage)
145+
ae = MagicMock()
146+
147+
mwl_assoc = MagicMock()
148+
mwl_assoc.is_established = True
149+
mwl_assoc.send_c_find.return_value = [(0x0000, None)]
150+
151+
pacs_assoc = MagicMock()
152+
pacs_assoc.is_established = True
153+
154+
ae = MagicMock()
155+
ae.associate.side_effect = [mwl_assoc, pacs_assoc]
156+
157+
emulator.process_worklist_items(ae)
158+
159+
mock_sleep.assert_not_called()
160+
pacs_assoc.send_c_store.assert_not_called()
161+
mwl_storage.update_status.assert_not_called()
162+
mwl_assoc.release.assert_called_once()
163+
pacs_assoc.release.assert_called_once()
164+
165+
@patch("modality_emulator.time.sleep")
166+
def test_process_worklist_items_handles_failed_association(
167+
self,
168+
_,
169+
):
170+
mwl_storage = MagicMock()
171+
pacs_storage = MagicMock()
172+
173+
emulator = ModalityEmulator(mwl_storage, pacs_storage)
174+
175+
mwl_assoc = MagicMock()
176+
mwl_assoc.is_established = True
177+
178+
pacs_assoc = MagicMock()
179+
pacs_assoc.is_established = False
180+
181+
ae = MagicMock()
182+
ae.associate.side_effect = [mwl_assoc, pacs_assoc]
183+
184+
emulator.process_worklist_items(ae)
185+
186+
mwl_assoc.send_c_find.assert_not_called()
187+
pacs_assoc.send_c_store.assert_not_called()
188+
mwl_storage.update_status.assert_not_called()
189+
mwl_assoc.release.assert_called_once()
190+
pacs_assoc.release.assert_called_once()
191+
192+
@patch("modality_emulator.Environment", production=True)
193+
def test_main_raises_in_production(self, _):
194+
with pytest.raises(RuntimeError, match="Modality Emulator should not be run in production environment"):
195+
main()

0 commit comments

Comments
 (0)