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:
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.
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:
A header containing an 128-byte preamble
A 4-byte
DICM
prefix (0x4449434D
in hex)The encoded File Meta Information, which is a small dataset containing meta information about the actual dataset
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 byevent.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
, butDataset.get()
orgetattr()
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.