pydicom / pynetdicom

A Python implementation of the DICOM networking protocol
https://pydicom.github.io/pynetdicom
MIT License
500 stars 176 forks source link

How do I get an SCP to accept different transfer syntaxes for the same SOP Class? #599

Closed aclark4life closed 3 years ago

aclark4life commented 3 years ago

Seems like I should be able to figure this out based on the wealth of knowledge in this repo's issues … but I'm stuck. Do I need to do anything to the assoc to make "Send to Orthanc" work for an ultrasound image? Everything else working great!! Thanks for any info

Code


# Based on https://pydicom.github.io/pynetdicom/stable/examples/storage.html#storage-scp

import os

from pydicom import dcmread

# from pydicom.uid import JPEG2000Lossless, JPEGBaseline, JPEGLosslessSV1
from pydicom.uid import JPEG2000Lossless, JPEGBaseline, JPEGLossless
from pynetdicom import AE, StoragePresentationContexts, debug_logger, evt
from pynetdicom.sop_class import VerificationSOPClass

# Implement a handler evt.EVT_C_STORE
def handle_store(event):
    """Handle a C-STORE request event. Write data to file system. Send data to Orthanc"""

    # --------------------------------------------------------------------------
    # Get the data
    #

    # Decode the C-STORE request's *Data Set* parameter to a pydicom Dataset
    dataset = event.dataset

    # Add the File Meta Information
    dataset.file_meta = event.file_meta

    # Get values from dataset
    instance_num = str(dataset["InstanceNumber"].value)
    patient_name = str(dataset["PatientName"].value)
    patient_id = str(dataset["PatientID"].value)
    study_date = str(dataset["StudyDate"].value)
    study_time = str(dataset["StudyTime"].value)
    series_desc = ""
    if "SeriesDescription" in dataset:
        series_desc = str(dataset["SeriesDescription"].value)
    series_num = str(dataset["SeriesNumber"].value)
    modality = str(dataset["Modality"].value)

    # Clean up values
    patient_name = patient_name.replace("^", "")

    # --------------------------------------------------------------------------
    # Write it the file system
    #

    # Use modality, patient, study, imaging series values to define directory tree and filename
    # unless value is empty string, in which case filter. E.g. create dir '4' instead of '4_' when
    # series_desc == ''.
    subdir_0 = modality
    subdir_1 = "_".join(filter(None, [patient_name, patient_id]))
    subdir_2 = "_".join(filter(None, [patient_name, study_date, study_time]))
    subdir_3 = "_".join(filter(None, [series_num, series_desc]))
    filename = "_".join(filter(None, [patient_name, series_num, instance_num]))
    filename += ".dcm"

    # Make directories
    try:
        os.mkdir(subdir_0)
    except FileExistsError:
        pass
    try:
        os.mkdir(os.path.join(subdir_0, subdir_1))
    except FileExistsError:
        pass
    try:
        os.mkdir(os.path.join(subdir_0, subdir_1, subdir_2))
    except FileExistsError:
        pass
    try:
        os.mkdir(os.path.join(subdir_0, subdir_1, subdir_2, subdir_3))
    except FileExistsError:
        pass

    # Configure perms
    os.chown(subdir_0, 101, gid=100)
    os.chown(os.path.join(subdir_0, subdir_1), 101, gid=100)
    os.chown(os.path.join(subdir_0, subdir_1, subdir_2), 101, gid=100)
    os.chown(os.path.join(subdir_0, subdir_1, subdir_2, subdir_3), 101, gid=100)
    path = os.path.join(subdir_0, subdir_1, subdir_2, subdir_3, filename)

    # Save the dataset using modality, patient, study, imaging series as directory tree and filename
    dataset.save_as(path, write_like_original=False)
    os.chown(path, 101, gid=100)

    # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
    #
    # Send it to Orthanc
    #
    # Based on https://pydicom.github.io/pynetdicom/stable/examples/storage.html#storage-scu

    # Read in our DICOM dataset
    # ds = dcmread(path)

    # Associate with peer AE running Orthanc at ORTHANC_IP_ADDRESS and port 4242
    assoc = ae.associate(ORTHANC_IP_ADDRESS, 4242)

    if assoc.is_established:
        # Use the C-STORE service to send the dataset
        assoc.send_c_store(dataset)
        # Release the association
        assoc.release()

    # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

    # Return a 'Success' status
    return 0x0000

handlers = [(evt.EVT_C_STORE, handle_store)]

# ------------------------------------------------------------------------------

# Initialise the Application Entity
ae = AE()

# Add the supported presentation contexts
# https://github.com/pydicom/pynetdicom/issues/591#issuecomment-798992575
# https://github.com/pydicom/pynetdicom/issues/591#issuecomment-801492853
for cx in StoragePresentationContexts:
    cx.add_transfer_syntax(JPEGBaseline)
    #    cx.add_transfer_syntax(JPEGLosslessSV1)
    cx.add_transfer_syntax(JPEGLossless)
    cx.add_transfer_syntax(JPEG2000Lossless)
