init: tested on hangtuah to cloud

This commit is contained in:
mario
2025-05-08 12:07:19 +07:00
parent 4e95512339
commit dfe0c18be2
12 changed files with 1992 additions and 1 deletions

272
services/dicom_finder.py Normal file
View File

@@ -0,0 +1,272 @@
"""
DICOM finder service - Implements findscu functionality using pynetdicom.
"""
import os
from datetime import datetime
from pydicom.dataset import Dataset
from pynetdicom import AE, evt, build_role, debug_logger
from pynetdicom.sop_class import (
PatientRootQueryRetrieveInformationModelFind,
StudyRootQueryRetrieveInformationModelFind,
PatientStudyOnlyQueryRetrieveInformationModelFind
)
from config import settings
from utils.logger import main_logger as logger
from utils.error_handler import DicomQueryError, dicom_retry
from utils.dicom_utils import parse_dicom_date
# Set debug level
# debug_logger() # Uncomment for detailed debug logs
class DicomFinder:
"""
Class to perform DICOM C-FIND operations at different levels (STUDY, SERIES, IMAGE).
"""
def __init__(self, pacs_config=None):
"""
Initialize DicomFinder with PACS settings.
Args:
pacs_config (dict, optional): PACS configuration dict containing host, port, aet
"""
self.pacs_config = pacs_config or settings.SOURCE_PACS
self.ae = AE(ae_title=settings.SOURCE_AET)
# Add the supported presentation contexts (Query/Retrieve SOP classes)
self.ae.add_requested_context(StudyRootQueryRetrieveInformationModelFind)
self.ae.add_requested_context(PatientRootQueryRetrieveInformationModelFind)
self.ae.add_requested_context(PatientStudyOnlyQueryRetrieveInformationModelFind)
logger.info(f"DicomFinder initialized with PACS: {self.pacs_config['aet']}@{self.pacs_config['host']}:{self.pacs_config['port']}")
@dicom_retry(exception_types=(DicomQueryError, ConnectionError))
def find_studies_by_date_range(self, start_date, end_date, additional_filters=None):
"""
Find studies within a date range.
Args:
start_date (str): Start date in YYYYMMDD format
end_date (str): End date in YYYYMMDD format
additional_filters (dict, optional): Additional DICOM attributes to filter by
Returns:
list: List of study datasets
"""
logger.info(f"Finding studies from {start_date} to {end_date}")
# Create query dataset
ds = Dataset()
ds.QueryRetrieveLevel = 'STUDY'
# Required fields (minimal set)
ds.StudyInstanceUID = ''
ds.StudyDate = ''
# Additional fields we want to retrieve
ds.AccessionNumber = ''
ds.PatientID = '' # MedrecID
ds.StudyDescription = ''
ds.StudyTime = ''
ds.NumberOfStudyRelatedSeries = ''
# Set date range
ds.StudyDate = f"{start_date}-{end_date}"
# Add any additional filters
if additional_filters:
for key, value in additional_filters.items():
setattr(ds, key, value)
studies = []
# Create association
assoc = self.ae.associate(
self.pacs_config['host'],
self.pacs_config['port'],
ae_title=self.pacs_config['aet']
)
if assoc.is_established:
try:
logger.debug("Association established, sending C-FIND request")
# Send C-FIND request
responses = assoc.send_c_find(
ds,
StudyRootQueryRetrieveInformationModelFind
)
# Process responses
for (status, dataset) in responses:
if status and status.Status == 0xFF00: # Pending
if dataset:
studies.append(dataset)
logger.debug(f"Found study: {dataset.StudyInstanceUID}")
elif status and status.Status != 0x0000: # Not success
logger.error(f"C-FIND error: {status}")
logger.info(f"Found {len(studies)} studies in date range {start_date}-{end_date}")
except Exception as e:
logger.error(f"Error during C-FIND: {str(e)}")
raise DicomQueryError(f"C-FIND operation failed: {str(e)}")
finally:
# Release the association
assoc.release()
logger.debug("Association released")
else:
error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}"
logger.error(error_msg)
raise DicomQueryError(error_msg)
return studies
@dicom_retry(exception_types=(DicomQueryError, ConnectionError))
def find_series_for_study(self, study_instance_uid, accession_number=None):
"""
Find all series for a given study.
Args:
study_instance_uid (str): StudyInstanceUID
Returns:
list: List of series datasets
"""
logger.info(f"Finding series for {accession_number} Study_IUID: {study_instance_uid}")
# Create query dataset
ds = Dataset()
ds.QueryRetrieveLevel = 'SERIES'
# Set the StudyInstanceUID filter
ds.StudyInstanceUID = study_instance_uid
# Required fields
ds.SeriesInstanceUID = ''
# Additional fields to retrieve
ds.SeriesNumber = ''
ds.SeriesDescription = ''
ds.Modality = ''
ds.NumberOfSeriesRelatedInstances = ''
series_list = []
# Create association
assoc = self.ae.associate(
self.pacs_config['host'],
self.pacs_config['port'],
ae_title=self.pacs_config['aet']
)
if assoc.is_established:
try:
logger.debug(f"Association established, sending SERIES-level C-FIND for study {study_instance_uid}")
# Send C-FIND request
responses = assoc.send_c_find(
ds,
StudyRootQueryRetrieveInformationModelFind
)
# Process responses
for (status, dataset) in responses:
if status and status.Status == 0xFF00: # Pending
if dataset:
series_list.append(dataset)
logger.debug(f"Found series: {dataset.SeriesInstanceUID}")
elif status and status.Status != 0x0000: # Not success
logger.error(f"C-FIND error: {status}")
logger.info(f"Found {len(series_list)} series for {accession_number} with Study_IUID {study_instance_uid}")
except Exception as e:
logger.error(f"Error during SERIES C-FIND: {str(e)}")
raise DicomQueryError(f"SERIES C-FIND operation failed: {str(e)}")
finally:
# Release the association
assoc.release()
logger.debug("Association released")
else:
error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}"
logger.error(error_msg)
raise DicomQueryError(error_msg)
return series_list
@dicom_retry(exception_types=(DicomQueryError, ConnectionError))
def find_first_instance_for_series(self, study_instance_uid, series_instance_uid):
"""
Find the first instance (SOP) for a given series.
Args:
study_instance_uid (str): StudyInstanceUID
series_instance_uid (str): SeriesInstanceUID
Returns:
Dataset: Dataset of the first instance found or None
"""
logger.info(f"Finding first instance for series: {series_instance_uid}")
# Create query dataset
ds = Dataset()
ds.QueryRetrieveLevel = 'IMAGE'
# Set the StudyInstanceUID and SeriesInstanceUID filters
ds.StudyInstanceUID = study_instance_uid
ds.SeriesInstanceUID = series_instance_uid
# Required fields
ds.SOPInstanceUID = ''
ds.SOPClassUID = ''
ds.InstanceNumber = ''
# Create association
assoc = self.ae.associate(
self.pacs_config['host'],
self.pacs_config['port'],
ae_title=self.pacs_config['aet']
)
first_instance = None
if assoc.is_established:
try:
logger.debug(f"Association established, sending IMAGE-level C-FIND for series {series_instance_uid}")
# Send C-FIND request
responses = assoc.send_c_find(
ds,
StudyRootQueryRetrieveInformationModelFind
)
# Process responses - just get the first one
for (status, dataset) in responses:
if status and status.Status == 0xFF00: # Pending
if dataset:
# Found one instance, break
first_instance = dataset
logger.debug(f"Found first instance: {dataset.SOPInstanceUID}")
break
elif status and status.Status != 0x0000: # Not success
logger.error(f"C-FIND error: {status}")
if first_instance:
logger.info(f"Found first instance {first_instance.SOPInstanceUID} for series {series_instance_uid}")
else:
logger.warning(f"No instances found for series {series_instance_uid}")
except Exception as e:
logger.error(f"Error during IMAGE C-FIND: {str(e)}")
raise DicomQueryError(f"IMAGE C-FIND operation failed: {str(e)}")
finally:
# Release the association
assoc.release()
logger.debug("Association released")
else:
error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}"
logger.error(error_msg)
raise DicomQueryError(error_msg)
return first_instance

