Writing your first SCP

This is the second tutorial for people who are new to pynetdicom. If you missed the first one you should check it out before continuing.

In this tutorial you’ll:

  • Learn about DICOM Data Sets and the DICOM File Format

  • Create a new Storage SCP application using pynetdicom

  • Learn about the event-handler system and add handlers to your SCP

  • Send data to your SCP using pynetdicom’s storescu application

If you need to install pynetdicom please follow the instructions in the installation guide. For this tutorial we’ll also be using the storescu application that comes with pynetdicom.

The Data Set

This tutorial is about creating an SCP for the DICOM storage service, which is used to transfer DICOM Data Sets from one AE to another. A Data Set, which from now on we’ll just refer to as a dataset, is a representation of a real world object, like a slice of a CT or a structured report. A dataset is a collection of Data Elements, where each element represents an attribute of the object.

Datasets are usually used to store information from the medical procedures undergone by a patient, however the DICOM Standard also puts them to use as part of the networking protocol and in service provision.

Note

While it’s not required for this tutorial, you should be comfortable using pydicom to create new datasets and read, write or modify existing ones. If you’re new to pydicom then you should start with the Dataset Basics tutorial.

Creating a Storage SCP

Let’s create a simple Storage SCP for receiving CT Image datasets encoded using the Explicit VR Little Endian transfer syntax. Create a new file my_scp.py, open it in a text editor and add the following:

 1 from pydicom.uid import ExplicitVRLittleEndian
 2
 3 from pynetdicom import AE, debug_logger
 4 from pynetdicom.sop_class import CTImageStorage
 5
 6 debug_logger()
 7
 8 ae = AE()
 9 ae.add_supported_context(CTImageStorage, ExplicitVRLittleEndian)
10 ae.start_server(("127.0.0.1", 11112), block=True)

Let’s break this down

1 from pydicom.uid import ExplicitVRLittleEndian
2
3 from pynetdicom import AE, debug_logger
4 from pynetdicom.sop_class import CTImageStorage

We import the UID for Explicit VR Little Endian from pydicom, and the AE class, debug_logger() function and the UID for CT Image Storage from pynetdicom.

6 debug_logger()
7
8 ae = AE()
9 ae.add_supported_context(CTImageStorage, ExplicitVRLittleEndian)

Just as with the Echo SCU from the previous tutorial, we create a new AE instance. However, because this time we’ll be the association acceptor, its up to us to specify what presentation contexts are supported rather than requested. Since we’ll be supporting the storage of CT Images encoded using the Explicit VR Little Endian transfer syntax we use add_supported_context() to add a corresponding presentation context.

10 ae.start_server(("127.0.0.1", 11112), block=True)

The call to start_server() starts our SCP listening for association requests on port 11112 in blocking mode.

Open a new terminal and start our SCP running:

$ python my_scp.py

And in another terminal, run storescu on this dataset:

$ python -m pynetdicom storescu 127.0.0.1 11112 CTImageStorage.dcm -v -cx

You should see the following output:

I: Requesting Association
I: Association Accepted
I: Sending file: CTImageStorage.dcm
I: Sending Store Request: MsgID 1, (CT)
I: Received Store Response (Status: 0xC211 - Failure)
I: Releasing Association

As you can see, storescu successfully associated with our SCP and sent a store request, but received a response containing a failure status 0xC211. For the storage service, statuses in the 0xC000 to 0xCFFF range fall under ‘cannot understand’, which is a generic failure status. In pynetdicom’s case this range of statuses is used to provide more specific error information; by checking the storage service class page in the documentation you can find the corresponding error to a given status.

In the case of 0xC211 the error is ‘Unhandled exception raised by the handler bound to evt.EVT_C_STORE’, so what does the output from the SCP look like?

...
I: Received Store Request
D: ========================== INCOMING DIMSE MESSAGE ==========================
D: Message Type                  : C-STORE RQ
D: Presentation Context ID       : 1
D: Message ID                    : 1
D: Affected SOP Class UID        : CT Image Storage
D: Affected SOP Instance UID     : 1.3.6.1.4.1.5962.1.1.1.1.1.20040119072730.12322
D: Data Set                      : Present
D: Priority                      : Low
D: ============================ END DIMSE MESSAGE =============================
E: Exception in the handler bound to 'evt.EVT_C_STORE'
E: No handler has been bound to 'evt.EVT_C_STORE'
Traceback (most recent call last):
  File ".../pynetdicom/service_class.py", line 1406, in SCP
    {'request' : req, 'context' : context.as_tuple}
  File ".../pynetdicom/events.py", line 212, in trigger
    return handlers[0](evt)
  File ".../pynetdicom/events.py", line 820, in _c_store_handler
    raise NotImplementedError("No handler has been bound to 'evt.EVT_C_STORE'")
