├── tests ├── __init__.py ├── stimuli │ ├── sample.pdf │ ├── CT_small.dcm │ ├── CT_small.zip │ ├── preview.png │ ├── MR │ │ ├── Brain │ │ │ ├── 1 │ │ │ │ ├── IM0 │ │ │ │ └── IM1 │ │ │ └── 2 │ │ │ │ └── IM-0001-0001.dcm │ │ └── IM-0001-0002.dcm │ ├── orthanc-logo.png │ └── encapsulated_pdf_instance.dcm └── docker-setup │ ├── uninhibit.lua │ ├── orthanc-b │ ├── Dockerfile │ └── plugin.py │ ├── inhibit.lua │ └── docker-compose.yml ├── requirements-tests.txt ├── .gitignore ├── orthanc_api_client ├── retrieve_method.py ├── labels_constraint.py ├── logging.py ├── resources │ ├── __init__.py │ ├── jobs.py │ ├── series_list.py │ ├── instances.py │ ├── patients.py │ ├── studies.py │ └── resources.py ├── helpers_internal.py ├── downloaded_instance.py ├── __init__.py ├── change.py ├── capabilities.py ├── peers.py ├── transfers.py ├── instance.py ├── job.py ├── exceptions.py ├── tags.py ├── patient.py ├── series.py ├── dicomweb_servers.py ├── study.py ├── http_client.py ├── helpers.py ├── instances_set.py ├── modalities.py └── api_client.py ├── setup.cfg ├── pyproject.toml ├── MANIFEST.in ├── .github └── workflows │ └── release.yml ├── tox.ini ├── setup.py ├── README.md ├── LICENSE.txt └── release-notes.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-tests.txt: -------------------------------------------------------------------------------- 1 | requests 2 | pydicom>=2.3.1 3 | strenum -------------------------------------------------------------------------------- /tests/stimuli/sample.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orthanc-team/python-orthanc-api-client/HEAD/tests/stimuli/sample.pdf -------------------------------------------------------------------------------- /tests/stimuli/CT_small.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orthanc-team/python-orthanc-api-client/HEAD/tests/stimuli/CT_small.dcm -------------------------------------------------------------------------------- /tests/stimuli/CT_small.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orthanc-team/python-orthanc-api-client/HEAD/tests/stimuli/CT_small.zip -------------------------------------------------------------------------------- /tests/stimuli/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orthanc-team/python-orthanc-api-client/HEAD/tests/stimuli/preview.png -------------------------------------------------------------------------------- /tests/stimuli/MR/Brain/1/IM0: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orthanc-team/python-orthanc-api-client/HEAD/tests/stimuli/MR/Brain/1/IM0 -------------------------------------------------------------------------------- /tests/stimuli/MR/Brain/1/IM1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orthanc-team/python-orthanc-api-client/HEAD/tests/stimuli/MR/Brain/1/IM1 -------------------------------------------------------------------------------- /tests/docker-setup/uninhibit.lua: -------------------------------------------------------------------------------- 1 | function IncomingHttpRequestFilter(method, uri, ip, username, httpHeaders) 2 | return true 3 | end -------------------------------------------------------------------------------- /tests/stimuli/orthanc-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orthanc-team/python-orthanc-api-client/HEAD/tests/stimuli/orthanc-logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | .venv/ 4 | __pycache__/ 5 | *.egg-info/ 6 | build/ 7 | dist/ 8 | 9 | .env 10 | .tox 11 | .cache -------------------------------------------------------------------------------- /tests/stimuli/MR/IM-0001-0002.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orthanc-team/python-orthanc-api-client/HEAD/tests/stimuli/MR/IM-0001-0002.dcm -------------------------------------------------------------------------------- /tests/stimuli/MR/Brain/2/IM-0001-0001.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orthanc-team/python-orthanc-api-client/HEAD/tests/stimuli/MR/Brain/2/IM-0001-0001.dcm -------------------------------------------------------------------------------- /tests/stimuli/encapsulated_pdf_instance.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orthanc-team/python-orthanc-api-client/HEAD/tests/stimuli/encapsulated_pdf_instance.dcm -------------------------------------------------------------------------------- /orthanc_api_client/retrieve_method.py: -------------------------------------------------------------------------------- 1 | from strenum import StrEnum 2 | 3 | class RetrieveMethod(StrEnum): 4 | 5 | MOVE = 'C_MOVE' 6 | GET = 'C-GET' 7 | -------------------------------------------------------------------------------- /orthanc_api_client/labels_constraint.py: -------------------------------------------------------------------------------- 1 | from strenum import StrEnum 2 | 3 | class LabelsConstraint(StrEnum): 4 | 5 | ANY = 'Any' 6 | ALL = 'All' 7 | NONE = 'None' 8 | -------------------------------------------------------------------------------- /orthanc_api_client/logging.py: -------------------------------------------------------------------------------- 1 | from strenum import StrEnum 2 | 3 | 4 | class LogLevel(StrEnum): 5 | 6 | DEFAULT = 'default' 7 | VERBOSE = 'verbose' 8 | TRACE = 'trace' 9 | -------------------------------------------------------------------------------- /tests/docker-setup/orthanc-b/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM orthancteam/orthanc:25.8.2 2 | 3 | RUN pip install --break-system-packages pydicom==3.0.1 4 | 5 | RUN mkdir /scripts 6 | COPY plugin.py /scripts -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | # This includes the license file(s) in the wheel. 3 | # https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file 4 | license_files = LICENSE.txt -------------------------------------------------------------------------------- /tests/docker-setup/inhibit.lua: -------------------------------------------------------------------------------- 1 | function IncomingHttpRequestFilter(method, uri, ip, username, httpHeaders) 2 | if string.match(uri, '/instances/')then 3 | return false 4 | end 5 | return true 6 | end -------------------------------------------------------------------------------- /orthanc_api_client/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from .resources import Resources 2 | from .instances import Instances 3 | from .series_list import SeriesList 4 | from .studies import Studies 5 | from .patients import Patients 6 | from .jobs import Jobs -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # These are the assumed default build requirements from pip: 3 | # https://pip.pypa.io/en/stable/reference/pip/#pep-517-and-518-support 4 | requires = ["setuptools>=40.8.0", "wheel"] 5 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include pyproject.toml 2 | 3 | # Include the README 4 | include *.md 5 | 6 | # Include the license file 7 | include LICENSE.txt 8 | 9 | # Include setup.py 10 | include setup.py 11 | 12 | # Include the data files 13 | # recursive-include data * -------------------------------------------------------------------------------- /orthanc_api_client/helpers_internal.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | 4 | def write_dataset_to_bytes(dataset) -> bytes: 5 | # create a buffer 6 | with BytesIO() as buffer: 7 | dataset.save_as(buffer) 8 | return buffer.getvalue() 9 | -------------------------------------------------------------------------------- /orthanc_api_client/downloaded_instance.py: -------------------------------------------------------------------------------- 1 | class DownloadedInstance: 2 | """ 3 | A structure to store the info about a downloaded file: 4 | - its instance id 5 | - the path where it has been downloaded 6 | """ 7 | 8 | def __init__(self, instance_id, path): 9 | self.instance_id = instance_id 10 | self.path = path 11 | 12 | def __str__(self): 13 | return self.instance_id -------------------------------------------------------------------------------- /orthanc_api_client/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_client import OrthancApiClient 2 | from .exceptions import * 3 | from .helpers import * 4 | from .capabilities import Capabilities 5 | from .change import ChangeType, ResourceType 6 | from .study import Study, StudyInfo 7 | from .series import Series, SeriesInfo 8 | from .instance import Instance, InstanceInfo 9 | from .instances_set import InstancesSet 10 | from .job import Job, JobInfo, JobType, JobStatus 11 | from .http_client import HttpClient 12 | from .downloaded_instance import DownloadedInstance 13 | from .labels_constraint import LabelsConstraint 14 | from .logging import LogLevel 15 | from .retrieve_method import RetrieveMethod 16 | from .transfers import RemoteJob 17 | -------------------------------------------------------------------------------- /orthanc_api_client/change.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from strenum import StrEnum 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | 6 | class ResourceType(StrEnum): 7 | 8 | PATIENT = 'Patient' 9 | STUDY = 'Study' 10 | SERIES = 'Series' 11 | INSTANCE = 'Instance' 12 | 13 | class ChangeType(StrEnum): 14 | 15 | NEW_INSTANCE = 'NewInstance' 16 | NEW_SERIES = 'NewSeries' 17 | NEW_STUDY = 'NewStudy' 18 | NEW_PATIENT = 'NewPatient' 19 | STABLE_SERIES = 'StableSeries' 20 | STABLE_STUDY = 'StableStudy' 21 | STABLE_PATIENT = 'StablePatient' 22 | 23 | 24 | @dataclass 25 | class Change: 26 | 27 | resource_type: ResourceType 28 | change_type: ChangeType 29 | sequence_id: int 30 | resource_id: str 31 | timestamp: datetime 32 | 33 | def __str__(self): 34 | return f"[{self.sequence_id:09}] {self.timestamp}: {self.change_type} - {self.resource_id}" 35 | -------------------------------------------------------------------------------- /orthanc_api_client/capabilities.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | class Capabilities: 5 | 6 | 7 | def __init__(self, api_client: 'OrthancApiClient'): 8 | self._api_client = api_client 9 | self._system_json = None 10 | 11 | @property 12 | def _system_info(self): 13 | if not self._system_json: 14 | self._system_json = self._api_client.get_system() 15 | return self._system_json 16 | @property 17 | def has_extended_find(self) -> bool: 18 | return "Capabilities" in self._system_info and self._system_info["Capabilities"].get("HasExtendedFind") 19 | 20 | @property 21 | def has_extended_changes(self) -> bool: 22 | return "Capabilities" in self._system_info and self._system_info["Capabilities"].get("HasExtendedChanges") 23 | 24 | @property 25 | def has_label_support(self) -> bool: 26 | return self._system_info["HasLabels"] 27 | 28 | @property 29 | def has_revision_support(self) -> bool: 30 | return self._system_info["CheckRevisions"] 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags: 8 | - '*' 9 | 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build-and-publish: 14 | runs-on: ubuntu-latest 15 | 16 | # from https://docs.pypi.org/trusted-publishers/using-a-publisher/ 17 | # Specifying a GitHub environment is optional, but strongly encouraged 18 | environment: release 19 | permissions: 20 | # IMPORTANT: this permission is mandatory for trusted publishing 21 | id-token: write 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | 27 | - name: Set up Python 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: '3.x' 31 | 32 | - name: Install build dependencies 33 | run: python -m pip install -U setuptools wheel build 34 | 35 | - name: Build 36 | run: python -m build . 37 | 38 | - name: Run python tests 39 | run: | 40 | python setup.py egg_info 41 | pip install -r requirements-tests.txt 42 | python -m unittest tests/test_api_client.py 43 | 44 | - name: Publish 45 | uses: pypa/gh-action-pypi-publish@release/v1 46 | with: 47 | skip-existing: true -------------------------------------------------------------------------------- /orthanc_api_client/resources/jobs.py: -------------------------------------------------------------------------------- 1 | from .resources import Resources 2 | from ..tags import Tags 3 | from typing import List, Any 4 | from ..exceptions import * 5 | from ..job import Job 6 | 7 | 8 | class Jobs(Resources): 9 | 10 | def __init__(self, api_client: 'OrthancApiClient'): 11 | super().__init__(api_client=api_client, url_segment='jobs') 12 | 13 | def get(self, orthanc_id: str) -> Job: 14 | return Job(api_client=self._api_client, orthanc_id=orthanc_id) 15 | 16 | def _post_job_action(self, orthanc_id: str, action: str): 17 | self._api_client.post( 18 | endpoint=f"{self._url_segment}/{orthanc_id}/{action}", 19 | data="") 20 | 21 | def retry(self, orthanc_id: str): 22 | self._post_job_action(orthanc_id=orthanc_id, action='resubmit') 23 | 24 | def resubmit(self, orthanc_id: str): 25 | self._post_job_action(orthanc_id=orthanc_id, action='resubmit') 26 | 27 | def cancel(self, orthanc_id: str): 28 | self._post_job_action(orthanc_id=orthanc_id, action='cancel') 29 | 30 | def pause(self, orthanc_id: str): 31 | self._post_job_action(orthanc_id=orthanc_id, action='pause') 32 | 33 | def resume(self, orthanc_id: str): 34 | self._post_job_action(orthanc_id=orthanc_id, action='resume') 35 | -------------------------------------------------------------------------------- /orthanc_api_client/peers.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from .exceptions import * 4 | from .job import Job 5 | from .change import ResourceType 6 | 7 | 8 | class Peers: 9 | 10 | def __init__(self, api_client: 'OrthancApiClient'): 11 | self._api_client = api_client 12 | self._url_segment = 'peers' 13 | 14 | def send_async(self, target_peer: str, resources_ids: Union[List[str], str]) -> Job : 15 | 16 | if isinstance(resources_ids, str): 17 | resources_ids = [resources_ids] 18 | 19 | payload_resources_ids = [] 20 | for resource_id in resources_ids: 21 | payload_resources_ids.append(resource_id) 22 | 23 | r = self._api_client.post( 24 | endpoint=f"{self._url_segment}/{target_peer}/store", 25 | json= { 26 | "Resources": payload_resources_ids, 27 | "Synchronous": False 28 | }) 29 | 30 | return Job(api_client=self._api_client, orthanc_id=r.json()['ID']) 31 | 32 | 33 | # sends a resource synchronously 34 | def send(self, target_peer: str, resources_ids: Union[List[str], str]): 35 | 36 | if isinstance(resources_ids, str): 37 | resources_ids = [resources_ids] 38 | 39 | payload_resources_ids = [] 40 | for resource_id in resources_ids: 41 | payload_resources_ids.append(resource_id) 42 | 43 | self._api_client.post( 44 | endpoint=f"{self._url_segment}/{target_peer}/store", 45 | json= { 46 | "Resources": payload_resources_ids, 47 | "Synchronous": True 48 | }) 49 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # this file is *not* meant to cover or endorse the use of tox or pytest or 2 | # testing in general, 3 | # 4 | # It's meant to show the use of: 5 | # 6 | # - check-manifest 7 | # confirm items checked into vcs are in your sdist 8 | # - python setup.py check 9 | # confirm required package meta-data in setup.py 10 | # - readme_renderer (when using a ReStructuredText README) 11 | # confirms your long_description will render correctly on PyPI. 12 | # 13 | # and also to help confirm pull requests to this project. 14 | 15 | [tox] 16 | envlist = py{36,37,38,39,310} 17 | 18 | # Define the minimal tox version required to run; 19 | # if the host tox is less than this the tool with create an environment and 20 | # provision it with a tox that satisfies it under provision_tox_env. 21 | # At least this version is needed for PEP 517/518 support. 22 | minversion = 3.3.0 23 | 24 | # Activate isolated build environment. tox will use a virtual environment 25 | # to build a source distribution from the source tree. For build tools and 26 | # arguments use the pyproject.toml file as specified in PEP-517 and PEP-518. 27 | isolated_build = true 28 | 29 | [testenv] 30 | deps = 31 | check-manifest >= 0.42 32 | # If your project uses README.rst, uncomment the following: 33 | # readme_renderer 34 | flake8 35 | pytest 36 | commands = 37 | check-manifest --ignore 'tox.ini,tests/**' 38 | # This repository uses a Markdown long_description, so the -r flag to 39 | # `setup.py check` is not needed. If your project contains a README.rst, 40 | # use `python setup.py check -m -r -s` instead. 41 | python setup.py check -m -s 42 | flake8 . 43 | py.test tests {posargs} 44 | 45 | [flake8] 46 | exclude = .tox,*.egg,build,data 47 | select = E,W,F -------------------------------------------------------------------------------- /tests/docker-setup/orthanc-b/plugin.py: -------------------------------------------------------------------------------- 1 | import orthanc 2 | import json 3 | from pydicom.uid import generate_uid 4 | from typing import List 5 | import pydicom 6 | 7 | from io import BytesIO 8 | 9 | 10 | TOKEN = orthanc.GenerateRestApiAuthorizationToken() 11 | 12 | 13 | def write_dataset_to_bytes(dataset): 14 | # create a buffer 15 | with BytesIO() as buffer: 16 | dataset.save_as(buffer) 17 | return buffer.getvalue() 18 | 19 | 20 | def get_api_token(output, uri, **request): 21 | # unsafe !!!! don't expose the token on a Rest API !!!! (don't run this experiment at home !) 22 | output.AnswerBuffer(TOKEN, 'text/plain') 23 | 24 | 25 | 26 | def worklist_callback(answers, query, issuerAet, calledAet): 27 | global worklist_handler 28 | 29 | orthanc.LogInfo(f'Received incoming C-FIND worklist request from {issuerAet} - calledAet={calledAet}') 30 | 31 | # Get a memory buffer containing the DICOM instance 32 | dicom = query.WorklistGetDicomQuery() 33 | json_tags = json.loads(orthanc.DicomBufferToJson(dicom, orthanc.DicomToJsonFormat.FULL, orthanc.DicomToJsonFlags.NONE, 0)) 34 | 35 | dataset = pydicom.dataset.Dataset() 36 | 37 | file_meta = pydicom.dataset.FileMetaDataset() 38 | 39 | # Set the FileMeta attributes 40 | file_meta.MediaStorageSOPClassUID = '1.2.840.10008.5.1.4.31' 41 | file_meta.MediaStorageSOPInstanceUID = generate_uid() 42 | file_meta.ImplementationClassUID = '1.2.840.10008.5.1.4.1.1.2' 43 | file_meta.TransferSyntaxUID = pydicom.uid.ExplicitVRLittleEndian 44 | dataset.file_meta = file_meta 45 | 46 | # dataset.TransferSyntaxUID = pydicom.uid.ExplicitVRLittleEndian 47 | dataset.AccessionNumber = 'A123456' 48 | dataset.StudyInstanceUID = '1.2.3.4' 49 | dataset.PatientName = 'PatientName' 50 | dataset.PatientID = 'PatientID' 51 | dataset.PatientBirthDate = '20220208' 52 | dataset.PatientSex = 'O' 53 | # dataset.is_little_endian = True 54 | # dataset.is_implicit_VR = False 55 | # dataset.file_meta.TransferSyntaxUID = pydicom.uid.ExplicitVRLittleEndian 56 | dataset_bytes = write_dataset_to_bytes(dataset) 57 | 58 | answers.WorklistAddAnswer(query, dataset_bytes) 59 | 60 | 61 | orthanc.RegisterWorklistCallback(worklist_callback) 62 | orthanc.RegisterRestCallback('/api-token', get_api_token) 63 | -------------------------------------------------------------------------------- /orthanc_api_client/transfers.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from .exceptions import * 4 | from .job import Job 5 | from .change import ResourceType 6 | 7 | 8 | class RemoteJob: 9 | def __init__(self, remote_job_id, remote_url): 10 | self.remote_job_id = remote_job_id 11 | self.remote_url = remote_url 12 | 13 | 14 | class Transfers: 15 | 16 | def __init__(self, api_client: 'OrthancApiClient'): 17 | self._api_client = api_client 18 | self._url_segment = 'transfers' 19 | 20 | def send_async(self, target_peer: str, resources_ids: Union[List[str], str], resource_type: ResourceType, compress: bool = True) -> Union[Job, RemoteJob]: 21 | 22 | if isinstance(resources_ids, str): 23 | resources_ids = [resources_ids] 24 | 25 | payload_resources_ids = [] 26 | for resource_id in resources_ids: 27 | payload_resources_ids.append({ 28 | "Level": resource_type, 29 | "ID": resource_id 30 | }) 31 | 32 | r = self._api_client.post( 33 | endpoint=f"{self._url_segment}/send", 34 | json={ 35 | "Resources": payload_resources_ids, 36 | "Compression": "gzip" if compress else "none", 37 | "Peer": target_peer 38 | }) 39 | if r.status_code == 200 and "RemoteJob" in r.json(): 40 | return RemoteJob(remote_job_id=r.json()["RemoteJob"], remote_url=r.json()["URL"]) 41 | elif r.status_code == 200 and "ID" in r.json(): 42 | return Job(api_client=self._api_client, orthanc_id=r.json()['ID']) 43 | else: 44 | raise HttpError(http_status_code=r.status_code, msg="Error while sending through transfers plugin", url=r.url, request_response=r) 45 | 46 | 47 | def send(self, target_peer: str, resources_ids: Union[List[str], str], resource_type: ResourceType, compress: bool = True, polling_interval: float = 0.2): 48 | 49 | job = self.send_async( 50 | target_peer=target_peer, 51 | resources_ids=resources_ids, 52 | resource_type=resource_type, 53 | compress=compress 54 | ) 55 | 56 | if isinstance(job, RemoteJob): 57 | raise OrthancApiException(msg="Pull jobs are not supported in send(), use send_async()") 58 | 59 | job.wait_completed(polling_interval=polling_interval) 60 | -------------------------------------------------------------------------------- /orthanc_api_client/instance.py: -------------------------------------------------------------------------------- 1 | from .tags import SimplifiedTags 2 | from typing import List, Optional 3 | 4 | 5 | class InstanceInfo: 6 | 7 | def __init__(self, json_instance: object): 8 | self.main_dicom_tags = SimplifiedTags(json_instance.get('MainDicomTags')) 9 | self.orthanc_id = json_instance.get('ID') 10 | self.dicom_id = self.main_dicom_tags.get('SOPInstanceUID') 11 | self.series_orthanc_id = json_instance.get('ParentSeries') 12 | self.labels = json_instance.get('Labels') or [] 13 | self.metadata = json_instance.get('Metadata') or None 14 | 15 | 16 | class Instance: 17 | 18 | 19 | def __init__(self, api_client: 'OrthancApiClient', orthanc_id: str): 20 | self._api_client = api_client 21 | self.orthanc_id = orthanc_id 22 | self._info: Optional[InstanceInfo] = None 23 | self._series: Optional['Series'] = None 24 | self._tags: Optional[SimplifiedTags] = None 25 | 26 | @staticmethod 27 | def from_json(api_client, json_instance: object): 28 | instance = Instance(api_client, json_instance.get('ID')) 29 | instance._info = InstanceInfo(json_instance) 30 | return instance 31 | 32 | @property 33 | def info(self): # lazy loading of main dicom tags .... 34 | if self._info is None: 35 | self._load_info() 36 | return self._info 37 | 38 | @property 39 | def dicom_id(self): 40 | return self.info.dicom_id 41 | 42 | def _load_info(self): 43 | json_instance = self._api_client.instances.get_json(self.orthanc_id) 44 | self._info = InstanceInfo(json_instance) 45 | 46 | @property 47 | def series(self) -> 'Series': # lazy creation of series object 48 | if self._series is None: 49 | self._series = self._api_client.series.get(orthanc_id=self.info.series_orthanc_id) 50 | return self._series 51 | 52 | @property 53 | def tags(self): # lazy loading of tags .... 54 | if self._tags is None: 55 | self._tags = self._api_client.instances.get_tags(orthanc_id=self.orthanc_id) 56 | return self._tags 57 | 58 | @property 59 | def labels(self): 60 | return self.info.labels 61 | 62 | def get_metadata(self, metadata_name: str) -> str: 63 | if self.info.metadata is None: 64 | self.info.metadata = self._api_client.instances.get_metadata(self.orthanc_id) 65 | return self.info.metadata.get(metadata_name) -------------------------------------------------------------------------------- /orthanc_api_client/job.py: -------------------------------------------------------------------------------- 1 | from strenum import StrEnum 2 | from .helpers import wait_until 3 | 4 | class JobType(StrEnum): 5 | 6 | DICOM_WEB_STOW_CLIENT = 'DicomWebStowClient' 7 | DICOM_MOVE_SCU = 'DicomMoveScu' 8 | DICOM_MODALITY_STORE = 'DicomModalityStore' 9 | MEDIA = 'Media' 10 | ARCHIVE = 'Archive' 11 | MERGE_STUDY = 'MergeStudy' 12 | SPLIT_STUDY = 'SplitStudy' 13 | ORTHANC_PEER_STORE = 'OrthancPeerStore' 14 | RESOURCE_MODIFICATION = 'ResourceModification' 15 | STORAGE_COMMITMENT_SCP = 'StorageCommitmentScp' 16 | PUSH_TRANSFER = "PushTransfer" 17 | PULL_TRANSFER = "PullTransfer" 18 | 19 | 20 | class JobStatus(StrEnum): 21 | 22 | PENDING = 'Pending' 23 | RUNNING = 'Running' 24 | SUCCESS = 'Success' 25 | FAILURE = 'Failure' 26 | PAUSED = 'Paused' 27 | RETRY = 'Retry' 28 | 29 | 30 | class JobInfo: 31 | 32 | def __init__(self, json_job: object): 33 | self.orthanc_id = json_job.get('ID') 34 | self.status = json_job.get('State') 35 | self.type = json_job.get('Type') 36 | self.content = json_job.get('Content') 37 | self.dimseErrorStatus = json_job.get('DimseErrorStatus') # new in Orthanc 1.12.10 38 | 39 | 40 | class Job: 41 | 42 | def __init__(self, api_client, orthanc_id): 43 | self._api_client = api_client 44 | self.orthanc_id = orthanc_id 45 | self._info = None 46 | 47 | @staticmethod 48 | def from_json(api_client, json_job: object): 49 | job = Job(api_client, json_job.get('ID')) 50 | job._info = JobInfo(json_job) 51 | return job 52 | 53 | @property 54 | def info(self): # lazy loading of job info .... 55 | if self._info is None: 56 | self._load_info() 57 | return self._info 58 | 59 | @property 60 | def content(self): 61 | return self.info.content 62 | 63 | def refresh(self) -> "Job": 64 | self._load_info() 65 | return self; 66 | 67 | def is_complete(self) -> bool: 68 | self.refresh() 69 | 70 | return self._info.status in [JobStatus.SUCCESS, JobStatus.FAILURE] 71 | 72 | def wait_completed(self, timeout: float = None, polling_interval: float = 1) -> bool: 73 | return wait_until(self.is_complete, timeout=timeout, polling_interval=polling_interval) 74 | 75 | def _load_info(self): 76 | json_job = self._api_client.jobs.get_json(self.orthanc_id) 77 | self._info = JobInfo(json_job) -------------------------------------------------------------------------------- /orthanc_api_client/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class OrthancApiException(Exception): 3 | 4 | def __init__(self, msg = "Unknown Orthanc Rest API exception", url = None): 5 | self.msg = msg 6 | self.url = url 7 | 8 | def __str__(self): 9 | return f"Orthanc API exception: '{self.msg}' while accessing '{self.url}'" 10 | 11 | 12 | class ConnectionError(OrthancApiException): 13 | def __init__(self, msg = "Could not connect to Orthanc.", url = None): 14 | super().__init__(msg = msg, url = url) 15 | 16 | 17 | class TimeoutError(OrthancApiException): 18 | def __init__(self, msg = "Timeout. Orthanc took too long to respond.", url = None): 19 | super().__init__(msg = msg, url = url) 20 | 21 | class TooManyResourcesFound(OrthancApiException): 22 | def __init__(self, msg = "Too many resources found with the same id.", url = None): 23 | super().__init__(msg = msg, url = url) 24 | 25 | 26 | class HttpError(OrthancApiException): 27 | 28 | def __init__(self, http_status_code = None, msg = "Unknown Orthanc HTTP Rest API exception", url = None, request_response = None, dimse_error_status = None): 29 | super().__init__(msg = msg, url = url) 30 | self.http_status_code = http_status_code 31 | self.request_response = request_response 32 | self.dimse_error_status = dimse_error_status 33 | 34 | def __str__(self): 35 | orthanc_error = (self.request_response.text if self.request_response is not None else "") 36 | return f"Orthanc HTTP API exception: '{self.http_status_code} - {self.msg}' while accessing '{self.url}' - Orthanc error: '{orthanc_error}'" 37 | 38 | 39 | class ResourceNotFound(HttpError): 40 | def __init__(self, msg = "Resource not found. The resource you're trying to access does not exist in Orthanc.", url = None): 41 | super().__init__(http_status_code = 404, msg = msg, url = url) 42 | 43 | 44 | class NotAuthorized(HttpError): 45 | def __init__(self, http_status_code, msg = "Not authorized. Make sure to provide login/pwd.", url = None): 46 | super().__init__(http_status_code = http_status_code, msg = msg, url = url) 47 | 48 | class Forbidden(HttpError): 49 | def __init__(self, http_status_code, msg = "Forbidden. Check Orthanc configuration.", url = None): 50 | super().__init__(http_status_code = http_status_code, msg = msg, url = url) 51 | 52 | 53 | class BadFileFormat(HttpError): 54 | """ Bad file format while uploading a DICOM file""" 55 | def __init__(self, http_error, msg = "Bad file format"): 56 | super().__init__(http_status_code = http_error.http_status_code, msg = msg, url = http_error.url) 57 | 58 | 59 | class Conflict(HttpError): 60 | def __init__(self, msg = "Conflict", url = None): 61 | super().__init__(http_status_code = 409, msg = msg, url = url) 62 | -------------------------------------------------------------------------------- /orthanc_api_client/tags.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class SimplifiedTags: 5 | 6 | def __init__(self, json_tags): 7 | self._tags_by_name = {} 8 | self._fill(json_tags) 9 | 10 | def _fill(self, json_tags: object): 11 | if json_tags is None: 12 | return 13 | for name, value in json_tags.items(): 14 | self._tags_by_name[name] = value 15 | 16 | def __getitem__(self, item): 17 | return self.get(item) 18 | 19 | def __contains__(self, name): 20 | return name in self._tags_by_name 21 | 22 | def get(self, name): 23 | return self._tags_by_name.get(name) 24 | 25 | 26 | class Tags: 27 | 28 | def __init__(self, json_tags): 29 | self._raw_tags = json_tags 30 | self._tags_by_group_and_id = {} 31 | self._tags_by_name = {} 32 | self._fill(json_tags) 33 | 34 | def _fill(self, json_tags: object): 35 | for group_and_id, json_value in json_tags.items(): 36 | name = json_value["Name"] 37 | type_ = json_value["Type"] 38 | value = json_value["Value"] 39 | if type_ == 'String': 40 | self._tags_by_group_and_id[group_and_id] = value 41 | self._tags_by_name[name] = value 42 | elif type_ == 'Sequence': 43 | sequence = TagsSequence(value) 44 | self._tags_by_group_and_id[group_and_id] = sequence 45 | self._tags_by_name[name] = sequence 46 | elif type_ == 'Null': 47 | self._tags_by_group_and_id[group_and_id] = None 48 | self._tags_by_name[name] = None 49 | 50 | def __getitem__(self, item): 51 | return self.get(item) 52 | 53 | def __contains__(self, accessor): 54 | return accessor in self._tags_by_name or accessor in self._tags_by_group_and_id 55 | 56 | def get(self, accessor): 57 | match_group_and_id = re.search('([0-9A-Fa-f]{4})[,-]([0-9A-Fa-f]{4})', accessor) 58 | if match_group_and_id: 59 | key = '{group},{id}'.format(group=match_group_and_id.group(1), id=match_group_and_id.group(2)) 60 | return self._tags_by_group_and_id.get(key) 61 | else: 62 | return self._tags_by_name.get(accessor) 63 | 64 | def append(self, other: 'Tags'): 65 | self._fill(other._raw_tags) 66 | 67 | 68 | class TagsSequence: 69 | def __init__(self, json_tags): 70 | self._raw_tags = json_tags 71 | self._sequence = [] 72 | 73 | for json_item in json_tags: 74 | tags = Tags(json_item) 75 | self._sequence.append(tags) 76 | 77 | def __getitem__(self, item): 78 | return self.get(item) 79 | 80 | def get(self, index): 81 | return self._sequence[index] 82 | 83 | -------------------------------------------------------------------------------- /orthanc_api_client/patient.py: -------------------------------------------------------------------------------- 1 | from .tags import SimplifiedTags 2 | from typing import List, Optional 3 | import datetime 4 | from .helpers import from_orthanc_datetime 5 | 6 | class PatientInfo: 7 | 8 | def __init__(self, json_patient: object): 9 | self.main_dicom_tags = SimplifiedTags(json_patient.get('MainDicomTags')) 10 | self.orthanc_id = json_patient.get('ID') 11 | self.dicom_id = self.main_dicom_tags.get('PatientID') 12 | self.studies_ids = json_patient.get('Studies') 13 | self.last_update = json_patient.get('LastUpdate') 14 | self.labels = json_patient.get('Labels') or [] 15 | 16 | 17 | class PatientStatistics: 18 | 19 | def __init__(self, json_patient_stats): 20 | self.instances_count = int(json_patient_stats['CountInstances']) 21 | self.series_count = int(json_patient_stats['CountSeries']) 22 | self.studies_count = int(json_patient_stats['CountStudies']) 23 | self.disk_size = int(json_patient_stats['DiskSize']) 24 | self.uncompressed_size = int(json_patient_stats['UncompressedSize']) 25 | 26 | 27 | class Patient: 28 | 29 | def __init__(self, api_client: 'OrthancApiClient', orthanc_id: str): 30 | self._api_client = api_client 31 | self.orthanc_id = orthanc_id 32 | self._info: PatientInfo = None 33 | self._statistics: PatientStatistics = None 34 | self._studies: Optional[List['Study']] = None 35 | 36 | @staticmethod 37 | def from_json(api_client, json_patient: object): 38 | patient = Patient(api_client, json_patient.get('ID')) 39 | patient._info = PatientInfo(json_patient) 40 | return patient 41 | 42 | @property 43 | def info(self): # lazy loading of main dicom tags .... 44 | if self._info is None: 45 | json_patient = self._api_client.patients.get_json(self.orthanc_id) 46 | self._info = PatientInfo(json_patient) 47 | return self._info 48 | 49 | @property 50 | def main_dicom_tags(self): 51 | return self.info.main_dicom_tags 52 | 53 | @property 54 | def dicom_id(self): 55 | return self.info.dicom_id 56 | 57 | @property 58 | def statistics(self): # lazy loading of statistics .... 59 | if self._statistics is None: 60 | json_patient_stats = self._api_client.patients.get_json_statistics(self.orthanc_id) 61 | self._statistics = PatientStatistics(json_patient_stats) 62 | return self._statistics 63 | 64 | @property 65 | def studies(self): # lazy creation of series objects 66 | if self._studies is None: 67 | self._studies = [] 68 | for id in self.info.studies_ids: 69 | s = self._api_client.studies.get(id) 70 | self._studies.append(s) 71 | 72 | return self._series 73 | 74 | @property 75 | def last_update(self): 76 | return from_orthanc_datetime(self.info.last_update) 77 | 78 | @property 79 | def labels(self): 80 | return self.info.labels -------------------------------------------------------------------------------- /orthanc_api_client/series.py: -------------------------------------------------------------------------------- 1 | from .tags import SimplifiedTags 2 | from typing import List, Optional 3 | 4 | 5 | class SeriesInfo: 6 | 7 | def __init__(self, json_series: object): 8 | self.main_dicom_tags = SimplifiedTags(json_series.get('MainDicomTags')) 9 | self.orthanc_id = json_series.get('ID') 10 | self.dicom_id = self.main_dicom_tags.get('SeriesInstanceUID') 11 | self.instances_orthanc_ids = json_series.get('Instances') 12 | self.study_orthanc_id = json_series.get('ParentStudy') 13 | self.labels = json_series.get('Labels') or [] 14 | 15 | 16 | class SeriesStatistics: 17 | 18 | def __init__(self, json_series_stats): 19 | self.instances_count = int(json_series_stats['CountInstances']) 20 | self.disk_size = int(json_series_stats['DiskSize']) # this is the total size used on disk by the series and all its attachments 21 | self.uncompressed_size = int(json_series_stats['UncompressedSize']) # this is the total size of the series and all its attachments once uncompressed 22 | 23 | 24 | class Series: 25 | 26 | 27 | def __init__(self, api_client: 'OrthancApiClient', orthanc_id: str): 28 | self._api_client = api_client 29 | self.orthanc_id = orthanc_id 30 | self._info: SeriesInfo = None 31 | self._statistics: SeriesStatistics = None 32 | self._instances: Optional[List['Instances']] = None 33 | self._study: Optional['Study'] = None 34 | 35 | @staticmethod 36 | def from_json(api_client, json_series: object): 37 | series = Series(api_client, json_series.get('ID')) 38 | series._info = SeriesInfo(json_series) 39 | return series 40 | 41 | @property 42 | def info(self): # lazy loading of main dicom tags .... 43 | if self._info is None: 44 | json_series = self._api_client.series.get_json(self.orthanc_id) 45 | self._info = SeriesInfo(json_series) 46 | return self._info 47 | 48 | @property 49 | def main_dicom_tags(self): 50 | return self.info.main_dicom_tags 51 | 52 | @property 53 | def dicom_id(self): 54 | return self.info.dicom_id 55 | 56 | @property 57 | def statistics(self): # lazy loading of statistics .... 58 | if self._statistics is None: 59 | json_series_stats = self._api_client.series.get_json_statistics(self.orthanc_id) 60 | self._statistics = SeriesStatistics(json_series_stats) 61 | return self._statistics 62 | 63 | @property 64 | def study(self) -> 'Study': # lazy creation of study object 65 | if self._study is None: 66 | self._study = self._api_client.studies.get(orthanc_id=self.info.study_orthanc_id) 67 | return self._study 68 | 69 | @property 70 | def instances(self) -> List['Instance']: # lazy creation of instances objects 71 | if self._instances is None: 72 | self._instances = [] 73 | for instance_id in self.info.instances_orthanc_ids: 74 | instance = self._api_client.instances.get(orthanc_id=instance_id) 75 | self._instances.append(instance) 76 | 77 | return self._instances 78 | 79 | @property 80 | def labels(self): 81 | return self.info.labels -------------------------------------------------------------------------------- /orthanc_api_client/dicomweb_servers.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from .exceptions import * 4 | from .job import Job 5 | 6 | 7 | class DicomWebServers: 8 | 9 | def __init__(self, api_client: 'OrthancApiClient'): 10 | self._api_client = api_client 11 | self._url_segment = 'dicom-web/servers' 12 | 13 | def send_async(self, target_server: str, resources_ids: Union[List[str], str]) -> Job: 14 | """sends a list of resources to a remote DicomWeb server 15 | 16 | Returns 17 | ------- 18 | The job that has been created 19 | """ 20 | 21 | if isinstance(resources_ids, str): 22 | resources_ids = [resources_ids] 23 | 24 | r = self._api_client.post( 25 | endpoint=f"{self._url_segment}/{target_server}/stow", 26 | json={ 27 | "Resources": resources_ids, 28 | "Synchronous": False 29 | }) 30 | 31 | return Job(api_client=self._api_client, orthanc_id=r.json()['ID']) 32 | 33 | def send(self, target_server: str, resources_ids: Union[List[str], str]): 34 | """sends a list of resources to a remote DicomWeb server 35 | """ 36 | 37 | if isinstance(resources_ids, str): 38 | resources_ids = [resources_ids] 39 | 40 | self._api_client.post( 41 | endpoint=f"{self._url_segment}/{target_server}/stow", 42 | json={ 43 | "Resources": resources_ids, 44 | "Synchronous": True 45 | }) 46 | 47 | def retrieve_instance(self, remote_server: str, study_instance_uid: str, series_instance_uid: str, sop_instance_uid: str) -> bool: 48 | """retrieves a list of series from a remote DicomWeb server 49 | Returns true if received. 50 | """ 51 | return self.retrieve_resources(remote_server=remote_server, resources=[{ 52 | 'Study': study_instance_uid, 53 | 'Series': series_instance_uid, 54 | 'Instance': sop_instance_uid 55 | }]) == 1 56 | 57 | def retrieve_series(self, remote_server: str, study_instance_uid: str, series_instance_uid: str) -> int: 58 | """retrieves a list of series from a remote DicomWeb server 59 | Returns the number of instances received. 60 | """ 61 | return self.retrieve_resources(remote_server=remote_server, resources=[{ 62 | 'Study': study_instance_uid, 63 | 'Series': series_instance_uid 64 | }]) 65 | 66 | def retrieve_study(self, remote_server: str, study_instance_uid: str) -> int: 67 | """retrieves a study from a remote DicomWeb server 68 | Returns the number of instances received. 69 | """ 70 | return self.retrieve_resources(remote_server=remote_server, resources=[{ 71 | 'Study': study_instance_uid 72 | }]) 73 | 74 | def retrieve_resources(self, remote_server: str, resources: List[object]) -> int: 75 | """Retrieves a list of resources from a remote DicomWeb server. 76 | Returns the number of instances received. 77 | """ 78 | 79 | r = self._api_client.post( 80 | endpoint=f"{self._url_segment}/{remote_server}/retrieve", 81 | json={ 82 | "Resources": resources, 83 | "Synchronous": True 84 | }) 85 | 86 | return int(r.json()['ReceivedInstancesCount']) -------------------------------------------------------------------------------- /tests/docker-setup/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | orthanc-a: 3 | image: orthancteam/orthanc:25.8.2 4 | ports: ["10042:8042"] 5 | environment: 6 | VERBOSE_STARTUP: "true" 7 | VERBOSE_ENABLED: "true" 8 | ORTHANC__DICOM_AET: ORTHANCA 9 | ORTHANC__NAME: orthanc-a-for-python-api-client-tests 10 | ORTHANC__STABLE_AGE: "2" 11 | ORTHANC__REGISTERED_USERS: | 12 | {"test": "test"} 13 | ORTHANC__DICOM_MODALITIES: | 14 | {"orthanc-a": ["ORTHANCA", "orthanc-a", 4242], "orthanc-b": ["ORTHANCB", "orthanc-b", 4242], "orthanc-c": ["ORTHANCC", "orthanc-c", 4242]} 15 | ORTHANC__DICOM_WEB__SERVERS: | 16 | {"orthanc-b": ["http://orthanc-b:8042/dicom-web/", "test", "test"]} 17 | ORTHANC__ORTHANC_PEERS: | 18 | { 19 | "orthanc-b": ["http://orthanc-b:8042/", "test", "test"], 20 | "orthanc-c" : { 21 | "Url": "http://orthanc-c:8042/", 22 | "Username": "test", 23 | "Password": "test", 24 | "RemoteSelf": "orthanc-a" 25 | } 26 | } 27 | 28 | ORTHANC__EXECUTE_LUA_ENABLED: "true" 29 | TRANSFERS_PLUGIN_ENABLED: "true" 30 | ORTHANC__CHECK_REVISIONS: "true" 31 | ORTHANC__OVERWRITE_INSTANCES: "true" 32 | # keep default KeepAliveTimeout and reduce the number of threads in order to test the retry mechanism 33 | ORTHANC__KEEP_ALIVE_TIMEOUT: "1" 34 | ORTHANC__HTTP_THREADS_COUNT: "10" 35 | 36 | orthanc-b: 37 | build: orthanc-b 38 | ports: ["10043:8042"] 39 | environment: 40 | VERBOSE_STARTUP: "true" 41 | VERBOSE_ENABLED: "true" 42 | ORTHANC__PYTHON_SCRIPT: "/scripts/plugin.py" 43 | ORTHANC__DICOM_AET: ORTHANCB 44 | ORTHANC__NAME: orthanc-b-for-python-api-client-tests 45 | ORTHANC__REGISTERED_USERS: | 46 | {"test": "test"} 47 | ORTHANC__DICOM_MODALITIES: | 48 | {"orthanc-a": ["ORTHANCA", "orthanc-a", 4242], "orthanc-b": ["ORTHANCB", "orthanc-b", 4242], "orthanc-c": ["ORTHANCC", "orthanc-c", 4242]} 49 | ORTHANC__DICOM_WEB__SERVERS: | 50 | {"orthanc-a": ["http://orthanc-a:8042/dicom-web/", "test", "test"]} 51 | 52 | TRANSFERS_PLUGIN_ENABLED: "true" 53 | ORTHANC__CHECK_REVISIONS: "false" 54 | ORTHANC__OVERWRITE_INSTANCES: "true" 55 | # keep default KeepAliveTimeout and reduce the number of threads in order to test the retry mechanism 56 | ORTHANC__KEEP_ALIVE_TIMEOUT: "1" 57 | ORTHANC__HTTP_THREADS_COUNT: "10" 58 | 59 | orthanc-c: 60 | image: orthancteam/orthanc:25.8.2 61 | ports: ["10044:8042"] 62 | environment: 63 | VERBOSE_STARTUP: "true" 64 | VERBOSE_ENABLED: "true" 65 | ORTHANC__DICOM_AET: ORTHANCC 66 | ORTHANC__NAME: orthanc-c-for-python-api-client-tests 67 | ORTHANC__REGISTERED_USERS: | 68 | {"test": "test"} 69 | ORTHANC__DICOM_MODALITIES: | 70 | {"orthanc-a": ["ORTHANCA", "orthanc-a", 4242], "orthanc-b": ["ORTHANCB", "orthanc-b", 4242], "orthanc-c": ["ORTHANCC", "orthanc-c", 4242]} 71 | ORTHANC__DICOM_WEB__SERVERS: | 72 | {"orthanc-a": ["http://orthanc-a:8042/dicom-web/", "test", "test"]} 73 | ORTHANC__ORTHANC_PEERS: | 74 | {"orthanc-a": ["http://orthanc-a:8042/", "test", "test"]} 75 | # keep default KeepAliveTimeout and reduce the number of threads in order to test the retry mechanism 76 | ORTHANC__KEEP_ALIVE_TIMEOUT: "1" 77 | ORTHANC__HTTP_THREADS_COUNT: "5" 78 | 79 | TRANSFERS_PLUGIN_ENABLED: "true" 80 | ORTHANC__OVERWRITE_INSTANCES: "true" 81 | -------------------------------------------------------------------------------- /orthanc_api_client/study.py: -------------------------------------------------------------------------------- 1 | from .tags import SimplifiedTags 2 | from typing import List, Optional 3 | import datetime 4 | from .helpers import from_orthanc_datetime 5 | 6 | class StudyInfo: 7 | 8 | def __init__(self, json_study: object): 9 | self.main_dicom_tags = SimplifiedTags(json_study.get('MainDicomTags')) 10 | self.patient_main_dicom_tags = SimplifiedTags(json_study.get('PatientMainDicomTags')) 11 | self.orthanc_id = json_study.get('ID') 12 | self.dicom_id = self.main_dicom_tags.get('StudyInstanceUID') 13 | self.series_ids = json_study.get('Series') 14 | self.patient_orthanc_id = json_study.get('ParentPatient') 15 | self.last_update = json_study.get('LastUpdate') 16 | self.labels = json_study.get('Labels') or [] 17 | self.requested_tags = SimplifiedTags(json_study.get('RequestedTags')) or [] 18 | 19 | 20 | class StudyStatistics: 21 | 22 | def __init__(self, json_study_stats): 23 | self.instances_count = int(json_study_stats['CountInstances']) 24 | self.series_count = int(json_study_stats['CountSeries']) 25 | self.disk_size = int(json_study_stats['DiskSize']) # this is the total size used on disk by the study and all its attachments 26 | self.uncompressed_size = int(json_study_stats['UncompressedSize']) # this is the total size of the study and all its attachments once uncompressed 27 | 28 | 29 | class Study: 30 | 31 | def __init__(self, api_client: 'OrthancApiClient', orthanc_id: str): 32 | self._api_client = api_client 33 | self.orthanc_id = orthanc_id 34 | self._info: StudyInfo = None 35 | self._statistics: StudyStatistics = None 36 | self._series: Optional[List['Series']] = None 37 | 38 | @staticmethod 39 | def from_json(api_client, json_study: object): 40 | study = Study(api_client, json_study.get('ID')) 41 | study._info = StudyInfo(json_study) 42 | return study 43 | 44 | @property 45 | def info(self): # lazy loading of main dicom tags .... 46 | if self._info is None: 47 | json_study = self._api_client.studies.get_json(self.orthanc_id) 48 | self._info = StudyInfo(json_study) 49 | return self._info 50 | 51 | @property 52 | def main_dicom_tags(self): 53 | return self.info.main_dicom_tags 54 | 55 | @property 56 | def patient_main_dicom_tags(self): 57 | return self.info.patient_main_dicom_tags 58 | 59 | @property 60 | def requested_tags(self): 61 | return self.info.requested_tags 62 | 63 | @property 64 | def dicom_id(self): 65 | return self.info.dicom_id 66 | 67 | @property 68 | def statistics(self): # lazy loading of statistics .... 69 | if self._statistics is None: 70 | json_study_stats = self._api_client.studies.get_json_statistics(self.orthanc_id) 71 | self._statistics = StudyStatistics(json_study_stats) 72 | return self._statistics 73 | 74 | @property 75 | def series(self): # lazy creation of series objects 76 | if self._series is None: 77 | self._series = [] 78 | for id in self.info.series_ids: 79 | s = self._api_client.series.get(id) 80 | self._series.append(s) 81 | 82 | return self._series 83 | 84 | @property 85 | def last_update(self): 86 | return from_orthanc_datetime(self._api_client.studies.get_json(self.orthanc_id).get('LastUpdate')) 87 | 88 | @property 89 | def labels(self): 90 | return self.info.labels 91 | 92 | @property 93 | def is_stable(self): 94 | return bool(self._api_client.studies.get_json(self.orthanc_id).get('IsStable')) 95 | 96 | -------------------------------------------------------------------------------- /orthanc_api_client/resources/series_list.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .resources import Resources 4 | from ..tags import Tags 5 | from typing import List, Any 6 | from ..downloaded_instance import DownloadedInstance 7 | from ..series import SeriesInfo, Series 8 | 9 | 10 | class SeriesList(Resources): 11 | 12 | def __init__(self, api_client: 'OrthancApiClient'): 13 | super().__init__(api_client=api_client, url_segment='series') 14 | 15 | def get(self, orthanc_id: str) -> Series: 16 | return Series(api_client=self._api_client, orthanc_id=orthanc_id) 17 | 18 | def get_instances_ids(self, orthanc_id: str) -> List[str]: 19 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}")["Instances"] 20 | 21 | def get_first_instance_id(self, orthanc_id: str) -> str: 22 | return self.get_instances_ids(orthanc_id=orthanc_id)[0] 23 | 24 | def get_parent_study_id(self, orthanc_id: str) -> str: 25 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/study")['ID'] 26 | 27 | def get_parent_patient_id(self, orthanc_id: str) -> str: 28 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/patient")['ID'] 29 | 30 | def get_ordered_instances_ids(self, orthanc_id: str) -> List[str]: 31 | ordered_slices = self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/ordered-slices") 32 | return [ss[0] for ss in ordered_slices.get('SlicesShort')] 33 | 34 | def get_middle_instance_id(self, orthanc_id: str) -> str: 35 | ordered_instances_ids = self.get_ordered_instances_ids(orthanc_id=orthanc_id) 36 | return ordered_instances_ids[int(len(ordered_instances_ids) / 2)] 37 | 38 | def get_preview_url(self, orthanc_id: str) -> str: 39 | middle_instance_id = self.get_middle_instance_id(orthanc_id=orthanc_id) 40 | return f"instances/{middle_instance_id}/preview" 41 | 42 | def get_preview_file(self, orthanc_id: str, jpeg_format: bool = False, return_unsupported_image: bool = True) -> bytes: 43 | """ 44 | downloads the preview file (middle instance of the series) in png format (or jpeg) 45 | Args: 46 | orthanc_id: the series id to download the preview file from 47 | jpeg_format: replace default png format by jpeg format 48 | return_unsupported_image: if True, an unavailable preview will return an 'unsupported image', if False, a 415 error 49 | 50 | Returns: 51 | 52 | """ 53 | url = self.get_preview_url(orthanc_id=orthanc_id) 54 | if jpeg_format: 55 | headers = { "Accept": "image/jpeg"} 56 | else: 57 | headers = { "Accept": "image/png"} 58 | 59 | if return_unsupported_image: 60 | parameters = {"returnUnsupportedImage": "true"} 61 | else: 62 | parameters = {} 63 | return self._api_client.get_binary(endpoint=url, headers=headers, params=parameters, allow_redirects=True) 64 | 65 | def anonymize(self, orthanc_id: str, replace_tags={}, keep_tags=[], delete_original=True, force=False) -> str: 66 | return self._anonymize( 67 | orthanc_id=orthanc_id, 68 | replace_tags=replace_tags, 69 | keep_tags=keep_tags, 70 | delete_original=delete_original, 71 | force=force 72 | ) 73 | 74 | def modify(self, orthanc_id: str, replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], delete_original=True, force=False) -> str: 75 | return self._modify( 76 | orthanc_id=orthanc_id, 77 | replace_tags=replace_tags, 78 | remove_tags=remove_tags, 79 | keep_tags=keep_tags, 80 | delete_original=delete_original, 81 | force=force 82 | ) 83 | 84 | def get_tags(self, orthanc_id: str) -> Tags: 85 | """ 86 | returns tags from a "random" instance of the series, it shall contain all series tags 87 | """ 88 | return self._api_client.instances.get_tags(self.get_first_instance_id(orthanc_id=orthanc_id)) 89 | 90 | def lookup(self, dicom_id: str) -> str: 91 | """ 92 | finds a series in Orthanc based on its SeriesInstanceUid 93 | 94 | Returns 95 | ------- 96 | the instance id of the series or None if not found 97 | """ 98 | return self._lookup(filter='Series', dicom_id=dicom_id) 99 | 100 | def download_instances(self, series_id, path) -> List[DownloadedInstance]: 101 | """ 102 | downloads all instances from the series to disk 103 | Args: 104 | series_id: the series id to download 105 | path: the directory path where to store the downloaded files 106 | 107 | Returns: 108 | an array of DownloadedInstance 109 | """ 110 | return self._api_client.instances.download_instances(self.get_instances_ids(series_id), path) 111 | -------------------------------------------------------------------------------- /orthanc_api_client/resources/instances.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .resources import Resources 4 | from ..tags import Tags 5 | from typing import Union, List, Optional, Any 6 | from ..downloaded_instance import DownloadedInstance 7 | from ..instance import InstanceInfo, Instance 8 | 9 | 10 | class Instances(Resources): 11 | 12 | def __init__(self, api_client: 'OrthancApiClient'): 13 | super().__init__(api_client=api_client, url_segment='instances') 14 | 15 | def get(self, orthanc_id: str) -> Instance: 16 | return Instance(api_client=self._api_client, orthanc_id=orthanc_id) 17 | 18 | def get_file(self, orthanc_id: str) -> bytes: 19 | return self._api_client.get_binary(f"{self._url_segment}/{orthanc_id}/file") 20 | 21 | def get_parent_series_id(self, orthanc_id: str) -> str: 22 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/series")['ID'] 23 | 24 | def get_parent_study_id(self, orthanc_id: str) -> str: 25 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/study")['ID'] 26 | 27 | def get_parent_patient_id(self, orthanc_id: str) -> str: 28 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/patient")['ID'] 29 | 30 | def get_tags(self, orthanc_id: str) -> Tags: 31 | json_tags = self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/tags") 32 | return Tags(json_tags) 33 | 34 | def get_metadata(self, orthanc_id: str) -> dict: 35 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/metadata?expand") 36 | 37 | def modify(self, orthanc_id: str, replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], force: bool = False) -> bytes: 38 | 39 | query = { 40 | "Force": force 41 | } 42 | 43 | if replace_tags is not None and len(replace_tags) > 0: 44 | query['Replace'] = replace_tags 45 | 46 | if remove_tags is not None and len(remove_tags) > 0: 47 | query['Remove'] = remove_tags 48 | 49 | if keep_tags is not None and len(keep_tags) > 0: 50 | query['Keep'] = keep_tags 51 | 52 | r = self._api_client.post( 53 | endpoint=f"instances/{orthanc_id}/modify", 54 | json=query) 55 | 56 | if r.status_code == 200: 57 | return r.content 58 | 59 | return None # TODO: raise ? 60 | 61 | def lookup(self, dicom_id: str) -> str: 62 | """ 63 | finds an instance in Orthanc based on its SOPInstanceUID 64 | 65 | Returns 66 | ------- 67 | the instance id of the instance or None if not found 68 | """ 69 | return self._lookup(filter='Instance', dicom_id=dicom_id) 70 | 71 | def is_pdf(self, instance_id: str): 72 | """ 73 | checks if the instance contains a pdf 74 | Args: 75 | instance_id: The id of the instance to check 76 | 77 | Returns: True if the instance contain a pdf, False ortherwise 78 | """ 79 | tags = self.get_tags(instance_id) 80 | mime_type = tags.get('MIMETypeOfEncapsulatedDocument') 81 | return mime_type == 'application/pdf' 82 | 83 | def download_pdf(self, instance_id: str, path: str): 84 | """ 85 | downloads the pdf from the instance (if the instance does contain a PDF !) 86 | Args: 87 | instance_id: The id of the instance 88 | path: the path where to save the PDF file 89 | 90 | Returns: 91 | the path where the PDF has been saved (same as input argument) 92 | """ 93 | 94 | response = self._api_client.get( 95 | endpoint = f"instances/{instance_id}/pdf") 96 | with open(path, 'wb') as f: 97 | f.write(response.content) 98 | 99 | return path 100 | 101 | def download_instance(self, instance_id: str, path: str) -> DownloadedInstance: 102 | """ 103 | downloads the instance DICOM file to disk 104 | Args: 105 | instance_id: the instance id to download 106 | path: the file path where to store the downloaded file 107 | 108 | Returns: 109 | a DownloadedInstance object with the instanceId and the path 110 | """ 111 | file_content = self.get_file(instance_id) 112 | with open(path, 'wb') as f: 113 | f.write(file_content) 114 | 115 | return DownloadedInstance(instance_id, path) 116 | 117 | def download_instances(self, instances_ids: List[str], path: str) -> List[DownloadedInstance]: 118 | """ 119 | downloads the instances DICOM files to disk 120 | Args: 121 | instances_ids: the instances ids to download 122 | path: the folder path where to store the downloaded files 123 | 124 | Returns: 125 | a list of DownloadedInstance objects with the instanceId and the path 126 | """ 127 | downloaded_instances = [] 128 | for instance_id in instances_ids: 129 | downloaded_instances.append(self.download_instance(instance_id = instance_id, 130 | path = os.path.join(path, instance_id + ".dcm"))) 131 | 132 | return downloaded_instances 133 | -------------------------------------------------------------------------------- /orthanc_api_client/resources/patients.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List, Any, Union, Set 3 | 4 | from .resources import Resources 5 | from ..tags import Tags 6 | from ..exceptions import * 7 | from ..patient import PatientInfo, Patient 8 | from ..helpers import to_dicom_date, to_dicom_time 9 | from ..downloaded_instance import DownloadedInstance 10 | from ..labels_constraint import LabelsConstraint 11 | 12 | class Patients(Resources): 13 | 14 | def __init__(self, api_client: 'OrthancApiClient'): 15 | super().__init__(api_client=api_client, url_segment='patients') 16 | 17 | def get(self, orthanc_id: str) -> Patient: 18 | return Patient(api_client=self._api_client, orthanc_id=orthanc_id) 19 | 20 | def get_instances_ids(self, orthanc_id: str) -> List[str]: 21 | instances_ids = [] 22 | patient_info = self._api_client.get_json(f"{self._url_segment}/{orthanc_id}") 23 | for study_id in patient_info["Studies"]: 24 | instances_ids.extend(self._api_client.studies.get_instances_ids(study_id)) 25 | 26 | return instances_ids 27 | 28 | def get_series_ids(self, orthanc_id: str) -> List[str]: 29 | series_ids = [] 30 | patient_info = self._api_client.get_json(f"{self._url_segment}/{orthanc_id}") 31 | for study_id in patient_info["Studies"]: 32 | series_ids.extend(self._api_client.studies.get_series_ids(study_id)) 33 | 34 | return series_ids 35 | 36 | def get_studies_ids(self, orthanc_id: str) -> List[str]: 37 | patient_info = self._api_client.get_json(f"{self._url_segment}/{orthanc_id}") 38 | return patient_info["Studies"] 39 | 40 | def get_first_instance_id(self, orthanc_id: str) -> str: 41 | return self.get_instances_ids(orthanc_id=orthanc_id)[0] 42 | 43 | def get_first_instance_tags(self, orthanc_id: str) -> Tags: 44 | return self._api_client.instances.get_tags(self.get_first_instance_id(orthanc_id)) 45 | 46 | """gets the list of modalities from all series""" 47 | def get_modalities(self, orthanc_id: str) -> Set[str]: 48 | modalities = set() 49 | for series_id in self.get_series_ids(orthanc_id): 50 | modalities.add(self._api_client.series.get(series_id).main_dicom_tags.get('Modality')) 51 | return modalities 52 | 53 | def lookup(self, dicom_id: str) -> str: 54 | """ 55 | finds a patient in Orthanc based on its PatientID 56 | 57 | Returns 58 | ------- 59 | the patient id of the patient or None if not found 60 | """ 61 | return self._lookup(filter='Patient', dicom_id=dicom_id) 62 | 63 | def find(self, query: object, case_sensitive: bool = True, labels: [str] = [], labels_constraint: LabelsConstraint = LabelsConstraint.ANY) -> List[Patient]: 64 | """ 65 | find a patient in Orthanc based on the query and the labels 66 | 67 | args: 68 | labels: the list of the labels to filter to 69 | labels_constraint: "Any" (=default value), "All", "None" 70 | """ 71 | payload = { 72 | "Level": "Patient", 73 | "Query": query, 74 | "Expand": True, 75 | "CaseSensitive": case_sensitive, 76 | "Labels": labels, 77 | "LabelsConstraint": labels_constraint 78 | } 79 | 80 | r = self._api_client.post( 81 | endpoint=f"tools/find", 82 | json=payload) 83 | 84 | patients = [] 85 | for json_patient in r.json(): 86 | patients.append(Patient.from_json(self._api_client, json_patient)) 87 | 88 | return patients 89 | 90 | def anonymize(self, orthanc_id: str, replace_tags={}, keep_tags=[], delete_original=True, force=False) -> str: 91 | return self._anonymize( 92 | orthanc_id=orthanc_id, 93 | replace_tags=replace_tags, 94 | keep_tags=keep_tags, 95 | delete_original=delete_original, 96 | force=force 97 | ) 98 | 99 | def modify(self, orthanc_id: str, replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], delete_original=True, force=False) -> str: 100 | return self._modify( 101 | orthanc_id=orthanc_id, 102 | replace_tags=replace_tags, 103 | remove_tags=remove_tags, 104 | delete_original=delete_original, 105 | keep_tags=keep_tags, 106 | force=force 107 | ) 108 | 109 | def get_tags(self, orthanc_id: str) -> Tags: 110 | """ 111 | returns tags from a "random" instance in which you should safely get the patient tags 112 | """ 113 | return self._api_client.instances.get_tags(self.get_first_instance_id(orthanc_id=orthanc_id)) 114 | 115 | def download_instances(self, patient_id: str, path: str) -> List['DownloadedInstance']: 116 | """ 117 | downloads all instances from the patient to disk 118 | Args: 119 | patient_id: the patientid to download 120 | path: the directory path where to store the downloaded files 121 | 122 | Returns: 123 | an array of DownloadedInstance 124 | """ 125 | return self._api_client.instances.download_instances(self.get_instances_ids(patient_id), path) 126 | -------------------------------------------------------------------------------- /orthanc_api_client/http_client.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import requests 3 | import urllib.parse 4 | import json 5 | from requests.adapters import HTTPAdapter, Retry 6 | 7 | from orthanc_api_client import exceptions as api_exceptions 8 | 9 | 10 | class HttpClient: 11 | 12 | def __init__(self, root_url: str, user: str = None, pwd: str = None, headers: any = None) -> None: 13 | self._root_url = root_url 14 | self._http_session = requests.Session() 15 | 16 | if user and pwd: 17 | self._http_session.auth = requests.auth.HTTPBasicAuth(user, pwd) 18 | if headers: 19 | self._http_session.headers.update(headers) 20 | 21 | self._user = user 22 | self._pwd = pwd 23 | 24 | # only retries on ConnectionError and on Transient errors when we are sure that the request has not reached to Orthanc 25 | retries = Retry( # doc: https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry 26 | connect=3, 27 | read=3, 28 | status=3, 29 | allowed_methods=frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE', 'POST'}), # allow "POST" because we retry only when the request has not reached Orthanc ! 30 | status_forcelist=frozenset({502, 503}), # only retry "Bad Gateway" and "Service Unavailable" 31 | backoff_factor=0.2 32 | ) 33 | url_schema = urllib.parse.urlparse(root_url).scheme + "://" 34 | self._http_session.mount(url_schema, HTTPAdapter(max_retries=retries)) 35 | 36 | 37 | def get_abs_url(self, endpoint: str) -> str: 38 | # remove the leading '/' because _root_url might be something like 'http://my.domain/orthanc/' and urljoin would then remove the '/orthanc' 39 | normalised_endpoint = endpoint[1:] if endpoint.startswith("/") else endpoint 40 | 41 | return urllib.parse.urljoin(self._root_url, normalised_endpoint) 42 | 43 | 44 | def get(self, endpoint: str, **kwargs) -> requests.Response: 45 | try: 46 | url = self.get_abs_url(endpoint) 47 | response = self._http_session.get(url, **kwargs) 48 | 49 | self._raise_on_errors(response, url=url) 50 | return response 51 | except requests.RequestException as request_exception: 52 | self._translate_exception(request_exception, url=url) 53 | 54 | def get_json(self, endpoint: str, **kwargs) -> Any: 55 | return self.get(endpoint, **kwargs).json() 56 | 57 | def get_binary(self, endpoint: str, **kwargs) -> Any: 58 | return self.get(endpoint, **kwargs).content 59 | 60 | def post(self, endpoint: str, **kwargs) -> requests.Response: 61 | try: 62 | url = self.get_abs_url(endpoint) 63 | response = self._http_session.post(url, **kwargs) 64 | 65 | self._raise_on_errors(response, url=url) 66 | return response 67 | except requests.RequestException as request_exception: 68 | self._translate_exception(request_exception, url=url) 69 | 70 | def put(self, endpoint: str, **kwargs) -> requests.Response: 71 | try: 72 | url = self.get_abs_url(endpoint) 73 | response = self._http_session.put(url, **kwargs) 74 | 75 | self._raise_on_errors(response, url=url) 76 | return response 77 | except requests.RequestException as request_exception: 78 | self._translate_exception(request_exception, url=url) 79 | 80 | def delete(self, endpoint: str, **kwargs) -> requests.Response: 81 | try: 82 | url = self.get_abs_url(endpoint) 83 | response = self._http_session.delete(url, **kwargs) 84 | 85 | self._raise_on_errors(response, url=url) 86 | return response 87 | except requests.RequestException as request_exception: 88 | self._translate_exception(request_exception, url=url) 89 | 90 | def close(self): 91 | self._http_session.close() 92 | 93 | def __exit__(self, exc_type, exc_value, traceback): 94 | self.close() 95 | 96 | def __del__(self): 97 | self.close() 98 | 99 | def _raise_on_errors(self, response, url): 100 | if response.status_code >= 200 and response.status_code < 300: 101 | return 102 | 103 | if response.status_code == 401: 104 | raise api_exceptions.NotAuthorized(response.status_code, url=url) 105 | elif response.status_code == 404: 106 | raise api_exceptions.ResourceNotFound( 107 | response.status_code, url=url) 108 | elif response.status_code == 409: 109 | raise api_exceptions.Conflict( 110 | msg=response.json()['Message'] if response.json() and 'Message' in response.json() else None, 111 | url=url 112 | ) 113 | else: 114 | error_messages = [] 115 | error_message = None 116 | # try to get details from the payload 117 | payload = {} 118 | if len(response.content) > 0: 119 | try: 120 | payload = json.loads(response.content) 121 | if 'Message' in payload: 122 | error_messages.append(payload['Message']) 123 | if 'Details' in payload and len(payload['Details']) > 0: 124 | error_messages.append(payload['Details']) 125 | except: 126 | pass 127 | 128 | if len(error_messages) > 0: 129 | error_message = " - ".join(error_messages) 130 | raise api_exceptions.HttpError( 131 | http_status_code=response.status_code, 132 | msg=error_message, 133 | url=url, 134 | request_response=response, 135 | dimse_error_status=payload.get("DimseErrorStatus")) 136 | 137 | def _translate_exception(self, request_exception, url): 138 | if isinstance(request_exception, requests.ConnectionError): 139 | raise api_exceptions.ConnectionError(url=url) 140 | elif isinstance(request_exception, requests.Timeout): 141 | raise api_exceptions.TimeoutError(url=url) 142 | elif isinstance(request_exception, requests.exceptions.SSLError): 143 | raise api_exceptions.SSLError(url=url) 144 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages, find_namespace_packages 2 | import pathlib 3 | 4 | here = pathlib.Path(__file__).parent.resolve() 5 | 6 | long_description = (here / 'README.md').read_text(encoding='utf-8') 7 | 8 | # Arguments marked as "Required" below must be included for upload to PyPI. 9 | # Fields marked as "Optional" may be commented out. 10 | 11 | setup( 12 | # This is the name of your project. The first time you publish this 13 | # package, this name will be registered for you. It will determine how 14 | # users can install this project, e.g.: 15 | # 16 | # $ pip install sampleproject 17 | # 18 | # And where it will live on PyPI: https://pypi.org/project/sampleproject/ 19 | # 20 | # There are some restrictions on what makes a valid project name 21 | # specification here: 22 | # https://packaging.python.org/specifications/core-metadata/#name 23 | name='orthanc_api_client', # Required 24 | 25 | # Versions should comply with PEP 440: 26 | # https://www.python.org/dev/peps/pep-0440/ 27 | # 28 | # For a discussion on single-sourcing the version across setup.py and the 29 | # project code, see 30 | # https://packaging.python.org/guides/single-sourcing-package-version/ 31 | version='0.22.1', # Required 32 | 33 | # This is a one-line description or tagline of what your project does. This 34 | # corresponds to the "Summary" metadata field: 35 | # https://packaging.python.org/specifications/core-metadata/#summary 36 | description='Python Orthanc REST API client', # Optional 37 | 38 | # This is an optional longer description of your project that represents 39 | # the body of text which users will see when they visit PyPI. 40 | # 41 | # Often, this is the same as your README, so you can just read it in from 42 | # that file directly (as we have already done above) 43 | # 44 | # This field corresponds to the "Description" metadata field: 45 | # https://packaging.python.org/specifications/core-metadata/#description-optional 46 | long_description=long_description, # Optional 47 | 48 | # Denotes that our long_description is in Markdown; valid values are 49 | # text/plain, text/x-rst, and text/markdown 50 | # 51 | # Optional if long_description is written in reStructuredText (rst) but 52 | # required for plain-text or Markdown; if unspecified, "applications should 53 | # attempt to render [the long_description] as text/x-rst; charset=UTF-8 and 54 | # fall back to text/plain if it is not valid rst" (see link below) 55 | # 56 | # This field corresponds to the "Description-Content-Type" metadata field: 57 | # https://packaging.python.org/specifications/core-metadata/#description-content-type-optional 58 | long_description_content_type='text/markdown', # Optional (see note above) 59 | 60 | # This should be a valid link to your project's main homepage. 61 | # 62 | # This field corresponds to the "Home-Page" metadata field: 63 | # https://packaging.python.org/specifications/core-metadata/#home-page-optional 64 | url='https://github.com/orthanc-team/python-orthanc-api-client', # Optional 65 | 66 | # This should be your name or the name of the organization which owns the 67 | # project. 68 | author='Orthanc Team', # Optional 69 | 70 | # This should be a valid email address corresponding to the author listed 71 | # above. 72 | author_email='info@orthanc.team', # Optional 73 | 74 | # Classifiers help users find your project by categorizing it. 75 | # 76 | # For a list of valid classifiers, see https://pypi.org/classifiers/ 77 | classifiers=[ # Optional 78 | # How mature is this project? Common values are 79 | # 3 - Alpha 80 | # 4 - Beta 81 | # 5 - Production/Stable 82 | 'Development Status :: 3 - Alpha', 83 | 84 | # Indicate who your project is intended for 85 | 'Intended Audience :: Developers', 86 | 87 | # Pick your license as you wish 88 | 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', 89 | 90 | # Specify the Python versions you support here. In particular, ensure 91 | # that you indicate you support Python 3. These classifiers are *not* 92 | # checked by 'pip install'. See instead 'python_requires' below. 93 | 'Programming Language :: Python :: 3', 94 | 'Programming Language :: Python :: 3.8', 95 | 'Programming Language :: Python :: 3.9', 96 | "Programming Language :: Python :: 3.10", 97 | 'Programming Language :: Python :: 3 :: Only', 98 | ], 99 | 100 | # This field adds keywords for your project which will appear on the 101 | # project page. What does your project relate to? 102 | # 103 | # Note that this is a list of additional keywords, separated 104 | # by commas, to be used to assist searching for the distribution in a 105 | # larger catalog. 106 | keywords='orthanc, dicom, rest api', # Optional 107 | 108 | # You can just specify package directories manually here if your project is 109 | # simple. Or you can use find_packages(). 110 | # 111 | # Alternatively, if you just want to distribute a single Python file, use 112 | # the `py_modules` argument instead as follows, which will expect a file 113 | # called `my_module.py` to exist: 114 | # 115 | # py_modules=["my_module"], 116 | # 117 | packages=["orthanc_api_client", "orthanc_api_client.resources"], # Required 118 | # packages=find_namespace_packages( 119 | # where='orthanc_api_client' 120 | # ), 121 | # namespace_packages=['resources'], 122 | 123 | # Specify which Python versions you support. In contrast to the 124 | # 'Programming Language' classifiers above, 'pip install' will check this 125 | # and refuse to install the project if the version does not match. See 126 | # https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires 127 | python_requires='>=3.8, <4', 128 | 129 | # This field lists other packages that your project depends on to run. 130 | # Any package you put here will be installed by pip when your project is 131 | # installed, so they must be valid existing projects. 132 | # 133 | # For an analysis of "install_requires" vs pip's requirements files see: 134 | # https://packaging.python.org/discussions/install-requires-vs-requirements/ 135 | install_requires=[ 136 | 'requests>=2.31.0', 137 | 'pydicom>=3.0.1', 138 | 'StrEnum>=0.4.15' 139 | ], 140 | 141 | # List additional groups of dependencies here (e.g. development 142 | # dependencies). Users will be able to install these using the "extras" 143 | # syntax, for example: 144 | # 145 | # $ pip install sampleproject[dev] 146 | # 147 | # Similar to `install_requires` above, these must be valid existing 148 | # projects. 149 | extras_require={ # Optional 150 | 'dev': ['check-manifest', 'pytest'], 151 | 'test': ['coverage', 'pytest'], 152 | }, 153 | 154 | 155 | project_urls={ # Optional 156 | 'Bug Reports': 'https://github.com/orthanc-team/python-orthanc-api-client/issues', 157 | 'Funding': 'https://orthanc-team', 158 | 'Source': 'https://github.com/orthanc-team/python-orthanc-api-client/', 159 | }, 160 | ) 161 | -------------------------------------------------------------------------------- /orthanc_api_client/helpers.py: -------------------------------------------------------------------------------- 1 | import time 2 | import re 3 | import pydicom 4 | import datetime 5 | import random 6 | from typing import Union, Optional 7 | from .helpers_internal import write_dataset_to_bytes 8 | import pydicom.uid 9 | from urllib3.filepost import encode_multipart_formdata, choose_boundary 10 | 11 | 12 | def wait_until(some_predicate, timeout, polling_interval=0.1, *args, **kwargs) -> bool: 13 | 14 | if timeout is None: 15 | while True: 16 | if some_predicate(*args, **kwargs): 17 | return True 18 | time.sleep(polling_interval) 19 | return False 20 | else: 21 | end_time = time.time() + timeout 22 | while time.time() < end_time: 23 | if some_predicate(*args, **kwargs): 24 | return True 25 | time.sleep(polling_interval) 26 | return False 27 | 28 | 29 | def get_random_dicom_date(date_from: datetime.date, date_to: datetime.date = datetime.date.today()) -> str: 30 | delta = date_to - date_from 31 | rand_date = date_from + datetime.timedelta(days=random.randint(0, delta.days)) 32 | return '{0:4}{1:02}{2:02}'.format(rand_date.year, rand_date.month, rand_date.day) 33 | 34 | 35 | def to_dicom_date(date: Union[datetime.date, datetime.datetime]) -> str: 36 | return '{0:4}{1:02}{2:02}'.format(date.year, date.month, date.day) 37 | 38 | def to_dicom_time(dt: datetime.datetime) -> str: 39 | return '{0:02}{1:02}{2:02}'.format(dt.hour, dt.minute, dt.second) 40 | 41 | def to_dicom_time_from_seconds(seconds: int) -> str: 42 | hours = seconds // 3600 43 | minutes = (seconds % 3600) // 60 44 | seconds = seconds % 60 45 | return to_dicom_time(datetime.datetime.today().replace(hour=hours, minute=minutes, second=seconds)) 46 | 47 | def from_dicom_date(dicom_date: str) -> datetime.date: 48 | if dicom_date is None or len(dicom_date) == 0: 49 | return None 50 | 51 | m = re.match('(?P[0-9]{4})(?P[0-9]{2})(?P[0-9]{2})', dicom_date) 52 | if m is None: 53 | raise ValueError("Not a valid DICOM date: '{0}'".format(dicom_date)) 54 | 55 | return datetime.date(int(m.group('year')), int(m.group('month')), int(m.group('day'))) 56 | 57 | def from_dicom_time(dicom_time: str, default: datetime.time = None) -> datetime.time: 58 | if dicom_time is None or len(dicom_time) == 0: 59 | if default: 60 | return default 61 | else: 62 | return None 63 | 64 | m = re.match('(?P[0-9]{2})(?P[0-9]{2})(?P[0-9]{2})\.(?P[0-9]{1,6})', dicom_time) 65 | if m: 66 | return datetime.time(int(m.group('hours')), int(m.group('minutes')), int(m.group('seconds')), 67 | microsecond=int(m.group('dec')) * pow(10, 6 - len(m.group('dec')))) 68 | 69 | m = re.match('(?P[0-9]{2})(?P[0-9]{2})(?P[0-9]{2})', dicom_time) 70 | if m: 71 | return datetime.time(int(m.group('hours')), int(m.group('minutes')), int(m.group('seconds'))) 72 | 73 | m = re.match('(?P[0-9]{2})(?P[0-9]{2})', dicom_time) 74 | if m: 75 | return datetime.time(int(m.group('hours')), int(m.group('minutes')), 0) 76 | 77 | m = re.match('(?P[0-9]{2})', dicom_time) 78 | if m: 79 | return datetime.time(int(m.group('hours')), 0, 0) 80 | 81 | if default: 82 | return default 83 | 84 | raise ValueError("Not a valid DICOM time: '{0}'".format(dicom_time)) 85 | 86 | 87 | def from_orthanc_datetime(orthanc_datetime: str) -> datetime.datetime: 88 | if orthanc_datetime is None or len(orthanc_datetime) == 0: 89 | return None 90 | 91 | return datetime.datetime.strptime(orthanc_datetime, "%Y%m%dT%H%M%S") 92 | 93 | def from_dicom_date_and_time(dicom_date: str, dicom_time: str) -> datetime.datetime: 94 | if dicom_date is None or len(dicom_date) == 0: 95 | return None 96 | 97 | date = from_dicom_date(dicom_date) 98 | time = from_dicom_time(dicom_time, default=datetime.time(0, 0, 0)) 99 | 100 | return datetime.datetime(date.year, date.month, date.day, time.hour, time.minute, time.second, time.microsecond) 101 | 102 | def generate_test_dicom_file( 103 | width: int = 128, 104 | height: int = 128, 105 | tags: any = {} 106 | ) -> bytes: 107 | buffer = bytearray(height * width * 2) 108 | 109 | file_meta = pydicom.dataset.FileMetaDataset() 110 | file_meta.MediaStorageSOPClassUID = pydicom.uid.MRImageStorage 111 | file_meta.MediaStorageSOPInstanceUID = pydicom.uid.generate_uid() 112 | file_meta.TransferSyntaxUID = pydicom.uid.ExplicitVRLittleEndian 113 | 114 | ds = pydicom.dataset.Dataset() 115 | ds.file_meta = file_meta 116 | 117 | ds.Modality = "MR" 118 | ds.SOPInstanceUID = pydicom.uid.generate_uid() 119 | ds.SeriesInstanceUID = pydicom.uid.generate_uid() 120 | ds.StudyInstanceUID = pydicom.uid.generate_uid() 121 | ds.FrameOfReferenceUID = pydicom.uid.generate_uid() 122 | 123 | ds.PatientName = "Test^Patient^Name" 124 | ds.PatientID = "Test-Patient-ID" 125 | ds.PatientSex = "U" 126 | ds.PatientBirthDate = "20000101" 127 | 128 | ds.ImagesInAcquisition = "1" 129 | ds.InstanceNumber = 1 130 | ds.ImagePositionPatient = r"0\0\1" 131 | ds.ImageOrientationPatient = r"1\0\0\0\-1\0" 132 | ds.ImageType = r"ORIGINAL\PRIMARY\AXIAL" 133 | 134 | ds.RescaleIntercept = "0" 135 | ds.RescaleSlope = "1" 136 | ds.PixelSpacing = r"1\1" 137 | ds.PhotometricInterpretation = "MONOCHROME2" 138 | ds.PixelRepresentation = 1 139 | 140 | # copy tags values in the dataset 141 | for (k, v) in tags.items(): 142 | ds.__setattr__(k, v) 143 | 144 | if ds.Modality == "MR": 145 | ds.SOPClassUID = pydicom.uid.MRImageStorage 146 | elif ds.Modality == "CT": 147 | ds.SOPClassUID = pydicom.uid.CTImageStorage 148 | elif ds.Modality == "CR": 149 | ds.SOPClassUID = pydicom.uid.ComputedRadiographyImageStorage 150 | elif ds.Modality == "DX": 151 | ds.SOPClassUID = pydicom.uid.DigitalXRayImageStorageForPresentation 152 | else: 153 | raise NotImplementedError 154 | 155 | 156 | ds.BitsStored = 16 157 | ds.BitsAllocated = 16 158 | ds.SamplesPerPixel = 1 159 | ds.HighBit = 15 160 | 161 | ds.Rows = height 162 | ds.Columns = width 163 | 164 | pydicom.dataset.validate_file_meta(ds.file_meta, enforce_standard=True) 165 | 166 | ds.PixelData = bytes(buffer) 167 | 168 | return write_dataset_to_bytes(ds) 169 | 170 | 171 | def encode_multipart_related(fields, boundary=None): 172 | if boundary is None: 173 | boundary = choose_boundary() 174 | 175 | body, _ = encode_multipart_formdata(fields, boundary) 176 | content_type = str('multipart/related; type=application/dicom; boundary=%s' % boundary) 177 | 178 | return body, content_type 179 | 180 | 181 | def is_version_at_least(version_string: str, expected_major: int, expected_minor: int, expected_patch: Optional[int] = None) -> bool: 182 | if version_string.startswith("mainline"): 183 | return True 184 | 185 | split_version = version_string.split(".") 186 | if len(split_version) == 0: 187 | return False 188 | 189 | if len(split_version) >= 1: 190 | if int(split_version[0]) < expected_major: 191 | return False 192 | 193 | if len(split_version) >= 2: 194 | if int(split_version[1]) < expected_minor: 195 | return False 196 | 197 | if len(split_version) >= 3 and expected_patch is not None: 198 | if int(split_version[2]) < expected_patch: 199 | return False 200 | return True 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-orthanc-api-client 2 | 3 | A python client to ease using the Orthanc Rest API. 4 | 5 | Functionalities are very limited now ! Backward compat will break a lot in the near future ! 6 | 7 | ## PyOrthanc or python-orthanc-api-client? 8 | 9 | Another project [`pyorthanc`](https://github.com/gacou54/pyorthanc) is quite similar to `python-orthanc-api-client`. 10 | 11 | If you are wondering which one to use, please refer to this [discussion](https://github.com/gacou54/pyorthanc/issues/80). 12 | **tl;dr**: this project is mainly an internal tool made public. `pyorthanc` is more feature complete and its documentation is much better. 13 | 14 | Installation: 15 | 16 | ```shell 17 | pip3 install orthanc-api-client 18 | ``` 19 | 20 | 21 | Examples: 22 | 23 | ```python 24 | from orthanc_api_client import OrthancApiClient, ResourceType, InstancesSet 25 | import datetime 26 | 27 | orthanc_a = OrthancApiClient('http://localhost:8042', user='orthanc', pwd='orthanc') 28 | orthanc_b = OrthancApiClient('http://localhost:8043', user='orthanc', pwd='orthanc') 29 | 30 | if not orthanc_a.wait_started(timeout=20): 31 | print("Orthanc has not started after 20 sec") 32 | 33 | if not orthanc_a.is_alive(): 34 | print("Could not connect to Orthanc, check it is running") 35 | 36 | # upload files/folders 37 | orthanc_a.upload_folder('/home/o/files', ignore_errors=True) 38 | instances_ids = orthanc_a.upload_file('/home/o/files/a.dcm') 39 | instances_ids = orthanc_a.upload_file('/home/o/files/a.zip') 40 | with open('/home/o/files/a.dcm', 'rb') as f: 41 | instances_ids = orthanc_a.upload(f.read()) 42 | orthanc_a.upload_files_dicom_web(['/home/o/files/a.dcm']) 43 | 44 | # list all resources ids 45 | all_patients_ids = orthanc_a.patients.get_all_ids() 46 | all_studies_ids = orthanc_a.studies.get_all_ids() 47 | all_series_ids = orthanc_a.series.get_all_ids() 48 | all_instances_ids = orthanc_a.instances.get_all_ids() 49 | 50 | # show some daily stats 51 | orthanc_a.studies.print_daily_stats(from_date=datetime.date(2022, 2, 4), to_date=datetime.date(2022, 2, 8)) 52 | orthanc_a.series.print_daily_stats() # show last 8 days per default 53 | orthanc_a.instances.print_daily_stats() 54 | 55 | # get system stats 56 | print(f"This Orthanc stores {orthanc_a.get_statistics().studies_count} studies for a total of {orthanc_a.get_statistics().total_disk_size_mb} MB") 57 | 58 | # instances methods 59 | dicom_file = orthanc_a.instances.get_file(orthanc_id=all_instances_ids[0]) 60 | instances_ids = orthanc_b.upload(buffer=dicom_file) 61 | study_id = orthanc_b.instances.get_parent_study_id(instances_ids[0]) 62 | 63 | # access study info & simplified tags 64 | study = orthanc_b.studies.get(study_id) 65 | patient_id = study.patient_main_dicom_tags.get('PatientID') 66 | study_description = study.main_dicom_tags.get('StudyDescription') 67 | dicom_id = study.dicom_id 68 | 69 | # get the ids of all the studies of a patient 70 | studies = orthanc_a.patients.get_studies_ids(patient_id) 71 | 72 | # access metadata 73 | orthanc_a.instances.set_string_metadata(orthanc_id=all_instances_ids[0], 74 | metadata_name=1024, 75 | content='my-value') 76 | 77 | # access tags 78 | tags = orthanc_a.instances.get_tags(orhtanc_id=all_instances_ids[0]) 79 | patient_name = tags['PatientName'] 80 | patient_id = tags['0010,0020'] 81 | patient_sex = tags['0010-0040'] 82 | 83 | # anonymize 84 | anon_study_id = orthanc_b.studies.anonymize( 85 | orthanc_id=study_id, 86 | keep_tags=['PatientName'], 87 | replace_tags={ 88 | 'PatientID': 'ANON' 89 | }, 90 | force=True, 91 | delete_original=False 92 | ) 93 | 94 | # find locally in Orthanc 95 | study_id = orthanc_a.studies.lookup(dicom_id='1.2.3.4') 96 | study_id = orthanc_a.studies.lookup(dicom_id='1.2.3.4', filter="Study") 97 | 98 | studies = orthanc_a.studies.find(query={ 99 | 'PatientName': 'A*', 100 | 'StudyDate': '20220101-20220109' 101 | }) 102 | 103 | # find in a remote modality 104 | remote_studies = orthanc_a.modalities.query_studies( 105 | from_modality='pacs', 106 | query={'PatientName': 'A*', 'StudyDate': '20220101-20220109'} 107 | ) 108 | orthanc_a.modalities.retrieve_study( 109 | from_modality=remote_studies[0].remote_modality_id, 110 | dicom_id=remote_studies[0].dicom_id 111 | ) 112 | 113 | # send to a remote modality 114 | orthanc_a.modalities.send( 115 | target_modality='orthanc-b', 116 | resources_ids=[study_id], 117 | synchronous=True 118 | ) 119 | 120 | # send to a remote peer (synchronous) 121 | orthanc_a.peers.send( 122 | target_peer='orthanc-b', 123 | resources_ids=[study_id] 124 | ) 125 | 126 | # send using transfer plugin 127 | orthanc_a.transfers.send( 128 | target_peer='orthanc-b', 129 | resources_ids=[study_id], 130 | resource_type=ResourceType.STUDY, 131 | compress=True 132 | ) 133 | 134 | # work with a snapshot of a study 135 | instances_set = InstancesSet.from_study(orthanc_a, study_id=study_id) 136 | modified_set = instances_set.modify( 137 | replace_tags={ 138 | 'InstitutionName' : 'MY' 139 | }, 140 | keep_tags=['SOPInstanceUID', 'SeriesInstanceUID', 'StudyInstanceUID'], 141 | force=True, 142 | keep_source=True # we are not changing orthanc IDs -> don't delete source since it is the same as destination 143 | ) 144 | 145 | # send instance_set 146 | orthanc_a.transfers.send( 147 | target_peer='orthanc-b', 148 | resources_ids=modified_set.instances_ids, 149 | resource_type=ResourceType.STUDY, 150 | compress=True 151 | ) 152 | 153 | # delete after send 154 | modified_set.delete() 155 | 156 | ``` 157 | 158 | ## helpers methods 159 | 160 | ```python 161 | import datetime 162 | from orthanc_api_client import helpers, OrthancApiClient 163 | 164 | dicom_date = helpers.to_dicom_date(datetime.date.today()) 165 | standard_date = helpers.from_dicom_date(dicom_date) 166 | 167 | # for tests: 168 | o = OrthancApiClient('http://localhost:8042', user='orthanc', pwd='orthanc') 169 | helpers.wait_until(lambda: len(o.instances.get_all_ids() > 50), timeout=30) 170 | 171 | dicom_date = helpers.get_random_dicom_date(date_from=datetime.date(2000, 1, 1), 172 | date_to=datetime.date.today()) 173 | dicom_file = helpers.generate_test_dicom_file(width=128, 174 | height=128, 175 | tags={ 176 | "PatientName": "Toto", 177 | "StudyInstanceUID": "123" 178 | }) 179 | 180 | ``` 181 | 182 | ## upload a folder to Orthanc 183 | 184 | ```python 185 | from orthanc_api_client import OrthancApiClient 186 | 187 | o = OrthancApiClient('http://localhost:8042', user='orthanc', pwd='orthanc') 188 | o.upload_folder('/home/o/files', ignore_errors=True) 189 | 190 | ``` 191 | 192 | ## running from inside an Orthanc python plugin 193 | 194 | ```python 195 | from orthanc_api_client import OrthancApiClient 196 | import orthanc 197 | import json 198 | 199 | orthanc_client = None 200 | 201 | def OnChange(changeType, level, resource): 202 | global orthanc_client 203 | 204 | if changeType == orthanc.ChangeType.ORTHANC_STARTED: 205 | orthanc.LogWarning("Starting python plugin") 206 | 207 | # at startup, use the python SDK direct access to the Rest API to retrieve info to pass to the OrthancApiClient that is using 'requests' 208 | system = json.loads(orthanc.RestApiGet('/system')) 209 | api_token = orthanc.GenerateRestApiAuthorizationToken() 210 | 211 | orthanc_client = OrthancApiClient( 212 | orthanc_root_url=f"http://localhost:{system['HttpPort']}", 213 | api_token=api_token 214 | ) 215 | ... 216 | 217 | orthanc.RegisterOnChangeCallback(OnChange) 218 | ``` -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /orthanc_api_client/resources/studies.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List, Any, Union, Set, Optional 3 | 4 | from .resources import Resources 5 | from ..tags import Tags 6 | from ..exceptions import * 7 | from ..study import Study 8 | from ..instance import Instance 9 | from ..helpers import to_dicom_date, to_dicom_time 10 | from ..downloaded_instance import DownloadedInstance 11 | from ..labels_constraint import LabelsConstraint 12 | 13 | class Studies(Resources): 14 | 15 | def __init__(self, api_client: 'OrthancApiClient'): 16 | super().__init__(api_client=api_client, url_segment='studies') 17 | 18 | def get(self, orthanc_id: str) -> Study: 19 | return Study(api_client=self._api_client, orthanc_id=orthanc_id) 20 | 21 | def get_instances_ids(self, orthanc_id: str) -> List[str]: 22 | instances_ids = [] 23 | study_info = self._api_client.get_json(f"{self._url_segment}/{orthanc_id}") 24 | for series_id in study_info["Series"]: 25 | instances_ids.extend(self._api_client.series.get_instances_ids(series_id)) 26 | 27 | return instances_ids 28 | 29 | def get_instances(self, orthanc_id: str) -> List[Instance]: 30 | instances = [] 31 | 32 | instances_info = self._api_client.post( 33 | f"tools/find", 34 | json={ 35 | "Level": "Instance", 36 | "Query": {}, 37 | "ResponseContent": ["MainDicomTags", "Metadata", "Parent", "Labels"], 38 | "ParentStudy": orthanc_id 39 | }).json() 40 | 41 | for instance_info in instances_info: 42 | instances.append(Instance.from_json(self._api_client, instance_info)) 43 | 44 | return instances 45 | 46 | def get_series_ids(self, orthanc_id: str) -> List[str]: 47 | study_info = self._api_client.get_json(f"{self._url_segment}/{orthanc_id}") 48 | return study_info["Series"] 49 | 50 | def get_first_instance_id(self, orthanc_id: str) -> str: 51 | return self.get_instances_ids(orthanc_id=orthanc_id)[0] 52 | 53 | def get_first_instance_tags(self, orthanc_id: str) -> Tags: 54 | return self._api_client.instances.get_tags(self.get_first_instance_id(orthanc_id)) 55 | 56 | """gets the list of modalities from all series""" 57 | def get_modalities(self, orthanc_id: str) -> Set[str]: 58 | modalities = set() 59 | for series_id in self.get_series_ids(orthanc_id): 60 | modalities.add(self._api_client.series.get(series_id).main_dicom_tags.get('Modality')) 61 | return modalities 62 | 63 | def get_parent_patient_id(self, orthanc_id: str) -> str: 64 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/patient")['ID'] 65 | 66 | def lookup(self, dicom_id: str) -> Optional[str]: 67 | """ 68 | finds a study in Orthanc based on its StudyInstanceUid 69 | 70 | Returns 71 | ------- 72 | the instance id of the study or None if not found 73 | """ 74 | return self._lookup(filter='Study', dicom_id=dicom_id) 75 | 76 | def find(self, 77 | query: object, 78 | case_sensitive: bool = True, 79 | labels: List[str] = [], 80 | labels_constraint: LabelsConstraint = LabelsConstraint.ANY, 81 | limit: int = 0, 82 | since: int = 0, 83 | order_by: List[dict] = [], 84 | requested_tags: [str] = [] 85 | ) -> List[Study]: 86 | """ 87 | find a study in Orthanc based on the query and the labels 88 | 89 | args: 90 | labels: the list of the labels to filter to 91 | labels_constraint: "Any" (=default value), "All", "None" 92 | limit: Limit the number of reported resources 93 | since: Show only the resources since the provided index (in conjunction with Limit) 94 | order_by: Array of associative arrays containing the requested ordering 95 | example: 96 | [{ 97 | "Type": "Metadata", 98 | "Key": "LastUpdate", 99 | "Direction": "DESC" 100 | }] 101 | """ 102 | payload = { 103 | "Level": "Study", 104 | "Query": query, 105 | "Expand": True, 106 | "CaseSensitive": case_sensitive, 107 | "Labels": labels, 108 | "LabelsConstraint": labels_constraint, 109 | "Limit": limit, 110 | "Since": since, 111 | "OrderBy": order_by, 112 | "RequestedTags": requested_tags 113 | } 114 | 115 | r = self._api_client.post( 116 | endpoint=f"tools/find", 117 | json=payload) 118 | 119 | studies = [] 120 | for json_study in r.json(): 121 | studies.append(Study.from_json(self._api_client, json_study)) 122 | 123 | return studies 124 | 125 | def anonymize(self, orthanc_id: str, replace_tags={}, keep_tags=[], delete_original=True, force=False) -> str: 126 | return self._anonymize( 127 | orthanc_id=orthanc_id, 128 | replace_tags=replace_tags, 129 | keep_tags=keep_tags, 130 | delete_original=delete_original, 131 | force=force 132 | ) 133 | 134 | def modify(self, orthanc_id: str, replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], delete_original=True, force=False) -> str: 135 | return self._modify( 136 | orthanc_id=orthanc_id, 137 | replace_tags=replace_tags, 138 | remove_tags=remove_tags, 139 | delete_original=delete_original, 140 | keep_tags=keep_tags, 141 | force=force 142 | ) 143 | 144 | def get_tags(self, orthanc_id: str) -> Tags: 145 | """ 146 | returns tags from a "random" instance in which you should safely get the study/patient tags 147 | """ 148 | return self._api_client.instances.get_tags(self.get_first_instance_id(orthanc_id=orthanc_id)) 149 | 150 | def merge(self, target_study_id: str, source_series_id: Union[List[str], str], keep_source: bool): 151 | 152 | if isinstance(source_series_id, str): 153 | source_series_id = [source_series_id] 154 | 155 | return self._api_client.post( 156 | endpoint=f"{self._url_segment}/{target_study_id}/merge", 157 | json={ 158 | "Resources": source_series_id, 159 | "KeepSource": keep_source 160 | } 161 | ) 162 | 163 | def attach_pdf(self, study_id: str, pdf_path: str, series_description: str, datetime: datetime.datetime = None) -> str: 164 | """ 165 | Creates a new instance with the PDF embedded. This instance is a part of a new series attached to an existing study 166 | 167 | Returns: 168 | the instance_orthanc_id of the created instance 169 | """ 170 | series_tags = {} 171 | series_tags["SeriesDescription"] = series_description 172 | if datetime is not None: 173 | series_tags["SeriesDate"] = to_dicom_date(datetime) 174 | series_tags["SeriesTime"] = to_dicom_time(datetime) 175 | 176 | return self._api_client.create_pdf(pdf_path, series_tags, parent_id = study_id) 177 | 178 | def get_pdf_instances(self, study_id: str, max_instance_count_in_series_to_analyze: int = 2) -> List[str]: 179 | """ 180 | Returns the instanceIds of the instances containing PDF 181 | Args: 182 | study_id: The id of the study to search in 183 | max_instance_count_in_series_to_analyze: skip series containing too many instances (they are very unlikely to contain pdf reports). set it to 0 to disable the check. 184 | 185 | Returns: an array of instance orthancId 186 | """ 187 | 188 | pdf_ids = [] 189 | series_list = self.get_series_ids(study_id) 190 | 191 | for series_id in series_list: 192 | instances_ids = self._api_client.series.get_instances_ids(series_id) 193 | if max_instance_count_in_series_to_analyze > 0 and len(instances_ids) <= max_instance_count_in_series_to_analyze: 194 | for instance_id in instances_ids: 195 | if self._api_client.instances.is_pdf(instance_id): 196 | pdf_ids.append(instance_id) 197 | 198 | return pdf_ids 199 | 200 | def download_instances(self, study_id: str, path: str) -> List['DownloadedInstance']: 201 | """ 202 | downloads all instances from the study to disk 203 | Args: 204 | study_id: the studyid to download 205 | path: the directory path where to store the downloaded files 206 | 207 | Returns: 208 | an array of DownloadedInstance 209 | """ 210 | return self._api_client.instances.download_instances(self.get_instances_ids(study_id), path) 211 | -------------------------------------------------------------------------------- /orthanc_api_client/instances_set.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Any 2 | from .study import Study 3 | from .series import Series 4 | from .instance import Instance 5 | from .job import Job 6 | import hashlib 7 | import base64 8 | 9 | # This class contains a set of Instances that represents the status of a study at a given time. 10 | # Its main use is to avoid this kind of situation: 11 | # - you wish to modify a study, forward it and delete it 12 | # - if new instances are received while you are processing the study and you simply "delete" 13 | # the whole study at the end, you might delete instances that have not been processed. 14 | # The InstancesSet makes a snapshot of the current state of a study to make sure you'll 15 | # process, forward and delete only the instances from the snapshot 16 | 17 | 18 | class InstancesSet: 19 | 20 | def __init__(self, api_client: 'OrthancApiClient', id: str = None): 21 | self.api_client = api_client 22 | self._id = id 23 | self._computed_id = None 24 | self._all_instances_ids = [] 25 | self._by_series = {} 26 | self.study_id = None 27 | 28 | def __str__(self): 29 | return f"{self.id} - {len(self.series_ids)} series / {len(self.instances_ids)} instances" 30 | 31 | def add_series(self, series_id: str): 32 | self._add_series( 33 | series_id=series_id, 34 | instances_ids=self.api_client.series.get_instances_ids(orthanc_id=series_id) 35 | ) 36 | 37 | def _add_series(self, series_id: str, instances_ids: List[str]): 38 | self._by_series[series_id] = instances_ids 39 | self._all_instances_ids.extend(instances_ids) 40 | self._computed_id = None # invalidate the computed id 41 | 42 | @property 43 | def id(self) -> str: 44 | if not self._computed_id: 45 | self._computed_id = base64.b16encode(hashlib.sha1(",".join(self._all_instances_ids).encode('utf-8')).digest())[:10].decode('utf-8') 46 | return self._computed_id 47 | 48 | @property 49 | def instances_ids(self) -> List[str]: 50 | return self._all_instances_ids 51 | 52 | @property 53 | def series_ids(self) -> List[str]: 54 | return list(self._by_series.keys()) 55 | 56 | def get_instances_ids(self, series_id: str) -> List[str]: 57 | if series_id in self._by_series: 58 | return self._by_series[series_id] 59 | else: 60 | return [] 61 | 62 | @staticmethod 63 | def from_study(api_client, study_id: Optional[str] = None, study: Optional[Study] = None) -> 'InstancesSet': 64 | instances_set = InstancesSet(api_client=api_client) 65 | if not study: 66 | study = api_client.studies.get(study_id) 67 | instances_set.study_id = study.orthanc_id 68 | 69 | for series_id in study.info.series_ids: 70 | instances_set.add_series(series_id) 71 | 72 | return instances_set 73 | 74 | @staticmethod 75 | def from_series(api_client, series_id: Optional[str] = None, series: Optional[Series] = None) -> 'InstancesSet': 76 | instances_set = InstancesSet(api_client=api_client) 77 | if not series: 78 | series = api_client.series.get(series_id) 79 | 80 | instances_set.study_id = series.study.orthanc_id 81 | instances_set.add_series(series_id=series.orthanc_id) 82 | 83 | return instances_set 84 | 85 | @staticmethod 86 | def from_instance(api_client, instance_id: Optional[str] = None, instance: Optional[Instance] = None) -> 'InstancesSet': 87 | instances_set = InstancesSet(api_client=api_client) 88 | if not instance: 89 | instance = api_client.instances.get(instance_id) 90 | 91 | instances_set.study_id = instance.series.study.orthanc_id 92 | instances_set._by_series[instance.series.orthanc_id] = [instance.orthanc_id] 93 | instances_set._all_instances_ids.append(instance.orthanc_id) 94 | 95 | return instances_set 96 | 97 | def delete(self): 98 | self.api_client.post( 99 | endpoint=f"tools/bulk-delete", 100 | json= { 101 | "Resources": self.instances_ids 102 | }) 103 | 104 | # returns a new InstancesSet with the modified resources 105 | def modify(self, replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], keep_source=True, force=False) -> Optional['InstancesSet']: 106 | 107 | query = { 108 | "Force": force, 109 | "Resources": self.instances_ids, 110 | "KeepSource": keep_source, 111 | "Level": "Instance" 112 | } 113 | 114 | if replace_tags is not None and len(replace_tags) > 0: 115 | query['Replace'] = replace_tags 116 | if remove_tags is not None and len(remove_tags) > 0: 117 | query['Remove'] = remove_tags 118 | if keep_tags is not None and len(keep_tags) > 0: 119 | query['Keep'] = keep_tags 120 | 121 | r = self.api_client.post( 122 | endpoint=f"tools/bulk-modify", 123 | json=query) 124 | 125 | if r.status_code == 200: 126 | rjson = r.json() 127 | 128 | # create the modified set from the response 129 | modified_set = InstancesSet(api_client=self.api_client) 130 | modified_instances_ids = [] 131 | modified_series_ids = [] 132 | modified_studies_ids = [] 133 | for r in rjson['Resources']: 134 | if r['Type'] == 'Study': 135 | modified_studies_ids.append(r['ID']) 136 | if r['Type'] == 'Series': 137 | modified_series_ids.append(r['ID']) 138 | if r['Type'] == 'Instance': 139 | modified_instances_ids.append(r['ID']) 140 | 141 | if len(modified_studies_ids) != 1: 142 | return None # we had a problem since there should be only one study !!! 143 | if len(modified_series_ids) != len(self._by_series.keys()): 144 | return None # we had a problem since the number of series has changed !!! 145 | if len(modified_instances_ids) != len(self._all_instances_ids): 146 | return None # we had a problem since the number of instances has changed !!! 147 | 148 | for s in modified_series_ids: 149 | series_all_instances_ids = set(self.api_client.series.get_instances_ids(orthanc_id=s)) 150 | 151 | # the series might contain some instances that do not come from our modification, ignore them ! 152 | series_instances_ids = list(series_all_instances_ids.intersection(set(modified_instances_ids))) 153 | modified_set._by_series[s] = series_instances_ids 154 | modified_set._all_instances_ids.extend(series_instances_ids) 155 | 156 | modified_set.study_id = modified_studies_ids[0] 157 | 158 | return modified_set 159 | 160 | return None # TODO: raise exception ??? 161 | 162 | # keep only the instances that satisfy the filter 163 | # prototype: filter(api_client, instance_id) 164 | # this method returns an InstanceSet containing the removed instances and series 165 | # example: filter_out_sets = s.filter_instances(filter=lambda api, id: api.instances.get(id).tags.get('SeriesDescription') == "keep this description") 166 | def filter_instances(self, filter) -> 'InstancesSet': 167 | series_to_delete = [] 168 | removed_set = InstancesSet(self.api_client) 169 | removed_set.study_id = self.study_id 170 | 171 | for series_id, instances_ids in self._by_series.items(): 172 | instances_to_delete = [] 173 | for instance_id in instances_ids: 174 | if not filter(self.api_client, instance_id): 175 | instances_to_delete.append(instance_id) 176 | 177 | for i in instances_to_delete: 178 | self._by_series[series_id].remove(i) 179 | self._all_instances_ids.remove(i) 180 | 181 | if len(self._by_series[series_id]) == 0: 182 | series_to_delete.append(series_id) 183 | 184 | if len(instances_to_delete) > 0: 185 | removed_set._add_series(series_id, instances_to_delete) 186 | 187 | for s in series_to_delete: 188 | del self._by_series[s] 189 | 190 | return removed_set 191 | 192 | # apply a method on all instances 193 | # prototype: processor(api_client, instance_id) 194 | def process_instances(self, processor): 195 | for instance_id in self._all_instances_ids: 196 | processor(self.api_client, instance_id) 197 | 198 | def download_archive(self, path: str): 199 | response = self.api_client.post( 200 | endpoint="tools/create-archive", 201 | json={ 202 | "Synchronous": True, 203 | "Resources": self.instances_ids 204 | } 205 | ) 206 | with open(path, 'wb') as f: 207 | f.write(response.content) 208 | 209 | def download_media(self, path: str): 210 | response = self.api_client.post( 211 | endpoint="tools/create-media", 212 | json={ 213 | "Synchronous": True, 214 | "Resources": self.instances_ids 215 | } 216 | ) 217 | with open(path, 'wb') as f: 218 | f.write(response.content) 219 | -------------------------------------------------------------------------------- /release-notes.md: -------------------------------------------------------------------------------- 1 | v 0.22.1 2 | ======== 3 | 4 | - Added `dicomweb_servers.retrieve_study`, `retrieve_series` and `retrieve_instance` 5 | 6 | 7 | v 0.21.0 8 | ======== 9 | 10 | - Added `requested_tags` for `find`method on `studies` 11 | 12 | v 0.20.1 13 | ======== 14 | 15 | - `HttpError` might now have a `dimse_error_status` field in case of DICOM related errors 16 | 17 | v 0.20.0 18 | ======== 19 | 20 | - Added `Modalities.get_async()` & `Modalities.move_async()` 21 | 22 | v 0.19.0 23 | ======== 24 | 25 | - Added `Instance.get_metadata()` 26 | - Added `Study.get_instances()` that also retrieves the metadata in a single API call 27 | - fix `Instances.has_metadata` 28 | 29 | 30 | v 0.18.8 31 | ======== 32 | 33 | - Added `is_stable` property for `study`. 34 | - Still in `study`, modified `last_update` property to reflect current value. 35 | 36 | 37 | v 0.18.7 38 | ======== 39 | 40 | - fix in `get_preview_file` method. 41 | 42 | 43 | v 0.18.6 44 | ======== 45 | 46 | - enhanced `Studies.find` to allow sorting and pagination. 47 | 48 | v 0.18.5 49 | ======== 50 | 51 | - New `endpoint` argument to `upload_files_dicom_web` method. 52 | - #7: fix wrong exception handling 53 | 54 | v 0.18.4 55 | ======== 56 | 57 | - Added `labels` property in `Patient`, `Study`, `Series` and `Instance`. 58 | 59 | v 0.18.3 60 | ======== 61 | 62 | - Fixed the `get_preview_file` method in case of unsupported image. 63 | 64 | v 0.18.2 65 | ======== 66 | 67 | - Added the `get_preview_file` method. 68 | 69 | 70 | v 0.18.1 71 | ======== 72 | 73 | - Fixed a bug in `get_preview_url` method. 74 | 75 | v 0.18.0 76 | ======== 77 | 78 | - Added `OrthancApiClient.modalities.get_study`, `get_series`, `get_instance` to retrieve resources with C-GET. 79 | 80 | v 0.17.0 81 | ======== 82 | 83 | - Added `OrthancApiClient.instances.anonymize_bulk` and `OrthancApiClient.instances.anonymize_bulk_async` 84 | 85 | v 0.16.3 86 | ======== 87 | 88 | - Avoid pydicom warning when generating test files 89 | 90 | v 0.16.2 91 | ======== 92 | 93 | - `o.is_orthanc_version_at_least()` and `o.is_plugin_version_at_least()` now support "mainline-commitId" patterns 94 | 95 | v 0.16.1 96 | ======== 97 | 98 | - Added `capabilities`: 99 | - `o.Capabilities.has_extended_find` 100 | - `o.Capabilities.has_extended_changes` 101 | - `o.Capabilities.has_label_support` 102 | - `o.Capabilities.has_revision_support` 103 | 104 | v 0.16.0 105 | ======== 106 | 107 | - Fixed an incompatibility with pydicom 3.0.0 108 | 109 | v 0.15.3 110 | ======== 111 | 112 | - Added an option to `upload_folder_return_details` method to unzip files (if any) before upload 113 | 114 | 115 | v 0.15.2 116 | ======== 117 | 118 | - Fix `to_dicom_time` method in Helpers 119 | - Added `to_dicom_time_from_seconds` method in Helpers 120 | 121 | 122 | v 0.15.1 123 | ======== 124 | 125 | - The `OrthancApiClient` now implements 3 retries in case of: 126 | - ConnectionError 127 | - 502 Bad Gateway 128 | - 503 Service Unavailable 129 | - added `permissive` argument to `OrthancApiClient.resources.modify_bulk` 130 | 131 | 132 | v 0.15.0 133 | ======== 134 | 135 | - **BREAKING CHANGE:** `OrthancApiClient.instances.modify_bulk` now returns a tuple with 136 | `modified_instances_ids, modifies_series_ids, modified_studies_ids, modifies_patients_ids` 137 | - **BREAKING CHANGE:** `modify_instance_by_instance` has been removed since recent Orthanc versions allow 138 | using `force=True` and `keep_tags` can preserve DICOM identifiers 139 | 140 | 141 | v 0.14.14 142 | ========= 143 | 144 | - Fix #4: re-allow `endpoint` argument to start with a `'/'` e.g. in `get_json()` 145 | 146 | v 0.14.12 147 | ======== 148 | - fixed slash bug affecting several methods: `get_changes`, `get_all_labels`, `get_log_level`, `set_log_level` 149 | 150 | v 0.14.11 151 | ======== 152 | - added `upload_folder_return_details` method in `OrthancApiClient` 153 | - added `__repr__` to `OrthancApiClient` for nice display in debugger. 154 | 155 | v 0.14.10 156 | ======== 157 | 158 | - added functions to check the Orthanc and plugin versions: 159 | `helpers.is_version_at_least`, `OrthancApiClient.is_orthanc_version_at_least` 160 | `OrthancApiClient.is_plugin_version_at_least`, `OrthancApiClient.has_loaded_plugin`. 161 | 162 | v 0.14.8 163 | ======== 164 | 165 | - added `helpers.from_dicom_date_and_time` and `helpers.from_dicom_time` 166 | 167 | v 0.14.6 168 | ======== 169 | 170 | - added a `RemoteJob` class that can be created when a `PULL_TRANSFER` is created 171 | 172 | v 0.14.5 173 | ======== 174 | 175 | - added `get_statistics()` in `OrthancApiClient` 176 | 177 | v 0.14.4 178 | ======== 179 | 180 | - `ignore_errors` in `upload` methods now ignoring 409 errors (conflict) 181 | 182 | v 0.14.3 183 | ======== 184 | 185 | - added `get_log_level` and `set_log_level` in `OrthancApiClient` 186 | 187 | v 0.14.2 188 | ======== 189 | 190 | - added `execute_lua_script` in `OrthancApiClient` 191 | 192 | 193 | v 0.14.1 194 | ======== 195 | 196 | - introduced `patients` 197 | 198 | v 0.14.0 199 | ======== 200 | 201 | - **BREAKING CHANGE:** `DicomModalities.send_async` was actually not asynchronous and 202 | now returns a job. 203 | 204 | v 0.13.8 205 | ======== 206 | 207 | - added `local_aet` arg for `DicomModalities.send` and `DicomModalities.send_async` 208 | 209 | v 0.13.7 210 | ======== 211 | 212 | - added `Study.last_update` 213 | 214 | 215 | v 0.13.6 216 | ======== 217 | 218 | - added `headers` arg to the `OrthancApiClient` constructor 219 | 220 | v 0.13.5 221 | ======== 222 | 223 | - added `Resources.download_media()` and `Resources.download_archive()` 224 | - added `InstancesSet.download_media()` and `InstancesSet.download_archive()` 225 | 226 | v 0.13.4 227 | ======== 228 | 229 | - added `Modalities.get_all_ids()` 230 | - added `Modalities.get_id_from_aet()` 231 | - added `Study.info.patient_orthanc_id` 232 | - added `Resources.exists()` 233 | 234 | v 0.13.3 235 | ======== 236 | 237 | - added `Studies.get_modalities` and `Studies.get_first_instance_tags()` 238 | 239 | v 0.13.2 240 | ======== 241 | 242 | - `Modalities.send` and `Modalities.store`: 243 | - `timeout` is now a float argument (more pythonic) 244 | - added `keep_tags` argument to `modify()` 245 | 246 | 247 | v 0.13.1 248 | ======== 249 | 250 | - added `get_labels`, `add_label`, `add_labels`, `delete_label`, `delete_labels` 251 | at all resources levels 252 | - added `OrthancApiClient.get_all_labels` to return all labels in Orthanc 253 | - added `labels` and `label_constraint` arguments to `studies.find` 254 | 255 | v 0.12.2 256 | ======== 257 | 258 | - `Modalities.send` and `Modalities.store`: 259 | - **BREAKING CHANGE:** removed `synchronous` argument: it is always synchronous 260 | - added an optional `timeout` argument 261 | 262 | v 0.11.8 263 | ======== 264 | 265 | - `InstancesSet` ids are reproducible (based on a hash of their initial content) 266 | - more detailed HttpError 267 | 268 | v 0.11.7 269 | ======== 270 | 271 | - fix `Series.statistics` and `Study.statistics` 272 | - uniformized logger names to `__name__` 273 | 274 | v 0.11.5 275 | ======== 276 | - added `Modalities.configure`, `Modalities.delete` and `Modalities.get_configuration` 277 | 278 | v 0.11.4 279 | ======== 280 | - fix `InstancesSet.filter_instances` 281 | 282 | v 0.11.3 283 | ======== 284 | - fix metadata default value 285 | 286 | v 0.11.2 287 | ======== 288 | 289 | - added `keep_tags` to `Instances.modify` 290 | 291 | v 0.11.1 292 | ======== 293 | 294 | - added `InstancesSet.id` 295 | - `InstancesSet.api_client` is now public 296 | 297 | v 0.11.0 298 | ======== 299 | 300 | - **BREAKING CHANGE:** renamed `dicomweb_servers.send_asynchronous` into `dicomweb_servers.send_async` 301 | - for every target (`peers, transfers, modalities, dicomweb_server`) we now have both: 302 | - `send()` that is synchronous 303 | - and `send_async()` that is asynchronous and returns the job that has been created 304 | 305 | v 0.10.2 306 | ======== 307 | 308 | - added synchronous `dicomweb_servers.send()` 309 | 310 | v 0.10.1 311 | ======== 312 | 313 | - InstancesSet.filter_instances() now returns and instance set with the excluded instances 314 | 315 | v 0.10.0 316 | ======== 317 | 318 | - **BREAKING CHANGE:** renamed `set_metadata` into `set_string_metadata` & `set_binary_metadata` 319 | - **BREAKING CHANGE:** renamed `get_metadata` into `get_string_metadata` & `get_binary_metadata` 320 | - added `InstancesSet.filter_instances()` & `InstancesSet.process_instances()` 321 | 322 | v 0.9.1 323 | ======= 324 | 325 | - introduced `InstancesSet` class 326 | 327 | v 0.9.0 328 | ======= 329 | 330 | - **BREAKING CHANGE:** renamed `download_study` and `download_series` into `download_instances` 331 | - introduced `Series`, `SeriesInfo`, `Instance` and `InstanceInfo` classes 332 | 333 | v 0.8.3 334 | ======= 335 | 336 | - added download methods for instances, series, studies 337 | 338 | v 0.8.2 339 | ======= 340 | 341 | - added pdf (and png/jpg) import tools 342 | 343 | v 0.8.1 344 | ======= 345 | 346 | - made HttpClient available for lib users 347 | 348 | v 0.8.0 349 | ======= 350 | 351 | - **BREAKING CHANGE:** removed the `stow_rs` method from the `DicomWebServers` class 352 | 353 | v 0.7.1 354 | ======= 355 | 356 | - fixed absolute url in `upload` methods. 357 | 358 | v 0.7.0 359 | ======= 360 | 361 | - **BREAKING CHANGE:** renamed the `modality` argument of `client.modalities.send()` and 362 | `client.modalities.store()` into `target_modality` to be more consistent with `send()` methods. 363 | 364 | 365 | v 0.6.1 366 | ======= 367 | 368 | - added `job.wait_completed()' 369 | 370 | v 0.6.0 371 | ======= 372 | 373 | - added `client.peers.send()' 374 | 375 | v 0.5.8 376 | ======= 377 | 378 | - **BREAKING CHANGE:** renamed `client.upload_file_dicom_web` into `client.upload_files_dicom_web` 379 | and added support for multiple files 380 | - any HTTP status between 200 and 300 is now considered as a success and won't 381 | raise exceptions anymore 382 | 383 | v 0.5.7 384 | ======= 385 | 386 | - added `client.upload_file_dicom_web` 387 | 388 | v 0.5.6 389 | ======= 390 | 391 | - added `client.transfers.send` 392 | 393 | v 0.5.5 394 | ======= 395 | 396 | - fix relative url of various methods 397 | 398 | v 0.5.1 399 | ======= 400 | 401 | - added `studies.merge` 402 | 403 | v 0.5.0 404 | ======= 405 | 406 | - BREAKING_CHANGE: renamed `relative_url` arg into `endpoint` for `get, put, post, get_json, ...` 407 | - added `retry, cancel, pause, ...` to `jobs` 408 | 409 | v 0.4.1 410 | ======= 411 | 412 | - added `ignore_errors` to `delete` methods 413 | 414 | v 0.4.0 415 | ======= 416 | 417 | - BREAKING_CHANGE: renamed `dicom_servers.send` into `dicom_servers.send_asynchronous` 418 | - added Job, JobType, JobStatus, JobInfo classes 419 | - new resource `jobs` in api_client: `orthanc.jobs.get(orthanc_id=...)` -------------------------------------------------------------------------------- /orthanc_api_client/modalities.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import List, Union, Optional 3 | from .tags import SimplifiedTags 4 | from .study import Study 5 | from .job import Job 6 | from .exceptions import ResourceNotFound 7 | from .retrieve_method import RetrieveMethod 8 | 9 | from .exceptions import * 10 | 11 | 12 | class RemoteModalityStudy: 13 | 14 | """ 15 | Represents a study on a remote modality. This is populated with the results of a C-Find request on that modality 16 | 17 | You can retrieve the study by calling: 18 | orthanc.modalities.retrieve_study(remote_study.remote_modality_id, remote_study.dicom_id) 19 | """ 20 | 21 | def __init__(self): 22 | self.dicom_id = None # the StudyInstanceUID dicom tag 23 | self.remote_modality_id = None # the alias of the modality where the study is 24 | self.tags = None # the tags that have been retrieved (depends on the query used to find it) 25 | 26 | 27 | class RemoteModalitySeries: 28 | 29 | """ 30 | Represents a series on a remote modality. This is populated with the results of a C-Find request on that modality 31 | """ 32 | 33 | def __init__(self): 34 | self.dicom_id = None # the SeriesInstanceUID dicom tag 35 | self.remote_modality_id = None # the alias of the modality where the study is 36 | self.tags = None # the tags that have been retrieved (depends on the query used to find it) 37 | 38 | 39 | class RemoteModalityInstance: 40 | 41 | """ 42 | Represents an instance on a remote modality. This is populated with the results of a C-Find request on that modality 43 | """ 44 | 45 | def __init__(self): 46 | self.dicom_id = None # the SOPInstanceUID dicom tag 47 | self.remote_modality_id = None # the alias of the modality where the study is 48 | self.tags = None # the tags that have been retrieved (depends on the query used to find it) 49 | 50 | 51 | class QueryResult: 52 | 53 | def __init__(self): 54 | self.tags = None 55 | self.retrieve_url = None 56 | 57 | 58 | class DicomModalities: 59 | 60 | def __init__(self, api_client: 'OrthancApiClient'): 61 | self._api_client = api_client 62 | self._url_segment = 'modalities' 63 | 64 | 65 | def find_worklist(self, modality: str, query = {}): 66 | r = self._api_client.post( 67 | endpoint=f"{self._url_segment}/{modality}/find-worklist", 68 | json=query 69 | ) 70 | 71 | if r.status_code == 200: 72 | return r.json() 73 | 74 | def store(self, target_modality: str, resources_ids: Union[List[str], str], timeout: Optional[float] = None): 75 | """alias for send""" 76 | return self.send(target_modality=target_modality, resources_ids=resources_ids, timeout=timeout) 77 | 78 | def send_async(self, target_modality: str, resources_ids: Union[List[str], str], local_aet: str = None) -> Job: 79 | """sends a list of resources to a remote DICOM modality 80 | 81 | Returns 82 | ------- 83 | the created job 84 | """ 85 | 86 | if isinstance(resources_ids, str): 87 | resources_ids = [resources_ids] 88 | 89 | payload = { 90 | "Resources": resources_ids, 91 | "Synchronous": False 92 | } 93 | if local_aet is not None: 94 | payload.update({"LocalAet": local_aet}) 95 | 96 | r = self._api_client.post( 97 | endpoint=f"{self._url_segment}/{target_modality}/store", 98 | json=payload 99 | ) 100 | 101 | return Job(api_client=self._api_client, orthanc_id=r.json()['ID']) 102 | 103 | def send(self, target_modality: str, resources_ids: Union[List[str], str], timeout: Optional[float] = None, local_aet: str = None): 104 | """sends a list of resources to a remote DICOM modality 105 | The transfer is synchronous 106 | 107 | Returns 108 | ------- 109 | Nothing, will raise if failing 110 | """ 111 | 112 | if isinstance(resources_ids, str): 113 | resources_ids = [resources_ids] 114 | 115 | payload = { 116 | "Synchronous": True, 117 | "Resources": resources_ids 118 | } 119 | 120 | if timeout is not None: 121 | payload["Timeout"] = int(timeout+0.5) 122 | 123 | if local_aet is not None: 124 | payload.update({"LocalAet": local_aet}) 125 | 126 | self._api_client.post( 127 | endpoint=f"{self._url_segment}/{target_modality}/store", 128 | json=payload) 129 | 130 | def retrieve_study(self, from_modality: str, dicom_id: str, retrieve_method: RetrieveMethod = RetrieveMethod.MOVE) -> str: 131 | """ 132 | retrieves a study from a remote modality (C-Move) 133 | 134 | this call is synchronous. It completes once the C-Move is complete. 135 | 136 | :param from_modality: the modality alias configured in orthanc 137 | :param dicom_id: the StudyInstanceUid of the study to retrieve 138 | :param retrieve_method: whether we should use C-MOVE, C-GET or the System default configuration 139 | 140 | Returns: the study orthanc_id of the study once it has been retrieved in orthanc 141 | """ 142 | 143 | if retrieve_method == RetrieveMethod.MOVE: 144 | # move the study from the remote modality to this orthanc 145 | self.move_study( 146 | from_modality=from_modality, 147 | dicom_id=dicom_id 148 | ) 149 | else: 150 | # retrieve the study from the remote modality to this orthanc 151 | self.get_study( 152 | from_modality=from_modality, 153 | dicom_id=dicom_id 154 | ) 155 | 156 | # this request has no real response '{}' if it succeeds 157 | return self._api_client.studies.lookup(dicom_id) 158 | 159 | def get_study(self, from_modality: str, dicom_id: str): 160 | """ 161 | retrieves a study from a remote modality (C-Get) 162 | 163 | this call is synchronous. It completes once the C-Get is complete. 164 | 165 | :param from_modality: the modality alias configured in orthanc 166 | :param dicom_id: the StudyInstanceUid of the study to move 167 | """ 168 | self._get( 169 | level="Study", 170 | resource={ 171 | "StudyInstanceUID": dicom_id 172 | }, 173 | from_modality=from_modality 174 | ) 175 | 176 | def get_study_async(self, from_modality: str, dicom_id: str) -> Job: 177 | """ 178 | retrieves a study from a remote modality (C-Get) 179 | 180 | This call is asynchronous and returns a job. 181 | 182 | :param from_modality: the modality alias configured in orthanc 183 | :param dicom_id: the StudyInstanceUid of the study to move 184 | """ 185 | return Job.from_json(self._api_client, self._get( 186 | level="Study", 187 | resource={ 188 | "StudyInstanceUID": dicom_id 189 | }, 190 | from_modality=from_modality, 191 | asynchronous=True 192 | )) 193 | 194 | 195 | def get_series(self, from_modality: str, dicom_id: str, study_dicom_id: str): 196 | """ 197 | retrieves a series from a remote modality (C-Get) 198 | 199 | this call is synchronous. It completes once the C-Get is complete. 200 | 201 | :param from_modality: the modality alias configured in orthanc 202 | :param dicom_id: the SeriesInstanceUID of the series to move 203 | :param study_dicom_id: the StudyInstanceUID of the parent study 204 | """ 205 | self._get( 206 | level="Series", 207 | resource={ 208 | "SeriesInstanceUID": dicom_id, 209 | "StudyInstanceUID": study_dicom_id 210 | }, 211 | from_modality=from_modality 212 | ) 213 | 214 | def get_instance(self, from_modality: str, dicom_id: str, series_dicom_id: str, study_dicom_id: str): 215 | """ 216 | retrieves an instance from a remote modality (C-Get) to a target modality (AET) 217 | 218 | this call is synchronous. It completes once the C-Get is complete. 219 | 220 | :param from_modality: the modality alias configured in orthanc 221 | :param dicom_id: the SOPInstanceUid of the instance to move 222 | :param series_dicom_id: the SeriesInstanceUID of the parent series 223 | :param study_dicom_id: the StudyInstanceUID of the parent study 224 | """ 225 | self._get( 226 | level="Instance", 227 | resource={ 228 | "SOPInstanceUID": dicom_id, 229 | "SeriesInstanceUID": series_dicom_id, 230 | "StudyInstanceUID": study_dicom_id 231 | }, 232 | from_modality=from_modality 233 | ) 234 | 235 | def move_study(self, from_modality: str, dicom_id: str, to_modality_aet: str = None): 236 | """ 237 | moves a study from a remote modality (C-Move) to a target modality (AET) 238 | 239 | this call is synchronous. It completes once the C-Move is complete. 240 | 241 | :param from_modality: the modality alias configured in orthanc 242 | :param dicom_id: the StudyInstanceUid of the study to move 243 | :param to_modality_aet: the AET of the target modality 244 | """ 245 | self._move( 246 | level="Study", 247 | resource={ 248 | "StudyInstanceUID": dicom_id 249 | }, 250 | from_modality=from_modality, 251 | to_modality_aet=to_modality_aet 252 | ) 253 | 254 | def move_study_async(self, from_modality: str, dicom_id: str, to_modality_aet: str = None) -> Job: 255 | """ 256 | moves a study from a remote modality (C-Move) to a target modality (AET) 257 | 258 | This call is asynchronous. It returns a job. 259 | 260 | :param from_modality: the modality alias configured in orthanc 261 | :param dicom_id: the StudyInstanceUid of the study to move 262 | :param to_modality_aet: the AET of the target modality 263 | """ 264 | return Job.from_json(self._api_client, self._move( 265 | level="Study", 266 | resource={ 267 | "StudyInstanceUID": dicom_id 268 | }, 269 | from_modality=from_modality, 270 | to_modality_aet=to_modality_aet, 271 | asynchronous=True 272 | )) 273 | 274 | def move_series(self, from_modality: str, dicom_id: str, study_dicom_id: str, to_modality_aet: str = None): 275 | """ 276 | moves a series from a remote modality (C-Move) to a target modality (AET) 277 | 278 | this call is synchronous. It completes once the C-Move is complete. 279 | 280 | :param from_modality: the modality alias configured in orthanc 281 | :param dicom_id: the SeriesInstanceUID of the series to move 282 | :param study_dicom_id: the StudyInstanceUID of the parent study 283 | :param to_modality_aet: the AET of the target modality 284 | """ 285 | self._move( 286 | level="Series", 287 | resource={ 288 | "SeriesInstanceUID": dicom_id, 289 | "StudyInstanceUID": study_dicom_id 290 | }, 291 | from_modality=from_modality, 292 | to_modality_aet=to_modality_aet 293 | ) 294 | 295 | def move_instance(self, from_modality: str, dicom_id: str, series_dicom_id: str, study_dicom_id: str, to_modality_aet: str = None): 296 | """ 297 | moves an instance from a remote modality (C-Move) to a target modality (AET) 298 | 299 | this call is synchronous. It completes once the C-Move is complete. 300 | 301 | :param from_modality: the modality alias configured in orthanc 302 | :param dicom_id: the SOPInstanceUid of the instance to move 303 | :param series_dicom_id: the SeriesInstanceUID of the parent series 304 | :param study_dicom_id: the StudyInstanceUID of the parent study 305 | :param to_modality_aet: the AET of the target modality 306 | """ 307 | self._move( 308 | level="Instance", 309 | resource={ 310 | "SOPInstanceUID": dicom_id, 311 | "SeriesInstanceUID": series_dicom_id, 312 | "StudyInstanceUID": study_dicom_id 313 | }, 314 | from_modality=from_modality, 315 | to_modality_aet=to_modality_aet 316 | ) 317 | 318 | def _move(self, level: str, resource: object, from_modality: str, to_modality_aet: str = None, asynchronous: bool = False): 319 | """ 320 | moves a study from a remote modality (C-Move) to a target modality (AET) 321 | 322 | :param from_modality: the modality alias configured in orthanc 323 | :param to_modality_aet: the AET of the target modality 324 | """ 325 | 326 | payload = { 327 | 'Level': level, 328 | 'Resources': [resource], 329 | 'Asynchronous': asynchronous 330 | } 331 | 332 | if to_modality_aet: 333 | payload['TargetAet'] = to_modality_aet 334 | 335 | return self._api_client.post( 336 | endpoint=f"{self._url_segment}/{from_modality}/move", 337 | json=payload).json() 338 | 339 | 340 | def _get(self, level: str, resource: object, from_modality: str, asynchronous: bool = False): 341 | """ 342 | retrieves a study from a remote modality (C-Get) 343 | 344 | this call is synchronous. It completes once the C-Get is complete. 345 | 346 | :param from_modality: the modality alias configured in orthanc 347 | """ 348 | 349 | payload = { 350 | 'Level': level, 351 | 'Resources': [resource], 352 | 'Asynchronous': asynchronous 353 | } 354 | 355 | return self._api_client.post( 356 | endpoint=f"{self._url_segment}/{from_modality}/get", 357 | json=payload).json() 358 | 359 | 360 | def query_studies(self, from_modality: str, query: object) -> typing.List[RemoteModalityStudy]: 361 | """ 362 | queries a remote modality for studies 363 | 364 | :param from_modality: the modality alias configured in orthanc 365 | :param query: DICOM queries; i.e: {PatientName:'TOTO*', StudyDate:'20150503-'} 366 | """ 367 | 368 | payload = { 369 | 'Level': 'Studies', 370 | 'Query': query 371 | } 372 | 373 | results = self._query(from_modality, payload) 374 | 375 | remote_studies = [] 376 | for result in results: 377 | remote_study = RemoteModalityStudy() 378 | remote_study.dicom_id = result.tags.get('StudyInstanceUID') 379 | remote_study.tags = result.tags 380 | remote_study.remote_modality_id = from_modality 381 | 382 | remote_studies.append(remote_study) 383 | 384 | return remote_studies 385 | 386 | def query_series(self, from_modality: str, query: object) -> typing.List[RemoteModalitySeries]: 387 | """ 388 | queries a remote modality for series 389 | 390 | :param from_modality: the modality alias configured in orthanc 391 | :param query: DICOM queries; i.e: {PatientName:'TOTO*', StudyDate:'20150503-'} 392 | """ 393 | 394 | payload = { 395 | 'Level': 'Series', 396 | 'Query': query 397 | } 398 | 399 | results = self._query(from_modality, payload) 400 | 401 | remote_series = [] 402 | for result in results: 403 | remote_serie = RemoteModalitySeries() 404 | remote_serie.dicom_id = result.tags.get('SeriesInstanceUID') 405 | remote_serie.tags = result.tags 406 | remote_serie.remote_modality_id = from_modality 407 | 408 | remote_series.append(remote_serie) 409 | 410 | return remote_series 411 | 412 | def query_instances(self, from_modality: str, query: object) -> typing.List[RemoteModalityInstance]: 413 | """ 414 | queries a remote modality for instances 415 | 416 | :param from_modality: the modality alias configured in orthanc 417 | :param query: DICOM queries; i.e: {PatientName:'TOTO*', StudyDate:'20150503-'} 418 | """ 419 | 420 | payload = { 421 | 'Level': 'Instance', 422 | 'Query': query 423 | } 424 | 425 | results = self._query(from_modality, payload) 426 | 427 | remote_instances = [] 428 | for result in results: 429 | remote_instance = RemoteModalityInstance() 430 | remote_instance.dicom_id = result.tags.get('SOPInstanceUID') 431 | remote_instance.tags = result.tags 432 | remote_instance.remote_modality_id = from_modality 433 | 434 | remote_instances.append(remote_instance) 435 | 436 | return remote_instances 437 | 438 | def _query(self, from_modality, payload) -> typing.List[QueryResult]: 439 | 440 | query = self._api_client.post( 441 | endpoint=f"{self._url_segment}/{from_modality}/query", 442 | json=payload) 443 | 444 | query_id = query.json()['ID'] 445 | 446 | results = [] 447 | 448 | answers = self._api_client.get(endpoint = f"queries/{query_id}/answers") 449 | 450 | for answer_id in answers.json(): 451 | result = QueryResult() 452 | result.tags = SimplifiedTags(self._api_client.get(f"queries/{query_id}/answers/{answer_id}/content?simplify").json()) 453 | result.retrieve_url = f"queries/{query_id}/answers/{answer_id}/retrieve" 454 | results.append(result) 455 | 456 | return results 457 | 458 | def delete(self, modality: str): 459 | query = self._api_client.delete( 460 | endpoint=f"{self._url_segment}/{modality}") 461 | 462 | def configure(self, modality: str, configuration: dict): 463 | 464 | query = self._api_client.put( 465 | endpoint=f"{self._url_segment}/{modality}", 466 | json=configuration 467 | ) 468 | 469 | def get_configuration(self, modality: str) -> dict: 470 | all_modalities = self._api_client.get_json( 471 | endpoint=f"{self._url_segment}?expand" 472 | ) 473 | 474 | if modality in all_modalities: 475 | return all_modalities[modality] 476 | else: 477 | raise ResourceNotFound(msg=f"The modality {modality} was not found") 478 | 479 | def get_all_ids(self) -> List[str]: 480 | return self._api_client.get_json( 481 | endpoint=f"{self._url_segment}" 482 | ) 483 | 484 | def get_id_from_aet(self, aet: str) -> str: 485 | all_modalities = self._api_client.get_json( 486 | endpoint=f"{self._url_segment}?expand" 487 | ) 488 | 489 | for (alias, values) in all_modalities.items(): 490 | if values["AET"] == aet: 491 | return alias 492 | else: 493 | raise ResourceNotFound(msg=f"No modality found with AET '{aet}'") 494 | -------------------------------------------------------------------------------- /orthanc_api_client/api_client.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import logging 4 | import typing 5 | import datetime 6 | import zipfile 7 | import tempfile 8 | from typing import List, Optional, Dict 9 | from urllib.parse import urlunsplit, urlencode 10 | 11 | from .http_client import HttpClient 12 | from .resources import Instances, SeriesList, Studies, Jobs, Patients 13 | 14 | from .helpers import wait_until, encode_multipart_related, is_version_at_least 15 | from .exceptions import * 16 | from .dicomweb_servers import DicomWebServers 17 | from .modalities import DicomModalities 18 | from .change import Change, ChangeType, ResourceType 19 | from .transfers import Transfers 20 | from .peers import Peers 21 | from .logging import LogLevel 22 | from .capabilities import Capabilities 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | class SystemStatistics: 27 | 28 | def __init__(self, json_stats): 29 | self.instances_count = json_stats['CountInstances'] 30 | self.patients_count = json_stats['CountPatients'] 31 | self.series_count = json_stats['CountSeries'] 32 | self.studies_count = json_stats['CountStudies'] 33 | self.total_disk_size = int(json_stats['TotalDiskSize']) 34 | self.total_disk_size_mb = json_stats['TotalDiskSizeMB'] 35 | self.total_uncompressed_size = int(json_stats['TotalUncompressedSize']) 36 | self.total_uncompressed_size_mb = json_stats['TotalUncompressedSizeMB'] 37 | 38 | 39 | class OrthancApiClient(HttpClient): 40 | 41 | def __init__(self, orthanc_root_url: str, user: Optional[str] = None, pwd: Optional[str] = None, api_token: Optional[str] = None, headers: Optional[Dict[str, str]] = None ) -> None: 42 | """Creates an HttpClient 43 | 44 | Parameters 45 | ---------- 46 | orthanc_root_url: base orthanc url: ex= 'http://localhost:8042' 47 | user: an orthanc user name (for basic Auth) 48 | pwd: the password for the orthanc user (for basic Auth) 49 | api_token: a token obtained from inside an Orthanc python plugin through orthanc.GenerateRestApiAuthorizationToken 50 | format: 'Bearer 3d03892c-fe...' or '3d03892c-fe...' 51 | headers: HTTP headers that will be included in each requests 52 | """ 53 | if api_token: 54 | if headers is None: 55 | headers = {} 56 | if api_token.startswith('Bearer '): 57 | header_value = api_token 58 | else: 59 | header_value = f'Bearer {api_token}' 60 | headers['authorization'] = header_value 61 | 62 | super().__init__(root_url=orthanc_root_url, user=user, pwd=pwd, headers=headers) 63 | 64 | self.patients = Patients(api_client=self) 65 | self.studies = Studies(api_client=self) 66 | self.series = SeriesList(api_client=self) 67 | self.instances = Instances(api_client=self) 68 | self.dicomweb_servers = DicomWebServers(api_client=self) 69 | self.modalities = DicomModalities(api_client=self) 70 | self.jobs = Jobs(api_client=self) 71 | self.transfers = Transfers(api_client=self) 72 | self.peers = Peers(api_client=self) 73 | self.capabilities = Capabilities(api_client=self) 74 | 75 | def __repr__(self) -> str: 76 | return f"{self._root_url}" 77 | 78 | def wait_started(self, timeout: float = None) -> bool: 79 | return wait_until(self.is_alive, timeout) 80 | 81 | def is_alive(self, timeout = 1) -> bool: 82 | """Checks if the orthanc server can be reached. 83 | 84 | Returns 85 | ------- 86 | True if orthanc can be reached, False otherwise 87 | """ 88 | try: 89 | # if we get an answer to a basic request, it means the server is alive 90 | self.get('system', timeout=timeout) 91 | return True 92 | except Exception as e: 93 | return False 94 | 95 | def is_orthanc_version_at_least(self, expected_major: int, expected_minor: int, expected_patch: Optional[int] = None) -> bool: 96 | s = self.get_system() 97 | return is_version_at_least(s.get("Version"), expected_major, expected_minor, expected_patch) 98 | 99 | def is_plugin_version_at_least(self, plugin_name: str, expected_major: int, expected_minor: int, expected_patch: Optional[int] = None) -> bool: 100 | if self.has_loaded_plugin(plugin_name): 101 | plugin = self.get_json(f"plugins/{plugin_name}") 102 | return is_version_at_least(plugin.get("Version"), expected_major, expected_minor, expected_patch) 103 | return False 104 | 105 | def has_loaded_plugin(self, plugin_name: str) -> bool: 106 | plugins = self.get_json('plugins') 107 | return plugin_name in plugins 108 | 109 | def get_system(self) -> object: 110 | return self.get_json('system') 111 | 112 | def get_statistics(self) -> SystemStatistics: 113 | return SystemStatistics(json_stats=self.get_json('statistics')) 114 | 115 | def delete_all_content(self): 116 | """Deletes all content from Orthanc""" 117 | self.patients.delete_all() 118 | 119 | def upload(self, buffer: bytes, ignore_errors: bool = False) -> List[str]: 120 | """Uploads the content of a binary buffer to Orthanc (can be a DICOM file or a zip file) 121 | 122 | Parameters 123 | ---------- 124 | ignore_errors: if True: does not raise exceptions 125 | 126 | Returns 127 | ------- 128 | the instance id of the uploaded file or None when uploading a zip file 129 | """ 130 | try: 131 | response = self.post('instances', data=buffer) 132 | if isinstance(response.json(), list): 133 | return [x['ID'] for x in response.json()] 134 | else: 135 | return [response.json()['ID']] 136 | except HttpError as ex: 137 | if ex.http_status_code == 409 and ignore_errors: # same instance being uploaded twice at the same time 138 | return [] 139 | if ex.http_status_code == 400 and ex.request_response.json()['OrthancStatus'] == 15: 140 | if ignore_errors: 141 | return [] 142 | else: 143 | raise BadFileFormat(ex) 144 | else: 145 | raise ex 146 | 147 | 148 | def upload_file(self, path, ignore_errors: bool = False) -> List[str]: 149 | """Uploads a file to Orthanc (can be a DICOM file or a zip file) 150 | 151 | Parameters 152 | ---------- 153 | ignore_errors: if True: does not raise exceptions 154 | 155 | Returns 156 | ------- 157 | the list of instances ids (one if a single file, can be multiple if the uploaded file is a zip) 158 | """ 159 | logger.info(f"uploading {path}") 160 | with open(path, 'rb') as f: 161 | return self.upload(f.read(), ignore_errors) 162 | 163 | 164 | def upload_folder(self, 165 | folder_path: str, 166 | skip_extensions: List[str] = None, 167 | ignore_dots: bool = True, 168 | ignore_errors: bool = False 169 | ) -> List[str]: 170 | """Uploads all files from a folder. 171 | 172 | Parameters 173 | ---------- 174 | folder_path: the folder to upload 175 | skip_extensions: a list of extensions to skip e.g: ['.ini', '.bmp'] 176 | ignore_dots: to ignore files/folders starting with a dot 177 | ignore_errors: if True: does not raise exceptions 178 | 179 | Returns 180 | ------- 181 | A list of instances id (one for each uploaded file) 182 | """ 183 | 184 | instances_ids = [] 185 | 186 | for path in os.listdir(folder_path): 187 | if ignore_dots and path.startswith('.'): 188 | continue 189 | 190 | full_path = os.path.join(folder_path, path) 191 | if os.path.isfile(full_path): 192 | if not skip_extensions or not any([full_path.endswith(ext) for ext in skip_extensions]): 193 | instances_ids.extend(self.upload_file(full_path, ignore_errors=ignore_errors)) 194 | elif os.path.isdir(full_path): 195 | instances_ids.extend(self.upload_folder(full_path, ignore_errors=ignore_errors, skip_extensions=skip_extensions)) 196 | 197 | return instances_ids 198 | 199 | def upload_folder_return_details(self, folder_path: str, unzip_before_upload: bool = False) -> (typing.Set, typing.Set, typing.List): 200 | ''' 201 | Uploads all the files contained in the folder, including the ones in the sub-folders. 202 | Returns some details 203 | Parameters 204 | ---------- 205 | folder_path: the folder to upload 206 | unzip_before_upload: if True, a zip file will be unzipped and all resulting files will be uploaded 207 | (if False, the zip file will be uploaded as it is) 208 | 209 | Returns 210 | ------- 211 | - A Set with all the StudyInstanceUID uploaded 212 | - A Set with all the Study orthanc Ids uploaded 213 | - A List with all the files names which were not correctly uploaded + corresponding error 214 | ''' 215 | dicom_ids_set = set() 216 | orthanc_ids_set = set() 217 | rejected_files_list = [] 218 | 219 | for path in os.listdir(folder_path): 220 | full_path = os.path.join(folder_path, path) 221 | if os.path.isfile(full_path): 222 | 223 | if unzip_before_upload and zipfile.is_zipfile(full_path): 224 | with tempfile.TemporaryDirectory() as tempDir: 225 | with zipfile.ZipFile(full_path, 'r') as z: 226 | z.extractall(tempDir) 227 | zip_dicom_ids_set, zip_orthanc_ids_set, zip_rejected_files_list = self.upload_folder_return_details(folder_path=tempDir) 228 | dicom_ids_set.update(zip_dicom_ids_set) 229 | orthanc_ids_set.update(zip_orthanc_ids_set) 230 | rejected_files_list.extend(zip_rejected_files_list) 231 | else: 232 | try: 233 | instance_orthanc_ids = self.upload_file(full_path, ignore_errors=False) 234 | for id in instance_orthanc_ids: 235 | dicom_ids_set.add(self.instances.get_tags(id)["StudyInstanceUID"]) 236 | orthanc_ids_set.add(self.instances.get_parent_study_id(id)) 237 | except Exception as e: 238 | rejected_files_list.append([str(full_path), str(e)]) 239 | elif os.path.isdir(full_path): 240 | sub_dicom_ids_set, sub_orthanc_ids_set, sub_rejected_files_list = self.upload_folder_return_details(full_path) 241 | dicom_ids_set.update(sub_dicom_ids_set) 242 | orthanc_ids_set.update(sub_orthanc_ids_set) 243 | rejected_files_list.extend(sub_rejected_files_list) 244 | 245 | return dicom_ids_set, orthanc_ids_set, rejected_files_list 246 | 247 | def upload_files_dicom_web(self, paths: List[str], ignore_errors: bool = False, endpoint: str = "dicom-web/studies") -> any: 248 | """Uploads files to Orthanc through its DicomWeb API (only DICOM files, no zip files) 249 | 250 | Parameters 251 | ---------- 252 | ignore_errors: if True: does not raise exceptions 253 | """ 254 | logger.info(f"uploading {len(paths)} files through DicomWeb STOW-RS") 255 | 256 | files = {} 257 | counter = 1 258 | for path in paths: 259 | with open(path, 'rb') as f: 260 | raw_file = f.read() 261 | files[f'file{counter}'] = (str(path), raw_file, 'application/dicom') 262 | counter += 1 263 | 264 | body, content_type = encode_multipart_related(fields=files) 265 | r = self.post(endpoint=endpoint, 266 | data=body, 267 | headers = { 268 | 'Accept':'application/json', 269 | 'Content-Type': content_type 270 | }) 271 | return r.json() 272 | 273 | def lookup(self, needle: str, filter: str = None) -> List[str]: 274 | """searches the Orthanc DB for the 'needle' 275 | 276 | Parameters: 277 | ---------- 278 | needle: the value to look for (may be a StudyInstanceUid, a PatientID, ...) 279 | filter: the only type returned, 'None' will return all types (Study, Patient, Series, Instance) 280 | 281 | Returns: 282 | ------- 283 | the list of resources ids 284 | """ 285 | response = self.post( 286 | endpoint="tools/lookup", 287 | data=needle 288 | ) 289 | 290 | resources = [] 291 | json_response = response.json() 292 | 293 | for r in json_response: 294 | if r['Type'] == 'Study' and (filter is None or filter == 'Study'): 295 | resources.append(r['ID']) 296 | elif r['Type'] == 'Patient' and (filter is None or filter == 'Patient'): 297 | resources.append(r['ID']) 298 | elif r['Type'] == 'Series' and (filter is None or filter == 'Series'): 299 | resources.append(r['ID']) 300 | elif r['Type'] == 'Instance' and (filter is None or filter == 'Instance'): 301 | resources.append(r['ID']) 302 | 303 | return resources 304 | 305 | def get_changes(self, since: int = None, limit: int = None) -> typing.Tuple[List[Change], int, bool]: 306 | """ get the changes 307 | 308 | Parameters: 309 | ---------- 310 | since: request changes from this sequence_id 311 | limit: limit the number of changes in the response 312 | 313 | Returns: 314 | ------- 315 | - the list of changes 316 | - the last sequence id returned 317 | - a boolean indicating if there are more changes to load 318 | """ 319 | 320 | args = {} 321 | 322 | if since: 323 | args['since'] = since 324 | if limit: 325 | args['limit'] = limit 326 | 327 | response = self.get_json( 328 | endpoint="changes?" + urlencode(args) 329 | ) 330 | 331 | changes = [] 332 | for c in response['Changes']: 333 | changes.append(Change( 334 | change_type=c.get('ChangeType'), 335 | timestamp=datetime.datetime.strptime(c.get('Date'), "%Y%m%dT%H%M%S"), 336 | sequence_id=c.get('Seq'), 337 | resource_type=c.get('ResourceType'), 338 | resource_id=c.get('ID') 339 | )) 340 | done = response['Done'] 341 | last_sequence_id = response['Last'] 342 | 343 | return changes, last_sequence_id, done 344 | 345 | 346 | def create_pdf(self, pdf_path: str, dicom_tags: object, parent_id: str = None): 347 | """ 348 | Creates an instance with an embedded pdf file. If not parent_id is specified, this instance is part of a new study. 349 | 350 | dicom_tags = { 351 | 'PatientID': '1234', 352 | 'PatientName': 'Toto', 353 | 'AccessionNumber': '1234', 354 | 'PatientSex' : 'M', 355 | 'PatientBirthDate' : '20000101', 356 | 'StudyDescription': 'test' 357 | } 358 | ) 359 | If you do not provide any value for 'SOPClassUID', the 'Encapsulated PDF Storage' will be used ('1.2.840.10008.5.1.4.1.1.104.1') 360 | 361 | Returns: 362 | the instance_orthanc_id of the created instance 363 | """ 364 | if 'SOPClassUID' not in dicom_tags: 365 | dicom_tags['SOPClassUID'] = '1.2.840.10008.5.1.4.1.1.104.1' 366 | 367 | return self._create_instance_from_data_path(data_path = pdf_path, 368 | content_type = 'application/pdf', 369 | dicom_tags = dicom_tags, 370 | parent_id = parent_id) 371 | 372 | def _create_instance_from_data_path(self, data_path: str, content_type: str, dicom_tags: object, parent_id: str = None): 373 | """ 374 | Creates an instance with embedded data. If not parent_id is specified, this instance is part of a new study. 375 | 376 | Returns: 377 | the instance_orthanc_id of the created instance 378 | """ 379 | with open(data_path, 'rb') as f: 380 | content = f.read() 381 | 382 | return self._create_instance_from_data(content, content_type, dicom_tags, parent_id) 383 | 384 | def _create_instance_from_data(self, content: bytes, content_type: str, dicom_tags: object, parent_id: str = None): 385 | 386 | request_data = { 387 | 'Tags': dicom_tags, 388 | 'Content': "data:{content_type};base64,{data}".format(content_type = content_type, data = base64.b64encode(content).decode('utf-8')) 389 | } 390 | 391 | if parent_id is not None: 392 | request_data['Parent'] = parent_id 393 | 394 | response = self.post( 395 | endpoint = 'tools/create-dicom', 396 | json = request_data 397 | ) 398 | return response.json()['ID'] 399 | 400 | 401 | def create_instance_from_png(self, image_path: str, dicom_tags: object, parent_id: str = None): 402 | """ 403 | Creates an instance with an embedded image. If not parent_id is specified, this instance is part of a new study. 404 | 405 | Note: it is recommended to provide at least all these tags: 406 | dicom_tags = { 407 | 'PatientID': '1234', 408 | 'PatientName': 'Toto', 409 | 'AccessionNumber': '1234', 410 | 'PatientSex' : 'M', 411 | 'PatientBirthDate' : '20000101', 412 | 'StudyDescription': 'test', 413 | 'Modality': 'MR'} 414 | ) 415 | If you do not provide any value for 'SOPClassUID', the 'CR Image Storage' will be used ('1.2.840.10008.5.1.4.1.1.1') 416 | 417 | Returns: 418 | the instance_orthanc_id of the created instance 419 | """ 420 | if 'SOPClassUID' not in dicom_tags: 421 | dicom_tags['SOPClassUID'] = '1.2.840.10008.5.1.4.1.1.1' 422 | 423 | return self._create_instance_from_data_path(data_path = image_path, 424 | content_type = 'image/png', 425 | dicom_tags = dicom_tags, 426 | parent_id = parent_id) 427 | 428 | def create_instance_from_jpeg(self, image_path: str, dicom_tags: object, parent_id: str = None): 429 | """ 430 | Creates an instance with an embedded image. If not parent_id is specified, this instance is part of a new study. 431 | 432 | Note: it is recommended to provide at least all these tags: 433 | dicom_tags = { 434 | 'PatientID': '1234', 435 | 'PatientName': 'Toto', 436 | 'AccessionNumber': '1234', 437 | 'PatientSex' : 'M', 438 | 'PatientBirthDate' : '20000101', 439 | 'StudyDescription': 'test', 440 | 'Modality': 'MR'} 441 | ) 442 | If you do not provide any value for 'SOPClassUID', the 'CR Image Storage' will be used ('1.2.840.10008.5.1.4.1.1.1') 443 | 444 | Returns: 445 | the instance_orthanc_id of the created instance 446 | """ 447 | 448 | if 'SOPClassUID' not in dicom_tags: 449 | dicom_tags['SOPClassUID'] = '1.2.840.10008.5.1.4.1.1.1' 450 | 451 | return self._create_instance_from_data_path(data_path = image_path, 452 | content_type = 'image/jpeg', 453 | dicom_tags = dicom_tags, 454 | parent_id = parent_id) 455 | 456 | def get_all_labels(self): 457 | """ 458 | List all the labels that are associated with any resource of the Orthanc database 459 | """ 460 | return self.get_json(endpoint="tools/labels") 461 | 462 | def execute_lua_script(self, buffer: bytes): 463 | """ 464 | Uploads the content of a binary buffer to be executed as a lua script 465 | 466 | Parameters: 467 | buffer: lua script content in a binary format 468 | 469 | Returns: 470 | The content of the response. 471 | """ 472 | try: 473 | response = self.post('tools/execute-script', data=buffer) 474 | return response.content 475 | except HttpError as ex: 476 | if ex.http_status_code == 403: 477 | raise Forbidden(ex) 478 | else: 479 | raise ex 480 | 481 | def get_log_level(self): 482 | return LogLevel(self.get_binary(endpoint="tools/log-level").decode('utf-8')) 483 | 484 | def set_log_level(self, level: LogLevel): 485 | self.put(endpoint="tools/log-level", data=level) 486 | return LogLevel(self.get_binary(endpoint="tools/log-level").decode('utf-8')) 487 | -------------------------------------------------------------------------------- /orthanc_api_client/resources/resources.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import json 4 | import logging 5 | from typing import List, Tuple, Optional, Any 6 | from ..exceptions import * 7 | from ..helpers import to_dicom_date 8 | from ..job import Job, JobStatus 9 | import orthanc_api_client.exceptions as api_exceptions 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Resources: 16 | 17 | def __init__(self, api_client: 'OrthancApiClient', url_segment: str): 18 | self._url_segment = url_segment 19 | self._api_client = api_client 20 | 21 | def _get_level(self): 22 | if self._url_segment == "studies": 23 | return "Study" 24 | elif self._url_segment == "series": 25 | return "Series" 26 | elif self._url_segment == "instances": 27 | return "Instance" 28 | elif self._url_segment == "patients": 29 | return "Patient" 30 | 31 | def get_json(self, orthanc_id: str): 32 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}") 33 | 34 | def get_json_statistics(self, orthanc_id: str): 35 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/statistics") 36 | 37 | def get_all_ids(self) -> List[str]: 38 | return self._api_client.get_json(f"{self._url_segment}/") 39 | 40 | def delete(self, orthanc_id: Optional[str] = None, orthanc_ids: Optional[List[str]] = None, ignore_errors: bool = False): 41 | 42 | if orthanc_ids: 43 | for oi in orthanc_ids: 44 | self.delete(orthanc_id=oi, ignore_errors=ignore_errors) 45 | 46 | if orthanc_id: 47 | logger.debug(f"deleting {self._url_segment} {orthanc_id}") 48 | try: 49 | self._api_client.delete(f"{self._url_segment}/{orthanc_id}") 50 | except ResourceNotFound as ex: 51 | if not ignore_errors: 52 | raise ex 53 | 54 | def delete_all(self, ignore_errors: bool = False) -> List[str]: 55 | all_ids = self.get_all_ids() 56 | deleted_ids = [] 57 | 58 | for orthanc_id in all_ids: 59 | self.delete(orthanc_id, ignore_errors=ignore_errors) 60 | deleted_ids.append(orthanc_id) 61 | 62 | return deleted_ids 63 | 64 | def set_attachment(self, orthanc_id: str, attachment_name: str, content: Optional[str] = None, path: Optional[str] = None, content_type: Optional[str] = None, match_revision: Optional[str] = None): 65 | 66 | if content is None and path is not None: 67 | with open(path, 'rb') as f: 68 | content = f.read() 69 | 70 | headers = {} 71 | 72 | if content_type: 73 | headers['Content-Type'] = content_type 74 | 75 | if match_revision is not None: 76 | headers['If-Match'] = match_revision 77 | 78 | self._api_client.put( 79 | endpoint=f"{self._url_segment}/{orthanc_id}/attachments/{attachment_name}", 80 | data=content, 81 | headers=headers 82 | ) 83 | 84 | def get_attachment(self, orthanc_id: str, attachment_name: str) -> bytes: 85 | 86 | content, revision = self.get_attachment_with_revision( 87 | orthanc_id=orthanc_id, 88 | attachment_name=attachment_name 89 | ) 90 | return content 91 | 92 | def get_attachment_with_revision(self, orthanc_id: str, attachment_name: str) -> Tuple[bytes, str]: 93 | 94 | headers = {} 95 | 96 | response = self._api_client.get( 97 | endpoint=f"{self._url_segment}/{orthanc_id}/attachments/{attachment_name}/data", 98 | headers=headers 99 | ) 100 | 101 | return response.content, response.headers.get('etag') 102 | 103 | def download_attachment(self, orthanc_id: str, attachment_name: str, path: str): 104 | content = self.get_attachment(orthanc_id, attachment_name) 105 | 106 | with open(path, 'wb') as f: 107 | f.write(content) 108 | 109 | def set_binary_metadata(self, orthanc_id: str, metadata_name: str, content: Optional[bytes] = None, path: Optional[str] = None, match_revision: Optional[str] = None): 110 | # sets the metadata only if the current revision matches `match_revision` 111 | # returns the new revision 112 | 113 | if content is None and path is not None: 114 | with open(path, 'rb') as f: 115 | content = f.read() 116 | 117 | headers = {} 118 | 119 | if match_revision is not None: 120 | headers['If-Match'] = match_revision 121 | 122 | self._api_client.put( 123 | endpoint=f"{self._url_segment}/{orthanc_id}/metadata/{metadata_name}", 124 | data=content, 125 | headers=headers 126 | ) 127 | 128 | def set_string_metadata(self, orthanc_id: str, metadata_name: str, content: Optional[str] = None, path: Optional[str] = None, match_revision: Optional[str] = None): 129 | 130 | if content is None and path is not None: 131 | with open(path, 'rt') as f: 132 | content = f.read() 133 | 134 | self.set_binary_metadata( 135 | orthanc_id=orthanc_id, 136 | metadata_name=metadata_name, 137 | content=content.encode('utf-8'), 138 | match_revision=match_revision 139 | ) 140 | 141 | 142 | def get_binary_metadata(self, orthanc_id: str, metadata_name: str, default_value: Optional[str] = None) -> bytes: 143 | 144 | content, revision = self.get_binary_metadata_with_revision( 145 | orthanc_id=orthanc_id, 146 | metadata_name=metadata_name, 147 | default_value=default_value 148 | ) 149 | 150 | return content 151 | 152 | def get_string_metadata(self, orthanc_id: str, metadata_name: str, default_value: Optional[str] = None) -> str: 153 | 154 | content, revision = self.get_binary_metadata_with_revision( 155 | orthanc_id=orthanc_id, 156 | metadata_name=metadata_name, 157 | default_value=default_value.encode('utf-8') if default_value is not None else None 158 | ) 159 | 160 | return content.decode('utf-8') if content is not None else None 161 | 162 | def get_binary_metadata_with_revision(self, orthanc_id: str, metadata_name: str, default_value: Optional[bytes] = None) -> Tuple[bytes, str]: 163 | 164 | headers = {} 165 | 166 | try: 167 | response = self._api_client.get( 168 | endpoint=f"{self._url_segment}/{orthanc_id}/metadata/{metadata_name}", 169 | headers=headers 170 | ) 171 | except ResourceNotFound: 172 | return default_value, None 173 | 174 | return response.content, response.headers.get('etag') 175 | 176 | def get_string_metadata_with_revision(self, orthanc_id: str, metadata_name: str, default_value: Optional[str] = None) -> Tuple[str, str]: 177 | 178 | content, revision = self.get_binary_metadata_with_revision( 179 | orthanc_id=orthanc_id, 180 | metadata_name=metadata_name, 181 | default_value=default_value.encode('utf-8') if default_value is not None else None 182 | ) 183 | 184 | return content.decode('utf-8') if content is not None else None, revision 185 | 186 | def has_metadata(self, orthanc_id: str, metadata_name: str) -> bool: 187 | return self.get_binary_metadata(orthanc_id=orthanc_id, metadata_name=metadata_name, default_value=None) is not None 188 | 189 | def _anonymize(self, orthanc_id: str, replace_tags={}, keep_tags=[], delete_original=True, force=False) -> str: 190 | """ 191 | anonymizes the study/series and possibly deletes the original resource (the one that has not be anonymized) 192 | 193 | Args: 194 | orthanc_id: the instance id to anonymize 195 | replace_tags: a dico with OrthancTagsId <-> values of the tags you want to force 196 | keep_tags: a list of tags you want to keep unmodified 197 | delete_original: True to delete the original study (the one that has not been anonymized) 198 | force: some tags like "PatientID" requires this flag set to True to confirm that you understand the risks 199 | Returns: 200 | the id of the new anonymized study/series 201 | """ 202 | 203 | query = { 204 | "Force": force 205 | } 206 | if replace_tags is not None and len(replace_tags) > 0: 207 | query['Replace'] = replace_tags 208 | if keep_tags is not None and len(keep_tags) > 0: 209 | query['Keep'] = keep_tags 210 | 211 | r = self._api_client.post( 212 | endpoint=f"{self._url_segment}/{orthanc_id}/anonymize", 213 | json=query) 214 | 215 | if r.status_code == 200: 216 | anonymized_id = r.json()['ID'] 217 | if delete_original and anonymized_id != orthanc_id: 218 | self.delete(orthanc_id) 219 | 220 | return anonymized_id 221 | 222 | return None # TODO: raise exception ??? 223 | 224 | def _modify(self, orthanc_id: str, replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], delete_original=True, force=False) -> str: 225 | """ 226 | modifies the study/series and possibly deletes the original resource (the one that has not be anonymized) 227 | 228 | Args: 229 | orthanc_id: the instance id to anonymize 230 | replace_tags: a dico with OrthancTagsId <-> values of the tags you want to force 231 | remove_tags: a list of tags you want to remove 232 | keep_tags: a list of tags you want to keep unmodified 233 | delete_original: True to delete the original study (the one that has not been anonymized) 234 | force: some tags like "PatientID" requires this flag set to True to confirm that you understand the risks 235 | Returns: 236 | the id of the new anonymized study/series 237 | """ 238 | 239 | query = { 240 | "Force": force 241 | } 242 | if replace_tags is not None and len(replace_tags) > 0: 243 | query['Replace'] = replace_tags 244 | if remove_tags is not None and len(remove_tags) > 0: 245 | query['Remove'] = remove_tags 246 | if keep_tags is not None and len(keep_tags) > 0: 247 | query['Keep'] = keep_tags 248 | 249 | r = self._api_client.post( 250 | endpoint=f"{self._url_segment}/{orthanc_id}/modify", 251 | json=query) 252 | 253 | if r.status_code == 200: 254 | modified_id = r.json()['ID'] 255 | if delete_original and modified_id != orthanc_id: 256 | self.delete(orthanc_id) 257 | 258 | return modified_id 259 | 260 | return None # TODO: raise exception ??? 261 | 262 | def modify_bulk(self, orthanc_ids: List[str] = [], replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], delete_original: bool = True, force: bool = False, transcode: Optional[str] = None, permissive: bool = False) -> Tuple[List[str], List[str], List[str], List[str]]: 263 | """ 264 | returns a tuple with: 265 | - the list of modified instances ids 266 | - the list of modified series ids 267 | - the list of modified studies ids 268 | - the list of modified patients ids 269 | """ 270 | modified_instances_ids = [] 271 | modified_series_ids = [] 272 | modified_studies_ids = [] 273 | modified_patients_ids = [] 274 | 275 | job = self.modify_bulk_async( 276 | orthanc_ids=orthanc_ids, 277 | replace_tags=replace_tags, 278 | remove_tags=remove_tags, 279 | keep_tags=keep_tags, 280 | delete_original=delete_original, 281 | force=force, 282 | transcode=transcode 283 | ) 284 | 285 | job.wait_completed() 286 | 287 | if job.info.status == JobStatus.SUCCESS and "Resources" in job.content: 288 | # extract the list of modified instances ids from the job content 289 | for r in job.content.get("Resources"): 290 | if r.get("Type") == "Instance": 291 | modified_instances_ids.append(r.get("ID")) 292 | elif r.get("Type") == "Series": 293 | modified_series_ids.append(r.get("ID")) 294 | elif r.get("Type") == "Study": 295 | modified_studies_ids.append(r.get("ID")) 296 | elif r.get("Type") == "Patient": 297 | modified_patients_ids.append(r.get("ID")) 298 | return modified_instances_ids, modified_series_ids, modified_studies_ids, modified_patients_ids 299 | else: 300 | raise api_exceptions.OrthancApiException(msg=f"Error while modifying bulk {self._get_level()}, job failed {json.dumps(job.info.content)}") 301 | 302 | 303 | def modify_bulk_async(self, orthanc_ids: List[str] = [], replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], delete_original: bool = True, force: bool = False, transcode: Optional[str] = None, permissive: bool = False) -> Job: 304 | return self._modify_bulk_async( 305 | operation="modify", 306 | orthanc_ids=orthanc_ids, 307 | replace_tags=replace_tags, 308 | remove_tags=remove_tags, 309 | keep_tags=keep_tags, 310 | delete_original=delete_original, 311 | force=force, 312 | transcode=transcode, 313 | permissive=permissive) 314 | 315 | def _modify_bulk(self, operation: str, orthanc_ids: List[str] = [], replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], delete_original: bool = True, force: bool = False, transcode: Optional[str] = None, permissive: bool = False) -> Tuple[List[str], List[str], List[str], List[str]]: 316 | """ 317 | returns a tuple with: 318 | - the list of modified instances ids 319 | - the list of modified series ids 320 | - the list of modified studies ids 321 | - the list of modified patients ids 322 | """ 323 | modified_instances_ids = [] 324 | modified_series_ids = [] 325 | modified_studies_ids = [] 326 | modified_patients_ids = [] 327 | 328 | job = self._modify_bulk_async( 329 | operation=operation, 330 | orthanc_ids=orthanc_ids, 331 | replace_tags=replace_tags, 332 | remove_tags=remove_tags, 333 | keep_tags=keep_tags, 334 | delete_original=delete_original, 335 | force=force, 336 | transcode=transcode 337 | ) 338 | 339 | job.wait_completed() 340 | 341 | if job.info.status == JobStatus.SUCCESS and "Resources" in job.content: 342 | # extract the list of modified instances ids from the job content 343 | for r in job.content.get("Resources"): 344 | if r.get("Type") == "Instance": 345 | modified_instances_ids.append(r.get("ID")) 346 | elif r.get("Type") == "Series": 347 | modified_series_ids.append(r.get("ID")) 348 | elif r.get("Type") == "Study": 349 | modified_studies_ids.append(r.get("ID")) 350 | elif r.get("Type") == "Patient": 351 | modified_patients_ids.append(r.get("ID")) 352 | return modified_instances_ids, modified_series_ids, modified_studies_ids, modified_patients_ids 353 | else: 354 | raise api_exceptions.OrthancApiException(msg=f"Error while {'modifying' if operation == 'modify' else 'anonymizing'} bulk {self._get_level()}, job failed {json.dumps(job.info.content)}") 355 | 356 | def _modify_bulk_async(self, operation: str, orthanc_ids: List[str] = [], replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], delete_original: bool = True, force: bool = False, transcode: Optional[str] = None, permissive: bool = False) -> Job: 357 | query = { 358 | "Force": force, 359 | "Level": self._get_level(), 360 | "Resources": orthanc_ids, 361 | "Asynchronous": True, 362 | "Permissive": permissive 363 | } 364 | 365 | if replace_tags is not None and len(replace_tags) > 0: 366 | query['Replace'] = replace_tags 367 | if remove_tags is not None and len(remove_tags) > 0: 368 | query['Remove'] = remove_tags 369 | if keep_tags is not None and len(keep_tags) > 0: 370 | query['Keep'] = keep_tags 371 | if transcode: 372 | query['Transcode'] = transcode 373 | if delete_original: 374 | query['KeepSource'] = False 375 | 376 | r = self._api_client.post( 377 | endpoint=f"/tools/bulk-{operation}", 378 | json=query) 379 | 380 | if r.status_code == 200 and "ID" in r.json(): 381 | return Job(api_client=self._api_client, orthanc_id=r.json()['ID']) 382 | else: 383 | raise HttpError(http_status_code=r.status_code, msg=f"Error in bulk-{operation}", url=r.url, request_response=r) 384 | 385 | 386 | def anonymize_bulk(self, orthanc_ids: List[str] = [], replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], delete_original: bool = False, force: bool = False, transcode: Optional[str] = None, permissive: bool = False) -> Tuple[List[str], List[str], List[str], List[str]]: 387 | """ 388 | returns a tuple with: 389 | - the list of anonymized instances ids 390 | - the list of anonymized series ids 391 | - the list of anonymized studies ids 392 | - the list of anonymized patients ids 393 | """ 394 | return self._modify_bulk( 395 | operation="anonymize", 396 | orthanc_ids=orthanc_ids, 397 | replace_tags=replace_tags, 398 | remove_tags=remove_tags, 399 | keep_tags=keep_tags, 400 | delete_original=delete_original, 401 | force=force, 402 | transcode=transcode, 403 | permissive=permissive) 404 | 405 | def anonymize_bulk_async(self, orthanc_ids: List[str] = [], replace_tags: Any = {}, remove_tags: List[str] = [], keep_tags: List[str] = [], delete_original: bool = False, force: bool = False, transcode: Optional[str] = None, permissive: bool = False) -> Job: 406 | return self._modify_bulk_async( 407 | operation="anonymize", 408 | orthanc_ids=orthanc_ids, 409 | replace_tags=replace_tags, 410 | remove_tags=remove_tags, 411 | keep_tags=keep_tags, 412 | delete_original=delete_original, 413 | force=force, 414 | transcode=transcode, 415 | permissive=permissive) 416 | 417 | def print_daily_stats(self, from_date: datetime.date = None, to_date: datetime.date = None): 418 | if self._url_segment == "patients": 419 | raise NotImplementedError("Print daily stats is not implemented for Patient level") 420 | 421 | if to_date is None: 422 | to_date = datetime.date.today() 423 | 424 | if from_date is None: 425 | from_date = to_date - datetime.timedelta(days=7) 426 | 427 | level = self._get_level() 428 | system = self._api_client.get_system() 429 | 430 | print(f"Daily {level} stats for " + system["DicomAet"] + " - " + system["Name"]) 431 | print("---------------------------------------") 432 | 433 | current_date = from_date 434 | 435 | while current_date <= to_date: 436 | 437 | payload = { 438 | "Level": level, 439 | "Query": { 440 | "StudyDate": to_dicom_date(current_date) 441 | }, 442 | "Expand": False, 443 | "CaseSensitive": False 444 | } 445 | 446 | r = self._api_client.post( 447 | endpoint=f"tools/find", 448 | json=payload) 449 | 450 | print(f"{current_date} - " + str(len(r.json()))) 451 | current_date += datetime.timedelta(days=1) 452 | 453 | def _lookup(self, filter: str, dicom_id: str) -> Optional[str]: 454 | """ 455 | finds a resource in Orthanc based on its dicom id 456 | 457 | Returns 458 | ------- 459 | the instance id of the study or None if not found 460 | """ 461 | resource_ids = self._api_client.lookup(needle=dicom_id, filter=filter) 462 | if len(resource_ids) == 1: 463 | return resource_ids[0] 464 | 465 | if len(resource_ids) > 1: 466 | raise TooManyResourcesFound() 467 | return None 468 | 469 | def get_labels(self, orthanc_id: str) -> List[str]: 470 | """ 471 | Gets all the labels of this resource 472 | 473 | Returns: 474 | A list containing oll the labels of this resource 475 | """ 476 | return self._api_client.get_json(f"{self._url_segment}/{orthanc_id}/labels") 477 | 478 | def add_label(self, orthanc_id: str, label: str): 479 | """ 480 | Add the label to the resource 481 | 482 | Args: 483 | orthanc_id: resource to add the label to 484 | label: the label to add to the resource 485 | """ 486 | 487 | self._api_client.put(f"{self._url_segment}/{orthanc_id}/labels/{label}") 488 | 489 | def add_labels(self, orthanc_id: str, labels: List[str]): 490 | """ 491 | Add the labels to the resource 492 | 493 | Args: 494 | orthanc_id: resource to add the labels to 495 | labels: the list of labels to add to the resource 496 | """ 497 | for label in labels: 498 | self.add_label(orthanc_id, label) 499 | 500 | def delete_label(self, orthanc_id: str, label: str): 501 | """ 502 | Delete the label from the resource 503 | 504 | Args: 505 | orthanc_id: resource to remove the label from 506 | label: the label to remove from the resource 507 | """ 508 | 509 | self._api_client.delete(f"{self._url_segment}/{orthanc_id}/labels/{label}") 510 | 511 | def delete_labels(self, orthanc_id: str, labels: List[str]): 512 | """ 513 | Delete the labels from the resource 514 | 515 | Args: 516 | orthanc_id: resource to remove the labels from 517 | labels: the labels to remove from the resource 518 | """ 519 | for label in labels: 520 | self.delete_label(orthanc_id, label) 521 | 522 | def exists(self, orthanc_id: str) -> bool: 523 | try: 524 | self._api_client.get( 525 | endpoint=f"{self._url_segment}/{orthanc_id}" 526 | ) 527 | return True 528 | except ResourceNotFound: 529 | return False 530 | 531 | def download_archive(self, orthanc_id: str, path: str): 532 | file_content = self._api_client.get_binary(f"{self._url_segment}/{orthanc_id}/archive") 533 | with open(path, 'wb') as f: 534 | f.write(file_content) 535 | 536 | def download_media(self, orthanc_id: str, path: str): 537 | file_content = self._api_client.get_binary(f"{self._url_segment}/{orthanc_id}/media") 538 | with open(path, 'wb') as f: 539 | f.write(file_content) 540 | --------------------------------------------------------------------------------