Associations of Patient's Dicom Objects for Radiotherapy

Dear Sebastien,

First of All many Congratulations for your work!

I am new to Orthanc, I recently installed it on a virtual server with the objective to query/retreive dicom objects from a PACS Server. It all worked fine - I am able to query/retreive patient data from the Orthanc PACS.

However, the Dicom Plans/Data of a patient can be numerous and when retreived on the Orthanc Server, they come as separate dicom entities - they are not associated to each other, i.e we do not know which RT-STRUCT, RT-PLAN, RT-DOSE is under which CT.

Is there any Plugin which does the association of the Dicom Files in Orthanc? Or could we work somehow towards that direction given the current capabilities of Orthanc?

Thanks very much in advance!
Vassia

Hi Vassia,

Indeed, currently, Orthanc won’t link them together if they are stored in different studies.

However, can you check that, e.g, the RT-STRUCT really references the CT by checking this tag in the RT-STRUCT:

I think that the ReferencedSOPInstanceUID shall actually be the StudyInstanceUID of the CT. If this is true, we should then be able to display “linked studies” in the Orthanc Explorer 2 UI.

However, I just checked at one of our customer’s install and the RT-STRUCT is actually self-referencing itself - but there are a lot of stuffs that are badly configured there :frowning:

Alain

  • the RTDOSE instance references the RTPLAN
  • the RTPLAN references the RTSTRUCT
  • the RTSTRUCT references a set of slices

If you’re interested, here’s a script that allows you to display the chain of references.

How the script works:

You need pass it the ID (either Orthanc ID or SOP Instance UID) of an RTDOSE, RTPLAN or RTSTRUCT instance, and it will display the various referenced instances as well as the Orthanc URL (for ease of use : it can be clicked on in the terminal)

Beware that if you pass the RTPLAN, it will not LOOK for the RTDOSE that is associated (if applicable) and it will only display RTSTRUCT and slices.

Conversely. if you pass the RTSTRUCT ID, it will only display the referenced slices, not the RTPLAN.

This script requires click and requests

If you don’t know how to run Python scripts, just ask for help.

"""
This tool will dump the objects referenced from an RTDOSE, RTPLAN or RTSTRUCT file
"""
from dataclasses import dataclass
import re
import sys
import click
import requests
from requests.auth import HTTPBasicAuth

from ..common import click_pathlib

orthanc_id_to_tags = {}

def get_orthanc_uid(dicom_uid, cli_params):
    """Retrieve Orthanc UID from SOPInstanceUID."""
    response = requests.post(f"{cli_params.orthanc_root_url}/tools/lookup", auth=cli_params.auth_obj, data=dicom_uid)
    response.raise_for_status()
    results = response.json()
    if results:
        return results[0]["ID"]
    return None

def get_instance_tags(orthanc_id: str, cli_params):
    """Get the tags of an instance from Orthanc."""
    if orthanc_id in orthanc_id_to_tags:
        return orthanc_id_to_tags[orthanc_id]
    response = requests.get(f"{cli_params.orthanc_root_url}/instances/{orthanc_id}/simplified-tags", auth=cli_params.auth_obj)
    response.raise_for_status()
    tags = response.json()
    orthanc_id_to_tags[orthanc_id] = tags
    return tags

def identify_id_type(id_str):
    sop_instance_uid_pattern = r'^\d+(\.\d+)+$'
    orthanc_id_pattern = r'^[0-9a-fA-F\-]+$'
    
    if re.match(sop_instance_uid_pattern, id_str):
        return "SOP Instance UID"
    elif re.match(orthanc_id_pattern, id_str):
        return "Orthanc ID"
    else:
        return "Unknown"

def get_sop_instance_uid(orthanc_id, cli_params):
    return get_instance_tags(orthanc_id, cli_params).get("SOPInstanceUID", None)