NotImplementedError: No handler has been bound to 'evt.EVT_C_STORE'

As the log confirms, the failure was caused by not having a handler bound to the evt.EVT_C_STORE event, so we’d better fix that.

Events and handlers

pynetdicom uses an event-handler system to give access to data exchanged between AEs and as a way to customise the responses to service requests. Events come in two types: notification events, where the user is notified some event has occurred, and intervention events, where the user must intervene in some way. The idea is that you bind a callable function, the handler, to an event, and then when the event occurs the handler is called.

There are two areas where user intervention is required:

  1. Responding to extended negotiation items during association negotiation. You most likely will only have to worry about this if you’re using User Identity negotiation.

  2. Responding to a service request when acting as an SCP, such as when an SCU sends a store request to a Storage SCP…

So we need to bind a handler to evt.EVT_C_STORE to respond to incoming store requests.

 1 from pydicom.uid import ExplicitVRLittleEndian
 2
 3 from pynetdicom import AE, debug_logger, evt
 4 from pynetdicom.sop_class import CTImageStorage
 5
 6 debug_logger()
 7
 8 def handle_store(event):
 9     """Handle EVT_C_STORE events."""
10     return 0x0000
11
12 handlers = [(evt.EVT_C_STORE, handle_store)]
13
14 ae = AE()
15 ae.add_supported_context(CTImageStorage, ExplicitVRLittleEndian)
16 ae.start_server(("127.0.0.1", 11112), block=True, evt_handlers=handlers)

We import evt, which contains all the events, and add a function handle_store which will be our handler. All handlers must, at a minimum, take a single parameter event, which is an Event instance. If you look at the documentation for EVT_C_STORE handlers, you’ll see that they must return status as either an int or pydicom Dataset. This is the same (0000,0900) Status value you saw in the previous tutorial, only this will be the value sent by the SCP to the SCU. In our handle_store function we’re returning an 0x0000 status, which indicates that the storage operation was a success, but at this stage we’re not actually storing anything.

We bind our handler to the corresponding event by passing handlers to start_server() via the evt_handlers keyword parameter.

Interrupt the terminal running my_scp.py using CTRL+C and then restart it. This time when you run storescu you should see:

$ python -m pynetdicom storescu 127.0.0.1 11112 CTImageStorage.dcm -v -cx
I: Requesting Association
I: Association Accepted
I: Sending file: CTImageStorage.dcm
I: Sending Store Request: MsgID 1, (CT)
I: Received Store Response (Status: 0x0000 - Success)
I: Releasing Association

Customising the handler

Our Storage SCP is returning success statuses for all incoming requests even though we’re not actually storing anything, so our next step is to modify the handler to write the dataset to file. Before we do that we need to know a bit about the DICOM File Format.

The DICOM File Format

To be conformant to the DICOM Standard, when a dataset is written to file it should be written in the DICOM File Format, which consists of four main parts:

  1. A header containing an 128-byte preamble

  2. A 4-byte DICM prefix (0x4449434D in hex)

  3. The encoded File Meta Information, which is a small dataset containing meta information about the actual dataset

  4. The encoded dataset itself

The File Meta Information should contain at least the following elements:

Tag

Description

VR

(0002,0000)

File Meta Information Group Length

UL

(0002,0001)

File Meta Information Version

OB

(0002,0002)

Media Storage SOP Class UID

UI

(0002,0003)

Media Storage SOP Instance UID

UI

(0002,0010)

Transfer Syntax UID

UI

(0002,0012)

Implementation Class UID

UI

While a dataset can be stored without the header, prefix and file meta information, to do so is non-conformant to the DICOM Standard. It also becomes more difficult to correctly determine the encoding of the dataset, which is important when trying to read or transfer it. Fortunately, pynetdicom and pydicom make it very easy to store datasets correctly. Change your handler code to:

def handle_store(event):
    """Handle EVT_C_STORE events."""
    ds = event.dataset
    ds.file_meta = event.file_meta
    ds.save_as(ds.SOPInstanceUID, write_like_original=False)

    return 0x0000

Where event.dataset is the decoded dataset received from the SCU as a pydicom Dataset and event.file_meta is a Dataset containing conformant File Meta Information elements. We set the dataset’s file_meta attribute and then save it to a file named after its (0008,0018) SOP Instance UID, which is an identifier unique to each dataset that should be present. We pass write_like_original = False to Dataset.save_as() to ensure that the file is written in the DICOM File Format.

