├── .gitignore ├── orthanc ├── Dockerfile └── ai-orchestrator.py ├── docker-compose.yml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | obsolete/ 2 | venv/ 3 | -------------------------------------------------------------------------------- /orthanc/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM osimis/orthanc 2 | 3 | RUN pip3 install pydicom 4 | RUN mkdir /python 5 | 6 | COPY ai-orchestrator.py /python/ 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "0.1" 2 | services: 3 | 4 | orthanc: 5 | build: orthanc 6 | ports: ["8042:8042"] 7 | environment: 8 | ORTHANC__NAME: "ai-orchestrator" 9 | 10 | VERBOSE_ENABLED: "true" 11 | VERBOSE_STARTUP: "true" 12 | ORTHANC__PYTHON_SCRIPT: "/python/ai-orchestrator.py" 13 | ORTHANC__PYTHON_VERBOSE: "true" 14 | 15 | ORTHANC__AUTHENTICATION_ENABLED: "false" 16 | ORTHANC__HTTP_DESCRIBE_ERRORS: "true" 17 | ORTHANC__STABLE_AGE: 5 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Society for Imaging Informatics in Medicine 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Wokflow Orchestrator 2 | Orthanc Plugin to simulate an AI Workflow Orchestrator (maintaining workitem tickets/jobs 3 | for AI pipeline "workitems"). This is an example implementation for demonstration 4 | purposes only at this point. It may evolve into a more mature product over time, 5 | but right now, it is 100% work in progress. 6 | 7 | ## How does it work? (tl;dr) 8 | 1. Create "workitems" (ie job ticket) via one of two ways: 9 | 1. Uploading a study into Orthanc (triggers will automatically create a new workitem) 10 | 2. Manually create your own worklitem 11 | 2. Retrieve workitems via `/workitems/` for a full list or `workitem/xxx` for a specific one 12 | 3. Update the state of an existing work item from SCHEDULE to IN PROGRESS, COMPLETED or CANCELED. 13 | 14 | --- 15 | 16 | # Usage 17 | ## Starting up 18 | After cloning this repository, run the following command to start the Orthanc docker 19 | with this plugin running: 20 | 21 | `sudo docker-compose up --build` 22 | 23 | Afterwards, upload one or more studies into Orthanc. After **5 seconds** (the configured 24 | waiting period for a "stable" study), you can perform an HTTP GET to: 25 | 26 | `http://localhost:8042/ai-orchestrator/workitems` 27 | 28 | Which should return a list of workitems (same number as the number of studies you uploaded). 29 | The workitems are [DICOM JSON objects](https://www.dicomstandard.org/dicomweb/dicom-json-format). 30 | 31 | ## Workitem Manipulation 32 | 33 | ### Get an individual workitem 34 | Perform an HTTP GET to 35 | 36 | `http://localhost:8042/ai-orchestrator/workitems/xxx` 37 | 38 | Replace `xxx` with the workitem's ID that you got from the listing. 39 | 40 | ### Changing a workitem's state 41 | Perform an HTTP PUT to 42 | 43 | `http://localhost:8042/ai-orchestrator/workitems/xxx/state` 44 | 45 | Replace `xxx` with the workitem's ID that you got from the listing. The body of your 46 | request should be identical to work item you are trying adjust, except for the 47 | 00741000 attribute (aka Procedure Step State). Which should have one of the following 48 | values: 49 | * SCHEDULED 50 | * IN PROGRESS 51 | * CANCELED 52 | * COMPLETED 53 | 54 | ### Manually creating a workitem 55 | Perform an HTTP POST to 56 | 57 | `http://localhost:8042/ai-orchestrator/workitems/` 58 | 59 | The body of your request should be a DICOM JSON object, similar to what you would 60 | get when doing an HTTP GET to `/workitems/xxx` (i.e. you can take that as a template 61 | and modify it to fit your needs). 62 | 63 | --- 64 | # Credits 65 | * [Brad Genereaux](https://twitter.com/IntegratorBrad) - The mastermind behind the idea 66 | and developer of the first, Java-based, proof-of-concept 67 | * [Mohannad Hussain](https://github.com/mohannadhussain) - Developed this iteration of 68 | the AI Orchestrator as an Orthanc Python Plugin 69 | * [Society for Imaging Informatics in Medicine (SIIM)](https://siim.org) - For fostering 70 | innovation in Imaging IT/Informatics and sponsoring this project 71 | --- 72 | # Contribution 73 | * Code: Fork this repository, make your changes, then submit a poll request. 74 | * Other: Contact [Mohannad Hussain](https://github.com/mohannadhussain) -------------------------------------------------------------------------------- /orthanc/ai-orchestrator.py: -------------------------------------------------------------------------------- 1 | import orthanc,pprint,json,datetime,random,sys 2 | 3 | #TODO store things into the DB 4 | #TODO set a timer to automatically expire workitems after a given amount of time? 5 | 6 | ############################################################################### 7 | # GLOBALS 8 | ############################################################################### 9 | WORKITEMS = dict() 10 | DICOM_UID_ROOT = '2.7446.76257' # 2.SIIM.ROCKS 11 | STATE_SCHEDULED = "SCHEDULED" 12 | STATE_IN_PROGRESS = "IN PROGRESS" 13 | STATE_COMPLETED = "COMPLETED" 14 | STATE_CANCELED = "CANCELED" 15 | STATES = [STATE_SCHEDULED, STATE_CANCELED, STATE_COMPLETED, STATE_IN_PROGRESS] 16 | REQUIRED_TAGS = ['00080016','00080018','00081195','00081199','00100010','00100020','00100030','00100040','0020000D','00404041','0040A370','0040E025','00404005','00741000','00741200','00741204'] 17 | 18 | ############################################################################### 19 | # ORTHANC EVENT HOOKS 20 | ############################################################################### 21 | # List all work 22 | def listOrCreateWorkitems(output, uri, **request): 23 | if request['method'] == 'GET': 24 | #TODO Add support for filtering via a GET query 25 | output.AnswerBuffer(json.dumps(list(WORKITEMS.values())), 'application/dicom+json') 26 | 27 | if request['method'] == 'POST': 28 | try: 29 | workitem = json.loads(request['body']) 30 | missingAttributes = checkRequiredTagsPresent(workitem) 31 | 32 | # Check this new object has the bare-minimum tags/attributes 33 | if len(missingAttributes) > 0: 34 | msg = "Your new object is missing the following attribute(s): " + ", ".join(missingAttributes) 35 | output.SendHttpStatus(400, msg, len(msg)) 36 | return 37 | 38 | # Check this study is NOT already listed 39 | if checkStudyUIDExists(workitem['0020000D']['Value'][0]): 40 | msg = "This study is already listed as a workitem" 41 | output.SendHttpStatus(400, msg, len(msg)) 42 | return 43 | 44 | # If all successfull so far, store the item 45 | workitemId = getDicomIdentifier() 46 | WORKITEMS[workitemId] = workitem 47 | output.AnswerBuffer(json.dumps(WORKITEMS[workitemId]), 'application/dicom+json') 48 | 49 | except: 50 | errorInfo = sys.exc_info() 51 | msg = "Unknown error occurred, might be caused by invalid data input. Error message was: " + errorInfo[0] 52 | print("Unhandled error while attempting to create a workitem manually: " + errorInfo[0]) 53 | print(errorInfo[2]) 54 | output.SendHttpStatus(500, msg, len(msg)) 55 | return 56 | 57 | else: 58 | output.SendMethodNotAllowed('GET,POST') 59 | return 60 | 61 | orthanc.RegisterRestCallback('/ai-orchestrator/workitems', listOrCreateWorkitems) 62 | 63 | def getWorkitem(output, uri, **request): 64 | if request['method'] != 'GET': 65 | output.SendMethodNotAllowed('GET') 66 | return 67 | 68 | workitemId = request['groups'][0] 69 | if (workitemId not in WORKITEMS): 70 | msg = "No workitem found matching the ID supplied: " + workitemId 71 | output.SendHttpStatus(404, msg, len(msg)) 72 | return 73 | output.AnswerBuffer(json.dumps(WORKITEMS[workitemId]), 'application/dicom+json') 74 | 75 | orthanc.RegisterRestCallback('/ai-orchestrator/workitems/([0-9\\.]*)', getWorkitem) 76 | 77 | 78 | def changeWorkItemState(output, uri, **request): 79 | if request['method'] != 'PUT': 80 | output.SendMethodNotAllowed('PUT') 81 | return 82 | 83 | workitemId = request['groups'][0] 84 | if (workitemId not in WORKITEMS): 85 | msg = "No workitem found matching the ID supplied: " + workitemId 86 | output.SendHttpStatus(404, msg, len(msg)) 87 | return 88 | # Check the integrity of the new object 89 | new = json.loads(request['body']) 90 | old = WORKITEMS[workitemId] 91 | missingAttributes = checkRequiredTagsPresent(new) 92 | 93 | # Check this new object has the bare-minimum tags/attributes 94 | if len(missingAttributes) > 0: 95 | msg = "Your new object is missing the following attribute(s): " + ", ".join(missingAttributes) 96 | output.SendHttpStatus(400, msg, len(msg)) 97 | return 98 | 99 | # Next check, the status should be one of the known statuses 100 | if new['00741000']['Value'][0] not in STATES: 101 | msg = "Your object's ProcedureStepState (00741000) must be one of: " + ", ".join(STATES) 102 | output.SendHttpStatus(400, msg, len(msg)) 103 | return 104 | 105 | # Check the correct succession of states (scheduled -> in progress (OR canceled) -> completed OR canceled) 106 | oldState = old['00741000']['Value'][0] 107 | newState = new['00741000']['Value'][0] 108 | if oldState == STATE_SCHEDULED and (newState != STATE_IN_PROGRESS and newState != STATE_CANCELED): 109 | msg = "A workitem that is currently in SCHEDULED state can only move to IN PROGRESS or CANCELED" 110 | output.SendHttpStatus(400, msg, len(msg)) 111 | return 112 | if oldState == STATE_IN_PROGRESS and (newState != STATE_COMPLETED and newState != STATE_CANCELED): 113 | msg = "A workitem that is currently in IN PROGRESS state can only move to COMPLETED or CANCELED" 114 | output.SendHttpStatus(400, msg, len(msg)) 115 | return 116 | 117 | # If successful - store the new object 118 | WORKITEMS[workitemId] = new 119 | output.AnswerBuffer(json.dumps(WORKITEMS[workitemId]), 'application/dicom+json') 120 | 121 | orthanc.RegisterRestCallback('/ai-orchestrator/workitems/([0-9\\.]*)/state', changeWorkItemState) 122 | 123 | 124 | def OnChange(changeType, level, resourceId): 125 | if changeType == orthanc.ChangeType.ORTHANC_STARTED: # Server start-up 126 | print('AI-orchestrator plugin running!') 127 | 128 | if changeType == orthanc.ChangeType.STABLE_STUDY: # Study has stopped receiving news instances/series 129 | print('Stable study: %s' % resourceId) 130 | 131 | # Get more information about this study 132 | study = json.loads(orthanc.RestApiGet('/studies/' + resourceId)) 133 | studyUid = study['MainDicomTags']['StudyInstanceUID'] 134 | series = [] 135 | bodyPart = None 136 | modality = None 137 | 138 | # Check this study is NOT already listed 139 | if checkStudyUIDExists(studyUid): 140 | print("This study is already listed as a workitem") 141 | return 142 | 143 | # Loop through the series within this study, and get additional attributes for each 144 | for seriesId in study['Series']: 145 | data = json.loads(orthanc.RestApiGet('/series/' + seriesId + '/shared-tags')) 146 | series.append(data) 147 | if( bodyPart == None ): 148 | bodyPart = str(data['0018,0015']['Value']) 149 | modality = str(data['0008,0060']['Value']) 150 | 151 | # TODO improve this to be more dynamic 152 | pipline = bodyPart.lower() + '-' + modality.lower() + '-pipeline' 153 | 154 | # Create a workitem for this study 155 | workitemId = getDicomIdentifier() 156 | workitem = { 157 | '00080016': {'vr':'UI', 'Value': ['1.2.840.10008.5.1.4.34.6.1']}, # SOPClassUID 158 | '00080018': {'vr':'UI', 'Value': [workitemId]}, # SOPInstanceUID 159 | '00081195': {'vr':'UI', 'Value': ['']}, # UI [] TransactionUID 160 | '00081199': {'vr':'SQ', 'Value': [ 161 | # This repeats for every series within the target study, so it is handled in a loop below 162 | ]}, # ReferencedSOPSequence 163 | '00100010': {'vr':'PN', 'Value': [study['PatientMainDicomTags']['PatientName']]}, # PatientName 164 | '00100020': {'vr':'LO', 'Value': [study['PatientMainDicomTags']['PatientID']]}, # PatientID 165 | '00100030': {'vr':'DA', 'Value': [study['PatientMainDicomTags']['PatientBirthDate']]}, # PatientBirthDate 166 | '00100040': {'vr':'CS', 'Value': [study['PatientMainDicomTags']['PatientSex']]}, # PatientSex 167 | '0020000D': {'vr':'UI', 'Value': [studyUid]}, # Study Instance UID 168 | '00404041': {'vr':'CS', 'Value': ['READY']}, # InputReadinessState 169 | '0040A370': {'vr':'SQ', 'Value': [{ 170 | '00080050': {'vr': 'UI', 'Value': [study['MainDicomTags']['AccessionNumber']]}, #AccessionNumber 171 | '0020000D': {'vr': 'UI', 'Value': [studyUid]}, # Study Instance UID 172 | }]}, # SQ ReferencedRequestSequence 173 | '0040E025': {'vr':'SQ', 'Value': [{ 174 | '00081190': {'vr': 'LO', 'Value': ['http://localhost:8042/dicom-web/studies/' + studyUid]}, # Retrieve URL 175 | }]}, # WADORSRetrievalSequence 176 | '00404005': {'vr':'DT', 'Value': [getDicomDate()]}, # Scheduled Procedure Step Start DateTime 177 | '00741000': {'vr':'CS', 'Value': [STATE_SCHEDULED]}, # ProcedureStepState 178 | '00741200': {'vr':'CS', 'Value': ['MEDIUM']}, # ScheduledProcedureStepPriority 179 | '00741204': {'vr':'LO', 'Value': [pipline]}, # ProcedureStepLabel 180 | 181 | } 182 | for curSeries in series: 183 | workitem['00081199']['Value'].append({ 184 | '00081150': {'vr': 'UI', 'Value': [curSeries['0008,0016']['Value']]}, # ReferencedSOPClassUID 185 | '00081155': {'vr': 'UI', 'Value': [curSeries['0020,000e']['Value']]}, # ReferencedSeriesUID 186 | }) 187 | WORKITEMS[workitemId] = workitem 188 | #pprint.pprint(workitem) 189 | 190 | orthanc.RegisterOnChangeCallback(OnChange) 191 | 192 | 193 | ############################################################################### 194 | # UTILITY METHODS 195 | ############################################################################### 196 | # Create a random DICOM UID 197 | def getDicomIdentifier(): 198 | uid = DICOM_UID_ROOT 199 | parts = random.randint(3,6) 200 | i = 0 201 | while i < parts: 202 | uid += '.' + str(random.randint(1,999999999)) 203 | i += 1 204 | return uid 205 | 206 | # Return DICOM-formatted date. If not date provided, it defaults to now 207 | def getDicomDate(date=None): 208 | if( date == None ): 209 | date = datetime.datetime.now() 210 | return date.strftime('%Y%m%d%H%M%S') 211 | 212 | # Check a given study is NOT already listed 213 | def checkStudyUIDExists(studyUid): 214 | for workitem in WORKITEMS.values(): 215 | if studyUid == workitem['0020000D']['Value'][0]: 216 | return True 217 | return False 218 | 219 | # Check a new/update workitem object to have the bare-minimum attributes 220 | def checkRequiredTagsPresent(workitem): 221 | missingAttributes = [] 222 | for key in REQUIRED_TAGS: 223 | if key not in workitem: 224 | missingAttributes.append(key) 225 | return missingAttributes --------------------------------------------------------------------------------