def get_orthanc_id(any_id, cli_params):
    # find out if the id is a SOPInstanceUID or an Orthanc ID
    id_type = identify_id_type(any_id)

    # if it's a SOP Instance UID, we need to find the Orthanc ID
    if id_type == "SOP Instance UID":
        orthanc_id = get_orthanc_uid(any_id, cli_params)
        if orthanc_id is None:
            print(f"Could not find Orthanc ID for SOPInstanceUID {any_id}")
            return 1
    else:
        orthanc_id = any_id
    
    return orthanc_id

def dump_instance_info(type_str, orthanc_id, cli_params):
    sop_instance_uid = get_sop_instance_uid(orthanc_id, cli_params)
    orthanc_oe1_url = f"{cli_params.orthanc_root_url}/app/explorer.html#instance?uuid={orthanc_id}"

    print(f"{type_str}:")
    print(f"    SOPInstanceUID: {sop_instance_uid}")
    print(f"       Orthanc URL: {orthanc_oe1_url}")

def process_rtdose(dose_id, cli_params):
    dose_orthanc_id = get_orthanc_id(dose_id, cli_params)
    dump_instance_info("RTDOSE", dose_orthanc_id, cli_params)

    ref_rt_plan = get_instance_tags(dose_orthanc_id, cli_params).get("ReferencedRTPlanSequence", [])
    if ref_rt_plan:
        for rt_plan in ref_rt_plan:
            rt_plan_orthanc_id = rt_plan.get("ReferencedSOPInstanceUID")
            process_rtplan(rt_plan_orthanc_id, cli_params)

def process_rtplan(plan_id, cli_params):
    plan_orthanc_id = get_orthanc_id(plan_id, cli_params)
    dump_instance_info("RTPLAN", plan_orthanc_id, cli_params)
    
    ref_rt_struct = get_instance_tags(plan_orthanc_id, cli_params).get("ReferencedStructureSetSequence", [])
    if ref_rt_struct:
        for rt_struct in ref_rt_struct:
            rt_struct_orthanc_id = rt_struct.get("ReferencedSOPInstanceUID")
            process_rtstruct(rt_struct_orthanc_id, cli_params)

def process_rtstruct(struct_id, cli_params):
    struct_orthanc_id = get_orthanc_id(struct_id, cli_params)
    dump_instance_info("RTSTRUCT", struct_orthanc_id, cli_params)
    roi_contour_seq = get_instance_tags(struct_orthanc_id, cli_params).get("ROIContourSequence", [])
    if roi_contour_seq:
        if cli_params.dump_individual_instances:
            for roi_contour in roi_contour_seq:
                contour_seq = roi_contour.get("ContourSequence", [])
                for contour in contour_seq:
                    contour_image_seq = contour.get("ContourImageSequence", [])
                    if contour_image_seq:
                        for contour_image in contour_image_seq:
                            ref_instance_uid = contour_image.get("ReferencedSOPInstanceUID")
                            ref_orthanc_id = get_orthanc_id(ref_instance_uid, cli_params)
                            print(f"    Referenced Instance:")
                            orthanc_oe1_url = f"{cli_params.orthanc_root_url}/app/explorer.html#instance?uuid={ref_orthanc_id}"
                            print(f"        SOPInstanceUID: {ref_instance_uid}")
                            print(f"           Orthanc URL: {orthanc_oe1_url}")
        else:
            series_instance_uids = set()
            for roi_contour in roi_contour_seq:
                contour_seq = roi_contour.get("ContourSequence", [])
                for contour in contour_seq:
                    contour_image_seq = contour.get("ContourImageSequence", [])
                    # we simply collect the series
                    for contour_image in contour_image_seq:
                        ref_instance_uid = contour_image.get("ReferencedSOPInstanceUID")
                        ref_orthanc_id = get_orthanc_id(ref_instance_uid, cli_params)
                        # find the series
                        series_instance_uid = get_instance_tags(ref_orthanc_id, cli_params).get("SeriesInstanceUID", [])
                        series_instance_uids.add(series_instance_uid)
            for series_instance_uid in series_instance_uids:
                series_orthanc_id = get_orthanc_uid(series_instance_uid, cli_params)
                print(f"    Referenced Series:")
                orthanc_oe1_url = f"{cli_params.orthanc_root_url}/app/explorer.html#series?uuid={series_orthanc_id}"
                print(f"        SeriesInstanceUID: {series_instance_uid}")
                print(f"              Orthanc URL: {orthanc_oe1_url}")

