Python plugin for Orthanc

Overview

This plugin can be used to write Orthanc plugins using the Python programming language instead of the more complex C/C++ programming languages.

The Orthanc plugins interfaces are exposed in an orthanc module that you should include in your script through import orthanc. This module is only available when the script is running inside Orthanc. It is therefore not possible to execute your script outside of Orthanc. If you need to develop a complex plugin, it is advised to split it in 2

  • One part that is independent from the orthanc module that you can develop and test locally.

  • One small part that is using the orthanc module and that acts as glue-code between Orthanc and your business logic.

Python plugins have access to more features and a more consistent SDK than Lua scripts. The largest part of the Python API is automatically generated from the Orthanc plugin SDK in C using the Clang compiler front-end.

As of release 4.3 of the plugin, the coverage of the C SDK is about 85% (140 functions are automatically wrapped in Python out of a total of 165 functions from the Orthanc SDK 1.10.0). Starting with release 4.3, the code model that is used to generate the Python wrapper is shared with the Java wrapper.

Source code

Licensing

Pay attention to the fact that this plugin is licensed under the terms of the AGPL license.

This has an important consequence: If you distribute Orthanc to clients together with one Python script, or if you put an Orthanc server equipped with one Python script on a Web portal, you must disclose the source code of your Python script to the Orthanc community under the terms of the AGPL license.

We suggest you to put the source code of your Python scripts on the dedicated “OrthancContributed” repository on GitHub, and/or to send it to the Orthanc Users discussion forum.

Check out the FAQ about licensing for more context.

Usage

Docker

The most direct way of starting Orthanc together with the Python plugin is through Docker. Let’s create the file /tmp/hello.py that contains the following basic Python script:

print('Hello world!')

Let’s also create the file /tmp/orthanc.json that contains the following minimal configuration for Orthanc:

{
  "StorageDirectory" : "/var/lib/orthanc/db",
  "RemoteAccessAllowed" : true,
  "Plugins" : [
    "/usr/local/share/orthanc/plugins"
  ],
  "PythonScript" : "/etc/orthanc/hello.py"
}

Given these two files, Orthanc can be started as follows:

$ docker run -p 4242:4242 -p 8042:8042 --rm \
  -v /tmp/orthanc.json:/etc/orthanc/orthanc.json:ro \
  -v /tmp/hello.py:/etc/orthanc/hello.py:ro \
  jodogne/orthanc-python

You’ll see the following excerpt in the log, which indicates that the Python plugin is properly loaded:

W0331 15:48:12.990661 PluginsManager.cpp:269] Registering plugin 'python' (version mainline)
W0331 15:48:12.990691 PluginsManager.cpp:168] Python plugin is initializing
W0331 15:48:12.990743 PluginsManager.cpp:168] Using Python script "hello.py" from directory: /etc/orthanc
W0331 15:48:12.990819 PluginsManager.cpp:168] Program name: /usr/local/sbin/Orthanc
Hello world!

Here is a full example of a more complex setup using the orthancteam/orthanc images.

Microsoft Windows

Pre-compiled binaries for Microsoft Windows are now part of the Windows installers but not installed by default. They are also available here.

Beware that one version of the Python plugin can only be run against one version of the Python interpreter. This version is clearly indicated in the filename of the precompiled binaries.

Pay also attention to pick the right 32/64 bits version. If you are running Orthanc 64bits, install Python in 64bits and select the 64bits Python plugin too.

When you install Python on your Windows machine, make sure to install Python for All Users and select the Add Python to Path option.

Compiling from source

For GNU/Linux

The procedure to compile this plugin from source is similar to that for the core of Orthanc. The following commands should work for most UNIX-like distribution (including GNU/Linux):

$ mkdir Build
$ cd Build
$ cmake .. -DPYTHON_VERSION=3.7 -DSTATIC_BUILD=ON -DCMAKE_BUILD_TYPE=Release
$ make

Before running CMake, make sure that the Python interpreter and its associated development library are installed. On Ubuntu 18.04 LTS, you would for instance install packages libpython3.7-dev and python3.7.

The compilation will produce the shared library OrthancPython, that can be loaded by properly setting the Plugins configuration option of Orthanc.

Warning: The shared library is only compatible with the Python interpreter whose version corresponds to the value of the PYTHON_VERSION argument that was given to CMake.

Note for OS X: As indicated by Stephen Douglas Scotti, here is a sample invocation of CMake to force the version of Python to be used on OS X:

$ cmake .. -DPYTHON_VERSION=3.8 -DSTATIC_BUILD=ON -DCMAKE_BUILD_TYPE=Release \
        -DPYTHON_LIBRARY=/usr/local/Cellar/python@3.8/3.8.5/Frameworks/Python.framework/Versions/3.8/lib/libpython3.8.dylib \
        -DPYTHON_INCLUDE_DIR=/usr/local/Cellar/python@3.8/3.8.5/Frameworks/Python.framework/Versions/3.8/include/python3.8/

For Microsoft Windows

You are of course free to compile the plugin from sources. You’ll have to explicitly specify the path to your Python installation while invoking CMake. For instance:

C:\orthanc-python\Build> cmake .. -DPYTHON_VERSION=3.8 -DPYTHON_WINDOWS_ROOT=C:/Python38 \
                                  -DSTATIC_BUILD=ON -DCMAKE_BUILD_TYPE=Release -G "Visual Studio 15 2017"

Note about debug builds: Usually, building Python modules such as the Python plugin for Orthanc in debug mode (where _DEBUG is defined) leads to a module (.exe or .dll) that requires a debug build of Python, and debug versions of all the Python libraries. This is quite cumbersome, for it requires building Python on your own or downloading additional debug files.

Since using a debug build of Python is only necessary in very specific cases (such as the debugging of code at the boundary between Python and an extension), we have changed the default behavior to use the release Python library by default.

This means that you are able to build this plugin in debug mode with the standard Python distribution.

In case you need to use the Python debug libraries, you can instruct the build system to do so by setting the PYTHON_WINDOWS_USE_RELEASE_LIBS CMake option, that is ON by default, to OFF. The previous build example would then be, should you require a full debug build:

C:\orthanc-python\Build> cmake .. -DPYTHON_VERSION=3.8 -DPYTHON_WINDOWS_ROOT=C:/Python38 \
                                  -DSTATIC_BUILD=ON -DPYTHON_WINDOWS_USE_RELEASE_LIBS=OFF \
                                  -DCMAKE_BUILD_TYPE=Debug -G "Visual Studio 15 2017"

Please note that this CMake option only impacts debug builds under Windows, when using (any version of) the Microsoft Visual Studio compiler.

The precompiled binaries all use release (i.e. non-debug) versions of Python.

Configuration options

The two main configuration options that are available for this plugin are the following:

  • PythonScript indicates where the Python script is located. If this configuration option is not provided, the Python plugin is not started.

  • PythonVerbose is a Boolean value to make the Python interpreter verbose.

Consequently, a minimal configuration file for Orthanc could be:

{
  "Plugins" : [ "." ],
  "PythonScript" : "my-plugin.py",
  "PythonVerbose" : false
}

Starting with release 4.1 of the Python plugin, it is also possible to specify the configuration of the plugin in a dedicated Python section as follows:

{
  "Plugins" : [ "." ],
  "Python" : {
    "Path" : "my-plugin.py",  // Alias for the global "PythonScript" option
    "Verbose" : false,        // Alias for the global "PythonVerbose" option
    "DisplayMemoryUsage" : false,
    "AllowThreads" : false
  }
}

The option Python.DisplayMemoryUsage was introduced in release 4.1 of the plugin. If set to true, Orthanc will display the memory usage of the Python interpreter every second.