There are a couple of things to be aware of when dealing with Datasets:

  • Because pydicom uses a deferred-read system, the Dataset returned by event.dataset may raise an exception when any element is first accessed.

  • The dataset may not contain a particular element, even if it’s supposed to. Always assume a dataset is non-conformant, check to see if what you need is present and handle missing elements appropriately. The easiest way to check if an element is in a dataset is with the in operator: 'PatientName' in ds, but Dataset.get() or getattr() are also handy.

If you restart my_scp.py and re-send the dataset using storescu you should see that a file containing the transferred dataset named 1.3.6.1.4.1.5962.1.1.1.1.1.20040119072730.12322 has been written to the directory containing my_scp.py.

Expanding the supported data

Our Storage SCP is pretty limited at the moment, only handling one type of dataset (technically, one SOP Class) encoded in a particular way. We can change that to handle all the storage service’s SOP Classes by adding more supported presentation contexts:

 1 from pynetdicom import (
 2     AE, debug_logger, evt, AllStoragePresentationContexts,
 3     ALL_TRANSFER_SYNTAXES
 4 )
 5
 6 debug_logger()
 7
 8 def handle_store(event):
 9     """Handle EVT_C_STORE events."""
10     ds = event.dataset
11     ds.file_meta = event.file_meta
12     ds.save_as(ds.SOPInstanceUID, write_like_original=False)
13
14     return 0x0000
15
16 handlers = [(evt.EVT_C_STORE, handle_store)]
17
18 ae = AE()
19 storage_sop_classes = [
20     cx.abstract_syntax for cx in AllStoragePresentationContexts
21 ]
22 for uid in storage_sop_classes:
23     ae.add_supported_context(uid, ALL_TRANSFER_SYNTAXES)
24
25 ae.start_server(("127.0.0.1", 11112), block=True, evt_handlers=handlers)

AllStoragePresentationContexts is a list of pre-built presentation contexts, one for every SOP Class in the storage service. However, by default these contexts only support the uncompressed transfer syntaxes. To support both compressed and uncompressed transfer syntaxes we separate out the abstract syntaxes then use add_supported_context() with ALL_TRANSFER_SYNTAXES instead.

Optimising and passing extra arguments

If you don’t actually need a decoded Dataset object then it’s faster to write the encoded dataset directly to file; this skips having to decode and then re-encode the dataset at the cost of slightly more complex code:

 1 import uuid
 2 from pathlib import Path
 3
 4 from pynetdicom import (
 5     AE, debug_logger, evt, AllStoragePresentationContexts,
 6     ALL_TRANSFER_SYNTAXES
 7 )
 8
 9 debug_logger()
10
11 def handle_store(event, storage_dir):
12     """Handle EVT_C_STORE events."""
13     try:
14         os.makedirs(storage_dir, exist_ok=True)
15     except:
16         # Unable to create output dir, return failure status
17         return 0xC001
18
19     path = Path(storage_dir) / f"{uuid.uuid4()}"
20     with path.open('wb') as f:
21         # Write the preamble, prefix, file meta information elements
22         #   and the raw encoded dataset to `f`
23         f.write(event.encoded_dataset())
24
25     return 0x0000
26
27 handlers = [(evt.EVT_C_STORE, handle_store, ['out'])]
28
29 ae = AE()
30 storage_sop_classes = [
31     cx.abstract_syntax for cx in AllStoragePresentationContexts
32 ]
33 for uid in storage_sop_classes:
34     ae.add_supported_context(uid, ALL_TRANSFER_SYNTAXES)
35
36 ae.start_server(("127.0.0.1", 11112), block=True, evt_handlers=handlers)

We’ve modified the handler to use encoded_dataset(), which writes the preamble, prefix, file meta information elements and the raw dataset received in the C-STORE request directly to file. If you need separate access to just the encoded dataset then you can call encoded_dataset() with include_meta=False instead.

The second change we’ve made is to demonstrate how extra parameters can be passed to the handler by binding using a 3-tuple rather than a 2-tuple. The third item in the tuple should be a list of objects; each of the list’s items will be passed as a separate parameter. In our case the string 'out' will be passed to the handler as the storage_dir parameter.

You should also handle exceptions in your code gracefully by returning an appropriate status value. In this case, if we failed to create the output directory we return an 0xC001 status, indicating that the storage operation has failed. However, as you’ve already seen, any unhandled exceptions in the handler will automatically return an 0xC211 status, so you really only need to deal with the exceptions important to you.

If you restart my_scp.py, you should now be able to use storescu to send it any DICOM dataset supported by the storage service.

Next steps

That’s it for the basics of pynetdicom. You might want to read through the User Guide, or check out the SCP examples available in the documentation or the applications that come with pynetdicom.