Skip to content

Commit af60d05

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 af60d05

7 files changed

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