The option Python.AllowThreads was introduced in release 4.3 of the plugin. If set to true, the Python GIL (Global Interpreter Lock) is released during the calls to the native SDK. This allows multiple Python threads to simultaneously access the Orthanc core. Internally, this corresponds to the Py_BEGIN_ALLOW_THREADS construction of Python. However, this could possibly introduce concurrency issues: Make sure that your Python code is thread-safe before enabling this option! Indeed, different threads must not modify the Python objects that are used during a call to the SDK of Orthanc.

Warning

Never call your Python plugin orthanc.py! Otherwise, your plugin will conflict with the orthanc module that is installed at runtime.

Documentation and code completion

Starting with release 4.3 of the Python plugin for Orthanc, a Python interface (cf. PEP 484 - Type Hints) is available. By downloading the file orthanc.pyi from this location and putting it in the same folder as your Python script, your IDE will provide you code completion, as well as full documentation of the Orthanc SDK in the Python language. This file is notably known to work with Visual Studio Code and PyCharm.

Samples

Extending the REST API

Here is a basic Python script that registers two new routes in the REST API:

import orthanc
import pprint

def OnRest(output, uri, **request):
    pprint.pprint(request)
    print('Accessing uri: %s' % uri)
    output.AnswerBuffer('ok\n', 'text/plain')

orthanc.RegisterRestCallback('/(to)(t)o', OnRest)
orthanc.RegisterRestCallback('/tata', OnRest)

Here is the associated minimal configuration file for Orthanc (provided the Python script is saved as rest.py):

{
  "Plugins" : [ "." ],
  "PythonScript" : "rest.py",
  "PythonVerbose" : false
}

The route can then be accessed as:

$ curl http://localhost:8042/toto
ok

Overriding the core REST API

You may also use a python plugin to replace an existing REST API route:

import orthanc
import pprint
import json

# override the /instances POST route
def OnInstances(output, uri, **request):
    
    # for POST, replace the core API route by your own implementation
    if request['method'] == 'POST':
        orthanc.LogWarning('I have received an instance')
        # implement your own logic here
        output.AnswerBuffer(json.dumps({"MyAnswer": "Instance Ignored"}), "application/json")
    else:
        # for GET, simply forward the call to the core API.
        # Note that you should not use RestApiGetAfterPlugins here since
        # this would call the /instances route from this python plugin
        # and end up in an infinite loop.
        instances = orthanc.RestApiGet(uri)
        output.AnswerBuffer(instances, "application/json")

# reimplement a DICOMWeb /studies/../metadata route
def OnDicomWebStudiesMetadata(output, uri, **request):
    
    orthanc.LogWarning("My DICOMWEB /studies/../metadata")

    # since we are calling a route from a plugin, we must use RestApiGetAfterPlugins
    metadata = json.loads(orthanc.RestApiGetAfterPlugins(uri.replace('/my-dicom-web/', '/dicom-web/')))
    
    # transform the metadata (remove all tags from group 0009)
    for m in metadata:
        tags_to_remove = [k for k in m if k.startswith('0009')]
        for k in tags_to_remove:
            del m[k]

    output.AnswerBuffer(json.dumps(metadata), "application/json")


# override the /instances route from the core API
orthanc.RegisterRestCallback('/instances', OnInstances)

# The code below should be avoided since you actually don't know which route will finally be called:
# the one from the DICOMWeb plugin or the one from this python plugin
# orthanc.RegisterRestCallback('/dicom-web/studies/(.*)/metadata', OnDicomWebStudiesMetadata)

# Therefore, you should use another base route to differentiate it from the DICOMWeb plugin route
orthanc.RegisterRestCallback('/my-dicom-web/studies/(.*)/metadata', OnDicomWebStudiesMetadata)

When calling the REST API from a python plugin, you may use e.g. RestApiPost to call the native Orthanc REST API and must call RestApiPostAfterPlugin to call the REST API from plugins.

Note however, that, as of Orthanc 1.12.4, the Orthanc plugin SDK does not support multiple plugins implementing the same route. Orthanc will actually accept e.g a Python plugin that overrides a DICOMWeb route but it is impossible to tell which route will be called in the end since this depends on the registration order of the plugins that is not deterministic.

Listening to changes

This sample uploads a DICOM file as soon as Orthanc is started:

import orthanc

def OnChange(changeType, level, resource):
    if changeType == orthanc.ChangeType.ORTHANC_STARTED:
        print('Started')

        with open('/tmp/sample.dcm', 'rb') as f:
            orthanc.RestApiPost('/instances', f.read())

    elif changeType == orthanc.ChangeType.ORTHANC_STOPPED:
        print('Stopped')

    elif changeType == orthanc.ChangeType.NEW_INSTANCE:
        print('A new instance was uploaded: %s' % resource)

orthanc.RegisterOnChangeCallback(OnChange)

Warning

In releases <= 3.0 of the Python plugin, deadlocks might emerge if you call other core primitives of Orthanc (such as the REST API) in your callback function. This issue has been fixed in release 3.1.

As a temporary workaround against such deadlocks in releases <= 3.0, if you have to call other primitives of Orthanc, you should make these calls in a separate thread, passing the pending events to be processed through a message queue. Here is the template of a possible solution to postpone such deadlocks as much as possible by relying on the multithreading primitives of Python:

import orthanc
import threading

def OnChange(changeType, level, resource):
    # One can safely invoke the "orthanc" module in this function
    orthanc.LogWarning("Hello world")

def _OnChange(changeType, level, resource):
    # Invoke the actual "OnChange()" function in a separate thread
    t = threading.Timer(0, function = OnChange, args = (changeType, level, resource))
    t.start()

orthanc.RegisterOnChangeCallback(_OnChange)

Beware that this workaround is imperfect and deadlocks have been observed even if using it! Make sure to upgrade your plugin to solve this issue for good. Note that this temporary workaround is not needed in releases >= 3.1 of the plugin.

Accessing the content of a new instance

import orthanc
import json
import pprint

def OnStoredInstance(dicom, instanceId):
    print('Received instance %s of size %d (transfer syntax %s, SOP class UID %s)' % (
        instanceId, dicom.GetInstanceSize(),
        dicom.GetInstanceMetadata('TransferSyntax'),
        dicom.GetInstanceMetadata('SopClassUid')))

    # Print the origin information
    if dicom.GetInstanceOrigin() == orthanc.InstanceOrigin.DICOM_PROTOCOL:
        print('This instance was received through the DICOM protocol')
    elif dicom.GetInstanceOrigin() == orthanc.InstanceOrigin.REST_API:
        print('This instance was received through the REST API')

    # Print the DICOM tags
    pprint.pprint(json.loads(dicom.GetInstanceSimplifiedJson()))

orthanc.RegisterOnStoredInstanceCallback(OnStoredInstance)

Warning

Your callback function will be called synchronously with the core of Orthanc. This implies that deadlocks might emerge if you call other core primitives of Orthanc in your callback (such deadlocks are particular visible in the presence of other plugins or Lua scripts). It is thus strongly advised to avoid any call to the REST API of Orthanc in the callback. If you have to call other primitives of Orthanc, you should make these calls in a separate thread, passing the pending events to be processed through a message queue.

Calling pydicom

Here is a sample Python plugin that registers a REST callback to dump the content of the dataset of one given DICOM instance stored in Orthanc, using pydicom:

import io
import orthanc
import pydicom

def DecodeInstance(output, uri, **request):
    if request['method'] == 'GET':
        # Retrieve the instance ID from the regular expression (*)
        instanceId = request['groups'][0]
        # Get the content of the DICOM file
        f = orthanc.GetDicomForInstance(instanceId)
        # Parse it using pydicom
        dicom = pydicom.dcmread(io.BytesIO(f))
        # Return a string representation the dataset to the caller
        output.AnswerBuffer(str(dicom), 'text/plain')
    else:
        output.SendMethodNotAllowed('GET')

