diff --git a/.dockerignore b/.dockerignore index 4a75d19..3765042 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,10 @@ snapshots_knee_grading/* *.egg-info snapshots_release_kneel/* +snapshots_release/* deepknee-frontend/* deepknee-backend-broker/* +pacs-integration/* +pics/* logs/* .git/ \ No newline at end of file diff --git a/docker/Dockerfile.cpu b/docker/Dockerfile.cpu index 32a1335..182cb94 100644 --- a/docker/Dockerfile.cpu +++ b/docker/Dockerfile.cpu @@ -3,6 +3,8 @@ FROM miptmloulu/kneel:cpu MAINTAINER Aleksei Tiulpin, University of Oulu, Version 1.0 +RUN pip install pynetdicom + RUN mkdir -p /opt/pkg-deepknee/ COPY . /opt/pkg-deepknee/ RUN pip install /opt/pkg-deepknee/ \ No newline at end of file diff --git a/docker/Dockerfile.gpu b/docker/Dockerfile.gpu index 3d91069..5f2ec12 100644 --- a/docker/Dockerfile.gpu +++ b/docker/Dockerfile.gpu @@ -3,6 +3,8 @@ FROM miptmloulu/kneel:gpu MAINTAINER Aleksei Tiulpin, University of Oulu, Version 1.0 +RUN pip install pynetdicom + RUN mkdir -p /opt/pkg-deepknee/ COPY . /opt/pkg-deepknee/ RUN pip install /opt/pkg-deepknee/ \ No newline at end of file diff --git a/docker/docker-compose-cpu.yml b/docker/docker-compose-cpu.yml index 3d17e3c..712177e 100644 --- a/docker/docker-compose-cpu.yml +++ b/docker/docker-compose-cpu.yml @@ -47,7 +47,7 @@ services: container_name: orthanc-pacs ports: - "6000:4242" - - "8042:8042" + - "6001:8042" volumes: - type: bind source: ../pacs-integration/orthanc.json @@ -60,7 +60,7 @@ services: condition: service_started orthanc-pacs: condition: service_started - image: "miptmloulu/kneel:cpu" + image: "miptmloulu/deepknee:cpu" container_name: dicom-router volumes: - type: bind @@ -68,8 +68,12 @@ services: target: /opt/change_polling.py entrypoint: ["python", "-u", "/opt/change_polling.py", "--deepknee_addr", "http://deepknee", + "--deepknee_port", "5001", "--orthanc_addr", "http://orthanc-pacs", - "--orthanc_port", "8042"] + "--orthanc_http_port", "8042", + "--orthanc_dicom_port", "4242", + '--remote_pacs_addr', 'orthanc-pacs', + '--remote_pacs_port', '4242'] backend-broker: depends_on: - kneel diff --git a/docker/docker-compose-gpu.yml b/docker/docker-compose-gpu.yml index 978f9dd..3cf5f32 100644 --- a/docker/docker-compose-gpu.yml +++ b/docker/docker-compose-gpu.yml @@ -51,7 +51,7 @@ services: container_name: orthanc-pacs ports: - "6000:4242" - - "8042:8042" + - "6001:8042" volumes: - type: bind source: ../pacs-integration/orthanc.json @@ -64,7 +64,7 @@ services: condition: service_started orthanc-pacs: condition: service_started - image: "miptmloulu/kneel:cpu" + image: "miptmloulu/deepknee:cpu" container_name: dicom-router volumes: - type: bind @@ -72,8 +72,12 @@ services: target: /opt/change_polling.py entrypoint: ["python", "-u", "/opt/change_polling.py", "--deepknee_addr", "http://deepknee", + "--deepknee_port", "5001", "--orthanc_addr", "http://orthanc-pacs", - "--orthanc_port", "8042"] + "--orthanc_http_port", "8042", + "--orthanc_dicom_port", "4242", + '--remote_pacs_addr', 'orthanc-pacs', + '--remote_pacs_port', '4242'] backend-broker: depends_on: - kneel diff --git a/pacs-integration/change_polling.py b/pacs-integration/change_polling.py index 249f460..ae035c3 100644 --- a/pacs-integration/change_polling.py +++ b/pacs-integration/change_polling.py @@ -1,45 +1,120 @@ # -*- coding: utf-8 -*- -import queue import argparse -import requests import base64 +import logging +import queue import time +import requests +from pydicom import dcmread +from pydicom.filebase import DicomBytesIO +from pydicom.uid import ImplicitVRLittleEndian +from pydicom.uid import generate_uid +from pynetdicom import AE, VerificationPresentationContexts queue = queue.Queue() def ingestion_loop(): + logger.log(logging.INFO, 'Creating application entity...') + ae = AE(ae_title=b'DEEPKNEE') + ae.requested_contexts = VerificationPresentationContexts + ae.add_requested_context('1.2.840.10008.5.1.4.1.1.1.1', transfer_syntax=ImplicitVRLittleEndian) + current = 0 - response = requests.get(f'{args.orthanc_addr}:{args.orthanc_port}/changes?since={current}&limit=10', - auth=('deepknee', 'deepknee')) + base_url = f'{args.orthanc_addr}:{args.orthanc_http_port}' + response = requests.get(f'{base_url}/changes?since={current}&limit=10', auth=('deepknee', 'deepknee')) if response.status_code == 200: - print('Connection to orthanc is healthy.') + logger.log(logging.INFO, 'Connection to Orthanc via REST is healthy') + + # Orthanc addr must have http, but DICOM communicates via sockets + assoc = ae.associate(args.orthanc_addr.split('http://')[1], args.orthanc_dicom_port) + if assoc.is_established: + logger.log(logging.INFO, 'Connection to Orthanc via DICOM is healthy') + assoc.release() + + assoc = ae.associate(args.remote_pacs_addr, args.remote_pacs_port) + if assoc.is_established: + logger.log(logging.INFO, 'Connection to Remote PACS via DICOM is healthy') + assoc.release() while True: - response = requests.get(f'{args.orthanc_addr}:{args.orthanc_port}/changes?since={current}&limit=10', - auth=('deepknee', 'deepknee')) + response = requests.get(f'{base_url}/changes?since={current}&limit=10', auth=('deepknee', 'deepknee')) response = response.json() for change in response['Changes']: + # We must also filter by the imaged body part in the future if change['ChangeType'] == 'NewInstance': - response = requests.get(f'{args.orthanc_addr}:{args.orthanc_port}/instances/{change["ID"]}/file', - auth=('deepknee', 'deepknee')) - dicom_base64 = base64.b64encode(response.content).decode('ascii') - - response = requests.post(f'{args.deepknee_addr}:{args.deepknee_port}/deepknee/predict/bilateral', - json={'dicom': dicom_base64}) - if response.status_code != 200: - print('Request has failed!') + logger.log(logging.INFO, 'Identified new received instance in Orthanc. ' + 'Checking if it has been created by DeepKnee...') + # We should not analyze the instances if they are produced by DeepKnee + # Checking if it was verified by DeepKnee + resp_verifier = requests.get(f'{base_url}/instances/{change["ID"]}/content/0040-a027', + auth=('deepknee', 'deepknee')) + resp_verifier.encoding = 'utf-8' + resp_content = requests.get(f'{base_url}/instances/{change["ID"]}/content/0070-0080', + auth=('deepknee', 'deepknee')) + + resp_content.encoding = 'utf-8' + + if resp_verifier.text.strip("\x00 ") == 'UniOulu-DeepKnee' and \ + resp_content.text.strip("\x00 ") == 'DEEPKNEE-XRAY': + continue + + # Once we are sure that the instance is new, we need to go ahead with teh analysis + response = requests.get(f'{base_url}/instances/{change["ID"]}/file', auth=('deepknee', 'deepknee')) + + logger.log(logging.INFO, 'Instance has been retrieved from Orthanc') + dicom_raw_bytes = response.content + dcm = dcmread(DicomBytesIO(dicom_raw_bytes)) + + dicom_base64 = base64.b64encode(dicom_raw_bytes).decode('ascii') + logger.log(logging.INFO, 'Sending API request to DeepKnee core') + url = f'{args.deepknee_addr}:{args.deepknee_port}/deepknee/predict/bilateral' + response_deepknee = requests.post(url, json={'dicom': dicom_base64}) + + if response_deepknee.status_code != 200: + logger.log(logging.INFO, 'DeepKnee analysis has failed') else: - response_res = response.json() - print(response_res['R']['kl'], response_res['L']['kl']) - response = requests.delete(f'{args.orthanc_addr}:{args.orthanc_port}/instances/{change["ID"]}', - auth=('deepknee', 'deepknee')) - if response.status_code == 200: - print('Instance has been removed from the orthanc router') + logger.log(logging.INFO, 'Getting rid of the instance in Orthanc') + if args.orthanc_addr.split('http://')[1] != args.remote_pacs_addr and \ + args.orthanc_dicom_port != args.remote_pacs_port: + response = requests.delete(f'{base_url}/instances/{change["ID"]}', + auth=('deepknee', 'deepknee')) + if response.status_code == 200: + logger.log(logging.INFO, 'Instance has been removed from the Orthanc') + else: + logger.log(logging.INFO, 'Remote PACS is DeepKnee. The instance will not be removed.') + + logger.log(logging.INFO, 'DeepKnee has successfully analyzed the image. Routing...') + + # Report + deepknee_json = response_deepknee.json() + dcm.add_new([0x40, 0xa160], 'LO', 'KL_right: {}, KL_left: {}'.format(deepknee_json['R']['kl'], + deepknee_json['L']['kl'])) + # Verifier + dcm.add_new([0x40, 0xa027], 'LO', 'UniOulu-DeepKnee') + # Content label + dcm.add_new([0x70, 0x80], 'CS', 'DEEPKNEE-XRAY') + + dcm[0x08, 0x8].value = 'DERIVED' + # Instance_UUID + current_uuid = dcm[0x08, 0x18].value + dcm[0x08, 0x18].value = generate_uid(prefix='.'.join(current_uuid.split('.')[:-1])+'.') + # Series UUID + current_uuid = dcm[0x20, 0x0e].value + dcm[0x20, 0x0e].value = generate_uid(prefix='.'.join(current_uuid.split('.')[:-1])+'.') + logger.log(logging.INFO, 'Connecting to Orthanc over DICOM') + assoc = ae.associate(args.remote_pacs_addr, args.remote_pacs_port) + if assoc.is_established: + logger.log(logging.INFO, 'Association with Orthanc has been established. Routing..') + routing_status = assoc.send_c_store(dcm) + logger.log(logging.INFO, f'Routing finished. Status: {routing_status}') + assoc.release() + else: + # Here there should be a code to remove the change from the pacs + # Now nothing is done here pass - current += 1 time.sleep(1) @@ -48,10 +123,17 @@ def ingestion_loop(): parser = argparse.ArgumentParser() parser.add_argument('--deepknee_addr', default='http://127.0.0.1', help='DeepKnee address') parser.add_argument('--deepknee_port', default=5001, help='DeepKnee backend port') + parser.add_argument('--orthanc_addr', default='http://127.0.0.1', help='The host address that runs Orthanc') - parser.add_argument('--orthanc_port', type=int, default=6001, help='Orthanc REST API port') + parser.add_argument('--orthanc_http_port', type=int, default=6001, help='Orthanc REST API port') + parser.add_argument('--orthanc_dicom_port', type=int, default=6000, help='Orthanc DICOM port') + parser.add_argument('--remote_pacs_addr', default='http://127.0.0.1', help='Remote PACS IP addr') - parser.add_argument('--remote_pacs_port', type=int, default=8042, help='Remote PACS IP addr') + parser.add_argument('--remote_pacs_port', type=int, default=6000, help='Remote PACS port') args = parser.parse_args() + + logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO) + logger = logging.getLogger(f'dicom-router') + ingestion_loop()