Files
dicom_utilities/archive_discovery.py
john f564c26919 ADd study description to Query
New Feature Added

    StudyDescription is now:

        Included in the query

        Written to the output file

        Clearly visible in the header and data rows
2025-05-14 21:38:31 +00:00

269 lines
11 KiB
Python

import tkinter as tk
from tkinter import messagebox, ttk
from datetime import datetime, timedelta
import os
import csv
import logging
import configparser
from threading import Thread, Event
from pydicom.dataset import Dataset
from pynetdicom import AE, evt, debug_logger
from pynetdicom.sop_class import StudyRootQueryRetrieveInformationModelFind
import ast
# === Constants and Logging Setup ===
LOG_FILE = "dicom_query.log"
CONFIG_FILE = "config.txt"
LOG_LEVELS = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL
}
logging.basicConfig(
filename=LOG_FILE,
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
debug_logger()
def load_all_profiles():
config = configparser.ConfigParser()
config.read(CONFIG_FILE)
return config
def save_all_profiles(config):
with open(CONFIG_FILE, 'w') as f:
config.write(f)
def generate_output_filename():
today = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"output_{today}.txt"
def create_cfind_dataset(start_datetime, end_datetime):
ds = Dataset()
ds.QueryRetrieveLevel = 'STUDY'
ds.StudyDate = start_datetime.strftime("%Y%m%d")
ds.StudyTime = f"{start_datetime.strftime('%H%M%S')}-{end_datetime.strftime('%H%M%S')}"
ds.PatientID = ''
ds.PatientName = ''
ds.AccessionNumber = ''
ds.StudyInstanceUID = ''
ds.StudyDescription = '' # Added StudyDescription
ds.ModalitiesInStudy = ''
ds.NumberOfStudyRelatedInstances = ''
return ds
class ConfigDialog:
def __init__(self, parent):
self.values = {}
self.current_profile = tk.StringVar()
self.log_level = tk.StringVar(value="INFO")
self.status = tk.StringVar(value="Ready")
self.record_count = tk.IntVar(value=0)
self.pause_event = Event()
self.stop_event = Event()
self.pause_event.set()
self.all_profiles = load_all_profiles()
self.profile_names = self.all_profiles.sections()
self.top = tk.Toplevel(parent)
self.top.title("DICOM Query Configuration")
self.top.protocol("WM_DELETE_WINDOW", self.on_cancel)
tk.Label(self.top, text="Select Profile").grid(row=0, column=0, sticky=tk.W, padx=10, pady=5)
self.profile_menu = ttk.Combobox(self.top, textvariable=self.current_profile, values=self.profile_names, state='readonly')
self.profile_menu.grid(row=0, column=1, padx=10, pady=5)
self.profile_menu.bind("<<ComboboxSelected>>", self.load_selected_profile)
self.fields = ['RemoteAET', 'RemoteIP', 'RemotePort', 'LocalAET', 'StartDate']
self.entries = {}
for i, field in enumerate(self.fields):
tk.Label(self.top, text=field).grid(row=i+1, column=0, sticky=tk.W, padx=10, pady=5)
entry = tk.Entry(self.top, width=30)
entry.grid(row=i+1, column=1, padx=10, pady=5)
self.entries[field] = entry
tk.Label(self.top, text="Logging Level").grid(row=len(self.fields)+1, column=0, sticky=tk.W, padx=10, pady=5)
log_level_menu = ttk.Combobox(self.top, textvariable=self.log_level, values=list(LOG_LEVELS.keys()), state='readonly')
log_level_menu.grid(row=len(self.fields)+1, column=1, padx=10, pady=5)
self.status_label = tk.Label(self.top, textvariable=self.status, fg="blue")
self.status_label.grid(row=len(self.fields)+2, column=0, columnspan=2, padx=10, pady=5)
self.record_label = tk.Label(self.top, text="Records Written: 0")
self.record_label.grid(row=len(self.fields)+3, column=0, columnspan=2, padx=10, pady=5)
button_frame = tk.Frame(self.top)
button_frame.grid(row=len(self.fields)+4, column=0, columnspan=2, pady=10)
tk.Button(button_frame, text="Run Query", command=self.on_run).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="Pause", command=self.on_pause).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="Stop", command=self.on_stop).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="Cancel", command=self.on_cancel).pack(side=tk.RIGHT, padx=5)
if self.profile_names:
self.current_profile.set(self.profile_names[0])
self.load_selected_profile()
self.top.grab_set()
def load_selected_profile(self, event=None):
profile = self.current_profile.get()
for field in self.fields:
value = self.all_profiles.get(profile, field, fallback='')
self.entries[field].delete(0, tk.END)
self.entries[field].insert(0, value)
def on_run(self):
profile = self.current_profile.get()
if not profile:
messagebox.showerror("Error", "Please select a profile.")
return
if profile not in self.all_profiles:
self.all_profiles.add_section(profile)
for field in self.fields:
value = self.entries[field].get().strip()
self.all_profiles.set(profile, field, value)
self.values[field] = value
save_all_profiles(self.all_profiles)
logging.getLogger().setLevel(LOG_LEVELS[self.log_level.get()])
self.values['log_level'] = self.log_level.get()
self.stop_event.clear()
self.pause_event.set()
thread = Thread(target=self.run_query)
thread.start()
def on_pause(self):
if self.pause_event.is_set():
self.pause_event.clear()
self.update_status("Paused")
else:
self.pause_event.set()
self.update_status("Resumed")
def on_stop(self):
self.stop_event.set()
self.update_status("Stopping...")
def on_cancel(self):
self.top.destroy()
def update_status(self, message):
self.top.after(0, self._set_status, message)
def _set_status(self, message):
self.status.set(message)
def update_record_count(self):
count = self.record_count.get()
self.record_label.config(text=f"Records Written: {count}")
def run_query(self):
config = self.values
self.update_status("Initializing DICOM query...")
ae = AE(ae_title=config['LocalAET'])
ae.add_requested_context(StudyRootQueryRetrieveInformationModelFind)
handlers = [
(evt.EVT_ACCEPTED, lambda x: logging.info("Association accepted.")),
(evt.EVT_REJECTED, lambda x: logging.warning("Association rejected.")),
(evt.EVT_RELEASED, lambda x: logging.info("Association released.")),
(evt.EVT_ABORTED, lambda x: logging.error("Association aborted.")),
]
assoc = ae.associate(
config['RemoteIP'],
int(config['RemotePort']),
ae_title=config['RemoteAET'],
evt_handlers=handlers
)
if not assoc.is_established:
logging.error("DICOM Association failed.")
self.update_status("Association failed.")
messagebox.showerror("DICOM Error", "Association to PACS failed.")
return
start_date = datetime.strptime(config['StartDate'], '%Y-%m-%d')
now = datetime.now()
if start_date >= now:
logging.error("Start date %s is in the future. Aborting query.", start_date)
messagebox.showerror("Date Error", "Start date must be earlier than today.")
return
output_path = os.path.join(os.getcwd(), generate_output_filename())
logging.info("Starting C-FIND loop from %s to %s", start_date, now)
with open(output_path, 'w', newline='', buffering=1) as f:
writer = csv.writer(f, delimiter='|')
writer.writerow([
'PatientID', 'PatientName', 'AccessionNumber', 'StudyDate',
'StudyInstanceUID', 'StudyDescription', 'ModalitiesInStudy', 'NumberOfStudyRelatedInstances'
])
while start_date < now and not self.stop_event.is_set():
self.pause_event.wait()
end_date = start_date + timedelta(hours=1)
ds = create_cfind_dataset(start_date, end_date)
self.update_status(f"Querying: {start_date.strftime('%Y-%m-%d %H:%M')}")
try:
responses = assoc.send_c_find(ds, StudyRootQueryRetrieveInformationModelFind)
for status, identifier in responses:
if self.stop_event.is_set():
break
self.pause_event.wait()
if status and status.Status in (0xFF00, 0xFF01) and identifier:
mod_raw = getattr(identifier, 'ModalitiesInStudy', '')
try:
if isinstance(mod_raw, list):
mod_list = mod_raw
elif isinstance(mod_raw, str) and mod_raw.startswith('[') and mod_raw.endswith(']'):
mod_list = ast.literal_eval(mod_raw)
else:
mod_list = [mod_raw] if mod_raw else []
mod_string = ", ".join(sorted(set(str(m).strip().upper() for m in mod_list if m)))
except Exception:
mod_string = str(mod_raw).strip()
try:
writer.writerow([
str(getattr(identifier, 'PatientID', '')).strip(),
str(getattr(identifier, 'PatientName', '')).strip(),
str(getattr(identifier, 'AccessionNumber', '')).strip(),
str(getattr(identifier, 'StudyDate', '')).strip(),
str(getattr(identifier, 'StudyInstanceUID', '')).strip(),
str(getattr(identifier, 'StudyDescription', '')).strip(), # New field
mod_string,
str(getattr(identifier, 'NumberOfStudyRelatedInstances', '')).strip()
])
self.record_count.set(self.record_count.get() + 1)
self.update_record_count()
except Exception as e:
logging.exception("Error writing to output file.")
except Exception as e:
logging.exception("Exception during C-FIND from %s to %s", start_date, end_date)
start_date = end_date
assoc.release()
self.update_status("Query complete.")
logging.info("C-FIND complete. Results saved to: %s", output_path)
messagebox.showinfo("Success", f"C-FIND complete.\nResults saved to:\n{output_path}")
self.top.destroy()
# === Main ===
if __name__ == "__main__":
root = tk.Tk()
root.withdraw()
ConfigDialog(root)
root.mainloop()