orthanc.RegisterRestCallback('/pydicom/(.*)', DecodeInstance)  # (*)

This callback can be called as follows:

$ curl http://localhost:8042/pydicom/19816330-cb02e1cf-df3a8fe8-bf510623-ccefe9f5

Auto-routing studies

Here is a sample Python plugin that routes any stable study to a modality named samples (as declared in the DicomModalities configuration option):

import orthanc

def OnChange(changeType, level, resourceId):
    if changeType == orthanc.ChangeType.STABLE_STUDY:
        print('Stable study: %s' % resourceId)
        orthanc.RestApiPost('/modalities/sample/store', resourceId)

orthanc.RegisterOnChangeCallback(OnChange)

Note that, if you want to use an orthanc plugin to transfer the study, you should use the RestApiPostAfterPlugins() method:

import orthanc

def OnChange(changeType, level, resourceId):
    if changeType == orthanc.ChangeType.STABLE_STUDY:
        print('Stable study: %s' % resourceId)
        orthanc.RestApiPostAfterPlugins('/dicom-web/servers/sample/store', resourceId)

orthanc.RegisterOnChangeCallback(OnChange)

Rendering a thumbnail using PIL/Pillow

from PIL import Image
import io
import orthanc

def DecodeInstance(output, uri, **request):
    if request['method'] == 'GET':
        # Retrieve the instance ID from the regular expression (*)
        instanceId = request['groups'][0]

        # Render the instance, then open it in Python using PIL/Pillow
        png = orthanc.RestApiGet('/instances/%s/rendered' % instanceId)
        image = Image.open(io.BytesIO(png))

        # Downsize the image as a 64x64 thumbnail
        image.thumbnail((64, 64), Image.ANTIALIAS)

        # Save the thumbnail as JPEG, then send the buffer to the caller
        jpeg = io.BytesIO()
        image.save(jpeg, format = "JPEG", quality = 80)
        jpeg.seek(0)
        output.AnswerBuffer(jpeg.read(), 'text/plain')

    else:
        output.SendMethodNotAllowed('GET')

orthanc.RegisterRestCallback('/pydicom/(.*)', DecodeInstance)  # (*)

Inspecting the available API

Thanks to Python’s introspection primitives, it is possible to inspect the API of the orthanc module in order to dump all the available features:

import inspect
import numbers
import orthanc

# Loop over the members of the "orthanc" module
for (name, obj) in inspect.getmembers(orthanc):
    if inspect.isroutine(obj):
        print('Function %s():\n  Documentation: %s\n' % (name, inspect.getdoc(obj)))

    elif inspect.isclass(obj):
        print('Class %s:\n  Documentation: %s' % (name, inspect.getdoc(obj)))

        # Loop over the members of the class
        for (subname, subobj) in inspect.getmembers(obj):
            if isinstance(subobj, numbers.Number):
                print('  - Enumeration value %s: %s' % (subname, subobj))
            elif (not subname.startswith('_') and
                  inspect.ismethoddescriptor(subobj)):
                print('  - Method %s(): %s' % (subname, inspect.getdoc(subobj)))
        print('')

Scheduling a task for periodic execution

The following Python script will periodically (every second) run the function Hello() thanks to the threading module:

import orthanc
import threading

TIMER = None

def Hello():
    global TIMER
    TIMER = None
    orthanc.LogWarning("In Hello()")
    # Do stuff...
    TIMER = threading.Timer(1, Hello)  # Re-schedule after 1 second
    TIMER.start()

def OnChange(changeType, level, resource):
    if changeType == orthanc.ChangeType.ORTHANC_STARTED:
        orthanc.LogWarning("Starting the scheduler")
        Hello()

    elif changeType == orthanc.ChangeType.ORTHANC_STOPPED:
        if TIMER != None:
            orthanc.LogWarning("Stopping the scheduler")
            TIMER.cancel()

orthanc.RegisterOnChangeCallback(OnChange)

Filtering and returning metadata

Besides the main DICOM tags, Orthanc associates some metadata to each resource it stores (this includes the date of last update, the transfer syntax, the remote AET…). People are often interested in getting such metadata while calling the /tools/find route in the REST API, or even in filtering this metadata the same way they look for DICOM tags.

This feature is not built in the core of Orthanc, as metadata is not indexed in the Orthanc database, contrarily to the main DICOM tags. Filtering metadata requires a linear search over all the matching resources, which induces a cost in the performance.

Nevertheless, here is a full sample Python script that overwrites the /tools/find route in order to give access to metadata:

import json
import orthanc
import re

# Get the path in the REST API to the given resource that was returned
# by a call to "/tools/find"
def GetPath(resource):
    if resource['Type'] == 'Patient':
        return '/patients/%s' % resource['ID']
    elif resource['Type'] == 'Study':
        return '/studies/%s' % resource['ID']
    elif resource['Type'] == 'Series':
        return '/series/%s' % resource['ID']
    elif resource['Type'] == 'Instance':
        return '/instances/%s' % resource['ID']
    else:
        raise Exception('Unknown resource level')

def FindWithMetadata(output, uri, **request):
    # The "/tools/find" route expects a POST method
    if request['method'] != 'POST':
        output.SendMethodNotAllowed('POST')
    else:
        # Parse the query provided by the user, and backup the "Expand" field
        query = json.loads(request['body'])

        if 'Expand' in query:
            originalExpand = query['Expand']
        else:
            originalExpand = False

        # Call the core "/tools/find" route
        query['Expand'] = True
        answers = orthanc.RestApiPost('/tools/find', json.dumps(query))

        # Loop over the matching resources
        filteredAnswers = []
        for answer in json.loads(answers):
            try:
                # Read the metadata that is associated with the resource
                metadata = json.loads(orthanc.RestApiGet('%s/metadata?expand' % GetPath(answer)))

                # Check whether the metadata matches the regular expressions
                # that were provided in the "Metadata" field of the user request
                isMetadataMatch = True
                if 'Metadata' in query:
                    for (name, pattern) in query['Metadata'].items():
                        if name in metadata:
                            value = metadata[name]
                        else:
                            value = ''

                        if re.match(pattern, value) == None:
                            isMetadataMatch = False
                            break

                # If all the metadata matches the provided regular
                # expressions, add the resource to the filtered answers
                if isMetadataMatch:
                    if originalExpand:
                        answer['Metadata'] = metadata
                        filteredAnswers.append(answer)
                    else:
                        filteredAnswers.append(answer['ID'])
            except:
                # The resource was deleted since the call to "/tools/find"
                pass

        # Return the filtered answers in the JSON format
        output.AnswerBuffer(json.dumps(filteredAnswers, indent = 3), 'application/json')

orthanc.RegisterRestCallback('/tools/find', FindWithMetadata)

Warning: In the sample above, the filtering of the metadata is done using Python’s library for regular expressions. It is evidently possible to adapt this script in order to use the DICOM conventions about attribute matching.

Here is a sample call to retrieve all the studies that were last updated in 2019 thanks to this Python script:

$ curl http://localhost:8042/tools/find -d '{"Level":"Study","Query":{},"Expand":true,"Metadata":{"LastUpdate":"^2019.*$"}}'

Implementing basic paging

As explained in the FAQ, the Orthanc Explorer interface is low-level, and is not adapted for end-users. One common need is to implement paging of studies, which calls for server-side sorting of studies. This can be done using the following sample Python plugin that registers a new route /sort-studies in the REST API of Orthanc:

import json
import orthanc

def GetStudyDate(study):
    if 'StudyDate' in study['MainDicomTags']:
        return study['MainDicomTags']['StudyDate']
    else:
        return ''