359
services/dicom_retriever.py Normal file
View File

@@ -0,0 +1,359 @@
"""
DICOM retriever service - Implements getscu functionality using pynetdicom.
"""
import os
import tempfile
from pydicom.dataset import Dataset
from pynetdicom import AE, evt, build_role, StoragePresentationContexts
from pynetdicom.sop_class import (
StudyRootQueryRetrieveInformationModelGet,
PatientRootQueryRetrieveInformationModelGet
)
from config import settings
from utils.logger import main_logger as logger
from utils.error_handler import DicomRetrieveError, dicom_retry
from utils.dicom_utils import create_directory_if_not_exists
from utils.cleanup import register_cleanup_dir
class DicomRetriever:
"""
Class to perform DICOM C-GET operations to retrieve studies.
"""
def __init__(self, pacs_config=None, store_dir=None):
"""
Initialize DicomRetriever with PACS settings.
Args:
pacs_config (dict, optional): PACS configuration dict containing host, port, aet
store_dir (str, optional): Directory to store retrieved DICOM files
"""
self.pacs_config = pacs_config or settings.SOURCE_PACS
self.store_dir = store_dir or settings.DICOM_STORE_DIR
self.ae = AE(ae_title=settings.SOURCE_AET)
# Add the Query/Retrieve SOP classes
self.ae.add_requested_context(StudyRootQueryRetrieveInformationModelGet)
self.ae.add_requested_context(PatientRootQueryRetrieveInformationModelGet)
# Add storage presentation contexts (needed for receiving the images)
# for context in StoragePresentationContexts:
# self.ae.add_requested_context(context.abstract_syntax)
storage_uids = [
'1.2.840.10008.5.1.4.1.1.1', # CR Storage
'1.2.840.10008.5.1.4.1.1.1.1', # Digital X-Ray Image Storage
'1.2.840.10008.5.1.4.1.1.2', # CT Image Storage
'1.2.840.10008.5.1.4.1.1.4', # MR Image Storage
'1.2.840.10008.5.1.4.1.1.7', # Secondary Capture Image Storage
'1.2.840.10008.5.1.4.1.1.6.1', # Ultrasound Image Storage
'1.2.840.10008.5.1.4.1.1.128', # PET Image Storage
'1.2.840.10008.5.1.4.1.1.20', # Nuclear Medicine Image Storage
'1.2.840.10008.5.1.4.1.1.9.1.1', # 12-lead ECG Waveform Storage
'1.2.840.10008.5.1.4.1.1.9.1.2', # General ECG Waveform Storage
]
self.ext_neg = []
for uid in storage_uids:
self.ae.add_requested_context(uid)
role = build_role(uid, scp_role=True)
self.ext_neg.append(role)
# Create storage directory if it doesn't exist
create_directory_if_not_exists(self.store_dir)
logger.info(f"DicomRetriever initialized with PACS: {self.pacs_config['aet']}@{self.pacs_config['host']}:{self.pacs_config['port']}")
logger.info(f"DICOM files will be stored in: {self.store_dir}")
def _handle_store(self, event):
"""
Handle C-STORE operations during a C-GET association.
Args:
event: DICOM C-STORE event
Returns:
int: Status code (0 = Success)
"""
dataset = event.dataset
# Get the study, series, and instance UIDs
study_uid = dataset.StudyInstanceUID
series_uid = dataset.SeriesInstanceUID
instance_uid = dataset.SOPInstanceUID
# Create nested directory structure: StudyInstanceUID/SeriesInstanceUID/
study_dir = os.path.join(self.store_dir, study_uid)
series_dir = os.path.join(study_dir, series_uid)
create_directory_if_not_exists(series_dir)
# Save the dataset to file
filename = f"{instance_uid}.dcm"
file_path = os.path.join(series_dir, filename)
try:
dataset.save_as(file_path, write_like_original=False)
logger.debug(f"Stored instance {instance_uid} to {file_path}")
return 0x0000 # Success status
except Exception as e:
logger.error(f"Error storing instance {instance_uid}: {str(e)}")
return 0xC001 # Failed - Unable to store
@dicom_retry(exception_types=(DicomRetrieveError, ConnectionError))
def retrieve_study(self, study_instance_uid, accession_number=None):
"""
Retrieve a complete study using C-GET.
Args:
study_instance_uid (str): StudyInstanceUID to retrieve
accession_number (str, optional): AccessionNumber to filter the study
Returns:
dict: Summary of retrieved data with counts and status
"""
logger.info(f"Retrieving study: {accession_number} with Study_IUID {study_instance_uid}")
# Create study-specific directory
study_dir = os.path.join(self.store_dir, study_instance_uid)
create_directory_if_not_exists(study_dir)
# Register this directory for cleanup on exit
register_cleanup_dir(study_dir)
# Create query dataset
ds = Dataset()
ds.QueryRetrieveLevel = 'STUDY'
ds.StudyInstanceUID = study_instance_uid
# Create association
# Bind the evt.EVT_C_STORE handler to our _handle_store function
handlers = [(evt.EVT_C_STORE, self._handle_store)]
assoc = self.ae.associate(
self.pacs_config['host'],
self.pacs_config['port'],
ae_title=self.pacs_config['aet'],
evt_handlers=handlers,
ext_neg=self.ext_neg, # No extended negotiation
)
result = {
'success': False,
'study_uid': study_instance_uid,
'total_instances': 0,
'successful_instances': 0,
'failed_instances': 0,
'status': '',
'error': '',
'study_dir': study_dir
}
if assoc.is_established:
try:
logger.debug(f"Association established, sending C-GET request for study {study_instance_uid}")
# Send C-GET request
responses = assoc.send_c_get(
ds,
StudyRootQueryRetrieveInformationModelGet )
# Track progress
for (status, identifier) in responses:
if status:
# Update status information
result['total_instances'] = getattr(status, 'NumberOfRemainingSuboperations', 0) + \
getattr(status, 'NumberOfCompletedSuboperations', 0) + \
getattr(status, 'NumberOfFailedSuboperations', 0) + \
getattr(status, 'NumberOfWarningSuboperations', 0)
result['successful_instances'] = getattr(status, 'NumberOfCompletedSuboperations', 0)
result['failed_instances'] = getattr(status, 'NumberOfFailedSuboperations', 0)
# Log progress for large retrievals
if status.Status == 0xFF00: # Pending
if hasattr(status, 'NumberOfRemainingSuboperations'):
logger.debug(f"C-GET progress: {result['successful_instances']}/{result['total_instances']} instances received")
# Check if operation was successful
if status.Status == 0x0000: # Success
result['success'] = True
result['status'] = 'Success'
elif status.Status == 0xB000: # Warning
result['success'] = True
result['status'] = 'Warning - Suboperations Complete with Failures'
else:
result['status'] = f"Error - Status code: {status.Status:04x}"
logger.info(f"C-GET completed: {result['successful_instances']}/{result['total_instances']} instances retrieved")
except Exception as e:
error_msg = f"Error during C-GET: {str(e)}"
logger.error(error_msg)
result['error'] = error_msg
raise DicomRetrieveError(error_msg)
finally:
# Release the association
assoc.release()
logger.debug("Association released")
else:
error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}"
logger.error(error_msg)
result['error'] = error_msg
raise DicomRetrieveError(error_msg)
return result
@dicom_retry(exception_types=(DicomRetrieveError, ConnectionError))
def retrieve_series(self, study_instance_uid, series_instance_uid):
"""
Retrieve a specific series using C-GET.
Args:
study_instance_uid (str): StudyInstanceUID
series_instance_uid (str): SeriesInstanceUID to retrieve
Returns:
dict: Summary of retrieved data with counts and status
"""
logger.info(f"Retrieving series: {series_instance_uid} from study: {study_instance_uid}")
# Create series-specific directory
study_dir = os.path.join(self.store_dir, study_instance_uid)
series_dir = os.path.join(study_dir, series_instance_uid)
create_directory_if_not_exists(series_dir)
# Register directories for cleanup on exit
register_cleanup_dir(study_dir)
register_cleanup_dir(series_dir)
# Create query dataset
ds = Dataset()
ds.QueryRetrieveLevel = 'SERIES'
ds.StudyInstanceUID = study_instance_uid
ds.SeriesInstanceUID = series_instance_uid
# Create association
# Bind the evt.EVT_C_STORE handler to our _handle_store function
handlers = [(evt.EVT_C_STORE, self._handle_store)]
assoc = self.ae.associate(
self.pacs_config['host'],
self.pacs_config['port'],
ae_title=self.pacs_config['aet'],
evt_handlers=handlers,
ext_neg=self.ext_neg, # No extended negotiation
)
result = {
'success': False,
'study_uid': study_instance_uid,
'series_uid': series_instance_uid,
'total_instances': 0,
'successful_instances': 0,
'failed_instances': 0,
'status': '',
'error': '',
'series_dir': series_dir
}
if assoc.is_established:
try:
logger.debug(f"Association established, sending C-GET request for series {series_instance_uid}")
# Send C-GET request
responses = assoc.send_c_get(
ds,
StudyRootQueryRetrieveInformationModelGet )
# Track progress
for (status, identifier) in responses:
if status:
# Update status information
result['total_instances'] = getattr(status, 'NumberOfRemainingSuboperations', 0) + \
getattr(status, 'NumberOfCompletedSuboperations', 0) + \
getattr(status, 'NumberOfFailedSuboperations', 0) + \
getattr(status, 'NumberOfWarningSuboperations', 0)
result['successful_instances'] = getattr(status, 'NumberOfCompletedSuboperations', 0)
result['failed_instances'] = getattr(status, 'NumberOfFailedSuboperations', 0)
# Check if operation was successful
if status.Status == 0x0000: # Success
result['success'] = True
result['status'] = 'Success'
elif status.Status == 0xB000: # Warning
result['success'] = True
result['status'] = 'Warning - Suboperations Complete with Failures'
else:
result['status'] = f"Error - Status code: {status.Status:04x}"
logger.info(f"C-GET completed: {result['successful_instances']}/{result['total_instances']} instances retrieved")
except Exception as e:
error_msg = f"Error during C-GET: {str(e)}"
logger.error(error_msg)
result['error'] = error_msg
raise DicomRetrieveError(error_msg)
finally:
# Release the association
assoc.release()
logger.debug("Association released")
else:
error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}"
logger.error(error_msg)
result['error'] = error_msg
raise DicomRetrieveError(error_msg)
return result
def get_retrieved_file_count(self, study_uid, series_uid=None):
"""
Count the number of files retrieved for a study or series.
Args:
study_uid (str): StudyInstanceUID
series_uid (str, optional): SeriesInstanceUID. If None, count all files in the study.
Returns:
int: Number of DICOM files found
"""
study_dir = os.path.join(self.store_dir, study_uid)
if not os.path.exists(study_dir):
logger.warning(f"Study directory does not exist: {study_dir}")
return 0
file_count = 0
if series_uid:
# Count files in a specific series
series_dir = os.path.join(study_dir, series_uid)
if os.path.exists(series_dir):
# Count only .dcm files
file_count = len([f for f in os.listdir(series_dir) if f.endswith('.dcm')])
else:
# Count files in all series of the study
for series_name in os.listdir(study_dir):
series_dir = os.path.join(study_dir, series_name)
if os.path.isdir(series_dir):
file_count += len([f for f in os.listdir(series_dir) if f.endswith('.dcm')])
return file_count
def is_study_complete(self, study_uid, expected_instance_count=None):
"""
Check if a study has been completely retrieved.
Args:
study_uid (str): StudyInstanceUID
expected_instance_count (int, optional): Expected number of instances.
Returns:
bool: True if study is complete, False otherwise
"""
if expected_instance_count is None:
logger.warning("No expected instance count provided, can't determine if study is complete")
return False
actual_count = self.get_retrieved_file_count(study_uid)
logger.info(f"Study {study_uid} completeness: {actual_count}/{expected_instance_count} instances")
return actual_count >= expected_instance_count

