Files
pydicom-migrasi-clarity/services/dicom_sender.py
2025-05-09 09:00:46 +07:00

358 lines
15 KiB
Python

"""
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