def SortStudiesByDate(output, uri, **request):
    if request['method'] == 'GET':
        # Retrieve all the studies
        studies = json.loads(orthanc.RestApiGet('/studies?expand'))

        # Sort the studies according to the "StudyDate" DICOM tag
        studies = sorted(studies, key = GetStudyDate)

        # Read the limit/offset arguments provided by the user
        offset = 0
        if 'offset' in request['get']:
            offset = int(request['get']['offset'])

        limit = 0
        if 'limit' in request['get']:
            limit = int(request['get']['limit'])

        # Truncate the list of studies
        if limit == 0:
            studies = studies[offset : ]
        else:
            studies = studies[offset : offset + limit]

        # Return the truncated list of studies
        output.AnswerBuffer(json.dumps(studies), 'application/json')
    else:
        output.SendMethodNotAllowed('GET')

orthanc.RegisterRestCallback('/sort-studies', SortStudiesByDate)

Here is a sample call to this new REST route, that could be issued by any JavaScript framework (the json_pp command-line pretty-prints a JSON file):

$ curl http://localhost:8042/sort-studies | json_pp

This route also implement paging (i.e. it can limit and offset the returned studies):

$ curl 'http://localhost:8042/sort-studies?offset=2&limit=2' | json_pp

Obviously, this basic sample can be improved in many ways. To improve performance, one could for instance cache the result of /studies?expand in memory by listening to changes in the list of studies (cf. orthanc.ChangeType.NEW_STUDY and orthanc.ChangeType.DELETED).

Creating a Microsoft Excel report

As Orthanc plugins have access to any installed Python module, it is very easy to implement a server-side plugin that generates a report in the Microsoft Excel .xls format. Here is a working example:

import io
import json
import orthanc
import xlwt

def CreateExcelReport(output, uri, **request):
    if request['method'] != 'GET' :
        output.SendMethodNotAllowed('GET')
    else:
        # Create an Excel writer
        excel = xlwt.Workbook()
        sheet = excel.add_sheet('Studies')

        # Loop over the studies stored in Orthanc
        row = 0
        studies = orthanc.RestApiGet('/studies?expand')
        for study in json.loads(studies):
            sheet.write(row, 0, study['PatientMainDicomTags'].get('PatientID'))
            sheet.write(row, 1, study['PatientMainDicomTags'].get('PatientName'))
            sheet.write(row, 2, study['MainDicomTags'].get('StudyDescription'))
            row += 1

        # Serialize the Excel workbook to bytes, and return it to the caller
        b = io.BytesIO()
        excel.save(b)
        output.AnswerBuffer(b.getvalue(), 'application/vnd.ms-excel')

orthanc.RegisterRestCallback('/report.xls', CreateExcelReport)

If opening the http://localhost:8042/report.xls URI, this Python will generate a workbook with one sheet that contains the list of studies, with the patient ID, the patient name and the study description.

Forbid or allow access to REST resources (authorization, new in 3.0)

The following Python script installs a callback that is triggered whenever the HTTP server of Orthanc is accessed:

import orthanc
import pprint

def Filter(uri, **request):
    print('User trying to access URI: %s' % uri)
    pprint.pprint(request)
    return True  # False to forbid access

orthanc.RegisterIncomingHttpRequestFilter(Filter)

If access is not granted, the Filter callback must return False. As a consequence, the HTTP status code would be set to 403 Forbidden. If access is granted, the Filter must return true. The request argument contains more information about the request (such as the HTTP headers, the IP address of the caller and the GET arguments).

Note that this is similar to the IncomingHttpRequestFilter() callback that is available in Lua scripts.

Thanks to Python, it is extremely easy to call remote Web services for authorization. Here is an example using the requests library:

import json
import orthanc
import requests

def Filter(uri, **request):
    body = {
        'uri' : uri,
        'headers' : request['headers']
    }
    r = requests.post('http://localhost:8000/authorize',
                      data = json.dumps(body))
    return r.json() ['granted']  # Must be a Boolean

orthanc.RegisterIncomingHttpRequestFilter(Filter)

This filter could be used together with the following Web service implemented using Node.js:

const http = require('http');

const requestListener = function(req, res) {
  let body = '';
  req.on('data', function(chunk) {
    body += chunk;
  });
  req.on('end', function() {
    console.log(JSON.parse(body));
    var answer = {
      'granted' : false  // Forbid access
    };
    res.writeHead(200);
    res.end(JSON.stringify(answer));
  });
}

http.createServer(requestListener).listen(8000);

Lookup DICOM dictionary (new in 3.2)

Python plugins can access the dictionary of the DICOM tags that are handled by Orthanc:

import json
import orthanc

# Create a dictionary mapping the numeric values in enumeration
# "orthanc.ValueRepresentation" to the name of the corresponding VR
VR_NAMES = {}
for name in dir(orthanc.ValueRepresentation):
    if not name.startswith('_'):
        value = getattr(orthanc.ValueRepresentation, name)
        VR_NAMES[value] = name

entry = orthanc.LookupDictionary('PatientID')

orthanc.LogWarning('Entry in the dictionary: %s' %
                   json.dumps(entry, indent = 4, sort_keys = True))

orthanc.LogWarning('Name of the value representation: %s' %
                   VR_NAMES[entry['ValueRepresentation']])

Note how Python introspection is used in order to map the values in enumeration orthanc.ValueRepresentation to a string description of the value representation. If started, the plugin above would output the following information in the Orthanc logs:

W0611 14:04:08.563957 PluginsManager.cpp:168] Entry in the dictionary: {
    "Element": 32,
    "Group": 16,
    "MaxMultiplicity": 1,
    "MinMultiplicity": 1,
    "ValueRepresentation": 11
}
W0611 14:04:08.563975 PluginsManager.cpp:168] Name of the value representation: LO

Creating DICOM instances (new in 3.2)

The following sample Python script will write on the disk a new DICOM instance including the traditional Lena sample image, and will decode the single frame of this DICOM instance:

import json
import orthanc

def OnChange(changeType, level, resource):
    if changeType == orthanc.ChangeType.ORTHANC_STARTED:
        tags = {
            'SOPClassUID' : '1.2.840.10008.5.1.4.1.1.1',
            'PatientID' : 'HELLO',
            'PatientName' : 'WORLD',
        }

        with open('Lena.png', 'rb') as f:
            img = orthanc.UncompressImage(f.read(), orthanc.ImageFormat.PNG)

        s = orthanc.CreateDicom(json.dumps(tags), img, orthanc.CreateDicomFlags.GENERATE_IDENTIFIERS)

        with open('/tmp/sample.dcm', 'wb') as f:
            f.write(s)

        dicom = orthanc.CreateDicomInstance(s)
        frame = dicom.GetInstanceDecodedFrame(0)
        print('Size of the frame: %dx%d' % (frame.GetImageWidth(), frame.GetImageHeight()))

orthanc.RegisterOnChangeCallback(OnChange)

Conversions between Orthanc and Python images (new in 3.2)

The Python method orthanc.Image.GetImageBuffer() returns a copy of the memory buffer of an image that is handled Orthanc. Conversely, the Python function orthanc.CreateImageFromBuffer() can be used to create an Orthanc image from a Python buffer. Taken together, these two functions can be used to do bidirectional conversions between Orthanc images and Python images.

Here is a full working example using PIL/Pillow that shows how to decode one frame of a DICOM instance using Orthanc, then to modify this image using PIL, and finally to upload the modified image as a new DICOM instance:

import json
import PIL.Image
import PIL.ImageDraw
import orthanc

