Skip to content

Commit 95f58e8

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 95f58e8

6 files changed

Lines changed: 330 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: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import datetime
2+
import logging
3+
import os
4+
import time
5+
6+
import numpy as np
7+
from PIL import Image
8+
from pydicom.dataset import Dataset, FileMetaDataset
9+
from pydicom.uid import ExplicitVRLittleEndian, generate_uid
10+
from pynetdicom import AE
11+
from pynetdicom.sop_class import (
12+
DigitalMammographyXRayImageStorageForPresentation,
13+
)
14+
15+
from environment import Environment
16+
from models import WorklistItem
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+
PACS_AET = os.getenv("PACS_AET", "SCREENING_PACS")
31+
PACS_ADDRESS = os.getenv("PACS_ADDRESS", "localhost")
32+
PACS_PORT = int(os.getenv("PACS_PORT", "4244"))
33+
DICOM_LATERALITIES = ["L", "R"]
34+
DICOM_VIEWS = ["CC", "MLO"]
35+
SAMPLE_IMAGES_PATH = os.getenv("SAMPLE_IMAGES_PATH", "/app/sample_images")
36+
EMULATED_PROCEDURE_DURATION_SECONDS = int(os.getenv("EMULATED_PROCEDURE_DURATION_SECONDS", "5"))
37+
38+
39+
class DicomExample:
40+
def __init__(self, worklist_item: WorklistItem, laterality: str, view: str, study_instance_uid: str):
41+
self.worklist_item = worklist_item
42+
self.laterality = laterality
43+
self.view = view
44+
self.data = self.generate_dicom(study_instance_uid)
45+
46+
def generate_dicom(self, study_instance_uid: str) -> Dataset:
47+
img_path = f"{SAMPLE_IMAGES_PATH}/{self.laterality}{self.view}.jpg"
48+
img = Image.open(img_path).convert("L")
49+
columns, rows = img.size
50+
pixel_array = np.array(img, dtype=np.uint8)
51+
pixel_bytes = pixel_array.tobytes()
52+
if len(pixel_bytes) % 2 != 0:
53+
pixel_bytes += b"\x00"
54+
55+
file_meta = FileMetaDataset()
56+
file_meta.MediaStorageSOPClassUID = DigitalMammographyXRayImageStorageForPresentation
57+
file_meta.MediaStorageSOPInstanceUID = generate_uid()
58+
file_meta.ImplementationClassUID = generate_uid()
59+
file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
60+
61+
ds = Dataset()
62+
ds.SOPClassUID = file_meta.MediaStorageSOPClassUID
63+
ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID
64+
ds.SamplesPerPixel = 1
65+
ds.PhotometricInterpretation = "MONOCHROME2"
66+
ds.Rows = rows
67+
ds.Columns = columns
68+
ds.BitsAllocated = 8
69+
ds.BitsStored = 8
70+
ds.HighBit = 7
71+
ds.PixelRepresentation = 0
72+
ds.PixelData = pixel_bytes
73+
ds.ImageLaterality = self.laterality
74+
ds.ViewPosition = self.view
75+
76+
ds.AccessionNumber = self.worklist_item.accession_number
77+
ds.PatientID = self.worklist_item.patient_id
78+
ds.PatientName = self.worklist_item.patient_name
79+
ds.PatientBirthDate = self.worklist_item.patient_birth_date
80+
ds.PatientSex = self.worklist_item.patient_sex
81+
ds.StudyDate = self.worklist_item.scheduled_date
82+
ds.StudyTime = self.worklist_item.scheduled_time
83+
ds.StudyInstanceUID = study_instance_uid
84+
ds.StudyID = self.worklist_item.accession_number
85+
ds.SeriesInstanceUID = generate_uid()
86+
ds.SeriesNumber = 1
87+
ds.Modality = "MG"
88+
ds.InstanceNumber = 1
89+
ds.file_meta = file_meta
90+
91+
logger.debug(
92+
f"Generated DICOM for worklist item {self.worklist_item.accession_number} - {self.laterality}{self.view}"
93+
)
94+
logger.debug(f"{ds}")
95+
96+
return ds
97+
98+
99+
class ModalityEmulator:
100+
def __init__(self, mwl_storage: MWLStorage, pacs_storage: PACSStorage):
101+
self.mwl_storage = mwl_storage
102+
self.pacs_storage = pacs_storage
103+
104+
def process_worklist_items(self, ae: AE):
105+
"""
106+
Queries the MWL for items scheduled for today and sends generated DICOM files to the PACS server for each item.
107+
"""
108+
today_formatted = datetime.date.today().strftime("%Y%m%d")
109+
worklist_items = self.mwl_storage.find_worklist_items(
110+
scheduled_date=today_formatted,
111+
)
112+
113+
if not worklist_items:
114+
return
115+
116+
time.sleep(EMULATED_PROCEDURE_DURATION_SECONDS)
117+
118+
assoc = ae.associate(PACS_ADDRESS, PACS_PORT, ae_title=PACS_AET)
119+
120+
if assoc.is_established:
121+
logger.info(f"Connected to server {PACS_ADDRESS}:{PACS_PORT} ({PACS_AET})")
122+
123+
for item in worklist_items:
124+
study_instance_uid = generate_uid()
125+
for laterality in DICOM_LATERALITIES:
126+
for view in DICOM_VIEWS:
127+
logger.info(
128+
f"Processing worklist item {item.accession_number} - generating DICOM for {laterality}{view}"
129+
)
130+
dicom_example = DicomExample(item, laterality, view, study_instance_uid)
131+
assoc.send_c_store(dicom_example.data)
132+
logger.info(f"Sent DICOM for {laterality}{view} of worklist item {item.accession_number}")
133+
134+
self.mwl_storage.update_status(item.accession_number, "COMPLETED")
135+
logger.info(f"Completed processing for worklist item {item.accession_number}")
136+
137+
assoc.release()
138+
else:
139+
logger.error(f"Failed to connect to server {PACS_ADDRESS}:{PACS_PORT} ({PACS_AET})")
140+
141+
142+
def main():
143+
if Environment().production:
144+
raise RuntimeError("Modality Emulator should not be run in production environment")
145+
146+
logger.info("Modality Emulator Starting...")
147+
mwl_storage = MWLStorage(db_path=MWL_DB_PATH)
148+
pacs_storage = PACSStorage(db_path=PACS_DB_PATH)
149+
emulator = ModalityEmulator(mwl_storage, pacs_storage)
150+
ae = AE(ae_title="ModalityEmulator")
151+
ae.add_requested_context(DigitalMammographyXRayImageStorageForPresentation)
152+
153+
while True:
154+
try:
155+
emulator.process_worklist_items(ae)
156+
time.sleep(5)
157+
except KeyboardInterrupt:
158+
logger.warning("\n Modality Emulator shutting down...")
159+
break
160+
161+
162+
if __name__ == "__main__":
163+
main()