@dataclass
class CliParams:
    orthanc_root_url: str
    auth_obj: HTTPBasicAuth
    dump_individual_instances: bool

def traverse_resources(initial_id, orthanc_root_url, auth_string, dump_individual_instances):
    # default Orthanc URL
    if orthanc_root_url is None:
        orthanc_root_url = "http://localhost:8042"
    
    # if orthanc_root_url has a trailing slash, remove it
    if orthanc_root_url[-1] == "/":
        orthanc_root_url = orthanc_root_url[:-1]

    # Parse the auth_string if provided
    auth = None
    if auth_string:
        user, password = auth_string.split(':')
        auth = HTTPBasicAuth(user, password)    

    cli_params = CliParams(orthanc_root_url, auth, dump_individual_instances)

    orthanc_id = get_orthanc_id(initial_id, cli_params)

    # now find out if it's an RTDOSE, RTPLAN or RTSTRUCT
    response = requests.get(f"{cli_params.orthanc_root_url}/instances/{orthanc_id}/simplified-tags", auth=auth)

    # add error handling
    modality = response.json().get("Modality", None)

    if modality is None:
        print(f"Could not find modality for Orthanc ID {orthanc_id}")
        return 1
    
    if modality == "RTDOSE":
        process_rtdose(orthanc_id, cli_params)
        
    elif modality == "RTPLAN":
        process_rtplan(orthanc_id, cli_params)
        
    elif modality == "RTSTRUCT":
        process_rtstruct(orthanc_id, cli_params)
        
    else:
        print(f"Modality {modality} not supported")
        return 1

@click.command()
@click.argument("initial_id", type=str)
@click.option(
    "-o",
    "--orthanc-url",
    "orthanc_url",
    required=False,
    type=str,
    help="The Orthanc root url, like https://my.orthanc.host:8442/. If not set, will default to localhost:8042",
)
@click.option(
    "-a",
    "--auth",
    "auth",
    required=False,
    type=str,
    help="The auth string like user:password",
)
@click.option(
    "-i",
    "--individual-instances",
    "dump_individual_instances",
    is_flag=True,
    help="If set, every referenced instance will be reported",
)
def click_main(initial_id, orthanc_url, auth, dump_individual_instances) -> int:
    """Picks an UID (SOPInstanceUID or Orthanc ID) to an RTDOSE, RTPLAN or RTSTRUCT, and display all the referenced resources"""

    traverse_resources(initial_id, orthanc_url, auth, dump_individual_instances)
    return 0

if __name__ == "__main__":
    click_main()

Hi Alain,

Thanks very much for the quick response !

I just checked the SOP Instance UID of the RT-Struct (0008,1155) & the corresponding Study Instance UID of the CT (0020, 000d) of an example Patient and they are the same!

I then loaded one of the studies on the Orthanc Explorer 2 and saw a list of the elements belonging to the study (various CTs, a few RT-STRUCTs, a few Plans & Doses), listed one below the other (not in a tree-like mode).

I tried viewing the study with the “Stone Viewer” and on the left side of the UI separate icons representing the CTs & the other objects (RT-Structs, Plans, Doses) are displayed.

I did not find a way to link the associated objects on the same/single image, i.e. a CT image with the respective Structure Contours on it & the Dose Matrix as well. Is there a way to do that ?

Thanks very much again !!
Vassia

Vassia,

If you’re willing to tinker at little and merely require displaying the combined CT, struct and dose, the Stone of Orthanc repository contains a sample MPR viewer that allows to display a set of CT slices, with an RTDOSE instance and an RTSTRUCT instance overlaid on top of it.

This is a sample viewer that does not provide much besides the ability to display all three axial, sagittal and coronal views on the MPR (reconstructed) volumes.

It builds fine with the latest version of Emscripten