URL = 'https://orthanc.uclouvain.be/hg/orthanc-tests/raw-file/Orthanc-1.11.0/Database/LenaTwiceWithFragments.dcm'
USERNAME = ''
PASSWORD = ''

def OnChange(changeType, level, resource):
    if changeType == orthanc.ChangeType.ORTHANC_STARTED:

        # (1) Download a sample DICOM instance and decode it
        orthanc.LogWarning('Downloading: %s' % URL)
        lena = orthanc.HttpGet(URL, USERNAME, PASSWORD)

        dicom = orthanc.CreateDicomInstance(lena)
        orthanc.LogWarning('Number of frames: %d' % dicom.GetInstanceFramesCount())

        # (2) Access the first frame of the instance as a PIL image
        frame = dicom.GetInstanceDecodedFrame(0)
        size = (frame.GetImageWidth(), frame.GetImageHeight())

        if frame.GetImagePixelFormat() == orthanc.PixelFormat.RGB24:
            mode = 'RGB'
        else:
            raise Exception('Unsupported pixel format')

        image = PIL.Image.frombuffer(mode, size, frame.GetImageBuffer(), 'raw', mode, 0, 1)

        # (3) Draw a red cross over the PIL image
        draw = PIL.ImageDraw.Draw(image)
        draw.line((0, 0) + image.size, fill=(255,0,0), width=10)
        draw.line((0, image.size[1], image.size[0], 0), fill=(255,0,0), width=10)

        # (4) Convert back the modified PIL image to an Orthanc image
        buf = image.tobytes()
        a = orthanc.CreateImageFromBuffer(frame.GetImagePixelFormat(), image.size[0], image.size[1],
                                          len(buf) // image.size[1], buf)

        # (5) Create and upload a new DICOM instance with the modified frame
        tags = {
            'SOPClassUID' : '1.2.840.10008.5.1.4.1.1.1',
            'PatientID' : 'HELLO',
            'PatientName' : 'WORLD',
        }

        s = orthanc.CreateDicom(json.dumps(tags), a, orthanc.CreateDicomFlags.GENERATE_IDENTIFIERS)
        orthanc.RestApiPost('/instances', s)
        orthanc.LogWarning('Image successfully modified and uploaded!')

orthanc.RegisterOnChangeCallback(OnChange)

Handling DICOM SCP requests (new in 3.2)

Starting with release 3.2 of the Python plugin, it is possible to replace the C-FIND SCP and C-MOVE SCP of Orthanc by a Python script. This feature can notably be used to create a custom DICOM proxy. Here is a minimal example:

import json
import orthanc
import pprint

def OnFind(answers, query, issuerAet, calledAet):
    print('Received incoming C-FIND request from %s:' % issuerAet)

    answer = {}
    for i in range(query.GetFindQuerySize()):
        print('  %s (%04x,%04x) = [%s]' % (query.GetFindQueryTagName(i),
                                           query.GetFindQueryTagGroup(i),
                                           query.GetFindQueryTagElement(i),
                                           query.GetFindQueryValue(i)))
        answer[query.GetFindQueryTagName(i)] = ('HELLO%d-%s' % (i, query.GetFindQueryValue(i)))

    answers.FindAddAnswer(orthanc.CreateDicom(
        json.dumps(answer), None, orthanc.CreateDicomFlags.NONE))

def OnMove(**request):
    orthanc.LogWarning('C-MOVE request to be handled in Python: %s' %
                       json.dumps(request, indent = 4, sort_keys = True))

    # To indicate a failure in the processing, one can raise an exception:
    #   raise Exception('Cannot handle C-MOVE')

orthanc.RegisterFindCallback(OnFind)
orthanc.RegisterMoveCallback(OnMove)

In this sample, the C-FIND SCP will send one single answer that reproduces the values provided by the SCU:

$ findscu localhost 4242 -S -k QueryRetrieveLevel=STUDY -k PatientName=TEST -k SeriesDescription=
I: ---------------------------
I: Find Response: 1 (Pending)
I:
I: # Dicom-Data-Set
I: # Used TransferSyntax: Little Endian Explicit
I: (0008,0005) CS [ISO_IR 100]                             #  10, 1 SpecificCharacterSet
I: (0008,0052) CS [HELLO0-STUDY]                           #  12, 1 QueryRetrieveLevel
I: (0008,103e) LO [HELLO1- ]                               #   8, 1 SeriesDescription
I: (0010,0010) PN [HELLO2-TEST ]                           #  12, 1 PatientName
I:

A more realistic Python script could for instance call the route /modalities/{...}/query in the REST API of Orthanc using orthanc.RestApiPost(), in order to query the content a remote modality through a second C-FIND SCU request (this time issued by Orthanc as a SCU).

The C-MOVE SCP can be invoked as follows:

$ movescu localhost 4242 -aem TARGET -aec SOURCE -aet MOVESCU -S -k QueryRetrieveLevel=IMAGE -k StudyInstanceUID=1.2.3.4

The C-MOVE request above would print the following information in the Orthanc logs:

W0610 18:30:36.840865 PluginsManager.cpp:168] C-MOVE request to be handled in Python: {
    "AccessionNumber": "",
    "Level": "INSTANCE",
    "OriginatorAET": "MOVESCU",
    "OriginatorID": 1,
    "PatientID": "",
    "SOPInstanceUID": "",
    "SeriesInstanceUID": "",
    "SourceAET": "SOURCE",
    "StudyInstanceUID": "1.2.3.4",
    "TargetAET": "TARGET"
}

It is now up to your Python callback to process the C-MOVE SCU request, for instance by calling the route /modalities/{...}/store in the REST API of Orthanc using orthanc.RestApiPost(). It is highly advised to create a Python thread to handle the request, in order to avoid blocking Orthanc as much as possible.

Note: In version 4.2, we have introduced a new version of the C-MOVE SCP handler that can be registered through orthanc.RegisterMoveCallback2(CreateMoveCallback, GetMoveSizeCallback, ApplyMoveCallback, FreeMoveCallback). This DICOM to DICOMWeb proxy sample project demonstrates how it can be used.

Handling worklist SCP requests (new in 3.2)

Starting with release 3.2 of the Python plugin, it is possible to answer worklist queries using a Python script. This is especially useful to easily create a bridge between Orthanc, HL7/FHIR messages and RIS systems. Indeed, Python provides many tools to handle HL7 such as python-hl7 library.

The following Python script reproduces features similar to the sample modality worklists plugin:

import json
import orthanc
import os

# Path to the directory containing the DICOM worklists
# https://orthanc.uclouvain.be/hg/orthanc/file/Orthanc-1.11.0/OrthancServer/Plugins/Samples/ModalityWorklists/WorklistsDatabase
WORKLIST_DIR = '/tmp/WorklistsDatabase'

def OnWorklist(answers, query, issuerAet, calledAet):
    print('Received incoming C-FIND worklist request from %s:' % issuerAet)

    # Get a memory buffer containing the DICOM instance
    dicom = query.WorklistGetDicomQuery()

    # Get the DICOM tags in the JSON format from the binary buffer
    jsonTags = json.loads(orthanc.DicomBufferToJson(
        dicom, orthanc.DicomToJsonFormat.SHORT, orthanc.DicomToJsonFlags.NONE, 0))

    orthanc.LogWarning('C-FIND worklist request to be handled in Python: %s' %
                       json.dumps(jsonTags, indent = 4, sort_keys = True))

    # Loop over the available DICOM worklists
    for path in os.listdir(WORKLIST_DIR):
        if os.path.splitext(path) [1] == '.wl':
            with open(os.path.join(WORKLIST_DIR, path), 'rb') as f:
                content = f.read()
                
                # Test whether the query matches the current worklist
                if query.WorklistIsMatch(content):
                    orthanc.LogWarning('Matching worklist: %s' % path)
                    answers.WorklistAddAnswer(query, content)

