Skip to content

DIMSE networking

pydcm.dimse is a drop-in DIMSE API over the native stack: import pydcm.dimse as pynetdicom and existing code mostly just works. SCU and a full SCP are both supported, over a single persistent association.

Connecting

A DIMSE peer is addressed by (host, port, Called-AE-Title) — the AE Title and port are per-product:

Server host:port (default) Called AE (ae_title=)
Orthanc localhost:4242 ORTHANC
dcm4chee-arc localhost:11112 DCM4CHEE

Your own Calling AE Title (AE(ae_title=…), here PYDCM) only matters for C-MOVE (the move destination, below) and on servers that allow-list calling AEs.

Verify connectivity (C-ECHO)

Existing code runs unchanged:

import pydcm.dimse as pynetdicom

ae = pynetdicom.AE(ae_title="PYDCM")
assoc = ae.associate("localhost", 4242, ae_title="ORTHANC")   # dcm4chee: 11112, "DCM4CHEE"
if assoc.is_established:
    assoc.send_c_echo()
assoc.release()

The rest of this guide imports it under its own name, dimse — the natural spelling for new code.

Store instances (C-STORE)

The association is persistent — negotiate once, send many, release once:

import pydcm.dimse as dimse

ae = dimse.AE(ae_title="PYDCM")
ae.requested_contexts = dimse.StoragePresentationContexts   # negotiate Storage SOP Classes
assoc = ae.associate("pacs.local", 11112)
for path in ["a.dcm", "b.dcm", "c.dcm"]:
    assoc.send_c_store(pydcm.dcmread(path))
assoc.release()

C-STORE ships verbatim over the negotiated presentation context, so the Storage SOP Class must be requested before associate() — set requested_contexts (as above) or add_requested_context(<sop_class>). (C-ECHO / C-FIND / C-GET / C-MOVE negotiate their context for you from the SOP class / model.)

Query & retrieve (C-FIND / C-GET / C-MOVE)

from pydcm.sop_class import StudyRootQueryRetrieveInformationModelFind

query = pydcm.Dataset()
query.QueryRetrieveLevel = "STUDY"
query.PatientID = "42"
query.StudyInstanceUID = ""

assoc = ae.associate("pacs.local", 11112)
for status, identifier in assoc.send_c_find(query, StudyRootQueryRetrieveInformationModelFind):
    if identifier:
        print(identifier.StudyInstanceUID)
assoc.release()

query_model is the Q/R model's FIND SOP Class UID — use the sop_class constants (spelled-out service names), not a one-letter shorthand.

C-GET — retrieve over the same association

The matched instances come back as inbound C-STORE sub-operations on the same association, so you must add_supported_context(<storage class>) for the classes you accept (this negotiates the role-flipped receive channels) and handle EVT_C_STORE:

from pydcm.dimse import sop_class as sc

got = []
def on_store(event):
    got.append(event.dataset)          # event.dataset, event.transfer_syntax, event.sop_instance_uid
    return 0x0000

ae = dimse.AE(ae_title="PYDCM")
ae.add_requested_context(sc.StudyRootQueryRetrieveInformationModelGet)
ae.add_supported_context(sc.CTImageStorage)         # REQUIRED — one per class the study holds
ae.add_supported_context(sc.SegmentationStorage)    # (a prior C-FIND lists them — see below)
assoc = ae.associate("localhost", 4242, ae_title="ORTHANC",
                     evt_handlers=[(dimse.evt.EVT_C_STORE, on_store)])
q = pydcm.Dataset(); q.QueryRetrieveLevel = "STUDY"; q.StudyInstanceUID = study_uid
for status, _ in assoc.send_c_get(q, sc.StudyRootQueryRetrieveInformationModelGet):
    pass
assoc.release()

Declare every Storage class the study holds. A non-transcoding server (Orthanc) returns each instance in its stored transfer syntax, so it can only send a class it has a negotiated context for — and it aborts the whole C-GET if a matched instance has none. The prior C-FIND lists the study's classes in SOPClassesInStudy (or read each series' Modality); pass one add_supported_context per class. Each opens the role-flipped receive channels with their full transfer-syntax set, and pydcm writes back whatever it negotiated byte-for-byte (compressed stays compressed — no transcode). Omit add_supported_context entirely and send_c_get falls back to a one-shot association offering the common image and report classes.

C-MOVE — ship matches to a destination AE

C-MOVE tells the server to send the matches to a third-party Storage-SCP, identified by AE Title. So you (a) run that listener and (b) register its AE Title on the server:

# (a) run the destination Storage-SCP (here, ourselves) — the server connects BACK to it
recv = []
listener = dimse.AE(ae_title="PYDCM")
handle = listener.start_server(("0.0.0.0", 11200), block=False,
                               evt_handlers=[(dimse.evt.EVT_C_STORE, lambda e: (recv.append(1), 0x0000)[1])])
# (b) register "PYDCM" → host:11200 on the server (Orthanc DicomModalities; dcm4chee device).
ae = dimse.AE(ae_title="PYDCM")
ae.add_requested_context(sc.StudyRootQueryRetrieveInformationModelMove)
assoc = ae.associate("localhost", 4242, ae_title="ORTHANC")
q = pydcm.Dataset(); q.QueryRetrieveLevel = "STUDY"; q.StudyInstanceUID = study_uid
for status, _ in assoc.send_c_move(q, "PYDCM", sc.StudyRootQueryRetrieveInformationModelMove):
    pass
assoc.release(); handle.shutdown()

An unregistered destination AE trips 0xA801 "Move Destination unknown".

TLS (DIMSE over TLS)

Set ae.tls to a dict of file paths (a Python ssl.SSLContext cannot be reused by the native OpenSSL engine). server_name must match a certificate SAN:

ae = dimse.AE(ae_title="PYDCM"); ae.add_requested_context(sc.Verification)
ae.tls = {"ca_file": "ca.crt", "server_name": "localhost"}   # + cert_file/key_file for mTLS
assoc = ae.associate("localhost", 2762, ae_title="ORTHANC")  # the server's DIMSE-TLS port
assoc.send_c_echo(); assoc.release()

Run an SCP

import pydcm.dimse as dimse

def handle_store(event):
    event.dataset.save_as(f"{event.dataset.SOPInstanceUID}.dcm", write_like_original=False)
    return 0x0000   # Success

ae = dimse.AE(ae_title="PYDCM-SCP")
ae.add_supported_context(dimse.sop_class.CTImageStorage)
ae.start_server(("0.0.0.0", 11112), evt_handlers=[(dimse.evt.EVT_C_STORE, handle_store)])

start_server wires EVT_C_STORE / EVT_C_ECHO / EVT_C_FIND / EVT_C_GET / EVT_C_MOVE plus the DIMSE-N events.