tests/test_modality_emulator.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
worklist_item = MagicMock()
42+
worklist_item.accession_number = "ACC123"
43+
worklist_item.patient_id = "PAT001"
44+
worklist_item.patient_name = "Jane^Doe"
45+
worklist_item.patient_birth_date = "19800101"
46+
worklist_item.patient_sex = "F"
47+
worklist_item.scheduled_date = "20260514"
48+
worklist_item.scheduled_time = "090000"
49+
50+
with patch("modality_emulator.np.array") as mock_np_array:
51+
mock_pixel_array = MagicMock()
52+
mock_pixel_array.tobytes.return_value = b"\x01\x02"
53+
mock_np_array.return_value = mock_pixel_array
54+
55+
dicom = DicomExample(
56+
worklist_item=worklist_item,
57+
laterality="L",
58+
view="CC",
59+
study_instance_uid=study_instance_uid,
60+
)
61+
62+
assert isinstance(dicom.data, Dataset)
63+
assert dicom.data.PatientID == "PAT001"
64+
assert dicom.data.PatientName == "Jane^Doe"
65+
assert dicom.data.ImageLaterality == "L"
66+
assert dicom.data.ViewPosition == "CC"
67+
assert dicom.data.Rows == 200
68+
assert dicom.data.Columns == 100
69+
assert dicom.data.StudyInstanceUID == study_instance_uid
70+
assert dicom.data.SOPInstanceUID == sop_instance_uid
71+
assert dicom.data.SeriesInstanceUID == series_instance_uid
72+
assert dicom.data.file_meta.TransferSyntaxUID == ExplicitVRLittleEndian
73+
assert dicom.data.file_meta.ImplementationClassUID == implementation_class_uid
74+
assert dicom.data.file_meta.MediaStorageSOPClassUID == DigitalMammographyXRayImageStorageForPresentation
75+
assert dicom.data.file_meta.MediaStorageSOPInstanceUID == sop_instance_uid
76+
77+
78+
class TestModalityEmulator:
79+
@patch("modality_emulator.time.sleep")
80+
@patch("modality_emulator.generate_uid")
81+
@patch("modality_emulator.DicomExample")
82+
def test_process_worklist_items_sends_all_dicoms(
83+
self,
84+
mock_dicom_example,
85+
mock_generate_uid,
86+
_,
87+
):
88+
mock_generate_uid.return_value = "1.2.3.study" # gitleaks: ignore
89+
90+
mwl_storage = MagicMock()
91+
pacs_storage = MagicMock()
92+
93+
worklist_item = MagicMock()
94+
worklist_item.accession_number = "ACC123"
95+
96+
mwl_storage.find_worklist_items.return_value = [worklist_item]
97+
98+
emulator = ModalityEmulator(mwl_storage, pacs_storage)
99+
100+
assoc = MagicMock()
101+
assoc.is_established = True
102+
103+
ae = MagicMock()
104+
ae.associate.return_value = assoc
105+
106+
mock_dataset = MagicMock()
107+
mock_dicom_example.return_value.data = mock_dataset
108+
109+
emulator.process_worklist_items(ae)
110+
111+
expected_send_count = len(DICOM_LATERALITIES) * len(DICOM_VIEWS)
112+
113+
assert assoc.send_c_store.call_count == expected_send_count
114+
115+
mwl_storage.update_status.assert_called_once_with(
116+
"ACC123",
117+
"COMPLETED",
118+
)
119+
120+
assoc.release.assert_called_once()
121+
122+
@patch("modality_emulator.time.sleep")
123+
def test_process_worklist_items_returns_when_no_items(
124+
self,
125+
mock_sleep,
126+
):
127+
mwl_storage = MagicMock()
128+
pacs_storage = MagicMock()
129+
mwl_storage.find_worklist_items.return_value = []
130+
emulator = ModalityEmulator(mwl_storage, pacs_storage)
131+
ae = MagicMock()
132+
133+
emulator.process_worklist_items(ae)
134+
135+
ae.associate.assert_not_called()
136+
mock_sleep.assert_not_called()
137+
138+
@patch("modality_emulator.time.sleep")
139+
def test_process_worklist_items_handles_failed_association(
140+
self,
141+
_,
142+
):
143+
mwl_storage = MagicMock()
144+
pacs_storage = MagicMock()
145+
worklist_item = MagicMock()
146+
worklist_item.accession_number = "ACC123"
147+
148+
mwl_storage.find_worklist_items.return_value = [worklist_item]
149+
150+
emulator = ModalityEmulator(mwl_storage, pacs_storage)
151+
152+
assoc = MagicMock()
153+
assoc.is_established = False
154+
155+
ae = MagicMock()
156+
ae.associate.return_value = assoc
157+
158+
emulator.process_worklist_items(ae)
159+
160+
assoc.send_c_store.assert_not_called()
161+
mwl_storage.update_status.assert_not_called()
162+
assoc.release.assert_not_called()
163+
164+
@patch("modality_emulator.Environment", production=True)
165+
def test_main_raises_in_production(self, mock_environment):
166+
with pytest.raises(RuntimeError, match="Modality Emulator should not be run in production environment"):
167+
main()

0 commit comments

Comments
 (0)