orthanc.RegisterWorklistCallback(OnWorklist)

Here is the result of this plugin on a sample call:

$ findscu -W -k "ScheduledProcedureStepSequence[0].Modality=MR" 127.0.0.1 4242
I: ---------------------------
I: Find Response: 1 (Pending)
I:
I: # Dicom-Data-Set
I: # Used TransferSyntax: Little Endian Explicit
I: (0008,0005) CS [ISO_IR 100]                             #  10, 1 SpecificCharacterSet
I: (0040,0100) SQ (Sequence with explicit length #=1)      #  18, 1 ScheduledProcedureStepSequence
I:   (fffe,e000) na (Item with explicit length #=1)          #  10, 1 Item
I:     (0008,0060) CS [MR]                                     #   2, 1 Modality
I:   (fffe,e00d) na (ItemDelimitationItem for re-encoding)   #   0, 0 ItemDelimitationItem
I: (fffe,e0dd) na (SequenceDelimitationItem for re-encod.) #   0, 0 SequenceDelimitationItem
I:
I: ---------------------------
I: Find Response: 2 (Pending)
I:
I: # Dicom-Data-Set
I: # Used TransferSyntax: Little Endian Explicit
I: (0008,0005) CS [ISO_IR 100]                             #  10, 1 SpecificCharacterSet
I: (0040,0100) SQ (Sequence with explicit length #=1)      #  18, 1 ScheduledProcedureStepSequence
I:   (fffe,e000) na (Item with explicit length #=1)          #  10, 1 Item
I:     (0008,0060) CS [MR]                                     #   2, 1 Modality
I:   (fffe,e00d) na (ItemDelimitationItem for re-encoding)   #   0, 0 ItemDelimitationItem
I: (fffe,e0dd) na (SequenceDelimitationItem for re-encod.) #   0, 0 SequenceDelimitationItem
I:

Replacing DICOM SCP of Orthanc by pynetdicom

Thanks to Python plugins, it is also possible to replace the built-in DICOM SCP of Orthanc by pynetdicom so as to customize how the DICOM protocol is handled. Firstly, in the configuration file, make sure to disable the Orthanc SCP by setting DicomServerEnabled to false:

{
  "Plugins" : [ "." ],
  "PythonScript" : "pynetdicom.py",
  "DicomServerEnabled" : false
}

Secondly, here a basic plugin illustrating how to start and stop the pynetdicom server, and handle incoming C-STORE requests:

import json
import orthanc
import pynetdicom

def HandleStore(event):
    orthanc.LogWarning('Handling C-STORE using pynetdicom')
    orthanc.RestApiPost('/instances', event.request.DataSet.getvalue())
    return 0x0000

ae = pynetdicom.AE()
ae.supported_contexts = pynetdicom.AllStoragePresentationContexts

SCP = None

def OnChange(changeType, level, resource):
    global SCP
    
    if changeType == orthanc.ChangeType.ORTHANC_STARTED:
        port = json.loads(orthanc.GetConfiguration()).get('DicomPort', 4242)
        
        SCP = ae.start_server(('', port), block = False, evt_handlers = [
            (pynetdicom.evt.EVT_C_STORE, HandleStore),
        ])
        
        orthanc.LogWarning('DICOM server using pynetdicom has started')

    elif changeType == orthanc.ChangeType.ORTHANC_STOPPED:
        orthanc.LogWarning('Stopping pynetdicom')
        SCP.shutdown()

orthanc.RegisterOnChangeCallback(OnChange)

As can be seen in this listing, whenever the pynetdicom receives an incoming C-STORE request, it makes a POST call to the URI /instances in the REST API of Orthanc in order to store the embedded DICOM dataset into Orthanc. Obviously, one can build more complex DICOM servers by handling more messages than C-STORE alone.

Catching exceptions

Starting with release 3.3 of the Python plugin, the plugin generates a Python exception derived from class orthanc.OrthancException if an error is encountered. This exception contains a tuple that provides the error code and its textual description.

In releases <= 3.2, the Python plugin raised the built-in exception ValueError.

Here is an example showing how to catch exceptions:

import orthanc

def OnChange(changeType, level, resource):
    if changeType == orthanc.ChangeType.ORTHANC_STARTED:
        try:
            print(orthanc.RestApiGet('/nope'))
        except ValueError as e:
            # Raised in releases <= 3.2 of the plugin (doesn't occur in releases >= 3.3)
            print(e)
        except orthanc.OrthancException as e:
            # Raised in releases >= 3.3 of the plugin (fails with releases <= 3.2)
            print(e)
            print(e.args[0])  # Error code of Orthanc (cf. "orthanc.ErrorCode" enumeration)
            print(e.args[1])  # Description of the error
            print(e.args[0] == orthanc.ErrorCode.UNKNOWN_RESOURCE)  # Returns "True"

orthanc.RegisterOnChangeCallback(OnChange)

Implementing a custom storage area (new in 3.3)

Starting with release 3.3 of the Python plugin, it is possible to replace the built-in storage area of Orthanc (that writes attachments onto the filesystem in the OrthancStorage folder by default), by providing 3 Python callbacks to the orthanc.RegisterStorageArea() function:

  • The first callback indicates how to create an attachment into the storage area.

  • The second callback indicates how to read an attachment from the storage area.

  • The third callback indicates how to remove an attachment out of the storage area.

This feature can be used to quickly and easily interface Orthanc with any object-based storage technology available in Python (such as Ceph or AWS S3-like tools). The performance will not be as good as a C/C++ native plugin (cf. the cloud storage, the PostgreSQL and the MySQL plugins), but it can be used for prototyping or for basic setups.

Here is a full, self-explaining sample:

import orthanc
import os

def GetPath(uuid, contentType):
    # Returns the path where to store the given attachment
    return 'attachment-%d-%s' % (contentType, uuid)

def OnCreate(uuid, contentType, data):
    with open(GetPath(uuid, contentType), 'wb') as f:
        f.write(data)

def OnRead(uuid, contentType):
    with open(GetPath(uuid, contentType), 'rb') as f:
        return f.read()

def OnRemove(uuid, contentType):
    os.remove(GetPath(uuid, contentType))

orthanc.RegisterStorageArea(OnCreate, OnRead, OnRemove)

The contentType can be used to apply a special treatment to some types of attachments (typically, DICOM instances). This parameter takes its values from the orthanc.ContentType enumeration.

Modifying received instances (new in 4.0)

Starting with release 4.0 of the Python plugin, it is possible to modify instances received by Orthanc before they are stored in the storage. This is usually easier to perform modification at this stage compared to using the /modify route once the instances has been stored.

from io import BytesIO

from pydicom import dcmread, dcmwrite
from pydicom.filebase import DicomFileLike

import orthanc

# from https://pydicom.github.io/pydicom/stable/auto_examples/memory_dataset.html
def write_dataset_to_bytes(dataset):
    with BytesIO() as buffer:
        memory_dataset = DicomFileLike(buffer)
        dcmwrite(memory_dataset, dataset)
        memory_dataset.seek(0)
        return memory_dataset.read()

def ReceivedInstanceCallback(receivedDicom, origin):
    if origin == orthanc.InstanceOrigin.REST_API:
        orthanc.LogWarning('DICOM instance received from the REST API')
    elif origin == orthanc.InstanceOrigin.DICOM_PROTOCOL:
        orthanc.LogWarning('DICOM instance received from the DICOM protocol')
    
    dataset = dcmread(BytesIO(receivedDicom))

    if dataset.PatientID.startswith('001-'):
        orthanc.LogWarning('Discard instance')
        return orthanc.ReceivedInstanceAction.DISCARD, None

    elif dataset.PatientID.startswith('002-'):
        orthanc.LogWarning('Store source instance as it is')
        return orthanc.ReceivedInstanceAction.KEEP_AS_IS, None

    else:
        orthanc.LogWarning('Modify the source instance')
        dataset.PatientName = str(dataset.PatientName).upper()
        dataset.PatientID = '002-' + dataset.PatientID
        dataset.InstitutionName = "MY INSTITUTION"
        return orthanc.ReceivedInstanceAction.MODIFY, write_dataset_to_bytes(dataset)

orthanc.RegisterReceivedInstanceCallback(ReceivedInstanceCallback)

Filtering incoming C-Store instances (new in 4.0)

Starting with release 4.0 of the Python plugin, it is possible to filter instances received from C-Store and return a specific error code to the sending modality.

This can be used, e.g, to implement a quota per modality or return an out-of-resources status if the Orthanc storage is almost full.

import orthanc

# this script accepts 3 instances from STORESCU and then, rejects the next ones

storeScuInstanceCounter = 0

def FilterIncomingCStoreInstance(receivedDicom):
    # The list of valid status codes for DIMSE C-STORE can be found:
    # https://dicom.nema.org/medical/Dicom/2021e/output/chtml/part04/sect_B.2.3.html

    global storeScuInstanceCounter

    origin = receivedDicom.GetInstanceOrigin()

    if origin == orthanc.InstanceOrigin.DICOM_PROTOCOL:  # should always be true in the CStore callback !

        remoteAet = receivedDicom.GetInstanceRemoteAet()
        
        if remoteAet == "STORESCU":
            storeScuInstanceCounter += 1

        if storeScuInstanceCounter > 3:
            # Non-zero return value: The DICOM instance is discarded
            return 0xA700
    
    return 0  # Success: Accept the DICOM instance

orthanc.RegisterIncomingCStoreInstanceFilter(FilterIncomingCStoreInstance)

Storage Commitment SCP (new in 4.1)

Starting with release 4.1 of the Python plugin, it is possible to provide your own implementation of the Storage Commitment.

This can be used, e.g, to check that you have backup the orthanc data in a long term storage.

import orthanc
import json

# this plugins provides the same behavior as the default Orthanc implementation

def StorageCommitmentScpCallback(jobId, transactionUid, sopClassUids, sopInstanceUids, remoteAet, calledAet):
    # At the beginning of a Storage Commitment operation, you can build a custom data structure
    # that will be provided as the "data" argument in the StorageCommitmentLookup
    return None


# Reference: `StorageCommitmentScpJob::Lookup` in `OrthancServer/Sources/ServerJobs/StorageCommitmentScpJob.cpp`
def StorageCommitmentLookup(sopClassUid, sopInstanceUid, data):
    success = False
    reason = orthanc.StorageCommitmentFailureReason.NO_SUCH_OBJECT_INSTANCE

    result = json.loads(orthanc.RestApiPost("/tools/lookup", sopInstanceUid))
    if len(result) == 1:
        tags = json.loads(orthanc.RestApiGet(result[0]["Path"] + "/simplified-tags"))
        if all(tag in tags for tag in ["SOPClassUID", "SOPInstanceUID"]) and \
            tags["SOPInstanceUID"] == sopInstanceUid:
            if tags["SOPClassUID"] == sopClassUid:
                success = True
                reason = orthanc.StorageCommitmentFailureReason.SUCCESS
            else:
                # Mismatch in the SOP class UID
                reason = orthanc.StorageCommitmentFailureReason.CLASS_INSTANCE_CONFLICT

    orthanc.LogInfo("  Storage commitment SCP job: " + ("Success" if success else "Failure") + \
                    " while looking for " + sopClassUid + " / " + sopInstanceUid)

    return reason

orthanc.RegisterStorageCommitmentScpCallback(StorageCommitmentScpCallback, StorageCommitmentLookup)

Extending the Orthanc Explorer interface

Here is a sample plugin that adds a new button to Orthanc Explorer that triggers a Python function:

import orthanc
import platform

def ExecutePython(output, uri, **request):
    s = 'Python version: %s' % platform.python_version()
    output.AnswerBuffer(s, 'text/plain')

orthanc.RegisterRestCallback('/execute-python', ExecutePython)

orthanc.ExtendOrthancExplorer('''
$('#lookup').live('pagebeforeshow', function() {
  $('#sample-python-button').remove();

  var b = $('<a>')
      .attr('id', 'sample-python-button')
      .attr('data-role', 'button')
      .attr('href', '#')
      .attr('data-icon', 'forward')
      .attr('data-theme', 'a')
      .text('Execute sample Python plugin')
      .button()
      .click(function(e) {
        $.get('../execute-python', function(answer) {
          alert(answer);
        });
      });

  b.insertAfter($('#lookup-result'));
});
''')

As can be seen in this sample:

  • The call to orthanc.ExtendOrthancExplorer() installs the button with JavaScript code that uses the jQuery Mobile framework (as of Orthanc 1.12.4, version 1.1.0 of jQuery Mobile is used in Orthanc Explorer).

  • If clicking on the button, a GET call to the REST API is made to ../execute-python. The prefix ../ stems from the fact that Orthanc Explorer is branched inside the app/ folder of the REST API of Orthanc.

  • The GET call to ../execute-python executes the ExecutePython() callback function that is written in Python.

Note that it is only possible to extend Orthanc Explorer 1, which is the built-in Web interface of Orthanc. It is not possible to extend the Orthanc Explorer 2 interface.

Generating a mosaic for a DICOM series

Thanks to the fact that Python plugins have access to PIL/Pillow, it is quite easy to generate a mosaic from a DICOM series:

../_images/mosaic.png

Here is the source code:

import PIL.Image
import io
import json
import math
import orthanc

def generate_mosaic(output, uri, **request):
    # Sort the slices of the series, using the REST API of Orthanc
    seriesId = request['groups'][0]
    slices = json.loads(orthanc.RestApiGet('/series/%s/ordered-slices' % seriesId)) ['Slices']

    # Retrieve the first slice of the mosaic
    firstSliceBytes = orthanc.RestApiGet(slices[0] + '/preview')
    firstSliceDecoded = PIL.Image.open(io.BytesIO(firstSliceBytes))

    # Compute the size of the mosaic
    sliceWidth, sliceHeight = firstSliceDecoded.size
    side = math.ceil(math.sqrt(len(slices)))

    # Create a PIL image to store the mosaic
    image = PIL.Image.new(mode = 'L', size = (side * sliceWidth, side * sliceHeight))

    # Loop over the instances of the series to populate the mosaic
    x = 0
    y = 0
    for i in range(len(slices)):
        sliceBytes = orthanc.RestApiGet(slices[i] + '/preview')
        sliceDecoded = PIL.Image.open(io.BytesIO(sliceBytes))

        image.paste(sliceDecoded, (x * sliceWidth, y * sliceHeight))

        x += 1
        if x == side:
            x = 0
            y += 1

    # Answer with the mosaic encoded as a PNG image
    with io.BytesIO() as png:
        image.save(png, format = 'PNG')
        png.seek(0)
        output.AnswerBuffer(png.read(), 'image/png')

orthanc.RegisterRestCallback('/series/(.*)/mosaic', generate_mosaic)

Performance and concurrency

Important: This section only applies to UNIX-like systems. The multiprocessing package will not work on Microsoft Windows as the latter OS has a different model for forking processes.

Using slave processes

Let us consider the following sample Python script that makes a CPU-intensive computation on a REST callback:

import math
import orthanc
import time

# CPU-intensive computation taking about 4 seconds
def SlowComputation():
    start = time.time()
    for i in range(1000):
        for j in range(30000):
            math.sqrt(float(j))
    end = time.time()
    duration = (end - start)
    return 'computation done in %.03f seconds\n' % duration

def OnRest(output, uri, **request):
    answer = SlowComputation()
    output.AnswerBuffer(answer, 'text/plain')

orthanc.RegisterRestCallback('/computation', OnRest)

Calling this REST route from the command-line returns the time that is needed to compute 30 million times a squared root on your CPU:

$ curl http://localhost:8042/computation
computation done in 4.208 seconds

Now, let us call this route three times concurrently (we use bash):

$ (curl http://localhost:8042/computation & curl http://localhost:8042/computation & curl http://localhost:8042/computation )
computation done in 11.262 seconds
computation done in 12.457 seconds
computation done in 13.360 seconds

As can be seen, the computation time has tripled. This means that the computations were not distributed across the available CPU cores. This might seem surprising, as Orthanc is a threaded server (in Orthanc, a pool of C++ threads serves concurrent requests).

The explanation is that the Python interpreter (CPython actually) is built on the top of a so-called Global Interpreter Lock (GIL). The GIL is basically a mutex that protects all the calls to the Python interpreter. If multiple C++ threads from Orthanc call a Python callback, only one can proceed at any given time. Note however that the GIL only applies to the Python script: The baseline REST API of Orthanc is not affected by the GIL.

The solution is to use the multiprocessing primitives of Python. The “master” Python interpreter that is initially started by the Orthanc plugin, can start several children processes, each of these processes running a separate Python interpreter. This allows to offload intensive computations from the “master” Python interpreter of Orthanc onto those “slave” interpreters. The multiprocessing library is actually quite straightforward to use:

import math
import multiprocessing
import orthanc
import signal
import time

# CPU-intensive computation taking about 4 seconds
# (same code as above)
def SlowComputation():
    start = time.time()
    for i in range(1000):
        for j in range(30000):
            math.sqrt(float(j))
    end = time.time()
    duration = (end - start)
    return 'computation done in %.03f seconds\n' % duration

# Ignore CTRL+C in the slave processes
def Initializer():
    signal.signal(signal.SIGINT, signal.SIG_IGN)

# Create a pool of 4 slave Python interpreters
POOL = multiprocessing.Pool(4, initializer = Initializer)

def OnRest(output, uri, **request):
    # Offload the call to "SlowComputation" onto one slave process.
    # The GIL is unlocked until the slave sends its answer back.
    answer = POOL.apply(SlowComputation)
    output.AnswerBuffer(answer, 'text/plain')

orthanc.RegisterRestCallback('/computation', OnRest)

Here is now the result of calling this route three times concurrently:

$ (curl http://localhost:8042/computation & curl http://localhost:8042/computation & curl http://localhost:8042/computation )
computation done in 4.211 seconds
computation done in 4.215 seconds
computation done in 4.225 seconds

As can be seen, the calls to the Python computation now fully run in parallel (the time is cut down from 12 seconds to 4 seconds, the same as for one isolated request).

Note also how the multiprocessing library allows to make a fine control over the computational resources that are available to the Python script: The number of “slave” interpreters can be easily changed in the constructor of the multiprocessing.Pool object, and are fully independent of the threads used by the Orthanc server.

Obviously, an in-depth discussion about the multiprocessing library is out of the scope of this document. There are many references available on Internet. Also, note that threading is not useful here, as Python multithreading is also limited by the GIL, and is more targeted at dealing with costly I/O operations or with the scheduling of commands.

Slave processes and the “orthanc” module

Very importantly, pay attention to the fact that only the “master” Python interpreter has access to the Orthanc SDK. The “slave” processes have no access to the orthanc module.

You must write your Python plugin so as that all the calls to orthanc are moved from the slaves process to the master process. For instance, here is how you would parse a DICOM file in a slave process:

import pydicom
import io

def OffloadedDicomParsing(dicom):
    # No access to the "orthanc" library here, as we are in the slave process
    dataset = pydicom.dcmread(io.BytesIO(dicom))
    return str(dataset)

def OnRest(output, uri, **request):
    # The call to "orthanc.RestApiGet()" is only possible in the master process
    dicom = orthanc.RestApiGet('/instances/19816330-cb02e1cf-df3a8fe8-bf510623-ccefe9f5/file')
    answer = POOL.apply(OffloadedDicomParsing, args = (dicom, ))
    output.AnswerBuffer(answer, 'text/plain')

Communication primitives such as multiprocessing.Queue are available to exchange messages from the “slave” Python interpreters to the “master” Python interpreter for more advanced scenarios.

NB: Starting with release 3.0 of the Python plugin, it is possible to call the REST API of Orthanc from a slave process in a more direct way. The function orthanc.GenerateRestApiAuthorizationToken() can be used to create an authorization token that provides full access to the REST API of Orthanc (without have to set credentials in your plugin). Any HTTP client library for Python, such as requests, can then be used to access the REST API of Orthanc. Here is a minimal example:

import json
import multiprocessing
import orthanc
import requests
import signal

TOKEN = orthanc.GenerateRestApiAuthorizationToken()

def SlaveProcess():
    r = requests.get('http://localhost:8042/instances',
                     headers = { 'Authorization' : TOKEN })
    return json.dumps(r.json())

def Initializer():
    signal.signal(signal.SIGINT, signal.SIG_IGN)

POOL = multiprocessing.Pool(4, initializer = Initializer)

def OnRest(output, uri, **request):
    answer = POOL.apply(SlaveProcess)
    output.AnswerBuffer(answer, 'text/plain')

orthanc.RegisterRestCallback('/computation', OnRest)

Working with virtual environments

By default, Orthanc uses the system-wide Python installation and therefore has access to the python modules that have been installed system-wide.

As of version 4.1 of the python plugin, there is no built-in support for working with a virtual environment. However, you may modify the python path at the very beginning of the script to instruct python to look for modules in your environment.

Example 1: On a Linux system, consider that you have created a virtual environment in /tmp/.venv and, you may just an environment variable to instruct the python interpreter to search for modules into your virtual env. E.g, in a Docker container, you may implement it this way:

FROM orthancteam/orthanc-pre-release:bookworm

# This example is using a virtual env that is not mandatory when using Docker containers
# but recommended since python 3.11 and Debian bookworm based images where you get a warning
# when installing system-wide packages.  RUN apt-get update && apt install -y python3-venv
RUN python3 -m venv /.venv

RUN /.venv/bin/pip install pydicom
ENV PYTHONPATH=/.venv/lib64/python3.11/site-packages/

RUN mkdir /python
COPY * /python/

Example 2: On a Linux system, consider that you have created a virtual environment in /tmp/.venv and you want to use only the modules that have been installed in this virtual environment. In this case, you may simply rewrite sys.path:

import orthanc
import sys

print("sys.path before modification: " + ", ".join(sys.path))

sys.path = ["/usr/lib/python3.8", "/usr/lib/python3.8/lib-dynload", "/tmp/.env/lib/python3.8/site-packages", "/tmp/.venv/lib64/python3.8/site-packages"]

print("sys.path after modification: " + ", ".join(sys.path))

import requests
# ....

Example 3: On a Windows system, consider that you have created a virtual environment in C:/tmp/.venv/. Instead of defining sys.path from scratch, it is possible to simply insert the venv-packages in the sys.path. By adding the venv to an early index (0), any package required by your code will be looked up in the venv first. And, as a consequence, if the package is not present, the system-wide installation of that package might be loaded:

import orthanc
import sys

print("sys.path before modification: " + ", ".join(sys.path))

sys.path.insert(0, "C:/tmp/.venv/Lib/site-packages")

print("sys.path after modification: " + ", ".join(sys.path))

import requests
# ....