Even if you’re not a developer, if you have a recent Linux install (or Windows with WSL2) machine handy, you can really give it a go.

Once it’s built (you may configure Orthanc to point to the viewer files in its Serve Folders plugin), you can build an URL like

http://localhost:8742/rt-viewer/index.html?ctseries=092452f7-bfcb93bd-c30a1d9d-63785bad-e20d589d&rtdose=8a6ba927-a1c4ef04-1f79655e-b836ef43-405c20ab&rtstruct=ec864de2-59f95bf0-b90cb6b2-f2c8909e-7f129dbe

(this Orthanc instance listens on port 8742)

And the viewer will display it like this:

(for some reason, some CT slices are missing in the TCIA series I have used for this test… I hope (and think) it’s not a bug in the viewer)

You can find the instructions here.

You’ll probably pass on this wonderful opportunity of wasting a lot of time trying to set up an experimental sample viewer, but I nevertheless wanted to mention its existence.

If want to give it a try and do not know how and where to start, just ask here.

HTH?

Dear Benjamin,

Thanks very much for the above messages - both the Python Script & the rt-viewer information!

I am actually very interested to test the rt-viewer. I will read the link you sent me carefully and install it. As far as I understand it provides the visualization of the linked data (i.e CT, RT-STRUCT, Plan, Dose) & at the moment this is important for us since we are aiming to retreive some old RT Plans from a PACS (Pre-irradiation plans).

Many thanks!
Vassia

1 Like

Hello,

For your interest, you can access access this viewer from our demo server, by clicking on the “Stone MPR RT Sample Viewer” of the following CT series.

Here is the direct link to the viewer.

Note that this is an old prototype, and that my research team at UCLouvain will resume the work on this topic in the following weeks/months.

Kind Regards,
SĂ©bastien-

Hi Sebastien,

Thanks very much for the link! I have the following questions:

  1. is there a way to see (for eg. within the RT-Viewer) which are the UIDs of the automatically linked CT Series, RTSTRUCT, RTPlan & RTDose objects?

  2. Why is there no Plan within the data?

  3. In the case in which we have various RTDose Objects (instead of 1) representing the different fields of the same RTDose Object, will RT-Viewer still be able to merge them into 1 & display their UIDs?

Thanks very much in advance!
Best Regards,
Vassia

Hello Vassia
SĂ©bastien is the definite authority on these matters but, unfortunately, as mentioned, this RT viewer is more like a proof of concept of the Stone of Orthanc capabilities rather than a polished and production-ready tool.

The way it works is that you simply call it through an URL that contains the IDs of the CT series and RTSTRUCT and RTDOSE instances.

It would definitely be possible to augment it with what you are asking for, but this requires some development to:

  • allow passing several RTDOSE instances and overlay them all
  • add some text overlay with the IDs
  • add a button in the Orthanc explorer that, for instance, could be enabled at the study level, that would automatically retrieve the Orthanc IDs and open this viewer URL.

Dear all,

Hi again and thanks for your support.

I would like to ask a short question: I am now transferring patients from a Server into the Orthanc Server using the following script:

def Query_Retrieve_from_MDD(transfer_patient):
modalities = orthanc.get_modalities()

modality = Modality(orthanc, 'sample')

# C-Echo
try:
    assert modality.echo()  # Test the connection
except Exception as e:
    print(e)
    pass

# Query (C-Find) on modality
data = {'Level': 'Study', 'Query': {'PatientID': transfer_patient, 'Modality': 'CT'}}
query_response = modality.query(data=data)

# Inspect the answer
answer = modality.get_query_answers()
print(answer)

# Retrieve (C-Move) results of query on a target modality (AET)
modality.move(query_response['ID'], {'TargetAet': 'ORTHANC'})

Even though the patient is transferred efficiently (after a few minutes), I get the following error in the python: “httpx.ReadTimeout: timed out”. Is this error related to the “DicomScuTimeout” : 10 parameter within the Orthanc configuration file? Do I need to increase the time? If yes, to what would you reccomend to set it to?

Thanks in advance,
Vassia Anagnostatou