ae.supported_contexts = StoragePresentationContexts

# Ultrasound wants to verify
# https://pydicom.github.io/pynetdicom/stable/examples/verification.html#verification-scp
ae.add_supported_context(VerificationSOPClass)

# Set requested presentation context for storage-scu to send to Orthanc
ae.requested_contexts = StoragePresentationContexts

# --------------------------------------------------------------------------------

# Get Orthanc IP address or bust
ORTHANC_IP_ADDRESS = os.environ.get("ORTHANC_IP_ADDRESS", None)
if not ORTHANC_IP_ADDRESS:
    print("Please `export ORTHANC_IP_ADDRESS=<ip_address>` before running scp.py")
    exit(1)

# Debug if DEBUG
DEBUG = os.environ.get("DEBUG", None)
if DEBUG:
    debug_logger()

# --------------------------------------------------------------------------------

# Start listening for incoming association requests
ae.start_server(("", 104), evt_handlers=handlers)

Error


D:   Context ID:        255 (Accepted)
D:     Abstract Syntax: =Hemodynamic Waveform Storage
D:     Accepted SCP/SCU Role: Default
D:     Accepted Transfer Syntax: =Explicit VR Little Endian
D: Accepted Extended Negotiation: None
D: Accepted Asynchronous Operations Window Negotiation: None
D: User Identity Negotiation Response: None
D: ========================== END A-ASSOCIATE-AC PDU ==========================
I: Association Accepted
E: No presentation context for 'Ultrasound Multi-frame Image Storage' has been accepted by the peer with 'JPEG Lossless, Non-Hierarchical, First-Order Prediction (Process 14 [Selection Value 1])' transfer syntax for the SCU role
E: Exception in handler bound to 'evt.EVT_C_STORE'
E: No presentation context for 'Ultrasound Multi-frame Image Storage' has been accepted by the peer with 'JPEG Lossless, Non-Hierarchical, First-Order Prediction (Process 14 [Selection Value 1])' transfer syntax for the SCU role
Traceback (most recent call last):
  File "/Users/alexclark/Developer/nif/nifscp/lib/python3.8/site-packages/pynetdicom/service_class.py", line 1449, in SCP
    rsp_status = evt.trigger(
  File "/Users/alexclark/Developer/nif/nifscp/lib/python3.8/site-packages/pynetdicom/events.py", line 212, in trigger
    return handlers[0](evt)
  File "scp.py", line 101, in handle_store
    assoc.send_c_store(dataset)
  File "/Users/alexclark/Developer/nif/nifscp/lib/python3.8/site-packages/pynetdicom/association.py", line 1700, in send_c_store
    context = self._get_valid_context(
  File "/Users/alexclark/Developer/nif/nifscp/lib/python3.8/site-packages/pynetdicom/association.py", line 444, in _get_valid_context
    raise ValueError(msg)
ValueError: No presentation context for 'Ultrasound Multi-frame Image Storage' has been accepted by the peer with 'JPEG Lossless, Non-Hierarchical, First-Order Prediction (Process 14 [Selection Value 1])' transfer syntax for the SCU role
I: Association Released
D: Abort Parameters:
D: =========================== INCOMING A-ABORT PDU ===========================
D: Abort Source: DUL service-user
D: Abort Reason: No reason given
D: ============================= END A-ABORT PDU ==============================
I: Association Aborted
scaramallion commented 3 years ago

The SCP is the one that gets to pick which transfer syntax it'll support and it looks like it's preferring Explicit VR, so I'd try adding a separate context for US with just the JPEG transfer syntax you're interested in.

The storescu app's --required-contexts option does something similar. Keep in mind there's a limit of 128 contexts when you're the association requestor, though.

The presentation context page in the user guide has a subsection on this (towards the bottom of that section).

aclark4life commented 3 years ago

@scaramallion Weee, thanks! Not the most elegant code, but works for my test data, much appreciated.


# Based on https://pydicom.github.io/pynetdicom/stable/examples/storage.html#storage-scp

import os

from pydicom import dcmread

# from pydicom.uid import JPEG2000Lossless, JPEGBaseline, JPEGLosslessSV1
from pydicom.uid import JPEG2000Lossless, JPEGBaseline, JPEGLossless
from pynetdicom import AE, StoragePresentationContexts, debug_logger, evt, build_context
from pynetdicom.sop_class import VerificationSOPClass, UltrasoundMultiframeImageStorage

# Implement a handler evt.EVT_C_STORE
def handle_store(event):
    """Handle a C-STORE request event. Write data to file system. Send data to Orthanc"""

    # --------------------------------------------------------------------------
    # Get the data
    #

    # Decode the C-STORE request's *Data Set* parameter to a pydicom Dataset
    dataset = event.dataset

    # Add the File Meta Information
    dataset.file_meta = event.file_meta

    # Get values from dataset
    instance_num = str(dataset["InstanceNumber"].value)
    patient_name = str(dataset["PatientName"].value)
    patient_id = str(dataset["PatientID"].value)
    study_date = str(dataset["StudyDate"].value)
    study_time = str(dataset["StudyTime"].value)
    series_desc = ""
    if "SeriesDescription" in dataset:
        series_desc = str(dataset["SeriesDescription"].value)
    series_num = str(dataset["SeriesNumber"].value)
    modality = str(dataset["Modality"].value)

    # Clean up values
    patient_name = patient_name.replace("^", "")

    # --------------------------------------------------------------------------
    # Write it the file system
    #

    # Use modality, patient, study, imaging series values to define directory tree and filename
    # unless value is empty string, in which case filter. E.g. create dir '4' instead of '4_' when
    # series_desc == ''.
    subdir_0 = modality
    subdir_1 = "_".join(filter(None, [patient_name, patient_id]))
    subdir_2 = "_".join(filter(None, [patient_name, study_date, study_time]))
    subdir_3 = "_".join(filter(None, [series_num, series_desc]))
    filename = "_".join(filter(None, [patient_name, series_num, instance_num]))
    filename += ".dcm"

    # Make directories
    try:
        os.mkdir(subdir_0)
    except FileExistsError:
        pass
    try:
        os.mkdir(os.path.join(subdir_0, subdir_1))
    except FileExistsError:
        pass
    try:
        os.mkdir(os.path.join(subdir_0, subdir_1, subdir_2))
    except FileExistsError:
        pass
    try:
        os.mkdir(os.path.join(subdir_0, subdir_1, subdir_2, subdir_3))
    except FileExistsError:
        pass

    # Configure perms
    os.chown(subdir_0, 101, gid=100)
    os.chown(os.path.join(subdir_0, subdir_1), 101, gid=100)
    os.chown(os.path.join(subdir_0, subdir_1, subdir_2), 101, gid=100)
    os.chown(os.path.join(subdir_0, subdir_1, subdir_2, subdir_3), 101, gid=100)
    path = os.path.join(subdir_0, subdir_1, subdir_2, subdir_3, filename)

    # Save the dataset using modality, patient, study, imaging series as directory tree and filename
    dataset.save_as(path, write_like_original=False)
    os.chown(path, 101, gid=100)

    # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
    #
    # Send it to Orthanc
    #
    # Based on https://pydicom.github.io/pynetdicom/stable/examples/storage.html#storage-scu

    # Read in our DICOM dataset
    # ds = dcmread(path)

    # Associate with peer AE running Orthanc at ORTHANC_IP_ADDRESS and port 4242
    try:
        assoc = ae.associate(ORTHANC_IP_ADDRESS, 4242)
        if assoc.is_established:
            # Use the C-STORE service to send the dataset
            assoc.send_c_store(dataset)
            # Release the association
            assoc.release()
    except ValueError:
        # https://github.com/pydicom/pynetdicom/issues/599
        assoc = ae.associate(
            ORTHANC_IP_ADDRESS,
            4242,
            contexts=[
                build_context(
                    UltrasoundMultiframeImageStorage, "1.2.840.10008.1.2.4.70"
                )
            ],
        )
        if assoc.is_established:
            # Use the C-STORE service to send the dataset
            assoc.send_c_store(dataset)
            # Release the association
            assoc.release()

    # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

    # Return a 'Success' status
    return 0x0000

handlers = [(evt.EVT_C_STORE, handle_store)]

# ------------------------------------------------------------------------------

# Initialise the Application Entity
ae = AE()

# Add the supported presentation contexts
# https://github.com/pydicom/pynetdicom/issues/591#issuecomment-798992575
# https://github.com/pydicom/pynetdicom/issues/591#issuecomment-801492853
for cx in StoragePresentationContexts:
    cx.add_transfer_syntax(JPEGBaseline)
    #    cx.add_transfer_syntax(JPEGLosslessSV1)
    cx.add_transfer_syntax(JPEGLossless)
    cx.add_transfer_syntax(JPEG2000Lossless)
ae.supported_contexts = StoragePresentationContexts

# Ultrasound wants to verify
# https://pydicom.github.io/pynetdicom/stable/examples/verification.html#verification-scp
ae.add_supported_context(VerificationSOPClass)

# Set requested presentation context for storage-scu to send to Orthanc
ae.requested_contexts = StoragePresentationContexts

# --------------------------------------------------------------------------------

# Get Orthanc IP address or bust
ORTHANC_IP_ADDRESS = os.environ.get("ORTHANC_IP_ADDRESS", None)
if not ORTHANC_IP_ADDRESS:
    print("Please `export ORTHANC_IP_ADDRESS=<ip_address>` before running scp.py")
    exit(1)

# Debug if DEBUG
DEBUG = os.environ.get("DEBUG", None)
if DEBUG:
    debug_logger()

# --------------------------------------------------------------------------------

# Start listening for incoming association requests
ae.start_server(("", 104), evt_handlers=handlers)