#!/usr/bin/env python3 """ DICOM Sender (C-STORE client) ============================== Sends DICOM files to a remote DICOM router/PACS over the network using the C-STORE service, selecting which files to send by matching Accession Number against an index CSV (the kind produced by dicom_indexer.py's 'scan' command, which has FilePath and AccessionNumber columns). This tool always works from TWO separate source files: --source-index the file/metadata index (built earlier by dicom_indexer.py) --source-accessions your list of accession numbers to send (a different file) Dependencies: pip install pydicom pynetdicom Usage: # Test connectivity to the destination router first (recommended) python dicom_sender.py echo --host 192.168.1.50 --port 104 --ae-title REMOTE_PACS # Send files whose AccessionNumber matches a list in a separate CSV python dicom_sender.py send \\ --source-index dicom_index.csv \\ --source-accessions accessions_to_send.csv \\ --host 192.168.1.50 --port 104 --ae-title REMOTE_PACS # Send files for a single accession number directly (no accession-list file needed) python dicom_sender.py send \\ --source-index dicom_index.csv \\ --accession ACC12345 \\ --host 192.168.1.50 --port 104 --ae-title REMOTE_PACS # Use a custom calling (your own) AE Title instead of the default python dicom_sender.py send ... --calling-ae-title MY_AE # Dry run: show what WOULD be sent without opening a network connection python dicom_sender.py send ... --dry-run """ import os import sys import csv import argparse import socket import time DEFAULT_CALLING_AE_TITLE = "DCM-RSABHK" # --------------------------------------------------------------------------- # Dependency check with a friendly message # --------------------------------------------------------------------------- MISSING = [] try: import pydicom except ImportError: MISSING.append("pydicom") try: from pynetdicom import AE, evt from pynetdicom.sop_class import Verification except ImportError: MISSING.append("pynetdicom") if MISSING: print("Missing required packages: " + ", ".join(MISSING)) print("Install them with:") print(f" pip install {' '.join(MISSING)}") sys.exit(1) # --------------------------------------------------------------------------- # DICOM C-STORE status code meanings (the common ones; peers may return # vendor-specific codes outside this list, which we just show as hex). # --------------------------------------------------------------------------- STATUS_MEANINGS = { 0x0000: "Success", 0x0105: "No such attribute", 0x0106: "Invalid attribute value", 0x0110: "Processing failure", 0x0117: "Invalid SOP instance", 0x0122: "SOP class not supported", 0x0124: "Not authorised", 0x0210: "Duplicate invocation", 0x0211: "Unrecognised operation", 0x0212: "Mistyped argument", 0xA700: "Out of resources", 0xA900: "Data set does not match SOP class", 0xB000: "Coercion of data elements (warning)", 0xB006: "Element discarded (warning)", 0xB007: "Data set does not match SOP class (warning)", } def describe_status(status_dataset): """Return a (code, label) tuple describing a C-STORE/C-ECHO response status.""" if status_dataset is None or "Status" not in status_dataset: return None, "No response (association/timeout/abort failure)" code = status_dataset.Status label = STATUS_MEANINGS.get(code) if label is None: # Out-of-resources and "cannot understand" are ranges, not single codes. if 0xA700 <= code <= 0xA7FF: label = "Out of resources" elif 0xC000 <= code <= 0xCFFF: label = "Cannot understand" else: label = "Unknown/vendor-specific status" return code, label def is_success(code): return code == 0x0000 # --------------------------------------------------------------------------- # Index / accession-list loading (streamed — see dicom_indexer.py's memory # notes; we apply the same approach here so large indexes don't balloon RAM) # --------------------------------------------------------------------------- def load_accession_list(path): """ Read a CSV of accession numbers to send. Accepts either: - a column literally named 'AccessionNumber' (case-insensitive), or - a single-column CSV with no recognizable header (first column used). Returns a set of accession number strings (stripped, deduplicated). """ accessions = set() with open(path, "r", newline="", encoding="utf-8") as f: reader = csv.reader(f) rows = list(reader) if not rows: return accessions header = [h.strip().lower() for h in rows[0]] col_idx = None for i, h in enumerate(header): if h in ("accessionnumber", "accession_number", "accession"): col_idx = i break if col_idx is not None: data_rows = rows[1:] else: # No recognizable header — treat every row's first column as data, # including row 0 (it wasn't actually a header). col_idx = 0 data_rows = rows for row in data_rows: if len(row) > col_idx: value = row[col_idx].strip() if value: accessions.add(value) return accessions def find_matching_files(index_path, accession_numbers): """ Stream the index CSV and yield (filepath, accession_number, sop_class_uid, patient_name, series_description) for every row whose AccessionNumber is in accession_numbers. accession_numbers: a set of accession number strings to match against. """ with open(index_path, "r", newline="", encoding="utf-8") as f: reader = csv.DictReader(f) if "FilePath" not in reader.fieldnames or "AccessionNumber" not in reader.fieldnames: print( "Error: index file does not have the expected 'FilePath' and " "'AccessionNumber' columns. Make sure this is an index produced " "by dicom_indexer.py's 'scan' command.", file=sys.stderr, ) sys.exit(1) for row in reader: acc = (row.get("AccessionNumber") or "").strip() if acc in accession_numbers: yield row # --------------------------------------------------------------------------- # Subcommand: echo (connectivity test) # --------------------------------------------------------------------------- def cmd_echo(args): ae = AE(ae_title=args.calling_ae_title) ae.add_requested_context(Verification) ae.acse_timeout = args.timeout ae.dimse_timeout = args.timeout ae.network_timeout = args.timeout print( f"Sending C-ECHO to {args.ae_title}@{args.host}:{args.port} " f"(calling AE: {args.calling_ae_title})..." ) try: assoc = ae.associate(args.host, args.port, ae_title=args.ae_title) except Exception as exc: print(f"Association error: {exc}", file=sys.stderr) sys.exit(1) if not assoc.is_established: print( "FAILED: could not establish association. Check host/port/AE title " "and that the destination router is reachable and accepting " "associations from this AE.", file=sys.stderr, ) sys.exit(1) status = assoc.send_c_echo() code, label = describe_status(status) assoc.release() if code is not None and is_success(code): print(f"SUCCESS: C-ECHO accepted (status 0x{code:04X} - {label}).") else: code_str = f"0x{code:04X}" if code is not None else "N/A" print(f"FAILED: C-ECHO returned status {code_str} - {label}.", file=sys.stderr) sys.exit(1) # --------------------------------------------------------------------------- # Subcommand: send # --------------------------------------------------------------------------- def cmd_send(args): # ---- Step 1: resolve the set of accession numbers to send ---- # Two independent source files are used here: # args.source_index -> which files exist + their metadata # args.source_accessions -> which accession numbers to actually send accession_numbers = set() if args.source_accessions: if not os.path.isfile(args.source_accessions): print(f"Error: accession source file not found: {args.source_accessions}", file=sys.stderr) sys.exit(1) accession_numbers |= load_accession_list(args.source_accessions) if args.accession: accession_numbers |= {a.strip() for a in args.accession if a.strip()} if not accession_numbers: print( "Error: no accession numbers given. Use --source-accessions and/or " "one or more --accession .", file=sys.stderr, ) sys.exit(1) if not os.path.isfile(args.source_index): print(f"Error: index source file not found: {args.source_index}", file=sys.stderr) sys.exit(1) print(f"Looking up {len(accession_numbers)} accession number(s) in index '{args.source_index}'...") # ---- Step 2: find matching files in the index ---- matched_rows = list(find_matching_files(args.source_index, accession_numbers)) if not matched_rows: print("No matching files found in the index for the given accession number(s).") sys.exit(0) found_accessions = {r["AccessionNumber"] for r in matched_rows} missing_accessions = accession_numbers - found_accessions print(f"Found {len(matched_rows)} file(s) across {len(found_accessions)} accession number(s).") if missing_accessions: print( f"Warning: {len(missing_accessions)} accession number(s) had no matching " f"file in the index: {', '.join(sorted(missing_accessions)[:10])}" + (" ..." if len(missing_accessions) > 10 else ""), file=sys.stderr, ) # ---- Step 3: verify files exist on disk, and (for real sends) preload # each file's SOP Class UID + Transfer Syntax UID so we can build # the minimal set of presentation contexts to request ---- sendable = [] missing_files = [] for row in matched_rows: filepath = row["FilePath"] if os.path.isfile(filepath): sendable.append(row) else: missing_files.append(filepath) if missing_files: print( f"Warning: {len(missing_files)} file(s) listed in the index no longer " f"exist on disk and will be skipped:", file=sys.stderr, ) for fp in missing_files[:10]: print(f" - {fp}", file=sys.stderr) if len(missing_files) > 10: print(f" ... and {len(missing_files) - 10} more", file=sys.stderr) if not sendable: print("No sendable files remain after checking disk. Aborting.") sys.exit(1) if args.dry_run: print("\n--- DRY RUN: the following files would be sent ---") for row in sendable: print(f" [{row.get('AccessionNumber','')}] {row['FilePath']}") print(f"\nTotal: {len(sendable)} file(s). No network connection was made.") return # ---- Step 4: read each file's SOP Class UID / Transfer Syntax UID to # build presentation contexts (max 128 per association) ---- print("Reading SOP Class / Transfer Syntax info from files to build presentation contexts...") file_info = [] # (filepath, row, sop_class_uid, transfer_syntax_uid) or None on read failure unreadable = [] for row in sendable: filepath = row["FilePath"] try: ds_meta = pydicom.dcmread(filepath, stop_before_pixels=True, force=True) sop_class = getattr(ds_meta, "SOPClassUID", None) ts = ds_meta.file_meta.TransferSyntaxUID if hasattr(ds_meta, "file_meta") else None if sop_class is None: unreadable.append(filepath) continue file_info.append((filepath, row, str(sop_class), str(ts) if ts else None)) except Exception as exc: unreadable.append(filepath) if unreadable: print( f"Warning: {len(unreadable)} file(s) could not be read as DICOM and " f"will be skipped:", file=sys.stderr, ) for fp in unreadable[:10]: print(f" - {fp}", file=sys.stderr) if not file_info: print("No readable DICOM files remain. Aborting.") sys.exit(1) # Build the minimal set of presentation contexts needed: one context per # DISTINCT (SOP Class UID, Transfer Syntax UID) pair actually used by the # files being sent. We deliberately do NOT bundle multiple transfer # syntaxes into a single context, because the peer only accepts ONE # transfer syntax per context — if a SOP Class has some files in (say) # uncompressed Explicit VR LE and others in JPEG Lossless, bundling them # into one context risks the peer accepting only one of the two, and # files using the other transfer syntax would still fail to send. A # separate context per pair guarantees every transfer syntax actually # present gets its own negotiation slot. # # This also fixes the root cause of "No presentation context ... has # been accepted by the peer with ''" errors: previously # contexts were requested using pynetdicom's DEFAULT transfer syntax # list (uncompressed only), so compressed files (JPEG Lossless, JPEG # 2000, RLE, etc.) never had a matching context at all. seen_pairs = set() context_pairs = [] # list of (sop_class, transfer_syntax_or_None) for _, _, sop_class, ts in file_info: pair = (sop_class, ts) if pair not in seen_pairs: seen_pairs.add(pair) context_pairs.append(pair) if len(context_pairs) > 128: print( f"Error: files span {len(context_pairs)} distinct SOP " f"Class/Transfer Syntax combinations, which exceeds the 128 " f"presentation contexts allowed per association. Split the " f"send into smaller batches.", file=sys.stderr, ) sys.exit(1) # ---- Step 5: build AE, request contexts, associate ---- ae = AE(ae_title=args.calling_ae_title) for sop_class, ts in context_pairs: if ts: ae.add_requested_context(sop_class, ts) else: # Transfer syntax couldn't be determined for this file # (unexpected/corrupt file_meta) — fall back to pynetdicom's # default uncompressed transfer syntax list rather than failing. ae.add_requested_context(sop_class) ae.acse_timeout = args.timeout ae.dimse_timeout = args.timeout ae.network_timeout = args.timeout distinct_sop_classes = {p[0] for p in context_pairs} print( f"\nConnecting to {args.ae_title}@{args.host}:{args.port} " f"(calling AE: {args.calling_ae_title}) requesting " f"{len(context_pairs)} presentation context(s) covering " f"{len(distinct_sop_classes)} SOP Class(es)..." ) try: assoc = ae.associate(args.host, args.port, ae_title=args.ae_title) except Exception as exc: print(f"Association error: {exc}", file=sys.stderr) sys.exit(1) if not assoc.is_established: print( "FAILED: could not establish association. Check host/port/AE title, " "that the destination accepts the SOP classes being sent, and that " "it allows associations from this calling AE title.", file=sys.stderr, ) sys.exit(1) # ---- Step 6: send each file via C-STORE ---- results = [] # (filepath, accession, code, label) try: for i, (filepath, row, sop_class, ts) in enumerate(file_info, start=1): accession = row.get("AccessionNumber", "") sys.stdout.write( f"\rSending {i}/{len(file_info)} (accession {accession})..." ) sys.stdout.flush() try: status = assoc.send_c_store(filepath) except Exception as exc: results.append((filepath, accession, None, f"Exception: {exc}")) continue code, label = describe_status(status) results.append((filepath, accession, code, label)) if args.delay: time.sleep(args.delay) finally: assoc.release() print() # newline after progress line # ---- Step 7: report results ---- succeeded = [r for r in results if r[2] is not None and is_success(r[2])] failed = [r for r in results if r not in succeeded] print(f"\n--- Send Summary ---") print(f"Total attempted: {len(results)}") print(f"Succeeded: {len(succeeded)}") print(f"Failed: {len(failed)}") if failed: print("\nFailed files:") for filepath, accession, code, label in failed: code_str = f"0x{code:04X}" if code is not None else "N/A" print(f" [{accession}] {filepath}") print(f" -> status {code_str}: {label}") if args.report: with open(args.report, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["FilePath", "AccessionNumber", "StatusCode", "StatusLabel"]) for filepath, accession, code, label in results: code_str = f"0x{code:04X}" if code is not None else "" writer.writerow([filepath, accession, code_str, label]) print(f"\nDetailed report saved to: {args.report}") if failed: sys.exit(1) # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- def build_parser(): parser = argparse.ArgumentParser( prog="dicom_sender.py", description=( "Send DICOM files to a remote DICOM router/PACS via C-STORE, " "selecting files by Accession Number from a dicom_indexer.py index." ), ) subparsers = parser.add_subparsers(dest="command", required=True) # Shared connection arguments def add_connection_args(p): p.add_argument("--host", required=True, help="Destination IP address or hostname.") p.add_argument("--port", required=True, type=int, help="Destination port number.") p.add_argument( "--ae-title", required=True, dest="ae_title", help="Destination (called) AE Title.", ) p.add_argument( "--calling-ae-title", dest="calling_ae_title", default=DEFAULT_CALLING_AE_TITLE, help=f"Your own (calling) AE Title (default: {DEFAULT_CALLING_AE_TITLE}).", ) p.add_argument( "--timeout", type=int, default=30, help="Network/association/DIMSE timeout in seconds (default: 30).", ) # echo p_echo = subparsers.add_parser("echo", help="Test connectivity to a destination via C-ECHO.") add_connection_args(p_echo) p_echo.set_defaults(func=cmd_echo) # send p_send = subparsers.add_parser("send", help="Send DICOM files matching accession number(s) via C-STORE.") add_connection_args(p_send) p_send.add_argument( "--source-index", "--index", dest="source_index", required=True, help=( "SOURCE FILE 1: path to the index CSV produced by dicom_indexer.py's " "'scan' command (needs FilePath + AccessionNumber columns). This is " "the catalog of files that exist and where they are on disk." ), ) p_send.add_argument( "--source-accessions", "--accessions", dest="source_accessions", help=( "SOURCE FILE 2: path to a separate CSV file containing the accession " "numbers you want to send (column named AccessionNumber, or a plain " "single-column list with no header). This is the work order — which " "of the files cataloged in --source-index should actually be sent." ), ) p_send.add_argument( "--accession", action="append", help="A single accession number to send. Can be given multiple times.", ) p_send.add_argument( "--dry-run", action="store_true", help="Show which files would be sent without making any network connection.", ) p_send.add_argument( "--delay", type=float, default=0.0, help="Delay in seconds between sending each file (default: 0, no delay).", ) p_send.add_argument( "-o", "--report", help="Save a detailed per-file CSV report (FilePath, AccessionNumber, StatusCode, StatusLabel) to this path.", ) p_send.set_defaults(func=cmd_send) return parser def main(): parser = build_parser() args = parser.parse_args() args.func(args) if __name__ == "__main__": main()