Skip to content

Commit e502fa4

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 e502fa4

7 files changed

Lines changed: 404 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: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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, PENDING_WARNING, 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__(
44+
self, dataset: Dataset | None, laterality: str, view: str, study_instance_uid: str, series_number: int
45+
):
46+
self.dataset = dataset
47+
self.laterality = laterality
48+
self.view = view
49+
self.study_instance_uid = study_instance_uid
50+
self.series_number = series_number
51+
self.data = self.generate_dicom()
52+
53+
def generate_dicom(self) -> Dataset | None:
54+
if not self.dataset:
55+
logger.error("No dataset provided for DICOM generation")
56+
return None
57+
58+
img_path = f"{SAMPLE_IMAGES_PATH}/{self.laterality}{self.view}.jpg"
59+
img = Image.open(img_path).convert("L")
60+
columns, rows = img.size
61+
pixel_array = np.array(img, dtype=np.uint8)
62+
pixel_bytes = pixel_array.tobytes()
63+
if len(pixel_bytes) % 2 != 0:
64+
pixel_bytes += b"\x00"
65+
66+
file_meta = FileMetaDataset()
67+
file_meta.MediaStorageSOPClassUID = DigitalMammographyXRayImageStorageForPresentation
68+
file_meta.MediaStorageSOPInstanceUID = generate_uid()
69+
file_meta.ImplementationClassUID = generate_uid()
70+
file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
71+
72+
ds = Dataset()
73+
ds.SOPClassUID = file_meta.MediaStorageSOPClassUID
74+
ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID
75+
ds.SamplesPerPixel = 1
76+
ds.PhotometricInterpretation = "MONOCHROME2"
77+
ds.Rows = rows
78+
ds.Columns = columns
79+
ds.BitsAllocated = 8
80+
ds.BitsStored = 8
81+
ds.HighBit = 7
82+
ds.PixelRepresentation = 0
83+
ds.PixelData = pixel_bytes
84+
ds.ImageLaterality = self.laterality
85+
ds.ViewPosition = self.view
86+
87+
ds.AccessionNumber = self.dataset.AccessionNumber
88+
ds.PatientID = self.dataset.PatientID
89+
ds.PatientName = self.dataset.PatientName
90+
ds.PatientBirthDate = self.dataset.PatientBirthDate
91+
ds.PatientSex = self.dataset.PatientSex
92+
scheduled_step = self.dataset.ScheduledProcedureStepSequence[0]
93+
ds.StudyDate = scheduled_step.ScheduledProcedureStepStartDate
94+
ds.StudyTime = scheduled_step.ScheduledProcedureStepStartTime
95+
ds.StudyInstanceUID = self.study_instance_uid
96+
ds.StudyID = f"STUDY{self.study_instance_uid[-8:]}"
97+
ds.SeriesInstanceUID = generate_uid()
98+
ds.SeriesNumber = self.series_number
99+
ds.Modality = "MG"
100+
ds.InstanceNumber = 1
101+
ds.file_meta = file_meta
102+
103+
logger.debug(f"Generated DICOM for worklist item {self.dataset.AccessionNumber} - {self.laterality}{self.view}")
104+
logger.debug(f"{ds}")
105+
106+
return ds
107+
108+
109+
class ModalityEmulator:
110+
def __init__(self, mwl_storage: MWLStorage, pacs_storage: PACSStorage):
111+
self.mwl_storage = mwl_storage
112+
self.pacs_storage = pacs_storage
113+
114+
def process_worklist_items(self, ae: AE):
115+
"""
116+
Queries the MWL for items scheduled for today and sends generated DICOM files to the PACS server for each item.
117+
"""
118+
mwl_assoc = ae.associate(MWL_ADDRESS, MWL_PORT, ae_title=MWL_AET)
119+
pacs_assoc = ae.associate(PACS_ADDRESS, PACS_PORT, ae_title=PACS_AET)
120+
121+
if mwl_assoc.is_established and pacs_assoc.is_established:
122+
logger.info(f"Connected to MWL server {MWL_ADDRESS}:{MWL_PORT} ({MWL_AET})")
123+
logger.info(f"Connected to PACS server {PACS_ADDRESS}:{PACS_PORT} ({PACS_AET})")
124+
125+
logger.info("Querying MWL for scheduled items...")
126+
responses = mwl_assoc.send_c_find(self.c_find_dataset, query_model=ModalityWorklistInformationFind)
127+
for status, ds in responses:
128+
status_code = getattr(status, "Status", SUCCESS)
129+
130+
if status_code in (PENDING, PENDING_WARNING):
131+
accession_number = getattr(ds, "AccessionNumber", "UNKNOWN")
132+
study_instance_uid = generate_uid()
133+
series_number = 1
134+
for laterality in DICOM_LATERALITIES:
135+
for view in DICOM_VIEWS:
136+
logger.info(
137+
f"Processing worklist item {accession_number} - generating DICOM for {laterality}{view}"
138+
)
139+
dicom_example = DicomExample(ds, laterality, view, study_instance_uid, series_number)
140+
pacs_assoc.send_c_store(dicom_example.data)
141+
logger.info(
142+
f"Sent DICOM for {laterality}{view} of worklist item {accession_number}. Series# {series_number}"
143+
)
144+
series_number += 1
145+
146+
self.mwl_storage.update_status(accession_number, MWLStatus.COMPLETED.value)
147+
logger.info(f"Completed processing for worklist item {accession_number}")
148+
elif status_code == SUCCESS:
149+
logger.info("C-FIND query completed successfully")
150+
else:
151+
logger.error(f"C-FIND query failed with status: 0x{status_code:04X}")
152+
153+
else:
154+
logger.error("Failed to make MWL and PACS associations")
155+
156+
mwl_assoc.release()
157+
pacs_assoc.release()
158+
159+
@cached_property
160+
def c_find_dataset(self) -> Dataset:
161+
date_today = datetime.date.today()
162+
ds = Dataset()
163+
sps_dataset = Dataset()
164+
sps_dataset.Modality = "MG"
165+
sps_dataset.ScheduledProcedureStepStartDate = date_today.strftime("%Y%m%d")
166+
sps_dataset.ScheduledProcedureStepStartTime = "080000-"
167+
ds.ScheduledProcedureStepSequence = [sps_dataset]
168+
return ds
169+
170+
171+
def main():
172+
if Environment().production:
173+
raise RuntimeError("Modality Emulator should not be run in production environment")
174+
175+
logger.info("Modality Emulator Starting...")
176+
mwl_storage = MWLStorage(db_path=MWL_DB_PATH)
177+
pacs_storage = PACSStorage(db_path=PACS_DB_PATH)
178+
emulator = ModalityEmulator(mwl_storage, pacs_storage)
179+
ae = AE(ae_title="ModalityEmulator")
180+
ae.add_requested_context(DigitalMammographyXRayImageStorageForPresentation)
181+
ae.add_requested_context(ModalityWorklistInformationFind)
182+
183+
while True:
184+
try:
185+
time.sleep(EMULATED_PROCEDURE_DURATION_SECONDS)
186+
emulator.process_worklist_items(ae)
187+
except KeyboardInterrupt:
188+
logger.warning("\n Modality Emulator shutting down...")
189+
break
190+
191+
192+
if __name__ == "__main__":
193+
main()

src/services/dicom/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
SUCCESS = 0x0000
88
FAILURE = 0xC000
99
PENDING = 0xFF00
10+
PENDING_WARNING = 0xFF01
1011
INVALID_ATTRIBUTE = 0x0106
1112
DUPLICATE_SOP_INSTANCE = 0x0111
1213
UNKNOWN_SOP_INSTANCE = 0x0112

0 commit comments

Comments
 (0)