358
services/dicom_sender.py Normal file
View File

@@ -0,0 +1,358 @@
"""
DICOM sender service - Implements storescu functionality using pynetdicom.
"""
import os
import glob
import pydicom
import shutil
from pydicom.dataset import Dataset
from pynetdicom import AE, StoragePresentationContexts, evt
from config import settings
from utils.logger import main_logger as logger
from utils.error_handler import DicomStoreError, dicom_retry
from utils.dicom_utils import create_directory_if_not_exists
class DicomSender:
"""
Class to perform DICOM C-STORE operations to send DICOM studies to a destination PACS.
"""
def __init__(self, pacs_config=None):
"""
Initialize DicomSender with PACS settings.
Args:
pacs_config (dict, optional): Destination PACS configuration dict containing host, port, aet
"""
self.pacs_config = pacs_config or settings.DESTINATION_PACS
self.ae = AE(ae_title=settings.SOURCE_AET)
# Add storage presentation contexts (all standard transfer syntaxes for each SOP class)
for context in StoragePresentationContexts:
self.ae.add_requested_context(context.abstract_syntax)
logger.info(f"DicomSender initialized with destination PACS: {self.pacs_config['aet']}@{self.pacs_config['host']}:{self.pacs_config['port']}")
def _remove_dicom_files(self, directory):
"""
Remove all DICOM files in the specified directory.
Args:
directory (str): Path to the directory to remove files from.
"""
try:
if os.path.exists(directory):
shutil.rmtree(directory)
logger.info(f"Removed DICOM files from directory: {directory}")
except Exception as e:
logger.error(f"Failed to remove DICOM files from {directory}: {str(e)}")
@dicom_retry(exception_types=(DicomStoreError, ConnectionError))
def send_study(self, study_path):
"""
Send all DICOM files in a study directory to the destination PACS.
Args:
study_path (str): Path to study directory containing series subdirectories with DICOM files
Returns:
dict: Summary of store operations with counts and status
"""
logger.info(f"Sending all DICOM files from study: {study_path}")
# Check if study directory exists
if not os.path.exists(study_path) or not os.path.isdir(study_path):
error_msg = f"Study directory does not exist: {study_path}"
logger.error(error_msg)
raise DicomStoreError(error_msg)
# Find all DICOM files in the study directory (recursively)
dicom_files = []
for root, _, files in os.walk(study_path):
for file in files:
if file.endswith('.dcm'):
dicom_files.append(os.path.join(root, file))
if not dicom_files:
logger.warning(f"No DICOM files found in study directory: {study_path}")
return {
'success': False,
'total_files': 0,
'successful_sends': 0,
'failed_sends': 0,
'error': 'No DICOM files found'
}
logger.info(f"Found {len(dicom_files)} DICOM files to send")
# Create association
assoc = self.ae.associate(
self.pacs_config['host'],
self.pacs_config['port'],
ae_title=self.pacs_config['aet']
)
result = {
'success': False,
'total_files': len(dicom_files),
'successful_sends': 0,
'failed_sends': 0,
'failures': [],
'study_path': study_path
}
if assoc.is_established:
try:
logger.debug(f"Association established with {self.pacs_config['aet']}, sending DICOM files")
for file_path in dicom_files:
try:
# Read the DICOM file
dataset = pydicom.dcmread(file_path, force=True)
# Get identifying information for logging
sop_instance_uid = getattr(dataset, 'SOPInstanceUID', 'Unknown')
series_instance_uid = getattr(dataset, 'SeriesInstanceUID', 'Unknown')
# Send the dataset
status = assoc.send_c_store(dataset)
if status and status.Status == 0x0000: # Success
logger.debug(f"Successfully sent: {os.path.basename(file_path)}")
result['successful_sends'] += 1
else:
logger.error(f"Failed to send {os.path.basename(file_path)}: {status}")
result['failed_sends'] += 1
result['failures'].append({
'file': file_path,
'sop_instance_uid': sop_instance_uid,
'series_instance_uid': series_instance_uid,
'status': str(status) if status else 'Unknown error'
})
except Exception as e:
logger.error(f"Error processing file {file_path}: {str(e)}")
result['failed_sends'] += 1
result['failures'].append({
'file': file_path,
'error': str(e)
})
# Update success flag based on results
if result['successful_sends'] == result['total_files']:
result['success'] = True
elif result['successful_sends'] > 0:
# Partial success
result['success'] = True
logger.warning(f"Partial success: {result['successful_sends']}/{result['total_files']} files sent")
logger.info(f"C-STORE completed: {result['successful_sends']}/{result['total_files']} files sent")
# Remove DICOM files after sending
self._remove_dicom_files(study_path)
except Exception as e:
error_msg = f"Error during C-STORE operations: {str(e)}"
logger.error(error_msg)
result['error'] = error_msg
raise DicomStoreError(error_msg)
finally:
# Release the association
assoc.release()
logger.debug("Association released")
else:
error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}"
logger.error(error_msg)
result['error'] = error_msg
raise DicomStoreError(error_msg)
return result
@dicom_retry(exception_types=(DicomStoreError, ConnectionError))
def send_series(self, study_uid, series_uid, base_dir=None):
"""
Send all DICOM files in a specific series to the destination PACS.
Args:
study_uid (str): StudyInstanceUID
series_uid (str): SeriesInstanceUID to send
base_dir (str, optional): Base directory where DICOM files are stored. Defaults to settings.DICOM_STORE_DIR.
Returns:
dict: Summary of store operations with counts and status
"""
base_dir = base_dir or settings.DICOM_STORE_DIR
series_path = os.path.join(base_dir, study_uid, series_uid)
logger.info(f"Sending DICOM files from series: {series_uid}")
# Check if series directory exists
if not os.path.exists(series_path) or not os.path.isdir(series_path):
error_msg = f"Series directory does not exist: {series_path}"
logger.error(error_msg)
raise DicomStoreError(error_msg)
# Find all DICOM files in the series directory
dicom_files = glob.glob(os.path.join(series_path, '*.dcm'))
if not dicom_files:
logger.warning(f"No DICOM files found in series directory: {series_path}")
return {
'success': False,
'total_files': 0,
'successful_sends': 0,
'failed_sends': 0,
'error': 'No DICOM files found'
}
logger.info(f"Found {len(dicom_files)} DICOM files to send")
# Create association
assoc = self.ae.associate(
self.pacs_config['host'],
self.pacs_config['port'],
ae_title=self.pacs_config['aet']
)
result = {
'success': False,
'total_files': len(dicom_files),
'successful_sends': 0,
'failed_sends': 0,
'failures': [],
'series_path': series_path
}
if assoc.is_established:
try:
logger.debug(f"Association established with {self.pacs_config['aet']}, sending DICOM files")
for file_path in dicom_files:
try:
# Read the DICOM file
dataset = pydicom.dcmread(file_path, force=True)
# Send the dataset
status = assoc.send_c_store(dataset)
if status and status.Status == 0x0000: # Success
logger.debug(f"Successfully sent: {os.path.basename(file_path)}")
result['successful_sends'] += 1
else:
logger.error(f"Failed to send {os.path.basename(file_path)}: {status}")
result['failed_sends'] += 1
result['failures'].append({
'file': file_path,
'status': str(status) if status else 'Unknown error'
})
except Exception as e:
logger.error(f"Error processing file {file_path}: {str(e)}")
result['failed_sends'] += 1
result['failures'].append({
'file': file_path,
'error': str(e)
})
# Update success flag based on results
if result['successful_sends'] == result['total_files']:
result['success'] = True
elif result['successful_sends'] > 0:
# Partial success
result['success'] = True
logger.warning(f"Partial success: {result['successful_sends']}/{result['total_files']} files sent")
logger.info(f"C-STORE completed: {result['successful_sends']}/{result['total_files']} files sent")
# Remove DICOM files after sending
self._remove_dicom_files(series_path)
except Exception as e:
error_msg = f"Error during C-STORE operations: {str(e)}"
logger.error(error_msg)
result['error'] = error_msg
raise DicomStoreError(error_msg)
finally:
# Release the association
assoc.release()
logger.debug("Association released")
else:
error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}"
logger.error(error_msg)
result['error'] = error_msg
raise DicomStoreError(error_msg)
return result
@dicom_retry(exception_types=(DicomStoreError, ConnectionError))
def send_file(self, file_path):
"""
Send a single DICOM file to the destination PACS.
Args:
file_path (str): Path to DICOM file to send
Returns:
dict: Summary of store operation with status
"""
logger.info(f"Sending DICOM file: {file_path}")
# Check if file exists
if not os.path.exists(file_path) or not os.path.isfile(file_path):
error_msg = f"DICOM file does not exist: {file_path}"
logger.error(error_msg)
raise DicomStoreError(error_msg)
# Create association
assoc = self.ae.associate(
self.pacs_config['host'],
self.pacs_config['port'],
ae_title=self.pacs_config['aet']
)
result = {
'success': False,
'file_path': file_path,
'status': '',
'error': ''
}
if assoc.is_established:
try:
logger.debug(f"Association established with {self.pacs_config['aet']}, sending file")
# Read the DICOM file
dataset = pydicom.dcmread(file_path, force=True)
# Get identifying information for logging
sop_instance_uid = getattr(dataset, 'SOPInstanceUID', 'Unknown')
series_instance_uid = getattr(dataset, 'SeriesInstanceUID', 'Unknown')
study_instance_uid = getattr(dataset, 'StudyInstanceUID', 'Unknown')
# Send the dataset
status = assoc.send_c_store(dataset)
if status and status.Status == 0x0000: # Success
result['success'] = True
result['status'] = 'Success'
logger.info(f"Successfully sent file: {file_path}")
logger.debug(f"File details: Study={study_instance_uid}, Series={series_instance_uid}, SOP={sop_instance_uid}")
else:
result['status'] = f"Failed - Status: {status}" if status else "Failed - Unknown error"
logger.error(f"Failed to send file: {file_path}")
logger.error(f"Status: {status}")
except Exception as e:
error_msg = f"Error sending file {file_path}: {str(e)}"
logger.error(error_msg)
result['error'] = error_msg
raise DicomStoreError(error_msg)
finally:
# Release the association
assoc.release()
logger.debug("Association released")
else:
error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}"
logger.error(error_msg)
result['error'] = error_msg
raise DicomStoreError(error_msg)
return result