├── tests ├── __init__.py ├── conftest.py ├── test_uid.py ├── test_valuerep.py ├── utils.py ├── test_color.py ├── test_valuetypes.py ├── test_utils.py ├── test_base.py └── test_ko.py ├── src └── highdicom │ ├── version.py │ ├── _icc_profiles │ └── sRGB_v4_ICC_preference.icc │ ├── py.typed │ ├── sc │ ├── __init__.py │ └── enum.py │ ├── ko │ └── __init__.py │ ├── pm │ ├── __init__.py │ └── enum.py │ ├── legacy │ └── __init__.py │ ├── ann │ ├── __init__.py │ └── enum.py │ ├── seg │ ├── __init__.py │ ├── enum.py │ └── utils.py │ ├── uid.py │ ├── pr │ ├── __init__.py │ └── enum.py │ ├── coding_schemes.py │ ├── __init__.py │ ├── sr │ ├── __init__.py │ ├── coding.py │ └── enum.py │ ├── valuerep.py │ ├── enum.py │ └── utils.py ├── docs ├── conformance.rst ├── images │ └── slide_screenshot.png ├── pm.rst ├── pr.rst ├── sc.rst ├── legacy.rst ├── kos.rst ├── license.rst ├── usage.rst ├── code_of_conduct.rst ├── general.rst ├── sr.rst ├── iods.rst ├── citation.rst ├── Makefile ├── installation.rst ├── overview.rst ├── index.rst ├── highdicom_and_pydicom.rst ├── coding.rst ├── package.rst ├── conf.py ├── development.rst ├── release_notes.rst └── remote.rst ├── data └── test_files │ ├── ct_image.dcm │ ├── dx_image.dcm │ ├── sm_image.dcm │ ├── frame_rgb.jpeg │ ├── sr_document.dcm │ ├── sm_annotations.dcm │ ├── sm_image_dots.dcm │ ├── frame_rgb_empty.jpeg │ ├── seg_image_sm_dots.dcm │ ├── sm_image_control.dcm │ ├── sm_image_jpegls.dcm │ ├── sm_image_numbers.dcm │ ├── seg_image_cr_binary.dcm │ ├── seg_image_ct_binary.dcm │ ├── sm_image_grayscale.dcm │ ├── seg_image_sm_control.dcm │ ├── seg_image_sm_numbers.dcm │ ├── sm_image_jpegls_nobot.dcm │ ├── seg_image_ct_binary_overlap.dcm │ ├── sm_image_grayscale_reversed.dcm │ ├── seg_image_ct_true_fractional.dcm │ ├── seg_image_sm_control_labelmap.dcm │ ├── seg_image_sm_dots_tiled_full.dcm │ ├── seg_image_ct_binary_fractional.dcm │ ├── seg_image_ct_binary_single_frame.dcm │ ├── sr_document_with_multiple_groups.dcm │ ├── seg_image_sm_control_labelmap_palette_color.dcm │ └── README.md ├── setup.cfg ├── .codespellrc ├── .github └── workflows │ ├── codespell.yml │ └── run_unit_tests.yml ├── examples ├── README.md └── Dockerfile ├── .readthedocs.yaml ├── LICENSE ├── CITATION.cff ├── .gitignore ├── pyproject.toml ├── README.md ├── bin └── create_iods_modules.py └── code_of_conduct.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/highdicom/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.27.0' 2 | -------------------------------------------------------------------------------- /docs/conformance.rst: -------------------------------------------------------------------------------- 1 | .. _conformance-statement: 2 | 3 | Conformance Statement 4 | ===================== 5 | 6 | -------------------------------------------------------------------------------- /data/test_files/ct_image.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/ct_image.dcm -------------------------------------------------------------------------------- /data/test_files/dx_image.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/dx_image.dcm -------------------------------------------------------------------------------- /data/test_files/sm_image.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sm_image.dcm -------------------------------------------------------------------------------- /data/test_files/frame_rgb.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/frame_rgb.jpeg -------------------------------------------------------------------------------- /data/test_files/sr_document.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sr_document.dcm -------------------------------------------------------------------------------- /docs/images/slide_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/docs/images/slide_screenshot.png -------------------------------------------------------------------------------- /data/test_files/sm_annotations.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sm_annotations.dcm -------------------------------------------------------------------------------- /data/test_files/sm_image_dots.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sm_image_dots.dcm -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | 4 | [flake8] 5 | max_line_length = 80 6 | ignore = E121 E125 W504 7 | statistics = True 8 | -------------------------------------------------------------------------------- /data/test_files/frame_rgb_empty.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/frame_rgb_empty.jpeg -------------------------------------------------------------------------------- /data/test_files/seg_image_sm_dots.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_sm_dots.dcm -------------------------------------------------------------------------------- /data/test_files/sm_image_control.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sm_image_control.dcm -------------------------------------------------------------------------------- /data/test_files/sm_image_jpegls.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sm_image_jpegls.dcm -------------------------------------------------------------------------------- /data/test_files/sm_image_numbers.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sm_image_numbers.dcm -------------------------------------------------------------------------------- /data/test_files/seg_image_cr_binary.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_cr_binary.dcm -------------------------------------------------------------------------------- /data/test_files/seg_image_ct_binary.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_ct_binary.dcm -------------------------------------------------------------------------------- /data/test_files/sm_image_grayscale.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sm_image_grayscale.dcm -------------------------------------------------------------------------------- /data/test_files/seg_image_sm_control.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_sm_control.dcm -------------------------------------------------------------------------------- /data/test_files/seg_image_sm_numbers.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_sm_numbers.dcm -------------------------------------------------------------------------------- /data/test_files/sm_image_jpegls_nobot.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sm_image_jpegls_nobot.dcm -------------------------------------------------------------------------------- /docs/pm.rst: -------------------------------------------------------------------------------- 1 | .. _pm: 2 | 3 | Parametric Maps 4 | =============== 5 | 6 | This page is under construction, and more detail will be added soon. 7 | -------------------------------------------------------------------------------- /data/test_files/seg_image_ct_binary_overlap.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_ct_binary_overlap.dcm -------------------------------------------------------------------------------- /data/test_files/sm_image_grayscale_reversed.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sm_image_grayscale_reversed.dcm -------------------------------------------------------------------------------- /docs/pr.rst: -------------------------------------------------------------------------------- 1 | .. _pr: 2 | 3 | Presentation States 4 | =================== 5 | 6 | This page is under construction, and more detail will be added soon. 7 | -------------------------------------------------------------------------------- /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | skip = .git,*.pdf,*.svg,*.ipynb 3 | # te,fo - either abbreviations of variables 4 | ignore-words-list = te,fo,socio-economic,laf 5 | -------------------------------------------------------------------------------- /data/test_files/seg_image_ct_true_fractional.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_ct_true_fractional.dcm -------------------------------------------------------------------------------- /data/test_files/seg_image_sm_control_labelmap.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_sm_control_labelmap.dcm -------------------------------------------------------------------------------- /data/test_files/seg_image_sm_dots_tiled_full.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_sm_dots_tiled_full.dcm -------------------------------------------------------------------------------- /data/test_files/seg_image_ct_binary_fractional.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_ct_binary_fractional.dcm -------------------------------------------------------------------------------- /data/test_files/seg_image_ct_binary_single_frame.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_ct_binary_single_frame.dcm -------------------------------------------------------------------------------- /data/test_files/sr_document_with_multiple_groups.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/sr_document_with_multiple_groups.dcm -------------------------------------------------------------------------------- /src/highdicom/_icc_profiles/sRGB_v4_ICC_preference.icc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/src/highdicom/_icc_profiles/sRGB_v4_ICC_preference.icc -------------------------------------------------------------------------------- /docs/sc.rst: -------------------------------------------------------------------------------- 1 | .. _sc: 2 | 3 | Secondary Capture (SC) Images 4 | ============================= 5 | 6 | This page is under construction, and more detail will be added soon. 7 | -------------------------------------------------------------------------------- /docs/legacy.rst: -------------------------------------------------------------------------------- 1 | .. _legacy: 2 | 3 | Legacy Converted Enhanced Images 4 | ================================ 5 | 6 | This page is under construction, and more detail will be added soon. 7 | -------------------------------------------------------------------------------- /data/test_files/seg_image_sm_control_labelmap_palette_color.dcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImagingDataCommons/highdicom/HEAD/data/test_files/seg_image_sm_control_labelmap_palette_color.dcm -------------------------------------------------------------------------------- /docs/kos.rst: -------------------------------------------------------------------------------- 1 | .. _kos: 2 | 3 | Key Object Selection (KOS) Documents 4 | ==================================== 5 | 6 | This page is under construction, and more detail will be added soon. 7 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | .. _license: 2 | 3 | License 4 | ======= 5 | 6 | *highdicom* is free and open source software licensed under the permissive `MIT license `_. 7 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. _user-guide: 2 | 3 | User Guide 4 | ========== 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Contents: 9 | 10 | quickstart 11 | general 12 | iods 13 | -------------------------------------------------------------------------------- /src/highdicom/py.typed: -------------------------------------------------------------------------------- 1 | # The presence of this file signifies that the highdicom package contains type 2 | # annotations in accordance with PEP 561. 3 | # See https://mypy.readthedocs.io/en/latest/installed_packages.html 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pydicom import config 4 | 5 | 6 | @pytest.fixture(autouse=True, scope='session') 7 | def setup_pydicom_config(): 8 | """Fixture that sets up pydicom config values for all tests.""" 9 | config.enforce_valid_values = True 10 | yield 11 | -------------------------------------------------------------------------------- /docs/code_of_conduct.rst: -------------------------------------------------------------------------------- 1 | .. _code_of_conduct: 2 | 3 | Code of Conduct 4 | =============== 5 | 6 | Highdicom has adopted the `Contributor Covenant `_ to govern the community around the project. 7 | 8 | Find the full Code of Conduct `here `_. 9 | -------------------------------------------------------------------------------- /src/highdicom/sc/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for creation of Secondary Capture (SC) Image instances.""" 2 | from highdicom.sc.sop import SCImage 3 | from highdicom.sc.enum import ConversionTypeValues 4 | 5 | SOP_CLASS_UIDS = { 6 | '1.2.840.10008.5.1.4.1.1.7', # SC Image 7 | } 8 | 9 | __all__ = [ 10 | 'ConversionTypeValues', 11 | 'SCImage', 12 | ] 13 | -------------------------------------------------------------------------------- /docs/general.rst: -------------------------------------------------------------------------------- 1 | .. _general-concepts: 2 | 3 | General Concepts 4 | ================ 5 | 6 | This section covers topics that are generally applicable across various 7 | parts of the library. 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | highdicom_and_pydicom 14 | image 15 | pixel_transforms 16 | volume 17 | coding 18 | remote 19 | -------------------------------------------------------------------------------- /src/highdicom/ko/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for creation of Key Object Selection instances.""" 2 | 3 | from highdicom.ko.sop import KeyObjectSelectionDocument 4 | from highdicom.ko.content import KeyObjectSelection 5 | 6 | SOP_CLASS_UIDS = { 7 | '1.2.840.10008.5.1.4.1.1.88.59', # Key Object Selection Document 8 | } 9 | 10 | __all__ = [ 11 | 'KeyObjectSelection', 12 | 'KeyObjectSelectionDocument', 13 | ] 14 | -------------------------------------------------------------------------------- /.github/workflows/codespell.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Codespell 3 | 4 | on: 5 | push: 6 | branches: [master] 7 | pull_request: 8 | branches: [master] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | codespell: 15 | name: Check for spelling errors 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Codespell 22 | uses: codespell-project/actions-codespell@v2 23 | -------------------------------------------------------------------------------- /docs/sr.rst: -------------------------------------------------------------------------------- 1 | .. _sr: 2 | 3 | Structured Report Documents (SRs) 4 | ================================= 5 | 6 | Structured report documents are DICOM files that contain information derived 7 | from a medical image in a structured and computer-readable way. `Highdicom` 8 | supports structured reports through the :mod:`highdicom.sr` sub-package. 9 | 10 | Since SRs are a complex topic, this section is sub-divided as follows: 11 | 12 | .. toctree:: 13 | :maxdepth: 1 14 | 15 | generalsr 16 | tid1500 17 | tid1500parsing 18 | -------------------------------------------------------------------------------- /docs/iods.rst: -------------------------------------------------------------------------------- 1 | .. _iods: 2 | 3 | Information Object Definitions (IODs) 4 | ===================================== 5 | 6 | An Information Object Definition defines a single "type" of DICOM file, such 7 | as a Segmentation, Presentation State or Structured Report. The following 8 | sections give in-depth explanations of the various IODs implemented within 9 | *highdicom*. 10 | 11 | .. toctree:: 12 | :maxdepth: 3 13 | :caption: Contents: 14 | 15 | seg 16 | sr 17 | kos 18 | ann 19 | pm 20 | pr 21 | sc 22 | legacy 23 | -------------------------------------------------------------------------------- /src/highdicom/pm/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for creation of Parametric Map instances.""" 2 | 3 | from highdicom.pm.content import DimensionIndexSequence, RealWorldValueMapping 4 | from highdicom.pm.enum import DerivedPixelContrastValues, ImageFlavorValues 5 | from highdicom.pm.sop import ParametricMap 6 | 7 | SOP_CLASS_UIDS = { 8 | '1.2.840.10008.5.1.4.1.1.30', # Parametric Map 9 | } 10 | 11 | __all__ = [ 12 | 'DerivedPixelContrastValues', 13 | 'DimensionIndexSequence', 14 | 'ImageFlavorValues', 15 | 'ParametricMap', 16 | 'RealWorldValueMapping', 17 | ] 18 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # highdicom examples 2 | 3 | A set of [Jupyter notebook](https://jupyter-notebook.readthedocs.io/en/stable/) examples that demo various aspects of the library. 4 | 5 | ## Usage 6 | 7 | Build and run container image using [Docker](https://www.docker.com/): 8 | 9 | ```none 10 | docker build . -t highdicom/examples:latest 11 | docker run --rm --name highdicom_examples -p 8888:8888 highdicom/examples:latest 12 | ``` 13 | 14 | After running the above commands, following the instructions printed into the standard output stream to access the notebooks in your browser. 15 | -------------------------------------------------------------------------------- /src/highdicom/sc/enum.py: -------------------------------------------------------------------------------- 1 | """Enumerate values specific to Secondary Capture (SC) Image IODs.""" 2 | from enum import Enum 3 | 4 | 5 | class ConversionTypeValues(Enum): 6 | 7 | """Enumerated values for attribute Conversion Type.""" 8 | 9 | DV = 'DV' 10 | """Digitized Video""" 11 | 12 | DI = 'DI' 13 | """Digital Interface""" 14 | 15 | DF = 'DF' 16 | """Digitized Film""" 17 | 18 | WSD = 'WSD' 19 | """Workstation""" 20 | 21 | SD = 'SD' 22 | """Scanned Document""" 23 | 24 | SI = 'SI' 25 | """Scanned Image""" 26 | 27 | DRW = 'DRW' 28 | """Drawing""" 29 | 30 | SYN = 'SYN' 31 | """Synthetic Image""" 32 | -------------------------------------------------------------------------------- /docs/citation.rst: -------------------------------------------------------------------------------- 1 | .. _citation: 2 | 3 | Citation 4 | ======== 5 | 6 | The following article describes in detail the motivation for creating the *highdicom* library, the design goals of the library, and experiments demonstrating the library's capabilities. 7 | If you use *highdicom* in research, please cite this article. 8 | 9 | `Highdicom: A Python library for standardized encoding of image annotations and machine learning model outputs in pathology and radiology `_. 10 | C.P. Bridge, C. Gorman, S. Pieper, S.W. Doyle, J.K. Lennerz, J. Kalpathy-Cramer, D.A. Clunie, A.Y. Fedorov, and M.D. Herrmann. 11 | 12 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = highdicom 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /src/highdicom/legacy/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for creation of Legacy Converted Enhanced CT, MR or PET Image 2 | instances. 3 | """ 4 | from highdicom.legacy.sop import ( 5 | LegacyConvertedEnhancedCTImage, 6 | LegacyConvertedEnhancedMRImage, 7 | LegacyConvertedEnhancedPETImage, 8 | ) 9 | 10 | SOP_CLASS_UIDS = { 11 | '1.2.840.10008.5.1.4.1.1.4.4', # Legacy Converted Enhanced MR Image 12 | '1.2.840.10008.5.1.4.1.1.2.2', # Legacy Converted Enhanced CT Image 13 | '1.2.840.10008.5.1.4.1.1.128.1', # Legacy Converted Enhanced PET Image 14 | } 15 | 16 | __all__ = [ 17 | 'LegacyConvertedEnhancedCTImage', 18 | 'LegacyConvertedEnhancedMRImage', 19 | 'LegacyConvertedEnhancedPETImage', 20 | ] 21 | -------------------------------------------------------------------------------- /tests/test_uid.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | import unittest 3 | 4 | import pydicom 5 | 6 | from highdicom.uid import UID 7 | 8 | 9 | class TestUID(unittest.TestCase): 10 | 11 | def test_construction_without_value(self): 12 | uid = UID() 13 | assert isinstance(uid, str) 14 | assert isinstance(uid, pydicom.uid.UID) 15 | assert uid.startswith('1.2.826.0.1.3680043.10.511.3.') 16 | 17 | def test_construction_with_value(self): 18 | value = '1.2.3.4' 19 | uid = UID(value) 20 | assert uid == value 21 | 22 | def test_construction_from_uuid(self): 23 | uuid = str(uuid4()) 24 | uid = UID.from_uuid(uuid) 25 | assert uid.startswith('2.25.') 26 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | build: 13 | os: ubuntu-22.04 14 | tools: 15 | python: "3.11" 16 | 17 | # Build documentation with MkDocs 18 | #mkdocs: 19 | # configuration: mkdocs.yml 20 | 21 | # Optionally build your docs in additional formats such as PDF and ePub 22 | formats: all 23 | 24 | # Optionally set the version of Python and requirements required to build your docs 25 | python: 26 | install: 27 | - method: pip 28 | path: . 29 | extra_requirements: 30 | - docs 31 | -------------------------------------------------------------------------------- /src/highdicom/ann/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for creation of Annotation (ANN) instances.""" 2 | from highdicom.ann.content import Measurements, AnnotationGroup 3 | from highdicom.ann.enum import ( 4 | AnnotationCoordinateTypeValues, 5 | AnnotationGroupGenerationTypeValues, 6 | GraphicTypeValues, 7 | PixelOriginInterpretationValues, 8 | ) 9 | from highdicom.ann.sop import MicroscopyBulkSimpleAnnotations, annread 10 | 11 | SOP_CLASS_UIDS = { 12 | '1.2.840.10008.5.1.4.1.1.91.1', # Microscopy Bulk Simple Annotations 13 | } 14 | 15 | __all__ = [ 16 | 'AnnotationCoordinateTypeValues', 17 | 'AnnotationGroup', 18 | 'AnnotationGroupGenerationTypeValues', 19 | 'GraphicTypeValues', 20 | 'Measurements', 21 | 'MicroscopyBulkSimpleAnnotations', 22 | 'PixelOriginInterpretationValues', 23 | 'annread', 24 | ] 25 | -------------------------------------------------------------------------------- /examples/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-buster 2 | 3 | RUN export DEBIAN_FRONTEND=noninteractive && \ 4 | export DEBCONF_NONINTERACTIVE_SEEN=true && \ 5 | apt-get update && \ 6 | apt-get install -y --no-install-suggests --no-install-recommends \ 7 | build-essential \ 8 | libjpeg62-turbo-dev \ 9 | libopenjp2-7-dev \ 10 | software-properties-common && \ 11 | apt-get clean 12 | 13 | RUN python -m pip install --upgrade pip && \ 14 | python -m pip install --prefix=/usr/local \ 15 | dicomweb-client \ 16 | dumb-init \ 17 | highdicom \ 18 | jupyterlab \ 19 | numpy \ 20 | matplotlib \ 21 | Pillow \ 22 | torch 23 | 24 | RUN useradd -m -s /bin/bash jupyter 25 | 26 | USER jupyter 27 | 28 | EXPOSE 8888 29 | 30 | COPY notebooks /usr/local/share/highdicom-examples 31 | 32 | WORKDIR /usr/local/share/highdicom-examples 33 | 34 | ENTRYPOINT ["/usr/local/bin/dumb-init", "--", \ 35 | "/usr/local/bin/jupyter", "lab", "--ip", "0.0.0.0", "--no-browser"] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 MGH Computational Pathology 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/highdicom/seg/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for creation of Segmentation (SEG) instances.""" 2 | from highdicom.seg.sop import Segmentation, segread 3 | from highdicom.seg.enum import ( 4 | SegmentAlgorithmTypeValues, 5 | SegmentationTypeValues, 6 | SegmentationFractionalTypeValues, 7 | SpatialLocationsPreservedValues, 8 | SegmentsOverlapValues, 9 | ) 10 | from highdicom.seg.content import ( 11 | SegmentDescription, 12 | DimensionIndexSequence, 13 | ) 14 | from highdicom.seg import utils 15 | from highdicom.seg.pyramid import create_segmentation_pyramid 16 | 17 | SOP_CLASS_UIDS = { 18 | '1.2.840.10008.5.1.4.1.1.66.4', # Segmentation 19 | '1.2.840.10008.5.1.4.1.1.66.7', # Label Map Segmentation 20 | } 21 | 22 | __all__ = [ 23 | 'DimensionIndexSequence', 24 | 'SegmentAlgorithmTypeValues', 25 | 'SegmentDescription', 26 | 'Segmentation', 27 | 'SegmentationFractionalTypeValues', 28 | 'SegmentationTypeValues', 29 | 'SegmentsOverlapValues', 30 | 'SpatialLocationsPreservedValues', 31 | 'create_segmentation_pyramid', 32 | 'segread', 33 | 'utils', 34 | ] 35 | -------------------------------------------------------------------------------- /src/highdicom/seg/enum.py: -------------------------------------------------------------------------------- 1 | """Enumerate values specific to Segmentation IODs.""" 2 | from enum import Enum 3 | 4 | 5 | class SegmentAlgorithmTypeValues(Enum): 6 | 7 | """Enumerated values for attribute Segment Algorithm Type.""" 8 | 9 | AUTOMATIC = 'AUTOMATIC' 10 | SEMIAUTOMATIC = 'SEMIAUTOMATIC' 11 | MANUAL = 'MANUAL' 12 | 13 | 14 | class SegmentationTypeValues(Enum): 15 | 16 | """Enumerated values for attribute Segmentation Type.""" 17 | 18 | BINARY = 'BINARY' 19 | FRACTIONAL = 'FRACTIONAL' 20 | LABELMAP = 'LABELMAP' 21 | 22 | 23 | class SegmentationFractionalTypeValues(Enum): 24 | 25 | """Enumerated values for attribute Segmentation Fractional Type.""" 26 | 27 | PROBABILITY = 'PROBABILITY' 28 | OCCUPANCY = 'OCCUPANCY' 29 | 30 | 31 | class SpatialLocationsPreservedValues(Enum): 32 | 33 | """Enumerated values for attribute Spatial Locations Preserved.""" 34 | 35 | YES = 'YES' 36 | NO = 'NO' 37 | REORIENTED_ONLY = 'REORIENTED_ONLY' 38 | """A projection radiograph that has been flipped, and/or rotated by a 39 | multiple of 90 degrees.""" 40 | 41 | 42 | class SegmentsOverlapValues(Enum): 43 | 44 | """Enumerated values for attribute Segments Overlap.""" 45 | 46 | YES = 'YES' 47 | UNDEFINED = 'UNDEFINED' 48 | NO = 'NO' 49 | -------------------------------------------------------------------------------- /src/highdicom/uid.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from uuid import UUID 3 | from typing import TypeVar 4 | from typing_extensions import Self 5 | 6 | import pydicom 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | T = TypeVar('T', bound='UID') 12 | 13 | 14 | class UID(pydicom.uid.UID): 15 | 16 | """Unique DICOM identifier. 17 | 18 | If an object is constructed without a value being provided, a value will be 19 | automatically generated using the highdicom-specific root. 20 | """ 21 | 22 | def __new__(cls: type[T], value: str | None = None) -> T: 23 | if value is None: 24 | prefix = '1.2.826.0.1.3680043.10.511.3.' 25 | value = pydicom.uid.generate_uid(prefix=prefix) 26 | return super().__new__(cls, value) 27 | 28 | @classmethod 29 | def from_uuid(cls, uuid: str) -> Self: 30 | """Create a DICOM UID from a UUID using the 2.25 root. 31 | 32 | Parameters 33 | ---------- 34 | uuid: str 35 | UUID 36 | 37 | Returns 38 | ------- 39 | highdicom.UID 40 | UID 41 | 42 | Examples 43 | -------- 44 | >>> from uuid import uuid4 45 | >>> import highdicom as hd 46 | >>> uuid = str(uuid4()) 47 | >>> uid = hd.UID.from_uuid(uuid) 48 | 49 | """ 50 | value = f'2.25.{UUID(uuid).int}' 51 | return cls(value) 52 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite our paper." 3 | authors: 4 | - family-names: "Herrmann" 5 | given-names: "Markus D." 6 | - family-names: "Bridge" 7 | given-names: "Christopher P." 8 | - family-names: "Fedorov" 9 | given-names: "Andriy Y." 10 | - family-names: "Pieper" 11 | given-names: "Steven" 12 | - family-names: "Doyle" 13 | given-names: "Sean W." 14 | - family-names: "Gorman" 15 | given-names: "Chris" 16 | preferred-citation: 17 | type: article 18 | authors: 19 | - family-names: "Bridge" 20 | given-names: "Christopher P." 21 | orcid: "https://orcid.org/0000-0002-2242-351X" 22 | - family-names: "Gorman" 23 | given-names: "Chris" 24 | - family-names: "Pieper" 25 | given-names: "Steven" 26 | - family-names: "Doyle" 27 | given-names: "Sean W." 28 | - family-names: "Lennerz" 29 | given-names: "Jochen K." 30 | - family-names: "Kalpathy-Cramer" 31 | given-names: "Jayashree " 32 | - family-names: "Clunie" 33 | given-names: "David A." 34 | - family-names: "Fedorov" 35 | given-names: "Andriy Y." 36 | - family-names: "Herrmann" 37 | given-names: "Markus D." 38 | orcid: "https://orcid.org/0000-0002-7257-9205" 39 | title: "Highdicom: a Python Library for Standardized Encoding of Image Annotations and Machine Learning Model Outputs in Pathology and Radiology" 40 | journal: "J Digit Imaging" 41 | year: 2022 42 | doi: 10.1007/s10278-022-00683-y 43 | -------------------------------------------------------------------------------- /data/test_files/README.md: -------------------------------------------------------------------------------- 1 | # Test Files 2 | 3 | These files are used for automated tests of the highdicom library. Note that 4 | many more test files are available as part of the pydicom package. 5 | 6 | ### Images 7 | * `ct_image.dcm` - A small CT image. 8 | * `sm_image.dcm` - A slide microscopy image. 9 | 10 | ### Segmentation Images 11 | * `seg_image_ct_binary.dcm` - Segmentation image of `BINARY` type derived 12 | from the series of small CT images in the pydicom test files 13 | (`dicomdirtests/77654033/CT2/*`) using the highdicom library. 14 | * `seg_image_ct_binary_overlap.dcm` - Segmentation image of `BINARY` type derived 15 | from the series of small CT images in the pydicom test files 16 | (`dicomdirtests/77654033/CT2/*`) using the highdicom library. This example 17 | contains 2 overlapping segments. 18 | * `seg_image_ct_binary_fractional.dcm` - Segmentation image of `FRACTIONAL` 19 | type but with binary values (i.e. only 0.0 and 1.0) derived from the series 20 | of small CT images in the pydicom test files (`dicomdirtests/77654033/CT2/*`) 21 | using the highdicom library. 22 | * `seg_image_ct_true_fractional.dcm` - Segmentation image of `FRACTIONAL` 23 | type with true fractional values derived from the series 24 | of small CT images in the pydicom test files (`dicomdirtests/77654033/CT2/*`) 25 | using the highdicom library. 26 | 27 | ### Structured Report Documents 28 | * `sr_document.dcm` - A simple SR document following TID 1500 describing 29 | measurements of a CT image. 30 | -------------------------------------------------------------------------------- /.github/workflows/run_unit_tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: unit tests 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ "master", "v*dev" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | # NB no 3.12 because an issue with coverage makes it extremely slow 19 | # and the likelihood of 3.12-specific bugs is considered low at this 20 | # stage 21 | python-version: ["3.10", "3.11", "3.13", "3.14"] 22 | dependencies: [".", "'.[libjpeg]'"] 23 | 24 | env: 25 | # Set this otherwise coverage on python 3.12 is absurdly slow 26 | COVERAGE_CORE: "sysmon" 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v4 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip setuptools 37 | pip install .[test] 38 | pip install ${{ matrix.dependencies }} 39 | - name: Lint with flake8 40 | run: | 41 | flake8 --exclude='bin,build,.eggs,src/highdicom/_*' 42 | - name: Test with pytest 43 | run: | 44 | pytest --cov=highdicom --cov-fail-under=80 45 | 46 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation-guide: 2 | 3 | Installation Guide 4 | ================== 5 | 6 | .. _requirements: 7 | 8 | Requirements 9 | ------------ 10 | 11 | * `Python `_ (version 3.10 or higher) 12 | * Python package manager `pip `_ 13 | 14 | .. _installation: 15 | 16 | Installation 17 | ------------ 18 | 19 | Pre-built package available at PyPi: 20 | 21 | .. code-block:: none 22 | 23 | pip install highdicom 24 | 25 | Or alternatively, through conda: 26 | 27 | .. code-block:: none 28 | 29 | conda install conda-forge::highdicom 30 | 31 | The library relies on the underlying ``pydicom`` package for decoding of pixel 32 | data, which internally delegates the task to either the ``pillow`` or the 33 | ``pylibjpeg`` packages. Since ``pillow`` is a dependency of *highdicom* and 34 | will automatically be installed, some transfer syntax can thus be readily 35 | decoded and encoded (baseline JPEG, JPEG-2000, JPEG-LS). Support for additional 36 | transfer syntaxes (e.g., lossless JPEG) requires installation of the 37 | ``pylibjpeg`` package as well as the ``pylibjpeg-libjpeg`` and 38 | ``pylibjpeg-openjpeg`` packages. Since ``pylibjpeg-libjpeg`` is licensed under 39 | a copyleft GPL v3 license, it is not installed by default when you install 40 | *highdicom*. To install the ``pylibjpeg`` packages along with *highdicom*, use 41 | 42 | .. code-block:: none 43 | 44 | pip install 'highdicom[libjpeg]' 45 | 46 | Install directly from source code (available on Github): 47 | 48 | .. code-block:: none 49 | 50 | git clone https://github.com/imagingdatacommons/highdicom ~/highdicom 51 | pip install ~/highdicom 52 | 53 | -------------------------------------------------------------------------------- /src/highdicom/pr/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for creation of Presentation State instances.""" 2 | 3 | from highdicom.pr.sop import ( 4 | AdvancedBlendingPresentationState, 5 | ColorSoftcopyPresentationState, 6 | GrayscaleSoftcopyPresentationState, 7 | PseudoColorSoftcopyPresentationState, 8 | ) 9 | from highdicom.pr.enum import ( 10 | AnnotationUnitsValues, 11 | BlendingModeValues, 12 | GraphicTypeValues, 13 | TextJustificationValues, 14 | ) 15 | from highdicom.pr.content import ( 16 | AdvancedBlending, 17 | BlendingDisplay, 18 | BlendingDisplayInput, 19 | GraphicAnnotation, 20 | GraphicGroup, 21 | GraphicLayer, 22 | GraphicObject, 23 | SoftcopyVOILUTTransformation, 24 | TextObject 25 | ) 26 | 27 | 28 | SOP_CLASS_UIDS = { 29 | '1.2.840.10008.5.1.4.1.1.11.1', # Grayscale Softcopy Presentation State 30 | '1.2.840.10008.5.1.4.1.1.11.2', # Color Softcopy Presentation State 31 | '1.2.840.10008.5.1.4.1.1.11.3', # Pseudo Color Softcopy Presentation State 32 | '1.2.840.10008.5.1.4.1.1.11.8', # Advanced Blending Presentation State 33 | } 34 | 35 | 36 | __all__ = [ 37 | 'AdvancedBlending', 38 | 'AdvancedBlendingPresentationState', 39 | 'AnnotationUnitsValues', 40 | 'BlendingDisplay', 41 | 'BlendingDisplayInput', 42 | 'BlendingModeValues', 43 | 'ColorSoftcopyPresentationState', 44 | 'GraphicAnnotation', 45 | 'GraphicGroup', 46 | 'GraphicLayer', 47 | 'GraphicObject', 48 | 'GraphicTypeValues', 49 | 'GrayscaleSoftcopyPresentationState', 50 | 'PseudoColorSoftcopyPresentationState', 51 | 'SoftcopyVOILUTTransformation', 52 | 'TextJustificationValues', 53 | 'TextObject', 54 | ] 55 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | .. _overview: 2 | 3 | Overview and Design 4 | =================== 5 | 6 | Motivation and goals 7 | -------------------- 8 | 9 | The DICOM standard is crucial for achieving interoperability between image analysis applications and image storage and communication systems during both development and clinical deployment. 10 | However, the standard is vast and complex and implementing it correctly can be challenging - even for DICOM experts. 11 | The main goal of *highdicom* is to abstract the complexity of the standard and allow developers of image analysis applications to focus on the algorithm and the data analysis rather than low-level data encoding. 12 | To this end, *highdicom* provides a high-level, intuitive application programming interface (API) that enables developers to create high-quality DICOM objects and access information in existing objects in a few lines of Python code. 13 | Importantly, the API is compatible with digital pathology and radiology imaging modalities, including Slide Microscopy (SM), Computed Tomography (CT) and Magnetic Resonance (MR) imaging. 14 | 15 | Design 16 | ------ 17 | 18 | The `highdicom` Python package exposes an object-orientated application programming interface (API) for the construction of DICOM Service Object Pair (SOP) instances according to the corresponding IODs. 19 | Each SOP class is implemented in form of a Python class that inherits from ``pydicom.Dataset``. 20 | The class constructor accepts the image-derived information (e.g. pixel data as a ``numpy.ndarray``) as well as required contextual information (e.g. patient identifier) as specified by the corresponding IOD. 21 | DICOM validation is built-in and is automatically performed upon object construction to ensure created SOP instances are compliant with the standard. 22 | -------------------------------------------------------------------------------- /tests/test_valuerep.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pydicom.valuerep import PersonName 4 | from highdicom.valuerep import check_person_name, _check_code_string 5 | 6 | 7 | test_names = [ 8 | 'Doe^John', 9 | 'Doe^John^^Mr', 10 | 'Doe^', 11 | '山田^太郎', 12 | '洪^吉洞' 13 | ] 14 | 15 | invalid_test_names = [ 16 | 'John Doe', 17 | 'Mr John Doe', 18 | 'Doe', 19 | '山田太郎', 20 | '洪吉洞' 21 | ] 22 | 23 | test_code_strings = [ 24 | 'FOO_BAR', 25 | 'FOOBAR', 26 | 'FOO01', 27 | 'FOO_BAR_33', 28 | 'ABCDEFGHIJKLMNOP', 29 | ] 30 | 31 | invalid_code_strings = [ 32 | 'foo_bar', 33 | 'FooBar', 34 | 'FOO-01', 35 | ' FOO', 36 | '_FOO', 37 | 'BAR_', 38 | 'BAR ', 39 | '-FOO', 40 | '1FOO', 41 | 'ABCDEFGHIJKLMNOPQ', 42 | ] 43 | 44 | 45 | @pytest.mark.parametrize('name', test_names) 46 | def test_valid_person_name_strings(name): 47 | check_person_name(name) 48 | 49 | 50 | @pytest.mark.parametrize('name', test_names) 51 | def test_valid_person_name_objects(name): 52 | check_person_name(PersonName(name)) 53 | 54 | 55 | @pytest.mark.parametrize('name', invalid_test_names) 56 | def test_invalid_person_name_strings(name): 57 | with pytest.warns(UserWarning): 58 | check_person_name(name) 59 | 60 | 61 | @pytest.mark.parametrize('name', invalid_test_names) 62 | def test_invalid_person_name_objects(name): 63 | with pytest.warns(UserWarning): 64 | check_person_name(PersonName(name)) 65 | 66 | 67 | @pytest.mark.parametrize('code_string', test_code_strings) 68 | def test_valid_code_strings(code_string): 69 | _check_code_string(code_string) 70 | 71 | 72 | @pytest.mark.parametrize('code_string', invalid_code_strings) 73 | def test_invalid_code_strings(code_string): 74 | with pytest.raises(ValueError): 75 | _check_code_string(code_string) 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # MacOS files 107 | .DS_Store 108 | 109 | #IntelliJ 110 | .idea 111 | 112 | # Visual Studio Code 113 | .vscode 114 | 115 | -------------------------------------------------------------------------------- /src/highdicom/seg/utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for working with SEG image instances.""" 2 | from collections.abc import Iterator 3 | 4 | import numpy as np 5 | from pydicom.dataset import Dataset 6 | 7 | 8 | def iter_segments(dataset: Dataset) -> Iterator: 9 | """Iterates over segments of a Segmentation image instance. 10 | 11 | Parameters 12 | ---------- 13 | dataset: pydicom.dataset.Dataset 14 | Segmentation image instance 15 | 16 | Returns 17 | ------- 18 | Iterator[Tuple[numpy.ndarray, Tuple[pydicom.dataset.Dataset, ...], pydicom.dataset.Dataset]] 19 | For each segment in the Segmentation image instance, provides the 20 | Pixel Data frames representing the segment, items of the Per-Frame 21 | Functional Groups Sequence describing the individual frames, and 22 | the item of the Segment Sequence describing the segment 23 | 24 | Raises 25 | ------ 26 | AttributeError 27 | When data set does not contain Content Sequence attribute. 28 | 29 | """ # noqa: E501 30 | if not hasattr(dataset, 'PixelData'): 31 | raise AttributeError( 32 | 'Data set does not contain a Pixel Data attribute.' 33 | ) 34 | segment_description_lut = { 35 | int(item.SegmentNumber): item 36 | for item in dataset.SegmentSequence 37 | } 38 | segment_number_per_frame = np.array([ 39 | int(item.SegmentIdentificationSequence[0].ReferencedSegmentNumber) 40 | for item in dataset.PerFrameFunctionalGroupsSequence 41 | ]) 42 | pixel_array = dataset.pixel_array 43 | if pixel_array.ndim == 2: 44 | pixel_array = pixel_array[np.newaxis, ...] 45 | for i in np.unique(segment_number_per_frame): 46 | indices = np.where(segment_number_per_frame == i)[0] 47 | yield ( 48 | pixel_array[indices, ...], 49 | tuple([ 50 | dataset.PerFrameFunctionalGroupsSequence[index] 51 | for index in indices 52 | ]), 53 | segment_description_lut[i], 54 | ) 55 | -------------------------------------------------------------------------------- /src/highdicom/pm/enum.py: -------------------------------------------------------------------------------- 1 | """Enumerate values specific to Parametric Map IODs.""" 2 | from enum import Enum 3 | 4 | 5 | class ImageFlavorValues(Enum): 6 | 7 | """Enumerated values for value 3 of attribute Image Type or Frame Type.""" 8 | 9 | ANGIO = 'ANGIO' 10 | CARDIAC = 'CARDIAC' 11 | CARDIAC_GATED = 'CARDIAC_GATED' 12 | CARDRESP_GATED = 'CARDRESP_GATED' 13 | DYNAMIC = 'DYNAMIC' 14 | FLUOROSCOPY = 'FLUOROSCOPY' 15 | LOCALIZER = 'LOCALIZER' 16 | MOTION = 'MOTION' 17 | PERFUSION = 'PERFUSION' 18 | PRE_CONTRAST = 'PRE_CONTRAST' 19 | POST_CONTRAST = 'POST_CONTRAST' 20 | RESP_GATED = 'RESP_GATED' 21 | REST = 'REST' 22 | STATIC = 'STATIC' 23 | STRESS = 'STRESS' 24 | VOLUME = 'VOLUME' 25 | NON_PARALLEL = 'NON_PARALLEL' 26 | PARALLEL = 'PARALLEL' 27 | WHOLE_BODY = 'WHOLE_BODY' 28 | # CT 29 | ATTENUATION = 'ATTENUATION' 30 | CARDIAC_CTA = 'CARDIAC_CTA' 31 | CARDIAC_CASCORE = 'CARDIAC_CASCORE' 32 | REFERENCE = 'REFERENCE' 33 | # MR 34 | ANGIO_TIME = 'ANGIO_TIME' 35 | ASL = 'ASL' 36 | CINE = 'CINE' 37 | DIFFUSION = 'DIFFUSION' 38 | DIXON = 'DIXON' 39 | FLOW_ENCODED = 'FLOW_ENCODED' 40 | FLUID_ATTENUATED = 'FLUID_ATTENUATED' 41 | FMRI = 'FMRI' 42 | MAX_IP = 'MAX_IP' 43 | MIN_IP = 'MIN_IP' 44 | M_MODE = 'M_MODE' 45 | METABOLITE_MAP = 'METABOLITE_MAP' 46 | MULTIECHO = 'MULTIECHO' 47 | PROTON_DENSITY = 'PROTON_DENSITY' 48 | REALTIME = 'REALTIME' 49 | STIR = 'STIR' 50 | TAGGING = 'TAGGING' 51 | TEMPERATURE = 'TEMPERATURE' 52 | T1 = 'T1' 53 | T2 = 'T2' 54 | T2_STAR = 'T2_STAR' 55 | TOF = 'TOF' 56 | VELOCITY = 'VELOCITY' 57 | 58 | 59 | class DerivedPixelContrastValues(Enum): 60 | 61 | """Enumerated values for value 4 of attribute Image Type or Frame Type.""" 62 | 63 | ADDITION = 'ADDITION' 64 | DIVISION = 'DIVISION' 65 | MASKED = 'MASKED' 66 | MAXIMUM = 'MAXIMUM' 67 | MEAN = 'MEAN' 68 | MINIMUM = 'MINIMUM' 69 | MULTIPLICATION = 'MULTIPLICATION' 70 | NONE = 'NONE' 71 | RESAMPLED = 'RESAMPLED' 72 | STD_DEVIATION = 'STD_DEVIATION' 73 | SUBTRACTION = 'SUBTRACTION' 74 | QUANTITY = 'QUANTITY' 75 | # CT 76 | FILTERED = 'FILTERED' 77 | MEDIAN = 'MEDIAN' 78 | ENERGY_PROP_WT = 'ENERGY_PROP_WT' 79 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Documentation of the Highdicom Package 2 | ====================================== 3 | 4 | ``highdicom`` is a pure Python package built on top of ``pydicom`` to provide a higher-level application programming interface (API) for working with DICOM files. Its focus is on common operations required for machine learning, computer vision, and other similar computational analyses. Broadly speaking, the package helps with three types of task: 5 | 6 | 1. Reading existing DICOM image files of a wide variety of modalities (covering radiology, pathology, and more) and selecting and formatting its frames for computational analysis. This includes considerations such as spatial arrangements of frames, and application of pixel transforms, which are not handled by ``pydicom``. 7 | 2. Storing image-derived information, for example from computational analyses or human annotation, in derived DICOM objects for communication and storage. This includes: 8 | 9 | - Annotations 10 | - Parametric Map images 11 | - Segmentation images 12 | - Structured Report documents (containing numerical results, qualitative evaluations, and/or vector graphic annotations) 13 | - Secondary Capture images 14 | - Key Object Selection documents 15 | - Legacy Converted Enhanced CT/PET/MR images (e.g., for single frame to multi-frame conversion) 16 | - Softcopy Presentation State instances (including Grayscale, Color, and Pseudo-Color) 17 | 18 | 3. Reading existing derived DICOM files of the above types and filtering and accessing the information contained within them. 19 | 20 | For new users looking to get an overview of the library's capabilities and perform basic tasks, we recommend starting with the :ref:`quick-start` page. For a detailed introduction to many of the library's capabilities, see the rest of the :ref:`user-guide`. Documentation of all classes and functions may be found in the :ref:`api-docs`. 21 | 22 | For questions, suggestions, or to report bugs, please use the issue tracker on our GitHub `repository`_. 23 | 24 | .. _repository: https://github.com/ImagingDataCommons/highdicom 25 | 26 | .. toctree:: 27 | :maxdepth: 3 28 | :caption: Contents: 29 | 30 | overview 31 | installation 32 | usage 33 | development 34 | code_of_conduct 35 | conformance 36 | citation 37 | license 38 | release_notes 39 | package 40 | 41 | 42 | 43 | Indices and tables 44 | ================== 45 | 46 | * :ref:`genindex` 47 | * :ref:`modindex` 48 | * :ref:`search` 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=77.0.3"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "highdicom" 7 | version = "0.27.0" 8 | description = "High-level DICOM abstractions." 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | authors = [ 12 | { name = "Markus D. Herrmann" }, 13 | { name = "Christopher P. Bridge" }, 14 | ] 15 | maintainers = [ 16 | { name = "Markus D. Herrmann" }, 17 | { name = "Christopher P. Bridge" }, 18 | ] 19 | license = "MIT" 20 | license-files = [ "LICENSE" ] 21 | classifiers = [ 22 | "Development Status :: 4 - Beta", 23 | "Intended Audience :: Science/Research", 24 | "Operating System :: MacOS", 25 | "Operating System :: Microsoft :: Windows", 26 | "Operating System :: POSIX :: Linux", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Programming Language :: Python :: 3.14", 33 | "Topic :: Multimedia :: Graphics", 34 | "Topic :: Scientific/Engineering :: Information Analysis", 35 | ] 36 | dependencies = [ 37 | "numpy>=1.19", 38 | "pillow>=8.3", 39 | "pydicom>=3.0.1", 40 | "pyjpegls>=1.0.0", 41 | "typing-extensions>=4.0.0", 42 | ] 43 | 44 | [project.optional-dependencies] 45 | libjpeg = [ 46 | "pylibjpeg-libjpeg>=2.1", 47 | "pylibjpeg-openjpeg>=2.0.0", 48 | "pylibjpeg>=2.0", 49 | ] 50 | test = [ 51 | "mypy==1.15.0", 52 | "pytest==8.3.5", 53 | "pytest-cov==6.1.1", 54 | "pytest-flake8==1.3.0", 55 | ] 56 | docs = [ 57 | "sphinx-autodoc-typehints==1.17.0", 58 | "sphinx-pyreverse==0.0.17", 59 | "sphinx-rtd-theme==1.0.0", 60 | "sphinxcontrib-autoprogram==0.1.7", 61 | "sphinxcontrib-websupport==1.2.4", 62 | ] 63 | 64 | [project.urls] 65 | homepage = "https://github.com/imagingdatacommons/highdicom" 66 | documentation = "https://highdicom.readthedocs.io/" 67 | repository = "https://github.com/ImagingDataCommons/highdicom.git" 68 | 69 | [tool.setuptools.packages.find] 70 | where = [ "src" ] 71 | 72 | [tool.setuptools.package-data] 73 | highdicom = [ "**/*.icc" ] 74 | 75 | [tool.pytest.ini_options] 76 | minversion = "7" 77 | addopts = ["--doctest-modules", "-ra", "--strict-config", "--strict-markers"] 78 | testpaths = ["tests"] 79 | log_cli_level = "INFO" 80 | xfail_strict = true 81 | 82 | [tool.mypy] 83 | warn_unreachable = true 84 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 85 | 86 | [[tool.mypy.overrides]] 87 | module = "mypy-pydicom.*" 88 | ignore_missing_imports = true 89 | 90 | [[tool.mypy.overrides]] 91 | module = "mypy-PIL.*" 92 | ignore_missing_imports = true 93 | -------------------------------------------------------------------------------- /docs/highdicom_and_pydicom.rst: -------------------------------------------------------------------------------- 1 | .. _pydicom-and-highdicom: 2 | 3 | Highdicom and Pydicom 4 | ===================== 5 | 6 | The ``highdicom`` library is built on top of ``pydicom``. This page summarizes 7 | the relationship between the two. 8 | 9 | Pydicom 10 | ------- 11 | 12 | `pydicom`_ is a widely-used Python package for working with DICOM files in 13 | Python. It uses a fundamental class, ``pydicom.Dataset``, to represent DICOM 14 | objects within Python programs. It handles operations such as: 15 | 16 | - Reading and writing ``Dataset`` objects to/from files. 17 | - Accessing and setting individual attributes on ``Dataset`` objects. 18 | - Decoding pixel data within DICOM files to NumPy arrays, and encoding pixel 19 | data from NumPy to raw bytes to store within ``Dataset`` objects. 20 | 21 | 22 | Highdicom 23 | --------- 24 | 25 | There is a wide variety of DICOM objects defined in the standard, covering many 26 | types of images (X-Ray, CT, MRI, Microscopy, Ophthalmic images) as well as 27 | various types of image-derived information, such as Structured Reports, 28 | Annotations, Presentation States, Segmentations, and Parametric Maps. Formally, 29 | these "types" are known as Information Object Definitions (IODs). Each IOD in 30 | the standard requires different combinations of attributes. For example, the 31 | "Echo Time" attribute exists with the *MRImage* IOD but not within the 32 | *CTImage* IOD. ``pydicom`` represents all of these objects using the same 33 | general ``Dataset`` class, which implements behavior that is common to all 34 | DICOM objects However, it does not attempt to specialize its representation to 35 | implement IOD-specific behavior, leaving this up to the user. 36 | 37 | The purpose of ``highdicom`` is to build upon ``pydicom`` to implement specific 38 | behaviors for various IODs to make it easier to correctly create and work with 39 | **specific** types of DICOM object. ``highdicom`` defines sub-classes of 40 | ``pydicom.Dataset`` that implement particular IODs, with a specific focus on 41 | IODs that store information derived from other images. For example: 42 | 43 | - :class:`highdicom.Image` (this actually covers many IODs) 44 | - :class:`highdicom.seg.Segmentation` 45 | - :class:`highdicom.sr.EnhancedSR` 46 | - :class:`highdicom.sr.ComprehensiveSR` 47 | - :class:`highdicom.sr.Comprehensive3DSR` 48 | - :class:`highdicom.pm.ParametricMap` 49 | - :class:`highdicom.pr.GrayscaleSoftcopyPresentationState` 50 | - :class:`highdicom.pr.PseudoColorSoftcopyPresentationState` 51 | - :class:`highdicom.pr.AdvancedBlendingPresentationState` 52 | - :class:`highdicom.ko.KeyObjectSelectionDocument` 53 | - :class:`highdicom.ann.MicroscopyBulkSimpleAnnotations` 54 | - :class:`highdicom.sc.SCImage` 55 | 56 | Since each of these objects are sub-classes of ``pydicom.Dataset``, they all 57 | inherit its behaviors for accessing and setting individual attributes and 58 | writing to file. They should also be interoperable with ``pydicom.Dataset``, 59 | such that they can be used anywhere a ``pydicom.Dataset`` is expected. However 60 | they also have: 61 | 62 | - A constructor that dramatically simplifies the creation of the objects while 63 | ensuring correctness. The constructors guide you through which attributes are 64 | required and enforce inter-relationships between them required by the 65 | standard. 66 | - Further methods that allow you to search, filter, and access the information 67 | within them more easily. 68 | 69 | However, some classes within ``highdicom`` are not DICOM objects and as such 70 | are not sub-classes of ``pydicom.Dataset``. Notable examples include 71 | :class:`highdicom.Volume`, 72 | :class:`highdicom.spatial.ImageToReferenceTransformer` (and other similar 73 | objects), :class:`highdicom.io.ImageFileReader`. 74 | 75 | .. _`pydicom`: https://pydicom.github.io/pydicom/stable/index.html 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/imagingdatacommons/highdicom/actions/workflows/run_unit_tests.yml/badge.svg)](https://github.com/imagingdatacommons/highdicom/actions) 2 | [![Documentation Status](https://readthedocs.org/projects/highdicom/badge/?version=latest)](https://highdicom.readthedocs.io/en/latest/?badge=latest) 3 | [![PyPi Distribution](https://img.shields.io/pypi/v/highdicom.svg)](https://pypi.python.org/pypi/highdicom/) 4 | [![Python Versions](https://img.shields.io/pypi/pyversions/highdicom.svg)](https://pypi.org/project/highdicom/) 5 | [![Downloads](https://pepy.tech/badge/highdicom)](https://pepy.tech/project/highdicom) 6 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](code_of_conduct.md) 7 | 8 | # Highdicom 9 | 10 | `highdicom` is a pure Python package built on top of `pydicom` to provide a higher-level application programming interface (API) for working with DICOM files. Its focus is on common operations required for machine learning, computer vision, and other similar computational analyses. Broadly speaking, the package helps with three types of task: 11 | 12 | 1. Reading existing DICOM image files of a wide variety of modalities (covering radiology, pathology, and more) and selecting and formatting its frames for computational analysis. This includes considerations such as spatial arrangements of frames, and application of pixel transforms, which are not handled by `pydicom`. 13 | 2. Storing image-derived information, for example from computational analyses or human annotation, in derived DICOM objects for communication and storage. This includes: 14 | * Annotations 15 | * Parametric Map images 16 | * Segmentation images 17 | * Structured Report documents (containing numerical results, qualitative evaluations, and/or vector graphic annotations) 18 | * Secondary Capture images 19 | * Key Object Selection documents 20 | * Legacy Converted Enhanced CT/PET/MR images (e.g., for single frame to multi-frame conversion) 21 | * Softcopy Presentation State instances (including Grayscale, Color, and Pseudo-Color) 22 | 23 | 3. Reading existing derived DICOM files of the above types and filtering and accessing the information contained within them. 24 | 25 | ## Documentation 26 | 27 | Please refer to the online documentation at [highdicom.readthedocs.io](https://highdicom.readthedocs.io), which includes installation instructions, a user guide with examples, a developer guide, and complete documentation of the application programming interface of the `highdicom` package. 28 | 29 | ## Citation 30 | 31 | For more information about the motivation of the library and the design of highdicom's API, please see the following article: 32 | 33 | > [Highdicom: A Python library for standardized encoding of image annotations and machine learning model outputs in pathology and radiology](https://link.springer.com/article/10.1007/s10278-022-00683-y) 34 | > C.P. Bridge, C. Gorman, S. Pieper, S.W. Doyle, J.K. Lennerz, J. Kalpathy-Cramer, D.A. Clunie, A.Y. Fedorov, and M.D. Herrmann. 35 | > Journal of Digital Imaging, August 2022 36 | 37 | If you use highdicom in your research, please cite the above article. 38 | 39 | ## Support 40 | 41 | The developers gratefully acknowledge their support: 42 | * The [Alliance for Digital Pathology](https://digitalpathologyalliance.org/) 43 | * The [MGH & BWH Center for Clinical Data Science](https://www.ccds.io/) 44 | * [Quantitative Image Informatics for Cancer Research (QIICR)](https://qiicr.org/) 45 | * [Radiomics](https://www.radiomics.io/) 46 | 47 | This software is maintained in part by the [NCI Imaging Data Commons](https://imaging.datacommons.cancer.gov/) project, 48 | which has been funded in whole or in part with Federal funds from the NCI, NIH, under task order no. HHSN26110071 49 | under contract no. HHSN261201500003l. 50 | -------------------------------------------------------------------------------- /src/highdicom/ann/enum.py: -------------------------------------------------------------------------------- 1 | """Enumerate values specific to Annotation IODs.""" 2 | from enum import Enum 3 | 4 | 5 | class AnnotationCoordinateTypeValues(Enum): 6 | 7 | """Enumerated values for attribute Annotation Coordinate Type.""" 8 | 9 | SCOORD = '2D' 10 | """Two-dimensional spatial coordinates denoted by (Column,Row) pairs. 11 | 12 | The coordinate system is the pixel matrix of an image and individual 13 | coordinates are defined relative to center of the (1,1) pixel of either 14 | the total pixel matrix of the entire image or of the pixel matrix of an 15 | individual frame, depending on the value of Pixel Origin Interpretation. 16 | 17 | Coordinates have pixel unit. 18 | 19 | """ 20 | 21 | SCOORD3D = '3D' 22 | """Three-dimensional spatial coordinates denoted by (X,Y,Z) triplets. 23 | 24 | The coordinate system is the Frame of Reference (slide or patient) and the 25 | coordinates are defined relative to origin of the Frame of Reference. 26 | 27 | Coordinates have millimeter unit. 28 | 29 | """ 30 | 31 | 32 | class AnnotationGroupGenerationTypeValues(Enum): 33 | 34 | """Enumerated values for attribute Annotation Group Generation Type.""" 35 | 36 | AUTOMATIC = 'AUTOMATIC' 37 | SEMIAUTOMATIC = 'SEMIAUTOMATIC' 38 | MANUAL = 'MANUAL' 39 | 40 | 41 | class GraphicTypeValues(Enum): 42 | 43 | """Enumerated values for attribute Graphic Type. 44 | 45 | Note 46 | ---- 47 | Coordinates may be either (Column,Row) pairs defined in the 2-dimensional 48 | Total Pixel Matrix or (X,Y,Z) triplets defined in the 3-dimensional 49 | Frame of Reference (patient or slide coordinate system). 50 | 51 | Warning 52 | ------- 53 | Despite having the same names, the definition of values for the Graphic 54 | Type attribute of the ANN modality may differ from those of the SR 55 | modality (SCOORD or SCOORD3D value types). 56 | 57 | """ 58 | 59 | POINT = 'POINT' 60 | """An individual point defined by a single coordinate.""" 61 | 62 | POLYLINE = 'POLYLINE' 63 | """Connected line segments defined by two or more ordered coordinates. 64 | 65 | The coordinates shall be coplanar. 66 | 67 | """ 68 | 69 | POLYGON = 'POLYGON' 70 | """Connected line segments defined by three or more ordered coordinates. 71 | 72 | The coordinates shall be coplanar and form a closed polygon. 73 | 74 | Warning 75 | ------- 76 | In contrast to the corresponding SR Graphic Type for content items of 77 | SCOORD3D value type, the first and last points shall NOT be the same. 78 | 79 | """ 80 | 81 | ELLIPSE = 'ELLIPSE' 82 | """An ellipse defined by four coordinates. 83 | 84 | The first two coordinates specify the endpoints of the major axis and 85 | the second two coordinates specify the endpoints of the minor axis. 86 | 87 | """ 88 | 89 | RECTANGLE = 'RECTANGLE' 90 | """Connected line segments defined by four ordered coordinates. 91 | 92 | The coordinates shall be coplanar and represent a closed, rectangular 93 | polygon. The first coordinate is the top left hand corner, the second 94 | coordinate is the top right hand corner, the third coordinate is the bottom 95 | right hand corner, and the fourth coordinate is the bottom left hand 96 | corner. 97 | 98 | The edges of the rectangle need not be aligned with the axes of the 99 | coordinate system. 100 | 101 | """ 102 | 103 | 104 | class PixelOriginInterpretationValues(Enum): 105 | 106 | """Enumerated values for attribute Pixel Origin Interpretation.""" 107 | 108 | FRAME = 'FRAME' 109 | """Relative to an individual image frame. 110 | 111 | Coordinates have been defined and need to be interpreted relative to the 112 | (1,1) pixel of an individual image frame. 113 | 114 | """ 115 | 116 | VOLUME = 'VOLUME' 117 | """Relative to the Total Pixel Matrix of a VOLUME image. 118 | 119 | Coordinates have been defined and need to be interpreted relative to the 120 | (1,1) pixel of the Total Pixel Matrix of the entire image. 121 | 122 | """ 123 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | from pathlib import Path 4 | from pydicom.data import get_testdata_files 5 | from pydicom.dataset import Dataset, FileMetaDataset 6 | from pydicom.filereader import dcmread 7 | from pydicom import uid 8 | 9 | 10 | from highdicom._module_utils import ( 11 | does_iod_have_pixel_data, 12 | ) 13 | 14 | 15 | def write_and_read_dataset(dataset: Dataset): 16 | """Write DICOM dataset to buffer and read it back from buffer.""" 17 | clone = Dataset(dataset) 18 | if hasattr(dataset, 'file_meta'): 19 | clone.file_meta = FileMetaDataset(dataset.file_meta) 20 | little_endian = None 21 | implicit_vr = None 22 | else: 23 | little_endian = True 24 | implicit_vr = True 25 | with BytesIO() as fp: 26 | clone.save_as( 27 | fp, 28 | implicit_vr=implicit_vr, 29 | little_endian=little_endian, 30 | ) 31 | return dcmread(fp, force=True) 32 | 33 | 34 | def find_readable_images() -> list[tuple[str, str | None]]: 35 | """Get a list of all images in highdicom and pydicom test data that should 36 | be expected to work with image reading routines. 37 | 38 | Returns a list of tuples (path, dependency), where path is the filepath, 39 | and dependency is either None if the file can be read using only required 40 | dependencies, or a str that can be used with pytest.importorskip if an 41 | optional dependency is required to decode pixel data. 42 | 43 | """ 44 | # All pydicom test files 45 | all_files = get_testdata_files() 46 | 47 | # Add highdicom test files 48 | file_path = Path(__file__) 49 | data_dir = file_path.parent.parent.joinpath('data/test_files') 50 | hd_files = [str(f) for f in data_dir.glob("*.dcm")] 51 | 52 | all_files.extend(hd_files) 53 | 54 | # Various files are not expected to work and should be excluded 55 | exclusions = [ 56 | # cannot be read due to bad VFR 57 | "badVR.dcm", 58 | # pixel data is truncated 59 | "MR_truncated.dcm", 60 | # missing number of frames 61 | "liver_1frame.dcm", 62 | # pydicom cannot decode pixels 63 | "JPEG2000-embedded-sequence-delimiter.dcm", 64 | # deflated transfer syntax cannot be read lazily 65 | "image_dfl.dcm", 66 | # pydicom cannot decode pixels 67 | "JPEG-lossy.dcm", 68 | # no pixels 69 | "TINY_ALPHA", 70 | # messed up transfer syntax 71 | "SC_rgb_jpeg.dcm", 72 | # Incorrect source image sequence. This can hopefully be added back 73 | # after https://github.com/pydicom/pydicom/pull/2204 74 | "SC_rgb_small_odd.dcm", 75 | ] 76 | 77 | files_to_use = [] 78 | 79 | for f in all_files: 80 | try: 81 | # Skip image files that can't even be opened (the test files 82 | # include some deliberately corrupted files) 83 | dcm = dcmread(f) 84 | except Exception: 85 | continue 86 | 87 | excluded = False 88 | if 'SOPClassUID' not in dcm: 89 | # Some are missing this... 90 | continue 91 | if not does_iod_have_pixel_data(dcm.SOPClassUID): 92 | # Exclude non images 93 | continue 94 | if not dcm.file_meta.TransferSyntaxUID.is_little_endian: 95 | # We don't support little endian 96 | continue 97 | 98 | for exc in exclusions: 99 | if exc in f: 100 | excluded = True 101 | break 102 | 103 | if excluded: 104 | continue 105 | 106 | dependency = None 107 | if dcm.file_meta.TransferSyntaxUID in ( 108 | uid.JPEGExtended12Bit, 109 | uid.JPEGLosslessSV1, 110 | ): 111 | dependency = "libjpeg" 112 | 113 | if dcm.file_meta.TransferSyntaxUID in ( 114 | uid.JPEG2000, 115 | uid.JPEG2000Lossless, 116 | ): 117 | dependency = "openjpeg" 118 | 119 | files_to_use.append((f, dependency)) 120 | 121 | return files_to_use 122 | -------------------------------------------------------------------------------- /src/highdicom/coding_schemes.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | 3 | from pydicom.dataset import Dataset 4 | 5 | 6 | class CodingSchemeResourceItem(Dataset): 7 | 8 | """Class for items of the Coding Scheme Resource Sequence.""" 9 | 10 | def __init__(self, url: str, url_type: str) -> None: 11 | """ 12 | Parameters 13 | ---------- 14 | url: str 15 | unique resource locator 16 | url_type: str 17 | type of resource `url` points to (options: `{"DOC", "OWL", "CSV"}`) 18 | 19 | """ 20 | super().__init__() 21 | self.CodingSchemeURL = str(url) 22 | if url_type not in {"DOC", "OWL", "CSV"}: 23 | raise ValueError('Unknonw URL type.') 24 | self.CodingSchemeURLType = str(url_type) 25 | 26 | 27 | class CodingSchemeIdentificationItem(Dataset): 28 | 29 | """Class for items of the Coding Scheme Identification Sequence.""" 30 | 31 | def __init__( 32 | self, 33 | designator: str, 34 | name: str | None = None, 35 | version: str | None = None, 36 | registry: str | None = None, 37 | uid: str | None = None, 38 | external_id: str | None = None, 39 | responsible_organization: str | None = None, 40 | resources: Sequence[CodingSchemeResourceItem] | None = None 41 | ) -> None: 42 | """ 43 | Parameters 44 | ---------- 45 | designator: str 46 | value of the Coding Scheme Designator attribute of a `CodedConcept` 47 | name: str, optional 48 | name of the scheme 49 | version: str, optional 50 | version of the scheme 51 | registry: str, optional 52 | name of an external registry where scheme may be obtained from; 53 | required if scheme is registered 54 | uid: str, optional 55 | unique identifier of the scheme; required if the scheme is 56 | registered by an ISO 8824 object identifier compatible with the 57 | UI value representation (VR) 58 | external_id: str, optional 59 | external identifier of the scheme; required if the scheme is 60 | registered and `uid` is not available 61 | responsible_organization: str, optional 62 | name of the organization that is responsible for the scheme 63 | resources: Sequence[pydicom.sr.coding.CodingSchemeResourceItem], optional 64 | one or more resources related to the scheme 65 | 66 | """ # noqa: E501 67 | super().__init__() 68 | self.CodingSchemeDesignator = str(designator) 69 | if name is not None: 70 | self.CodingSchemeName = str(name) 71 | if version is not None: 72 | self.CodingSchemeVersion = str(version) 73 | if responsible_organization is not None: 74 | self.CodingSchemeResponsibleOrganization = \ 75 | str(responsible_organization) 76 | if registry is not None: 77 | self.CodingSchemeRegistry = str(registry) 78 | if uid is None and external_id is None: 79 | raise ValueError( 80 | 'UID or external ID is required if coding scheme is ' 81 | 'registered.' 82 | ) 83 | if uid is not None and external_id is not None: 84 | raise ValueError( 85 | 'Either UID or external ID should be specified for ' 86 | 'registered coding scheme.' 87 | ) 88 | if uid is not None: 89 | self.CodingSchemeUID = str(uid) 90 | elif external_id is not None: 91 | self.CodingSchemeExternalID = str(external_id) 92 | if resources is not None: 93 | self.CodingSchemeResourcesSequence: Sequence[Dataset] = [] 94 | for r in resources: 95 | if not isinstance(r, CodingSchemeResourceItem): 96 | raise TypeError( 97 | 'Resources must have type CodingSchemeResourceItem.' 98 | ) 99 | self.CodingSchemeResourcesSequence.append(r) 100 | -------------------------------------------------------------------------------- /src/highdicom/__init__.py: -------------------------------------------------------------------------------- 1 | from highdicom import ann 2 | from highdicom import color 3 | from highdicom import ko 4 | from highdicom import legacy 5 | from highdicom import pm 6 | from highdicom import pr 7 | from highdicom import sc 8 | from highdicom import seg 9 | from highdicom import sr 10 | from highdicom.base import SOPClass 11 | from highdicom.base_content import ( 12 | ContributingEquipment, 13 | ) 14 | from highdicom.content import ( 15 | AlgorithmIdentificationSequence, 16 | ContentCreatorIdentificationCodeSequence, 17 | IssuerOfIdentifier, 18 | LUT, 19 | ModalityLUT, 20 | ModalityLUTTransformation, 21 | PaletteColorLUT, 22 | PaletteColorLUTTransformation, 23 | PixelMeasuresSequence, 24 | PlaneOrientationSequence, 25 | PlanePositionSequence, 26 | PresentationLUT, 27 | PresentationLUTTransformation, 28 | ReferencedImageSequence, 29 | SegmentedPaletteColorLUT, 30 | SpecimenCollection, 31 | SpecimenDescription, 32 | SpecimenPreparationStep, 33 | SpecimenProcessing, 34 | SpecimenSampling, 35 | SpecimenStaining, 36 | VOILUT, 37 | VOILUTTransformation, 38 | ) 39 | from highdicom.enum import ( 40 | AxisHandedness, 41 | AnatomicalOrientationTypeValues, 42 | CoordinateSystemNames, 43 | ContentQualificationValues, 44 | DimensionOrganizationTypeValues, 45 | LateralityValues, 46 | PadModes, 47 | PatientSexValues, 48 | PhotometricInterpretationValues, 49 | PixelIndexDirections, 50 | PixelRepresentationValues, 51 | PlanarConfigurationValues, 52 | PatientOrientationValuesBiped, 53 | PatientOrientationValuesQuadruped, 54 | PresentationLUTShapeValues, 55 | RescaleTypeValues, 56 | RGBColorChannels, 57 | UniversalEntityIDTypeValues, 58 | VOILUTFunctionValues, 59 | ) 60 | from highdicom import frame 61 | from highdicom.image import ( 62 | Image, 63 | imread, 64 | get_volume_from_series, 65 | ) 66 | from highdicom import io 67 | from highdicom import pixels 68 | from highdicom import spatial 69 | from highdicom.uid import UID 70 | from highdicom import utils 71 | from highdicom.version import __version__ 72 | from highdicom.volume import ( 73 | RGB_COLOR_CHANNEL_DESCRIPTOR, 74 | ChannelDescriptor, 75 | Volume, 76 | VolumeGeometry, 77 | VolumeToVolumeTransformer, 78 | ) 79 | 80 | 81 | __all__ = [ 82 | 'RGB_COLOR_CHANNEL_DESCRIPTOR', 83 | 'AlgorithmIdentificationSequence', 84 | 'AnatomicalOrientationTypeValues', 85 | 'AxisHandedness', 86 | 'ChannelDescriptor', 87 | 'ContentCreatorIdentificationCodeSequence', 88 | 'ContentQualificationValues', 89 | 'ContributingEquipment', 90 | 'CoordinateSystemNames', 91 | 'DimensionOrganizationTypeValues', 92 | 'Image', 93 | 'IssuerOfIdentifier', 94 | 'LUT', 95 | 'LateralityValues', 96 | 'ModalityLUT', 97 | 'ModalityLUTTransformation', 98 | 'PadModes', 99 | 'PaletteColorLUT', 100 | 'PaletteColorLUTTransformation', 101 | 'PatientOrientationValuesBiped', 102 | 'PatientOrientationValuesQuadruped', 103 | 'PatientSexValues', 104 | 'PhotometricInterpretationValues', 105 | 'PixelMeasuresSequence', 106 | 'PixelIndexDirections', 107 | 'PixelRepresentationValues', 108 | 'PlanarConfigurationValues', 109 | 'PlaneOrientationSequence', 110 | 'PlanePositionSequence', 111 | 'PresentationLUT', 112 | 'PresentationLUTShapeValues', 113 | 'PresentationLUTTransformation', 114 | 'ReferencedImageSequence', 115 | 'RescaleTypeValues', 116 | 'RGBColorChannels', 117 | 'SOPClass', 118 | 'SegmentedPaletteColorLUT', 119 | 'SpecimenCollection', 120 | 'SpecimenDescription', 121 | 'SpecimenPreparationStep', 122 | 'SpecimenProcessing', 123 | 'SpecimenSampling', 124 | 'SpecimenStaining', 125 | 'UID', 126 | 'UniversalEntityIDTypeValues', 127 | 'VOILUT', 128 | 'VOILUTFunctionValues', 129 | 'VOILUTTransformation', 130 | 'Volume', 131 | 'VolumeGeometry', 132 | 'VolumeToVolumeTransformer', 133 | '__version__', 134 | 'ann', 135 | 'color', 136 | 'frame', 137 | 'imread', 138 | 'io', 139 | 'ko', 140 | 'legacy', 141 | 'pixels', 142 | 'pm', 143 | 'pr', 144 | 'sc', 145 | 'seg', 146 | 'spatial', 147 | 'sr', 148 | 'utils', 149 | 'get_volume_from_series', 150 | ] 151 | -------------------------------------------------------------------------------- /tests/test_color.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | import pytest 5 | from PIL.ImageCms import ImageCmsProfile, createProfile 6 | 7 | from highdicom.color import ColorManager, CIELabColor 8 | 9 | 10 | @pytest.mark.parametrize( 11 | 'l_in,a_in,b_in,out', 12 | [ 13 | [0.0, -128.0, -128.0, (0x0000, 0x0000, 0x0000)], 14 | [100.0, -128.0, -128.0, (0xFFFF, 0x0000, 0x0000)], 15 | [100.0, 0.0, 0.0, (0xFFFF, 0x8080, 0x8080)], 16 | [100.0, 0.0, 0.0, (0xFFFF, 0x8080, 0x8080)], 17 | [100.0, 127.0, 127.0, (0xFFFF, 0xFFFF, 0xFFFF)], 18 | [100.0, -128.0, 127.0, (0xFFFF, 0x0000, 0xFFFF)], 19 | ] 20 | ) 21 | def test_cielab(l_in, a_in, b_in, out): 22 | color = CIELabColor(l_in, a_in, b_in) 23 | assert color.value == out 24 | 25 | 26 | @pytest.mark.parametrize( 27 | # Examples generated from colormine.org 28 | 'r,g,b,l_out,a_out,b_out', 29 | [ 30 | [0, 0, 0, 0.0, 0.0, 0.0], 31 | [255, 0, 0, 53.23, 80.11, 67.22], 32 | [0, 255, 0, 87.74, -86.18, 83.18], 33 | [0, 0, 255, 32.30, 79.20, -107.86], 34 | [0, 255, 255, 91.11, -48.08, -14.14], 35 | [255, 255, 0, 97.14, -21.56, 94.48], 36 | [255, 0, 255, 60.32, 98.25, -60.84], 37 | [255, 255, 255, 100.0, 0.0, -0.01], 38 | [45, 123, 198, 50.45, 2.59, -45.75], 39 | ] 40 | ) 41 | def test_from_rgb(r, g, b, l_out, a_out, b_out): 42 | color = CIELabColor.from_rgb(r, g, b) 43 | 44 | assert abs(color.l_star - l_out) < 0.1 45 | assert abs(color.a_star - a_out) < 0.1 46 | assert abs(color.b_star - b_out) < 0.1 47 | 48 | l_star, a_star, b_star = color.lab 49 | assert abs(l_star - l_out) < 0.1 50 | assert abs(a_star - a_out) < 0.1 51 | assert abs(b_star - b_out) < 0.1 52 | 53 | assert color.to_rgb() == (r, g, b) 54 | 55 | 56 | def test_to_rgb_invalid(): 57 | # A color that cannot be represented with RGB 58 | color = CIELabColor(93.21, 117.12, -100.7) 59 | 60 | with pytest.raises(ValueError): 61 | color.to_rgb() 62 | 63 | # With clip=True, will clip to closest representable value 64 | r, g, b = color.to_rgb(clip=True) 65 | assert r == 255 66 | assert g == 125 67 | assert b == 255 68 | 69 | 70 | @pytest.mark.parametrize( 71 | 'color,r_out,g_out,b_out', 72 | [ 73 | ['black', 0, 0, 0], 74 | ['white', 255, 255, 255], 75 | ['red', 255, 0, 0], 76 | ['green', 0, 128, 0], 77 | ['blue', 0, 0, 255], 78 | ['yellow', 255, 255, 0], 79 | ['orange', 255, 165, 0], 80 | ['DARKORCHID', 153, 50, 204], 81 | ['LawnGreen', 124, 252, 0], 82 | ['#232489', 0x23, 0x24, 0x89], 83 | ['#567832', 0x56, 0x78, 0x32], 84 | ['#a6e83c', 0xa6, 0xe8, 0x3c], 85 | ] 86 | ) 87 | def test_from_string(color, r_out, g_out, b_out): 88 | color = CIELabColor.from_string(color) 89 | r, g, b = color.to_rgb() 90 | 91 | assert r == r_out 92 | assert g == g_out 93 | assert b == b_out 94 | 95 | 96 | def test_from_dicom(): 97 | v = (1000, 3456, 4218) 98 | color = CIELabColor.from_dicom_value(v) 99 | assert color.value == v 100 | 101 | 102 | @pytest.mark.parametrize( 103 | 'l_in,a_in,b_in', 104 | [ 105 | (-1.0, -128.0, -128.0), 106 | (100.1, -128.0, -128.0), 107 | (100.0, -128.1, 127.0), 108 | (100.0, -128.0, 127.1), 109 | ] 110 | ) 111 | def test_cielab_invalid(l_in, a_in, b_in): 112 | with pytest.raises(ValueError): 113 | CIELabColor(l_in, a_in, b_in) 114 | 115 | 116 | class TestColorManager(unittest.TestCase): 117 | 118 | def setUp(self) -> None: 119 | super().setUp() 120 | self._icc_profile = ImageCmsProfile(createProfile('sRGB')).tobytes() 121 | 122 | def test_construction(self) -> None: 123 | ColorManager(self._icc_profile) 124 | 125 | def test_construction_without_profile(self) -> None: 126 | with pytest.raises(TypeError): 127 | ColorManager() # type: ignore 128 | 129 | def test_transform_frame(self) -> None: 130 | manager = ColorManager(self._icc_profile) 131 | frame = np.ones((10, 10, 3), dtype=np.uint8) * 255 132 | output = manager.transform_frame(frame) 133 | assert output.shape == frame.shape 134 | assert output.dtype == frame.dtype 135 | 136 | def test_transform_frame_wrong_shape(self) -> None: 137 | manager = ColorManager(self._icc_profile) 138 | frame = np.ones((10, 10), dtype=np.uint8) * 255 139 | with pytest.raises(ValueError): 140 | manager.transform_frame(frame) 141 | -------------------------------------------------------------------------------- /src/highdicom/pr/enum.py: -------------------------------------------------------------------------------- 1 | """Enumerated values specific to Presentation State IODs.""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class AnnotationUnitsValues(Enum): 7 | 8 | """ 9 | 10 | Enumerated values for annotation units, describing how the stored values 11 | relate to the image position. 12 | 13 | """ 14 | 15 | PIXEL = 'PIXEL' 16 | """Image coordinates within an individual image image frame. 17 | 18 | Image coordinates in pixel unit specified with sub-pixel resolution such 19 | that the origin, which is at the Top Left Hand Corner (TLHC) of the TLHC 20 | pixel is (0.0, 0.0), the Bottom Right Hand Corner (BRHC) of the TLHC pixel 21 | is (1.0, 1.0), and the BRHC of the BRHC pixel is (Columns, Rows). The 22 | values must be within the range (0, 0) to (Columns, Rows). 23 | 24 | """ 25 | 26 | DISPLAY = 'DISPLAY' 27 | """Display coordinates. 28 | 29 | Display coordinates in pixel unit specified with sub-pixel resolution, 30 | where (0.0, 0.0) is the top left hand corner of the displayed area and 31 | (1.0, 1.0) is the bottom right hand corner of the displayed area. Values 32 | are between 0.0 and 1.0. 33 | 34 | """ 35 | 36 | MATRIX = 'MATRIX' 37 | """Image coordinates relative to the total pixel matrix of a tiled image. 38 | 39 | Image coordinates in pixel unit specified with sub-pixel resolution such 40 | that the origin, which is at the Top Left Hand Corner (TLHC) of the TLHC 41 | pixel of the Total Pixel Matrix, is (0.0, 0.0), the Bottom Right Hand 42 | Corner (BRHC) of the TLHC pixel is (1.0, 1.0), and the BRHC of the BRHC 43 | pixel of the Total Pixel Matrix is (Total Pixel Matrix Columns,Total Pixel 44 | Matrix Rows). The values must be within the range (0.0, 0.0) to (Total 45 | Pixel Matrix Columns, Total Pixel Matrix Rows). MATRIX may be used only if 46 | the referenced image is tiled (i.e. has attributes Total Pixel Matrix Rows 47 | and Total Pixel Matrix Columns). 48 | 49 | """ 50 | 51 | 52 | class TextJustificationValues(Enum): 53 | 54 | """Enumerated values for attribute Bounding Box Text Horizontal 55 | Justification. 56 | 57 | """ 58 | 59 | LEFT = 'LEFT' 60 | CENTER = 'CENTER' 61 | RIGHT = 'RIGHT' 62 | 63 | 64 | class GraphicTypeValues(Enum): 65 | 66 | """Enumerated values for attribute Graphic Type. 67 | 68 | See :dcm:`C.10.5.2 `. 69 | 70 | """ 71 | 72 | CIRCLE = 'CIRCLE' 73 | """A circle defined by two (column,row) pairs. 74 | 75 | The first pair is the central point and 76 | the second pair is a point on the perimeter of the circle. 77 | 78 | """ 79 | 80 | ELLIPSE = 'ELLIPSE' 81 | """An ellipse defined by four pixel (column,row) pairs. 82 | 83 | The first two pairs specify the endpoints of the major axis and 84 | the second two pairs specify the endpoints of the minor axis. 85 | 86 | """ 87 | 88 | INTERPOLATED = 'INTERPOLATED' 89 | """List of end points between which a line is to be interpolated. 90 | 91 | The exact nature of the interpolation is an implementation detail of 92 | the software rendering the object. 93 | 94 | Each point is represented by a (column,row) pair. 95 | 96 | """ 97 | 98 | POINT = 'POINT' 99 | """A single point defined by two values (column,row).""" 100 | 101 | POLYLINE = 'POLYLINE' 102 | """List of end points between which straight lines are to be drawn. 103 | 104 | Each point is represented by a (column,row) pair. 105 | 106 | """ 107 | 108 | 109 | class BlendingModeValues(Enum): 110 | 111 | """Enumerated values for the Blending Mode attribute. 112 | 113 | Pixel values are additively blended using alpha compositioning with 114 | premultiplied alpha. The Blending Mode attribute describes how the 115 | premultiplier alpha value is computed for each image. 116 | 117 | """ 118 | 119 | EQUAL = 'EQUAL' 120 | """Additive blending of two or more images with equal alpha premultipliers. 121 | 122 | Pixel values of *n* images are additively blended in an iterative fashion 123 | after premultiplying pixel values with a constant alpha value, which is 124 | either 0 or 1/n of the value of the Relative Opacity attribute: 125 | 1/n * Relative Opacity * first value + 1/n * Relative Opacity * second value 126 | 127 | """ 128 | 129 | FOREGROUND = 'FOREGROUND' 130 | """Additive blending of two images with different alpha premultipliers. 131 | 132 | The first image serves as background and the second image serves as 133 | foreground. 134 | Pixel values of the two images are additively blended after premultiplying 135 | the pixel values of each image with a different alpha value, which is 136 | computed from the value of the Relative Opacity attribute: 137 | Relative Opacity * first value + (1 - Relative Opacity) * second value 138 | 139 | """ 140 | -------------------------------------------------------------------------------- /src/highdicom/sr/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for creation of Structured Report (SR) instances.""" 2 | from highdicom.sr.coding import CodedConcept 3 | from highdicom.sr.content import ( 4 | CoordinatesForMeasurement, 5 | CoordinatesForMeasurement3D, 6 | FindingSite, 7 | ImageRegion, 8 | ImageRegion3D, 9 | LongitudinalTemporalOffsetFromEvent, 10 | SourceImageForMeasurement, 11 | SourceImageForMeasurementGroup, 12 | SourceImageForSegmentation, 13 | SourceImageForRegion, 14 | SourceSeriesForSegmentation, 15 | RealWorldValueMap, 16 | ReferencedSegment, 17 | ReferencedSegmentationFrame, 18 | VolumeSurface, 19 | ) 20 | from highdicom.sr.enum import ( 21 | GraphicTypeValues, 22 | GraphicTypeValues3D, 23 | PixelOriginInterpretationValues, 24 | RelationshipTypeValues, 25 | TemporalRangeTypeValues, 26 | ValueTypeValues, 27 | ) 28 | from highdicom.sr.sop import ( 29 | EnhancedSR, 30 | ComprehensiveSR, 31 | Comprehensive3DSR, 32 | srread, 33 | ) 34 | from highdicom.sr.templates import ( 35 | AlgorithmIdentification, 36 | DeviceObserverIdentifyingAttributes, 37 | ImageLibrary, 38 | ImageLibraryEntryDescriptors, 39 | LanguageOfContentItemAndDescendants, 40 | Measurement, 41 | MeasurementProperties, 42 | MeasurementReport, 43 | MeasurementsAndQualitativeEvaluations, 44 | MeasurementStatisticalProperties, 45 | NormalRangeProperties, 46 | ObserverContext, 47 | ObservationContext, 48 | PersonObserverIdentifyingAttributes, 49 | PlanarROIMeasurementsAndQualitativeEvaluations, 50 | QualitativeEvaluation, 51 | SubjectContext, 52 | SubjectContextDevice, 53 | SubjectContextFetus, 54 | SubjectContextSpecimen, 55 | TrackingIdentifier, 56 | TimePointContext, 57 | VolumetricROIMeasurementsAndQualitativeEvaluations, 58 | ) 59 | from highdicom.sr import utils 60 | from highdicom.sr.value_types import ( 61 | ContentItem, 62 | ContentSequence, 63 | CodeContentItem, 64 | ContainerContentItem, 65 | CompositeContentItem, 66 | DateContentItem, 67 | DateTimeContentItem, 68 | ImageContentItem, 69 | NumContentItem, 70 | PnameContentItem, 71 | ScoordContentItem, 72 | Scoord3DContentItem, 73 | TcoordContentItem, 74 | TextContentItem, 75 | TimeContentItem, 76 | UIDRefContentItem, 77 | WaveformContentItem, 78 | ) 79 | 80 | SOP_CLASS_UIDS = { 81 | '1.2.840.10008.5.1.4.1.1.88.11', # Basic Text SR 82 | '1.2.840.10008.5.1.4.1.1.88.22', # Enhanced SR 83 | '1.2.840.10008.5.1.4.1.1.88.33', # Comprehensive SR 84 | '1.2.840.10008.5.1.4.1.1.88.34', # Comprehensive 3D SR 85 | '1.2.840.10008.5.1.4.1.1.88.35', # Extensible SR 86 | '1.2.840.10008.5.1.4.1.1.88.40', # Procedure Log 87 | '1.2.840.10008.5.1.4.1.1.88.50', # Mammography CAD SR 88 | '1.2.840.10008.5.1.4.1.1.88.65', # Chest CAD SR 89 | '1.2.840.10008.5.1.4.1.1.88.67', # X-Ray Radiation Dose SR 90 | '1.2.840.10008.5.1.4.1.1.88.68', # Radiopharmaceutical Radiation Dose SR 91 | '1.2.840.10008.5.1.4.1.1.88.69', # Colon CAD SR 92 | '1.2.840.10008.5.1.4.1.1.88.70', # Implantation Plan SR 93 | '1.2.840.10008.5.1.4.1.1.88.71', # Acquisition Context SR 94 | '1.2.840.10008.5.1.4.1.1.88.72', # Simplified Adult Echo SR 95 | '1.2.840.10008.5.1.4.1.1.88.73', # Patient Radiation Dose SR 96 | } 97 | 98 | __all__ = [ 99 | 'AlgorithmIdentification', 100 | 'CodeContentItem', 101 | 'CodedConcept', 102 | 'CompositeContentItem', 103 | 'Comprehensive3DSR', 104 | 'ComprehensiveSR', 105 | 'ContainerContentItem', 106 | 'ContentItem', 107 | 'ContentSequence', 108 | 'ContentSequence', 109 | 'CoordinatesForMeasurement', 110 | 'CoordinatesForMeasurement3D', 111 | 'DateContentItem', 112 | 'DateTimeContentItem', 113 | 'DeviceObserverIdentifyingAttributes', 114 | 'EnhancedSR', 115 | 'FindingSite', 116 | 'GraphicTypeValues', 117 | 'GraphicTypeValues3D', 118 | 'ImageContentItem', 119 | 'ImageLibrary', 120 | 'ImageLibraryEntryDescriptors', 121 | 'ImageRegion', 122 | 'ImageRegion3D', 123 | 'LanguageOfContentItemAndDescendants', 124 | 'LongitudinalTemporalOffsetFromEvent', 125 | 'Measurement', 126 | 'MeasurementProperties', 127 | 'MeasurementReport', 128 | 'MeasurementStatisticalProperties', 129 | 'MeasurementsAndQualitativeEvaluations', 130 | 'NormalRangeProperties', 131 | 'NumContentItem', 132 | 'ObservationContext', 133 | 'ObserverContext', 134 | 'PersonObserverIdentifyingAttributes', 135 | 'PixelOriginInterpretationValues', 136 | 'PlanarROIMeasurementsAndQualitativeEvaluations', 137 | 'PnameContentItem', 138 | 'QualitativeEvaluation', 139 | 'RealWorldValueMap', 140 | 'ReferencedSegment', 141 | 'ReferencedSegmentationFrame', 142 | 'RelationshipTypeValues', 143 | 'Scoord3DContentItem', 144 | 'ScoordContentItem', 145 | 'SourceImageForMeasurement', 146 | 'SourceImageForMeasurementGroup', 147 | 'SourceImageForRegion', 148 | 'SourceImageForSegmentation', 149 | 'SourceSeriesForSegmentation', 150 | 'SubjectContext', 151 | 'SubjectContextDevice', 152 | 'SubjectContextFetus', 153 | 'SubjectContextSpecimen', 154 | 'TcoordContentItem', 155 | 'TemporalRangeTypeValues', 156 | 'TextContentItem', 157 | 'TimeContentItem', 158 | 'TimePointContext', 159 | 'TrackingIdentifier', 160 | 'UIDRefContentItem', 161 | 'ValueTypeValues', 162 | 'VolumeSurface', 163 | 'VolumetricROIMeasurementsAndQualitativeEvaluations', 164 | 'WaveformContentItem', 165 | 'srread', 166 | 'utils', 167 | ] 168 | -------------------------------------------------------------------------------- /bin/create_iods_modules.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import datetime 3 | import collections 4 | import json 5 | import logging 6 | import os 7 | import sys 8 | 9 | from pydicom.datadict import dictionary_keyword 10 | from pydicom.tag import Tag 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | PGK_PATH = os.path.join( 16 | os.path.dirname(__file__), 17 | '..', 18 | 'src', 19 | 'highdicom' 20 | ) 21 | 22 | 23 | def _load_json_from_file(filename): 24 | with open(filename) as f: 25 | return json.load(f) 26 | 27 | 28 | def _dump_json(data): 29 | return json.dumps(data, indent=4, sort_keys=True) 30 | 31 | 32 | def _create_sop_to_iods(directory): 33 | filename = os.path.join(directory, 'sops.json') 34 | sops = _load_json_from_file(filename) 35 | 36 | filename = os.path.join(directory, 'ciods.json') 37 | ciods = _load_json_from_file(filename) 38 | 39 | sop_id_to_ciod_name = {sop['id']: sop['ciod'] for sop in sops} 40 | ciod_name_to_ciod_id = {ciod['name']: ciod['id'] for ciod in ciods} 41 | sop_id_to_ciod_id = {} 42 | for sop_id in sop_id_to_ciod_name: 43 | ciod_name = sop_id_to_ciod_name[sop_id] 44 | try: 45 | sop_id_to_ciod_id[sop_id] = ciod_name_to_ciod_id[ciod_name] 46 | except KeyError: 47 | logger.error(f'could not map IOD "{ciod_name}"') 48 | return sop_id_to_ciod_id 49 | 50 | 51 | def _create_iods(directory): 52 | filename = os.path.join(directory, 'ciod_to_modules.json') 53 | ciod_to_modules = _load_json_from_file(filename) 54 | iods = collections.defaultdict(list) 55 | for item in ciod_to_modules: 56 | mapping = { 57 | 'key': item['moduleId'], 58 | 'usage': item['usage'], 59 | 'ie': item['informationEntity'], 60 | } 61 | iods[item['ciodId']].append(mapping) 62 | return iods 63 | 64 | 65 | def _create_modules(directory): 66 | filename = os.path.join(directory, 'module_to_attributes.json') 67 | module_to_attributes = _load_json_from_file(filename) 68 | modules = collections.defaultdict(list) 69 | for item in module_to_attributes: 70 | path = item['path'].split(':')[1:] 71 | tag_string = path.pop(-1) 72 | # Handle attributes used for real-time communication, which are neither 73 | # in DicomDictionary nor in RepeaterDictionary 74 | if any(p.startswith('0006') for p in path): 75 | logger.warning(f'skip attribute "{tag_string}"') 76 | continue 77 | logger.debug(f'add attribute "{tag_string}"') 78 | # Handle attributes that are in RepeatersDictionary 79 | tag_string = tag_string.replace('xx', '00') 80 | tag = Tag(tag_string) 81 | try: 82 | keyword = dictionary_keyword(tag) 83 | except KeyError: 84 | logger.error(f'keyword not found for attribute "{tag}"') 85 | continue 86 | try: 87 | kw_path = [dictionary_keyword(t) for t in path] 88 | except KeyError: 89 | logger.error(f'keyword in path of attribute "{tag}" not found') 90 | continue 91 | mapping = { 92 | 'keyword': keyword, 93 | 'type': item['type'], 94 | 'path': kw_path, 95 | } 96 | modules[item['moduleId']].append(mapping) 97 | return modules 98 | 99 | 100 | if __name__ == '__main__': 101 | 102 | logging.basicConfig() 103 | logger.setLevel(logging.DEBUG) 104 | 105 | # Positional argument is path to directory containing JSON files generated 106 | # using the dicom-standard Python package, see 107 | # https://github.com/innolitics/dicom-standard/tree/master/standard 108 | try: 109 | directory = sys.argv[1] 110 | except IndexError as e: 111 | raise ValueError('Path to directory must be provided.') from e 112 | if not os.path.exists(directory): 113 | raise OSError(f'Path does not exist: "{directory}"') 114 | if not os.path.isdir(directory): 115 | raise OSError(f'Path is not a directory: "{directory}"') 116 | 117 | now = datetime.datetime.now() 118 | current_date = datetime.datetime.date(now).strftime('%Y-%m-%d') 119 | current_time = datetime.datetime.time(now).strftime('%H:%M:%S') 120 | 121 | iods = _create_iods(directory) 122 | iods_docstr = '\n'.join([ 123 | '"""DICOM Information Object Definitions (IODs)', 124 | f'auto-generated on {current_date} at {current_time}.', 125 | '"""', 126 | 'from typing import Dict, List' 127 | ]) 128 | sop_to_iods = _create_sop_to_iods(directory) 129 | iods_filename = os.path.join(PGK_PATH, '_iods.py') 130 | with open(iods_filename, 'w') as fp: 131 | fp.write(iods_docstr) 132 | fp.write('\n\n') 133 | iods_formatted = _dump_json(iods).replace('null', 'None') 134 | fp.write( 135 | f'IOD_MODULE_MAP: Dict[str, List[Dict[str, str]]] = {iods_formatted}' 136 | ) 137 | fp.write('\n\n') 138 | sop_to_iods_formatted = _dump_json(sop_to_iods).replace('null', 'None') 139 | fp.write(f'SOP_CLASS_UID_IOD_KEY_MAP = {sop_to_iods_formatted}') 140 | 141 | modules = _create_modules(directory) 142 | modules_docstr = '\n'.join([ 143 | '"""DICOM modules' 144 | f'auto-generated on {current_date} at {current_time}.' 145 | '"""', 146 | 'from typing import Dict, List, Sequence, Union' 147 | ]) 148 | modules_filename = os.path.join(PGK_PATH, '_modules.py') 149 | with open(modules_filename, 'w') as fp: 150 | fp.write(modules_docstr) 151 | fp.write('\n\n') 152 | modules_formatted = _dump_json(modules).replace('null', 'None') 153 | fp.write( 154 | f'MODULE_ATTRIBUTE_MAP: Dict[str, List[Dict[str, Union[str, Sequence[str]]]]] = {modules_formatted}' # noqa: E501 155 | ) 156 | -------------------------------------------------------------------------------- /code_of_conduct.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [chris@chrisbridge.science](mailto:chris@chrisbridge.science). 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | 135 | -------------------------------------------------------------------------------- /tests/test_valuetypes.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | from pydicom import Dataset 5 | from pydicom.sr.codedict import codes 6 | from pydicom.sr.coding import Code 7 | from pydicom.valuerep import DT, DA, TM 8 | from pydicom import config 9 | 10 | from highdicom.sr.coding import CodedConcept 11 | from highdicom.sr.enum import ValueTypeValues 12 | from highdicom.sr.value_types import ( 13 | DateContentItem, 14 | DateTimeContentItem, 15 | TimeContentItem 16 | ) 17 | from tests.utils import write_and_read_dataset 18 | 19 | 20 | class TestDateTimeContentItem: 21 | test_datetime_values = [ 22 | DT("2023"), 23 | DT("202306"), 24 | DT("20230623"), 25 | DT("2023062311"), 26 | DT("202306231112"), 27 | DT("20230623111247"), 28 | DT("20230623111247.123456"), 29 | ] 30 | 31 | @pytest.mark.parametrize("datetime_value", test_datetime_values) 32 | def test_construct_from_datetime(self, datetime_value: DT): 33 | name = codes.DCM.DatetimeOfProcessing 34 | assert isinstance(name, Code) 35 | value_type = ValueTypeValues.DATETIME 36 | item = DateTimeContentItem( 37 | name=name, 38 | value=datetime_value 39 | ) 40 | 41 | assert item.name == name 42 | assert item.value == datetime_value 43 | assert item.value_type == value_type 44 | assert isinstance(item.value, datetime.datetime) 45 | assert item.value.isoformat() == datetime_value.isoformat() 46 | 47 | @pytest.mark.parametrize("datetime_value", test_datetime_values) 48 | @pytest.mark.parametrize("datetime_conversion", [True, False]) 49 | def test_from_dataset( 50 | self, 51 | datetime_value: DT, 52 | datetime_conversion: bool 53 | ): 54 | config.datetime_conversion = datetime_conversion 55 | name = codes.DCM.DatetimeOfProcessing 56 | assert isinstance(name, Code) 57 | value_type = ValueTypeValues.DATETIME 58 | dataset = Dataset() 59 | dataset.ValueType = value_type.value 60 | dataset.ConceptNameCodeSequence = [CodedConcept.from_code(name)] 61 | dataset.DateTime = datetime_value 62 | 63 | dataset_reread = write_and_read_dataset(dataset) 64 | item = DateTimeContentItem.from_dataset(dataset_reread) 65 | 66 | assert item.name == name 67 | assert item.value == datetime_value 68 | assert item.value_type == value_type 69 | assert isinstance(item.value, datetime.datetime) 70 | assert item.value.isoformat() == datetime_value.isoformat() 71 | 72 | 73 | class TestDateContentItem: 74 | def test_construct_from_date(self): 75 | date_value = DA("20230623") 76 | name = codes.DCM.AcquisitionDate 77 | assert isinstance(name, Code) 78 | value_type = ValueTypeValues.DATE 79 | item = DateContentItem( 80 | name=name, 81 | value=date_value 82 | ) 83 | 84 | assert item.name == name 85 | assert item.value == date_value 86 | assert item.value_type == value_type 87 | assert isinstance(item.value, datetime.date) 88 | assert item.value.isoformat() == date_value.isoformat() 89 | 90 | @pytest.mark.parametrize("datetime_conversion", [True, False]) 91 | def test_from_dataset(self, datetime_conversion: bool): 92 | config.datetime_conversion = datetime_conversion 93 | date_value = DA("20230623") 94 | name = codes.DCM.AcquisitionDate 95 | assert isinstance(name, Code) 96 | value_type = ValueTypeValues.DATE 97 | dataset = Dataset() 98 | dataset.ValueType = value_type.value 99 | dataset.ConceptNameCodeSequence = [CodedConcept.from_code(name)] 100 | dataset.Date = date_value 101 | 102 | dataset_reread = write_and_read_dataset(dataset) 103 | item = DateContentItem.from_dataset(dataset_reread) 104 | 105 | assert item.name == name 106 | assert item.value == date_value 107 | assert item.value_type == value_type 108 | assert isinstance(item.value, datetime.date) 109 | assert item.value.isoformat() == date_value.isoformat() 110 | 111 | 112 | class TestTimeContentItem: 113 | test_time_values = [ 114 | TM("11"), 115 | TM("1112"), 116 | TM("111247"), 117 | TM("111247.123456"), 118 | ] 119 | 120 | @pytest.mark.parametrize("time_value", test_time_values) 121 | def test_construct_from_time(self, time_value: TM): 122 | name = codes.DCM.AcquisitionTime 123 | assert isinstance(name, Code) 124 | value_type = ValueTypeValues.TIME 125 | item = TimeContentItem( 126 | name=name, 127 | value=time_value 128 | ) 129 | 130 | assert item.name == name 131 | assert item.value == time_value 132 | assert item.value_type == value_type 133 | assert isinstance(item.value, datetime.time) 134 | assert item.value.isoformat() == time_value.isoformat() 135 | 136 | @pytest.mark.parametrize("time_value", test_time_values) 137 | @pytest.mark.parametrize("datetime_conversion", [True, False]) 138 | def test_from_dataset( 139 | self, 140 | time_value: TM, 141 | datetime_conversion: bool 142 | ): 143 | config.datetime_conversion = datetime_conversion 144 | name = codes.DCM.AcquisitionDate 145 | assert isinstance(name, Code) 146 | value_type = ValueTypeValues.TIME 147 | dataset = Dataset() 148 | dataset.ValueType = value_type.value 149 | dataset.ConceptNameCodeSequence = [CodedConcept.from_code(name)] 150 | dataset.Time = time_value 151 | 152 | dataset_reread = write_and_read_dataset(dataset) 153 | item = TimeContentItem.from_dataset(dataset_reread) 154 | 155 | assert item.name == name 156 | assert item.value == time_value 157 | assert item.value_type == value_type 158 | assert isinstance(item.value, datetime.time) 159 | assert item.value.isoformat() == time_value.isoformat() 160 | -------------------------------------------------------------------------------- /docs/coding.rst: -------------------------------------------------------------------------------- 1 | .. _coding: 2 | 3 | Coding 4 | ====== 5 | 6 | "Coding" is a key concept used throughout `highdicom`. By "coding", we are 7 | referring to the use of standardized nomenclatures or terminologies to describe 8 | medical (or related) concepts. For example, instead of using the English word 9 | "liver" (or a word in another human language) to describe the liver, we instead 10 | use a code such as '10200004' from the SNOMED-CT nomenclature to describe the 11 | liver in standardized way. Use of coding is vital to ensure that these concepts 12 | are expressed unambiguously within DICOM files. Coding is especially 13 | fundamental within structured reporting, but is also found in other places 14 | around the DICOM standard and, in turn, `highdicom`. 15 | 16 | To communicate a concept in DICOM using a coding scheme, three elements are 17 | necessary: 18 | 19 | - A **coding scheme**: an identifier of the pre-defined terminology used to 20 | define the concept. 21 | - A code **value**: the code value conveys a unique identifier for the specific 22 | concept. It is often a number or alphanumeric string that may not have any 23 | inherent meaning outside of the terminology. 24 | - A code **meaning**. The code meaning conveys the concept in a way that is 25 | understandable to humans. 26 | 27 | Any coding scheme that operates in this way may be used within DICOM objects, 28 | including ones that you create yourself. However, it is highly recommended to 29 | use a well-known and widely accepted standard terminology to ensure that your 30 | DICOM objects will be as widely understood and as interoperable as possible. 31 | Examples of widely used medical terminologies include: 32 | 33 | - The `DCM `_ 34 | terminology. This terminology is defined within the DICOM standard itself and 35 | is used to refer to DICOM concepts, as well as other concepts 36 | within the radiology workflow. 37 | - `SNOMED-CT `_. This terminology contains codes to 38 | describe medical concepts including anatomy, diseases, and procedures. 39 | - `RadLex `_. A standardized terminology for concepts 40 | in radiology. 41 | - `UCUM `_. A terminology specifically to describe units of 42 | measurement. 43 | 44 | See 45 | `this page `_ 46 | for a list of terminologies used within DICOM. 47 | 48 | `Highdicom` defines the :class:`highdicom.sr.CodedConcept` class to encapsulate 49 | a coded concept. To create a :class:`highdicom.sr.CodedConcept`, you pass 50 | values for the coding scheme, code value, and code meaning. For example, to 51 | describe a tumor using the SNOMED-CT terminology, you could do this: 52 | 53 | .. code-block:: python 54 | 55 | import highdicom as hd 56 | 57 | tumor_code = hd.sr.CodedConcept( 58 | value="108369006", 59 | scheme_designator="SCT", 60 | meaning="Tumor" 61 | ) 62 | 63 | Codes within Pydicom 64 | -------------------- 65 | 66 | The `pydicom` library, upon which `highdicom` is built, has its own class 67 | ``pydicom.sr.coding.Code`` that captures coded concepts in the same way that 68 | :class:`highdicom.sr.CodedConcept` does. The reason for the difference is that 69 | the `highdicom` class is a sub-class of ``pydicom.Dataset`` with the relevant 70 | attributes such that it can be included directly into a DICOM object. `pydicom` 71 | also includes within it values for a large number of coded concepts within 72 | the DCM, SNOMED-CT, and UCUM terminologies. For example, instead of manually 73 | creating the "tumor" concept above, we could have just used the pre-defined 74 | value in `pydicom`: 75 | 76 | .. code-block:: python 77 | 78 | from pydicom.sr.codedict import codes 79 | 80 | tumor_code = codes.SCT.Tumor 81 | print(tumor_code.value) 82 | # '1083690006' 83 | print(tumor_code.scheme_designator) 84 | # 'SCT' 85 | print(tumor_code.meaning) 86 | # 'tumor' 87 | 88 | Here are some other examples of codes within `pydicom`: 89 | 90 | .. code-block:: python 91 | 92 | from pydicom.sr.codedict import codes 93 | 94 | # A patient, as described by the DCM terminology 95 | patient_code = codes.DCM.Patient 96 | print(patient_code) 97 | # Code(value='121025', scheme_designator='DCM', meaning='Patient', scheme_version=None) 98 | 99 | # A centimeter, a described by the UCUM coding scheme 100 | cm_code = codes.UCUM.cm 101 | print(cm_code) 102 | # Code(value='cm', scheme_designator='UCUM', meaning='cm', scheme_version=None) 103 | 104 | 105 | The two classes can be used interoperably throughout highdicom: anywhere in the 106 | `highdicom` API that you can pass a :class:`highdicom.sr.CodedConcept`, you 107 | can pass an ``pydicom.sr.coding.Code`` instead and it will be converted behind 108 | the scenes for you. Furthermore, equality is defined between the two classes 109 | such that it evaluates to true if they represent the same concept, and they 110 | hash to the same value if you use them within sets or as keys in dictionaries. 111 | 112 | .. code-block:: python 113 | 114 | import highdicom as hd 115 | from pydicom.sr.codedict import codes 116 | 117 | tumor_code_hd = hd.sr.CodedConcept( 118 | value="108369006", 119 | scheme_designator="SCT", 120 | meaning="Tumor" 121 | ) 122 | tumor_code = codes.SCT.Tumor 123 | 124 | assert tumor_code_hd == tumor_code 125 | assert len({tumor_code_hd, tumor_code}) == 1 126 | 127 | For equality and hashing, two codes are considered equivalent if they have the 128 | same coding scheme, and value, regardless of how their meaning is represented. 129 | 130 | Finding Suitable Codes 131 | ---------------------- 132 | 133 | The `pydicom` code dictionary allows searching for concepts via simple string 134 | matching. However, for more advanced searching it is generally advisable to 135 | search the documentation for the coding scheme itself. 136 | 137 | .. code-block:: python 138 | 139 | from pydicom.sr.codedict import codes 140 | 141 | print(codes.SCT.dir('liver')) 142 | # ['DeliveredRadiationDose', 143 | # 'HistoryOfPrematureDelivery', 144 | # 'Liver', 145 | # 'LiverStructure'] 146 | 147 | print(codes.SCT.Liver) 148 | # Code(value='10200004', scheme_designator='SCT', meaning='Liver', scheme_version=None) 149 | -------------------------------------------------------------------------------- /docs/package.rst: -------------------------------------------------------------------------------- 1 | .. _api-docs: 2 | 3 | API Documentation 4 | ================= 5 | 6 | .. _highdicom-package: 7 | 8 | highdicom package 9 | ----------------- 10 | 11 | .. automodule:: highdicom 12 | :members: 13 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 14 | :special-members: __call__ 15 | :undoc-members: 16 | :show-inheritance: 17 | 18 | highdicom.color module 19 | ++++++++++++++++++++++ 20 | 21 | .. automodule:: highdicom.color 22 | :members: 23 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 24 | :special-members: __call__ 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | highdicom.frame module 29 | ++++++++++++++++++++++ 30 | 31 | .. automodule:: highdicom.frame 32 | :members: 33 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 34 | :special-members: __call__ 35 | :undoc-members: 36 | :show-inheritance: 37 | 38 | highdicom.io module 39 | +++++++++++++++++++ 40 | 41 | .. automodule:: highdicom.io 42 | :members: 43 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 44 | :special-members: __call__ 45 | :undoc-members: 46 | :show-inheritance: 47 | 48 | highdicom.spatial module 49 | ++++++++++++++++++++++++ 50 | 51 | .. automodule:: highdicom.spatial 52 | :members: 53 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 54 | :special-members: __call__ 55 | :undoc-members: 56 | :show-inheritance: 57 | 58 | highdicom.valuerep module 59 | +++++++++++++++++++++++++ 60 | 61 | .. automodule:: highdicom.valuerep 62 | :members: 63 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 64 | :special-members: __call__ 65 | :undoc-members: 66 | :show-inheritance: 67 | 68 | highdicom.utils module 69 | ++++++++++++++++++++++ 70 | 71 | .. automodule:: highdicom.utils 72 | :members: 73 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 74 | :special-members: __call__ 75 | :undoc-members: 76 | :show-inheritance: 77 | 78 | highdicom.pixels module 79 | +++++++++++++++++++++++ 80 | 81 | .. automodule:: highdicom.pixels 82 | :members: 83 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 84 | :special-members: __call__ 85 | :undoc-members: 86 | :show-inheritance: 87 | 88 | .. _highdicom-legacy-subpackage: 89 | 90 | highdicom.legacy package 91 | ------------------------ 92 | 93 | .. automodule:: highdicom.legacy 94 | :members: 95 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 96 | :special-members: __call__ 97 | :undoc-members: 98 | :show-inheritance: 99 | 100 | .. _highdicom-ann-subpackage: 101 | 102 | highdicom.ann package 103 | --------------------- 104 | 105 | .. automodule:: highdicom.ann 106 | :members: 107 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 108 | :special-members: __call__ 109 | :undoc-members: 110 | :show-inheritance: 111 | 112 | .. _highdicom-ko-subpackage: 113 | 114 | highdicom.ko package 115 | --------------------- 116 | 117 | .. automodule:: highdicom.ko 118 | :members: 119 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 120 | :special-members: __call__ 121 | :undoc-members: 122 | :show-inheritance: 123 | 124 | .. _highdicom-pm-subpackage: 125 | 126 | highdicom.pm package 127 | --------------------- 128 | 129 | .. automodule:: highdicom.pm 130 | :members: 131 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 132 | :special-members: __call__ 133 | :undoc-members: 134 | :show-inheritance: 135 | 136 | .. _highdicom-pr-subpackage: 137 | 138 | highdicom.pr package 139 | --------------------- 140 | 141 | .. automodule:: highdicom.pr 142 | :members: 143 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 144 | :special-members: __call__ 145 | :undoc-members: 146 | :show-inheritance: 147 | 148 | .. _highdicom-seg-subpackage: 149 | 150 | highdicom.seg package 151 | --------------------- 152 | 153 | .. automodule:: highdicom.seg 154 | :members: 155 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 156 | :special-members: __call__ 157 | :undoc-members: 158 | :show-inheritance: 159 | 160 | highdicom.seg.utils module 161 | ++++++++++++++++++++++++++ 162 | 163 | .. automodule:: highdicom.seg.utils 164 | :members: 165 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 166 | :special-members: __call__ 167 | :undoc-members: 168 | :show-inheritance: 169 | 170 | .. _highdicom-sr-subpackage: 171 | 172 | highdicom.sr package 173 | -------------------- 174 | 175 | .. automodule:: highdicom.sr 176 | :members: 177 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 178 | :special-members: __call__ 179 | :undoc-members: 180 | :show-inheritance: 181 | 182 | highdicom.sr.utils module 183 | +++++++++++++++++++++++++ 184 | 185 | .. automodule:: highdicom.sr.utils 186 | :members: 187 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 188 | :special-members: __call__ 189 | :undoc-members: 190 | :show-inheritance: 191 | 192 | .. _highdicom-sc-subpackage: 193 | 194 | highdicom.sc package 195 | --------------------- 196 | 197 | .. automodule:: highdicom.sc 198 | :members: 199 | :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, 200 | :special-members: __call__ 201 | :undoc-members: 202 | :show-inheritance: 203 | -------------------------------------------------------------------------------- /src/highdicom/sr/coding.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | import logging 3 | from typing import Union 4 | from typing_extensions import Self 5 | 6 | from pydicom.dataset import Dataset 7 | from pydicom.sr.coding import Code 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class CodedConcept(Dataset): 13 | 14 | """Coded concept of a DICOM SR document content module attribute.""" 15 | 16 | def __init__( 17 | self, 18 | value: str, 19 | scheme_designator: str, 20 | meaning: str, 21 | scheme_version: str | None = None 22 | ) -> None: 23 | """ 24 | Parameters 25 | ---------- 26 | value: str 27 | code 28 | scheme_designator: str 29 | designator of coding scheme 30 | meaning: str 31 | meaning of the code 32 | scheme_version: Union[str, None], optional 33 | version of coding scheme 34 | 35 | """ 36 | super().__init__() 37 | if len(value) > 16: 38 | if value.startswith('urn') or '://' in value: 39 | self.URNCodeValue = str(value) 40 | else: 41 | self.LongCodeValue = str(value) 42 | else: 43 | self.CodeValue = str(value) 44 | if len(meaning) > 64: 45 | raise ValueError('Code meaning can have maximally 64 characters.') 46 | self.CodeMeaning = str(meaning) 47 | self.CodingSchemeDesignator = str(scheme_designator) 48 | if scheme_version is not None: 49 | self.CodingSchemeVersion = str(scheme_version) 50 | # TODO: Enhanced Code Sequence Macro Attributes 51 | 52 | def __hash__(self) -> int: 53 | return hash(self.scheme_designator + self.value) 54 | 55 | def __eq__(self, other: object) -> bool: 56 | """Compares `self` and `other` for equality. 57 | 58 | Parameters 59 | ---------- 60 | other: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code] 61 | code 62 | 63 | Returns 64 | ------- 65 | bool 66 | whether `self` and `other` are considered equal 67 | 68 | """ 69 | if isinstance(other, (Code, CodedConcept)): 70 | this = Code( 71 | self.value, 72 | self.scheme_designator, 73 | self.meaning, 74 | self.scheme_version 75 | ) 76 | return Code.__eq__(this, other) 77 | return super().__eq__(other) 78 | 79 | def __ne__(self, other: object) -> bool: 80 | """Compares `self` and `other` for inequality. 81 | 82 | Parameters 83 | ---------- 84 | other: Union[CodedConcept, pydicom.sr.coding.Code] 85 | code 86 | 87 | Returns 88 | ------- 89 | bool 90 | whether `self` and `other` are not considered equal 91 | 92 | """ 93 | return not (self == other) 94 | 95 | @classmethod 96 | def from_dataset( 97 | cls, 98 | dataset: Dataset, 99 | copy: bool = True 100 | ) -> Self: 101 | """Construct a CodedConcept from an existing dataset. 102 | 103 | Parameters 104 | ---------- 105 | dataset: pydicom.dataset.Dataset 106 | Dataset representing a coded concept. 107 | copy: bool 108 | If True, the underlying dataset is deep-copied such that the 109 | original dataset remains intact. If False, this operation will 110 | alter the original dataset in place. 111 | 112 | Returns 113 | ------- 114 | highdicom.sr.CodedConcept: 115 | Coded concept representation of the dataset. 116 | 117 | Raises 118 | ------ 119 | TypeError: 120 | If the passed dataset is not a pydicom dataset. 121 | AttributeError: 122 | If the dataset does not contain the required elements for a 123 | coded concept. 124 | 125 | """ 126 | if not isinstance(dataset, Dataset): 127 | raise TypeError( 128 | 'Dataset must be a pydicom.dataset.Dataset.' 129 | ) 130 | code_value_kws = ['CodeValue', 'LongCodeValue', 'URNCodeValue'] 131 | num_code_values = sum(hasattr(dataset, kw) for kw in code_value_kws) 132 | if num_code_values != 1: 133 | raise AttributeError( 134 | 'Dataset should have exactly one of the following attributes: ' 135 | f'{", ".join(code_value_kws)}.' 136 | ) 137 | for kw in ['CodeMeaning', 'CodingSchemeDesignator']: 138 | if not hasattr(dataset, kw): 139 | raise AttributeError( 140 | 'Dataset does not contain the following attribute ' 141 | f'required for coded concepts: {kw}.' 142 | ) 143 | if copy: 144 | concept = deepcopy(dataset) 145 | else: 146 | concept = dataset 147 | concept.__class__ = cls 148 | return concept 149 | 150 | @classmethod 151 | def from_code(cls, code: Union[Code, 'CodedConcept']) -> Self: 152 | """Construct a CodedConcept for a pydicom Code. 153 | 154 | Parameters 155 | ---------- 156 | code: Union[pydicom.sr.coding.Code, highdicom.sr.CodedConcept] 157 | Code. 158 | 159 | Returns 160 | ------- 161 | highdicom.sr.CodedConcept: 162 | CodedConcept dataset for the code. 163 | 164 | """ 165 | if isinstance(code, cls): 166 | return code 167 | return cls(*code) 168 | 169 | @property 170 | def value(self) -> str: 171 | """str: value of either `CodeValue`, `LongCodeValue` or `URNCodeValue` 172 | attribute""" 173 | return getattr( 174 | self, 'CodeValue', 175 | getattr( 176 | self, 'LongCodeValue', 177 | getattr( 178 | self, 'URNCodeValue', 179 | None 180 | ) 181 | ) 182 | ) 183 | 184 | @property 185 | def meaning(self) -> str: 186 | """str: meaning of the code""" 187 | return self.CodeMeaning 188 | 189 | @property 190 | def scheme_designator(self) -> str: 191 | """str: designator of the coding scheme (e.g. ``"DCM"``)""" 192 | 193 | return self.CodingSchemeDesignator 194 | 195 | @property 196 | def scheme_version(self) -> str | None: 197 | """Union[str, None]: version of the coding scheme (if specified)""" 198 | return getattr(self, 'CodingSchemeVersion', None) 199 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file does only contain a selection of the most common options. For a 4 | # full list see the documentation: 5 | # http://www.sphinx-doc.org/en/stable/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | import pkg_resources 16 | import datetime 17 | 18 | source_dir = os.path.dirname(__file__) 19 | pkg_dir = os.path.join(source_dir, '..', '..', 'src', 'highdicom') 20 | sys.path.insert(0, os.path.abspath(pkg_dir)) 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = 'highdicom' 25 | copyright = f'2020-{datetime.datetime.now().year}, highdicom contributors' 26 | author = 'Markus D. Herrmann' 27 | 28 | # The full version, including alpha/beta/rc tags 29 | try: 30 | release = pkg_resources.get_distribution('highdicom').version 31 | except pkg_resources.DistributionNotFound: 32 | print('Package "highdicom" must be installed to build docs.') 33 | sys.exit(1) 34 | # The short X.Y version 35 | version = '.'.join(release.split('.')[:2]) 36 | 37 | 38 | # -- General configuration --------------------------------------------------- 39 | 40 | # If your documentation needs a minimal Sphinx version, state it here. 41 | # 42 | # needs_sphinx = '1.0' 43 | 44 | # Add any Sphinx extension module names here, as strings. They can be 45 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 46 | # ones. 47 | extensions = [ 48 | 'sphinxcontrib.autoprogram', 49 | 'sphinx.ext.autodoc', 50 | 'sphinx.ext.napoleon', 51 | 'sphinx_autodoc_typehints', 52 | 'sphinx.ext.extlinks', 53 | ] 54 | 55 | napoleon_google_docstring = False 56 | 57 | # Add any paths that contain templates here, relative to this directory. 58 | templates_path = ['_templates'] 59 | 60 | # The suffix(es) of source filenames. 61 | # You can specify multiple suffix as a list of string: 62 | # 63 | # source_suffix = ['.rst', '.md'] 64 | source_suffix = '.rst' 65 | 66 | # The master toctree document. 67 | master_doc = 'index' 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = 'en' 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This pattern also affects html_static_path and html_extra_path . 79 | exclude_patterns = [] 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = 'sphinx' 83 | 84 | # Concatenate docstring of class definion and __init__ method definition. 85 | autoclass_content = 'both' 86 | 87 | typehints_fully_qualified = True 88 | 89 | # Shortcuts for sphinx.ext.extlinks 90 | extlinks = { 91 | # 'alias' : (url_prefix, caption) 92 | # Usage :dcm:`link text ` 93 | 'dcm': ( 94 | 'http://dicom.nema.org/medical/dicom/current/output/chtml/%s', 95 | None 96 | ), 97 | } 98 | 99 | # -- Options for HTML output ------------------------------------------------- 100 | 101 | # The theme to use for HTML and HTML Help pages. See the documentation for 102 | # a list of builtin themes. 103 | # 104 | html_theme = 'sphinx_rtd_theme' 105 | 106 | # Theme options are theme-specific and customize the look and feel of a theme 107 | # further. For a list of options available for each theme, see the 108 | # documentation. 109 | # 110 | # html_theme_options = {} 111 | 112 | # Add any paths that contain custom static files (such as style sheets) here, 113 | # relative to this directory. They are copied after the builtin static files, 114 | # so a file named "default.css" will overwrite the builtin "default.css". 115 | html_static_path = [] 116 | 117 | # Custom sidebar templates, must be a dictionary that maps document names 118 | # to template names. 119 | # 120 | # The default sidebars (for documents that don't match any pattern) are 121 | # defined by theme itself. Builtin themes are using these templates by 122 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 123 | # 'searchbox.html']``. 124 | # 125 | # html_sidebars = {} 126 | 127 | 128 | # -- Options for HTMLHelp output --------------------------------------------- 129 | 130 | # Output file base name for HTML help builder. 131 | htmlhelp_basename = 'highdicomdoc' 132 | 133 | 134 | # -- Options for LaTeX output ------------------------------------------------ 135 | 136 | latex_elements = { 137 | # The paper size ('letterpaper' or 'a4paper'). 138 | # 139 | # 'papersize': 'letterpaper', 140 | 141 | # The font size ('10pt', '11pt' or '12pt'). 142 | # 143 | # 'pointsize': '10pt', 144 | 145 | # Additional stuff for the LaTeX preamble. 146 | # 147 | # 'preamble': '', 148 | 149 | # Latex figure (float) alignment 150 | # 151 | # 'figure_align': 'htbp', 152 | } 153 | 154 | # Grouping the document tree into LaTeX files. List of tuples 155 | # (source start file, target name, title, 156 | # author, documentclass [howto, manual, or own class]). 157 | latex_documents = [ 158 | ( 159 | master_doc, 160 | 'highdicom.tex', 161 | 'highdicom Documentation', 162 | author, 163 | 'manual' 164 | ), 165 | ] 166 | 167 | 168 | # -- Options for manual page output ------------------------------------------ 169 | 170 | # One entry per manual page. List of tuples 171 | # (source start file, name, description, authors, manual section). 172 | man_pages = [ 173 | ( 174 | master_doc, 175 | 'highdicom.tex', 176 | 'highdicom Documentation', 177 | [author], 178 | 1 179 | ), 180 | ] 181 | 182 | 183 | # -- Options for Texinfo output ---------------------------------------------- 184 | 185 | # Grouping the document tree into Texinfo files. List of tuples 186 | # (source start file, target name, title, author, 187 | # dir menu entry, description, category) 188 | texinfo_documents = [ 189 | ( 190 | master_doc, 191 | 'highdicom', 192 | 'highdicom Documentation', 193 | author, 194 | 'highdicom', 195 | ( 196 | 'High-level Python abstractions for the creation ' 197 | 'of derived DICOM objects.' 198 | ), 199 | 'Miscellaneous' 200 | ), 201 | ] 202 | 203 | 204 | # -- Extension configuration ------------------------------------------------- 205 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | .. _developer-guide: 2 | 3 | Developer Guide 4 | =============== 5 | 6 | Source code is available at Github and can be cloned via git: 7 | 8 | .. code-block:: none 9 | 10 | git clone https://github.com/imagingdatacommons/highdicom ~/highdicom 11 | 12 | The :mod:`highdicom` package can be installed in *develop* mode for local development: 13 | 14 | .. code-block:: none 15 | 16 | pip install -e ~/highdicom 17 | 18 | 19 | .. _pull-requests: 20 | 21 | Pull requests 22 | ------------- 23 | 24 | We encourage contributions from the users of the library (provided that they fit within the scope of the project). 25 | 26 | If you are planning to make a contribution to the library, we encourage you to leave an issue first on the `issue tracker `_ detailing your proposed contribution. 27 | This way, the maintainers can vet your proposal, make sure it is within the scope of the project, and guide you through the process of creating a successful pull request. 28 | Before creating a pull request on Github, read the coding style guideline, run the tests and check PEP8 compliance. 29 | 30 | We follow a `gitflow `_-like process for development. 31 | Therefore, please do not commit code changes to the ``master`` branch. 32 | New features should be implemented in a separate branch called ``feature/*``, and a pull request should be created with the target set as the development branch with the name of the *next* release (e.g. ``v0.22.0dev``). 33 | Bug fixes that do not affect the public API of the project should be applied in separate branch called ``bugfix/*`` and a pull request should be created with targeted at ``master`` branch. 34 | 35 | .. _coding-style: 36 | 37 | Coding style 38 | ------------ 39 | 40 | Code must comply with `PEP 8 `_. 41 | The `flake8 `_ package is used to enforce compliance. 42 | 43 | The project uses `numpydoc `_ for documenting code according to `PEP 257 `_ docstring conventions. 44 | Further information and examples for the NumPy style can be found at the `NumPy Github repository `_ and the website of the `Napoleon sphinx extension `_. 45 | 46 | All API classes, functions and modules must be documented (including "private" functions and methods). 47 | Each docstring must describe input parameters and return values. 48 | Types must be specified using type hints as specified by `PEP 484 `_ (see `typing `_ module) in both the function definition as well as the docstring. 49 | 50 | 51 | .. _running-tests: 52 | 53 | Running tests 54 | ------------- 55 | 56 | The project uses `pytest `_ to write and runs unit tests. 57 | Tests should be placed in a separate ``tests`` folder within the package root folder. 58 | Files containing actual test code should follow the pattern ``test_*.py``. 59 | 60 | Install requirements: 61 | 62 | .. code-block:: none 63 | 64 | pip install .[test] 65 | 66 | Run tests (including checks for PEP8 compliance): 67 | 68 | .. code-block:: none 69 | 70 | cd ~/highdicom 71 | pytest --flake8 72 | 73 | .. _building-documentation: 74 | 75 | Building documentation 76 | ---------------------- 77 | 78 | Install requirements: 79 | 80 | .. code-block:: none 81 | 82 | pip install .[docs] 83 | 84 | Build documentation in *HTML* format: 85 | 86 | .. code-block:: none 87 | 88 | cd ~/highdicom 89 | sphinx-build -b html docs/ docs/build/ 90 | 91 | The built ``index.html`` file will be located in ``docs/build``. 92 | 93 | Design principles 94 | ----------------- 95 | 96 | **Interoperability with Pydicom** - Highdicom is built on the pydicom library. 97 | Highdicom types are typically derived from the ``pydicom.dataset.Dataset`` or 98 | ``pydicom.sequence.Sequence`` classes and should remain interoperable with them 99 | as far as possible such that experienced users can use the lower-level pydicom 100 | API to inspect or change the object if needed. 101 | 102 | **Standard DICOM Terminology** - Where possible, highdicom types, functions, 103 | parameters, enums, etc map onto concepts within the DICOM standard and should 104 | follow the same terminology to ensure that the meaning is unambiguous. Where 105 | the terminology used in the standard may not be easily understood by those 106 | unfamiliar with it, this should be addressed via documentation rather than 107 | using alternative terminology. 108 | 109 | **Standard Compliance on Encoding** - Highdicom should not allow users to 110 | create DICOM objects that are not in compliance with the standard. The library 111 | should validate all parameters passed to it and should raise an exception if 112 | they would result in the creation of an invalid object, and give a clear 113 | explanation to the user why the parameters passed are invalid. Furthermore, 114 | highdicom objects should always exist in a state of standards compliance, 115 | without any intermediate invalid states. Once a constructor has completed, the 116 | user should be confident that they have a valid object. 117 | 118 | **Standard Compliance on Decoding** - Unfortunately, many DICOM objects found 119 | in the real world have minor deviations from the standard. When decoding DICOM 120 | objects, highdicom should tolerate minor deviations as far as they do not 121 | interfere with its functionality. When highdicom needs to assume that objects 122 | are standard compliant in order to function, it should check this assumption 123 | first and raise an exception explaining the issue to the user if it finds an 124 | error. Unless there are exceptional circumstances, highdicom should not attempt 125 | to work around issues in non-compliant files produced by other implementations. 126 | 127 | **The Decoding API** - Highdicom classes implement functionality for 128 | conveniently accessing information contained within the relevant dataset. To 129 | use this functionality with existing pydicom dataset, such as those read in 130 | from file or received over network, the dataset must first be converted to the 131 | relevant highdicom type. This is implemented by the alternative 132 | ``from_dataset()`` or ``from_sequence()`` constructors on highdicom types. 133 | These methods should perform "eager" type conversion of the dataset and all 134 | datasets contained within it into the relevant highdicom types, where they 135 | exist. This way, objects created from scratch by users and those converted from 136 | pydicom datasets using ``from_dataset()`` or ``from_sequence()`` should appear 137 | identical to users and developers as far as possible. 138 | -------------------------------------------------------------------------------- /src/highdicom/valuerep.py: -------------------------------------------------------------------------------- 1 | """Functions for working with DICOM value representations.""" 2 | import re 3 | import warnings 4 | 5 | from pydicom.valuerep import PersonName 6 | 7 | 8 | def check_person_name(person_name: str | PersonName) -> None: 9 | """Check value is valid for the value representation "person name". 10 | 11 | The DICOM Person Name (PN) value representation has a specific format with 12 | multiple components (family name, given name, middle name, prefix, suffix) 13 | separated by caret characters ('^'), where any number of components may be 14 | missing and trailing caret separators may be omitted. Unfortunately it is 15 | both easy to make a mistake when constructing names with this format, and 16 | impossible to check for certain whether it has been done correctly. 17 | 18 | This function checks for strings representing person names that have a high 19 | likelihood of having been encoded incorrectly and raises an exception if 20 | such a case is found. 21 | 22 | A string is considered to be an invalid person name if it contains no caret 23 | characters. 24 | 25 | Note 26 | ---- 27 | A name consisting of only a family name component (e.g. ``'Bono'``) is 28 | valid according to the standard but will be disallowed by this function. 29 | However if necessary, such a name can be still be encoded by adding a 30 | trailing caret character to disambiguate the meaning (e.g. ``'Bono^'``). 31 | 32 | Parameters 33 | ---------- 34 | person_name: Union[str, pydicom.valuerep.PersonName] 35 | Name to check. 36 | 37 | Raises 38 | ------ 39 | ValueError 40 | If the provided value is highly likely to be an invalid person name. 41 | TypeError 42 | If the provided person name has an invalid type. 43 | 44 | """ 45 | if not isinstance(person_name, (str, PersonName)): 46 | raise TypeError('Invalid type for a person name.') 47 | 48 | name_url = ( 49 | 'https://dicom.nema.org/dicom/2013/output/chtml/part05/' 50 | 'sect_6.2.html#sect_6.2.1.2' 51 | ) 52 | if '^' not in person_name and person_name != '': # empty string is allowed 53 | warnings.warn( 54 | f'The string "{person_name}" is unlikely to represent the ' 55 | 'intended person name since it contains only a single component. ' 56 | 'Construct a person name according to the format in described ' 57 | f'in {name_url}, or, in pydicom 2.2.0 or later, use the ' 58 | 'pydicom.valuerep.PersonName.from_named_components() method ' 59 | 'to construct the person name correctly. If a single-component ' 60 | 'name is really intended, add a trailing caret character to ' 61 | 'disambiguate the name.', 62 | UserWarning, 63 | stacklevel=2, 64 | ) 65 | 66 | 67 | def _check_code_string(value: str) -> None: 68 | """Check value is valid for the value representation "code string". 69 | 70 | Parameters 71 | ---------- 72 | value: str 73 | Code string 74 | 75 | Raises 76 | ------ 77 | TypeError 78 | When `value` is not a string 79 | ValueError 80 | When `value` has zero or more than 16 characters or when `value` 81 | contains characters that are invalid for the value representation 82 | 83 | Note 84 | ---- 85 | The checks performed by this function are stricter than requirements 86 | imposed by the standard. For example, it does not allow leading or trailing 87 | spaces or underscores. 88 | Therefore, it should only be used to check values for creation of objects 89 | but not for parsing of existing objects. 90 | 91 | """ 92 | if not isinstance(value, str): 93 | raise TypeError('Invalid type for a code string.') 94 | 95 | if re.match(r'[A-Z0-9_ ]{1,16}$', value) is None: 96 | raise ValueError( 97 | 'Code string must contain between 1 and 16 characters that are ' 98 | 'either uppercase letters, numbers, spaces, or underscores.' 99 | ) 100 | 101 | if re.match(r'[0-9 _]{1}.*', value) is not None: 102 | raise ValueError( 103 | 'Code string must not start with a number, space, or underscore.' 104 | ) 105 | 106 | if re.match(r'.*[_ ]$', value) is not None: 107 | raise ValueError( 108 | 'Code string must not end with a space or underscore.' 109 | ) 110 | 111 | 112 | def _check_short_string(s: str) -> None: 113 | """Check that a Python string is valid for use as DICOM Short String. 114 | 115 | Parameters 116 | ---------- 117 | s: str 118 | Python string to check. 119 | 120 | Raises 121 | ------ 122 | ValueError: 123 | If the string s is not valid as a DICOM Short String due to length or 124 | the characters it contains. 125 | 126 | """ 127 | if len(s) > 16: 128 | raise ValueError( 129 | 'Values of DICOM value representation Short String (SH) must not ' 130 | 'exceed 16 characters.' 131 | ) 132 | if '\\' in s: 133 | raise ValueError( 134 | 'Values of DICOM value representation Short String (SH) must not ' 135 | 'contain the backslash character.' 136 | ) 137 | 138 | 139 | def _check_long_string(s: str) -> None: 140 | """Check that a Python string is valid for use as DICOM Long String. 141 | 142 | Parameters 143 | ---------- 144 | s: str 145 | Python string to check. 146 | 147 | Raises 148 | ------ 149 | ValueError: 150 | If the string s is not valid as a DICOM Long String due to length or 151 | the characters it contains. 152 | 153 | """ 154 | if len(s) > 64: 155 | raise ValueError( 156 | 'Values of DICOM value representation Long String (LO) must not ' 157 | 'exceed 64 characters.' 158 | ) 159 | if '\\' in s: 160 | raise ValueError( 161 | 'Values of DICOM value representation Long String (LO) must not ' 162 | 'contain the backslash character.' 163 | ) 164 | 165 | 166 | def _check_short_text(s: str) -> None: 167 | """Check that a Python string is valid for use as DICOM Short Text. 168 | 169 | Parameters 170 | ---------- 171 | s: str 172 | Python string to check. 173 | 174 | Raises 175 | ------ 176 | ValueError: 177 | If the string s is not valid as a DICOM Short Text due to length or 178 | the characters it contains. 179 | 180 | """ 181 | if len(s) > 1024: 182 | raise ValueError( 183 | 'Values of DICOM value representation Short Text (ST) must not ' 184 | 'exceed 1024 characters.' 185 | ) 186 | if '\\' in s: 187 | raise ValueError( 188 | 'Values of DICOM value representation Short Text (ST) must not ' 189 | 'contain the backslash character.' 190 | ) 191 | 192 | 193 | def _check_long_text(s: str) -> None: 194 | """Check that a Python string is valid for use as DICOM Long Text. 195 | 196 | Parameters 197 | ---------- 198 | s: str 199 | Python string to check. 200 | 201 | Raises 202 | ------ 203 | ValueError: 204 | If the string s is not valid as a DICOM Long Text due to length or 205 | the characters it contains. 206 | 207 | """ 208 | if len(s) > 10240: 209 | raise ValueError( 210 | 'Values of DICOM value representation Long Text (LT) must not ' 211 | 'exceed 10240 characters.' 212 | ) 213 | if '\\' in s: 214 | raise ValueError( 215 | 'Values of DICOM value representation Long Text (LT) must not ' 216 | 'contain the backslash character.' 217 | ) 218 | -------------------------------------------------------------------------------- /src/highdicom/sr/enum.py: -------------------------------------------------------------------------------- 1 | """Enumerate values specific to Structured Report IODs.""" 2 | from enum import Enum 3 | 4 | 5 | class ValueTypeValues(Enum): 6 | 7 | """Enumerated values for attribute Value Type. 8 | 9 | See :dcm:`Table C.17.3.2.1 `. 10 | 11 | """ 12 | 13 | CODE = 'CODE' 14 | """Coded expression of the concept.""" 15 | 16 | COMPOSITE = 'COMPOSITE' 17 | """Reference to UIDs of Composite SOP Instances.""" 18 | 19 | CONTAINER = 'CONTAINER' 20 | """The content of the CONTAINER. 21 | 22 | The value of a CONTAINER Content Item is the collection of Content Items 23 | that it contains. 24 | 25 | """ 26 | 27 | DATE = 'DATE' 28 | """Calendar date.""" 29 | 30 | DATETIME = 'DATETIME' 31 | """Concatenated date and time.""" 32 | 33 | IMAGE = 'IMAGE' 34 | """Reference to UIDs of Image Composite SOP Instances.""" 35 | 36 | NUM = 'NUM' 37 | """Numeric value and associated Unit of Measurement.""" 38 | 39 | PNAME = 'PNAME' 40 | """Name of person.""" 41 | 42 | SCOORD = 'SCOORD' 43 | """Listing of spatial coordinates defined in 2D pixel matrix.""" 44 | 45 | SCOORD3D = 'SCOORD3D' 46 | """Listing of spatial coordinates defined in 3D frame of reference.""" 47 | 48 | TCOORD = 'TCOORD' 49 | """Listing of temporal coordinates.""" 50 | 51 | TEXT = 'TEXT' 52 | """Textual expression of the concept.""" 53 | 54 | TIME = 'TIME' 55 | """Time of day.""" 56 | 57 | UIDREF = 'UIDREF' 58 | """Unique Identifier.""" 59 | 60 | WAVEFORM = 'WAVEFORM' 61 | """Reference to UIDs of Waveform Composite SOP Instances.""" 62 | 63 | 64 | class GraphicTypeValues(Enum): 65 | 66 | """Enumerated values for attribute Graphic Type. 67 | 68 | See :dcm:`C.18.6.1.1 `. 69 | 70 | """ 71 | 72 | CIRCLE = 'CIRCLE' 73 | """A circle defined by two (Column,Row) coordinates. 74 | 75 | The first coordinate is the central point and 76 | the second coordinate is a point on the perimeter of the circle. 77 | 78 | """ 79 | 80 | ELLIPSE = 'ELLIPSE' 81 | """An ellipse defined by four pixel (Column,Row) coordinates. 82 | 83 | The first two coordinates specify the endpoints of the major axis and 84 | the second two coordinates specify the endpoints of the minor axis. 85 | 86 | """ 87 | 88 | MULTIPOINT = 'MULTIPOINT' 89 | """Multiple pixels each denoted by an (Column,Row) coordinates.""" 90 | 91 | POINT = 'POINT' 92 | """A single pixel denoted by a single (Column,Row) coordinate.""" 93 | 94 | POLYLINE = 'POLYLINE' 95 | """Connected line segments with vertices denoted by (Column,Row) coordinate. 96 | 97 | If the first and last coordinates are the same it is a closed polygon. 98 | 99 | """ 100 | 101 | 102 | class GraphicTypeValues3D(Enum): 103 | 104 | """Enumerated values for attribute Graphic Type 3D. 105 | 106 | See :dcm:`C.18.9.1.2 `. 107 | 108 | """ 109 | 110 | ELLIPSE = 'ELLIPSE' 111 | """An ellipse defined by four (X,Y,Z) coordinates. 112 | 113 | The first two coordinates specify the endpoints of the major axis and 114 | the second two coordinates specify the endpoints of the minor axis. 115 | 116 | """ 117 | 118 | ELLIPSOID = 'ELLIPSOID' 119 | """A three-dimensional geometric surface defined by six (X,Y,Z) coordinates. 120 | 121 | The plane sections of the surface are either ellipses or circles and 122 | the surface contains three intersecting orthogonal axes: 123 | "a", "b", and "c". 124 | The first and second coordinates specify the endpoints of axis "a", 125 | the third and fourth coordinates specify the endpoints of axis "b", and 126 | the fifth and sixth coordinates specify the endpoints of axis "c". 127 | 128 | """ 129 | 130 | MULTIPOINT = 'MULTIPOINT' 131 | """Multiple points each denoted by an (X,Y,Z) coordinate. 132 | 133 | The points need not be coplanar. 134 | 135 | """ 136 | 137 | POINT = 'POINT' 138 | """An individual point denoted by a single (X,Y,Z) coordinate.""" 139 | 140 | POLYLINE = 'POLYLINE' 141 | """Connected line segments with vertices denoted by (X,Y,Z) coordinates. 142 | 143 | The coordinates need not be coplanar. 144 | 145 | """ 146 | 147 | POLYGON = 'POLYGON' 148 | """Connected line segments with vertices denoted by (X,Y,Z) coordinates. 149 | 150 | The first and last coordinates shall be the same forming a closed polygon. 151 | The points shall be coplanar. 152 | 153 | """ 154 | 155 | 156 | class TemporalRangeTypeValues(Enum): 157 | 158 | """Enumerated values for attribute Temporal Range Type. 159 | 160 | See :dcm:`C.18.7.1.1 `. 161 | 162 | """ 163 | 164 | BEGIN = 'BEGIN' 165 | """A range that begins at the identified temporal point. 166 | 167 | It extends beyond the end of the acquired data. 168 | 169 | """ 170 | 171 | END = 'END' 172 | """A range that ends at the identified temporal point. 173 | 174 | It begins before the start of the acquired data and 175 | extends to (and includes) the identified temporal point. 176 | 177 | """ 178 | 179 | MULTIPOINT = 'MULTIPOINT' 180 | """Multiple temporal points.""" 181 | 182 | MULTISEGMENT = 'MULTISEGMENT' 183 | """Multiple segments, each denoted by two temporal points.""" 184 | 185 | POINT = 'POINT' 186 | """A single temporal point.""" 187 | 188 | SEGMENT = 'SEGMENT' 189 | """A range between two temporal points.""" 190 | 191 | 192 | class RelationshipTypeValues(Enum): 193 | 194 | """Enumerated values for attribute Relationship Type. 195 | 196 | See :dcm:`C.17.3.2.4 `. 197 | 198 | """ 199 | 200 | CONTAINS = 'CONTAINS' 201 | """Parent item contains child content item.""" 202 | 203 | HAS_ACQ_CONTEXT = 'HAS ACQ CONTEXT' 204 | """Has acquisition context. 205 | 206 | The child content item describes the conditions present during data 207 | acquisition of the source content item. 208 | 209 | """ 210 | 211 | HAS_CONCEPT_MOD = 'HAS CONCEPT MOD' 212 | """Has concept modifier. 213 | 214 | The child content item qualifies or describes the concept name of the 215 | parent content item. 216 | 217 | """ 218 | 219 | HAS_OBS_CONTEXT = 'HAS OBS CONTEXT' 220 | """Has observation context. 221 | 222 | Child content items shall convey any specialization of observation context 223 | needed for unambiguous documentation of the parent content item. 224 | 225 | """ 226 | 227 | HAS_PROPERTIES = 'HAS PROPERTIES' 228 | """Child content items describe properties of the parent content item.""" 229 | 230 | INFERRED_FROM = 'INFERRED FROM' 231 | """Parent content item is inferred from the child content item. 232 | 233 | The Parent content item conveys a measurement or other inference made from 234 | the child content item(s). Denotes the supporting evidence for a measurement 235 | or judgment. 236 | 237 | """ 238 | 239 | SELECTED_FROM = 'SELECTED FROM' 240 | """Parent content item is selected from the child content items. 241 | 242 | The parent content item conveys spatial or temporal coordinates selected 243 | from the child content item(s). 244 | 245 | """ 246 | 247 | 248 | class PixelOriginInterpretationValues(Enum): 249 | 250 | """Enumerated values for attribute Pixel Origin Interpretation.""" 251 | 252 | FRAME = 'FRAME' 253 | """Relative to the individual frame.""" 254 | 255 | VOLUME = 'VOLUME' 256 | """Relative to the Total Pixel Matrix of the VOLUME image. 257 | 258 | Note that this is only appropriate if the source image is a tiled pathology 259 | image with ``'VOLUME'`` as the third value of Image Type. ``'FRAME'`` 260 | should be used in all other situations, including all radiology and other 261 | non-pathology images. 262 | 263 | """ 264 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import math 3 | import itertools 4 | 5 | from pydicom import dcmread 6 | from pydicom.dataset import Dataset 7 | from pydicom.uid import VLWholeSlideMicroscopyImageStorage 8 | 9 | import pytest 10 | 11 | from highdicom import PlanePositionSequence 12 | from highdicom.sr import CodedConcept 13 | from highdicom.enum import CoordinateSystemNames 14 | from highdicom.utils import ( 15 | compute_plane_position_tiled_full, 16 | compute_plane_position_slide_per_frame, 17 | are_plane_positions_tiled_full, 18 | ) 19 | 20 | 21 | params_plane_positions = [ 22 | pytest.param( 23 | dict( 24 | row_index=1, 25 | column_index=1, 26 | x_offset=0.0, 27 | y_offset=0.0, 28 | rows=16, 29 | columns=8, 30 | image_orientation=(0.0, 1.0, 0.0, 1.0, 0.0, 0.0), 31 | pixel_spacing=(1.0, 1.0), 32 | ), 33 | PlanePositionSequence( 34 | coordinate_system=CoordinateSystemNames.SLIDE, 35 | image_position=(0.0, 0.0, 0.0), 36 | pixel_matrix_position=(1, 1) 37 | ), 38 | ), 39 | pytest.param( 40 | dict( 41 | row_index=2, 42 | column_index=2, 43 | x_offset=0.0, 44 | y_offset=0.0, 45 | rows=16, 46 | columns=8, 47 | image_orientation=(0.0, 1.0, 0.0, 1.0, 0.0, 0.0), 48 | pixel_spacing=(1.0, 1.0), 49 | ), 50 | PlanePositionSequence( 51 | coordinate_system=CoordinateSystemNames.SLIDE, 52 | image_position=(16.0, 8.0, 0.0), 53 | pixel_matrix_position=(9, 17) 54 | ), 55 | ), 56 | pytest.param( 57 | dict( 58 | row_index=4, 59 | column_index=1, 60 | x_offset=10.0, 61 | y_offset=20.0, 62 | rows=16, 63 | columns=8, 64 | image_orientation=(0.0, 1.0, 0.0, 1.0, 0.0, 0.0), 65 | pixel_spacing=(1.0, 1.0), 66 | ), 67 | PlanePositionSequence( 68 | coordinate_system=CoordinateSystemNames.SLIDE, 69 | image_position=(58.0, 20.0, 0.0), 70 | pixel_matrix_position=(1, 49) 71 | ), 72 | ), 73 | pytest.param( 74 | dict( 75 | row_index=4, 76 | column_index=1, 77 | x_offset=10.0, 78 | y_offset=60.0, 79 | rows=16, 80 | columns=8, 81 | image_orientation=(1.0, 0.0, 0.0, 0.0, -1.0, 0.0), 82 | pixel_spacing=(1.0, 1.0), 83 | ), 84 | PlanePositionSequence( 85 | coordinate_system=CoordinateSystemNames.SLIDE, 86 | image_position=(10.0, 12.0, 0.0), 87 | pixel_matrix_position=(1, 49) 88 | ), 89 | ), 90 | pytest.param( 91 | dict( 92 | row_index=4, 93 | column_index=1, 94 | x_offset=10.0, 95 | y_offset=60.0, 96 | rows=16, 97 | columns=8, 98 | image_orientation=(1.0, 0.0, 0.0, 0.0, -1.0, 0.0), 99 | pixel_spacing=(1.0, 1.0), 100 | slice_index=2, 101 | spacing_between_slices=1.0 102 | ), 103 | PlanePositionSequence( 104 | coordinate_system=CoordinateSystemNames.SLIDE, 105 | image_position=(10.0, 12.0, 1.0), 106 | pixel_matrix_position=(1, 49) 107 | ), 108 | ), 109 | ] 110 | 111 | 112 | @pytest.mark.parametrize('inputs,expected_output', params_plane_positions) 113 | def test_compute_plane_position_tiled_full(inputs, expected_output): 114 | output = compute_plane_position_tiled_full(**inputs) 115 | assert output == expected_output 116 | 117 | 118 | def test_compute_plane_position_tiled_full_with_missing_parameters(): 119 | with pytest.raises(TypeError): 120 | compute_plane_position_tiled_full( 121 | row_index=1, 122 | column_index=1, 123 | x_offset=0.0, 124 | y_offset=0.0, 125 | rows=16, 126 | columns=8, 127 | image_orientation=(0.0, 1.0, 0.0, 1.0, 0.0, 0.0), 128 | pixel_spacing=(1.0, 1.0), 129 | slice_index=1 130 | ) 131 | 132 | with pytest.raises(TypeError): 133 | compute_plane_position_tiled_full( 134 | row_index=1, 135 | column_index=1, 136 | x_offset=0.0, 137 | y_offset=0.0, 138 | rows=16, 139 | columns=8, 140 | image_orientation=(0.0, 1.0, 0.0, 1.0, 0.0, 0.0), 141 | pixel_spacing=(1.0, 1.0), 142 | spacing_between_slices=1.0 143 | ) 144 | 145 | 146 | def test_compute_plane_position_slide_per_frame(): 147 | iterator = itertools.product(range(1, 4), range(1, 3)) 148 | for num_optical_paths, num_focal_planes in iterator: 149 | image = Dataset() 150 | image.SOPClassUID = VLWholeSlideMicroscopyImageStorage 151 | image.Rows = 4 152 | image.Columns = 4 153 | image.TotalPixelMatrixRows = 16 154 | image.TotalPixelMatrixColumns = 16 155 | image.TotalPixelMatrixFocalPlanes = num_focal_planes 156 | image.NumberOfOpticalPaths = num_optical_paths 157 | image.ImageOrientationSlide = [0.0, 1.0, 0.0, 1.0, 0.0, 0.0] 158 | shared_fg_item = Dataset() 159 | pixel_measures_item = Dataset() 160 | pixel_measures_item.PixelSpacing = [1.0, 1.0] 161 | pixel_measures_item.SliceThickness = 1.0 162 | pixel_measures_item.SpacingBetweenSlices = 1.0 163 | shared_fg_item.PixelMeasuresSequence = [pixel_measures_item] 164 | image.SharedFunctionalGroupsSequence = [shared_fg_item] 165 | origin_item = Dataset() 166 | origin_item.XOffsetInSlideCoordinateSystem = 0.0 167 | origin_item.YOffsetInSlideCoordinateSystem = 0.0 168 | image.TotalPixelMatrixOriginSequence = [origin_item] 169 | image.DimensionOrganizationType = "TILED_FULL" 170 | optical_path_item = Dataset() 171 | optical_path_item.OpticalPathIdentifier = '1' 172 | optical_path_item.IlluminationTypeCodeSequence = [ 173 | CodedConcept( 174 | value="111744", 175 | meaning="Brightfield illumination", 176 | scheme_designator="DCM", 177 | ) 178 | ] 179 | image.OpticalPathSequence = [optical_path_item] 180 | 181 | plane_positions = compute_plane_position_slide_per_frame(image) 182 | 183 | tiles_per_column = math.ceil(image.TotalPixelMatrixRows / image.Rows) 184 | tiles_per_row = math.ceil(image.TotalPixelMatrixColumns / image.Columns) 185 | assert len(plane_positions) == ( 186 | num_optical_paths * 187 | num_focal_planes * 188 | tiles_per_row * 189 | tiles_per_column 190 | ) 191 | 192 | 193 | def test_are_plane_positions_tiled_full(): 194 | 195 | sm_path = Path(__file__).parents[1].joinpath( 196 | 'data/test_files/sm_image.dcm' 197 | ) 198 | sm_image = dcmread(sm_path) 199 | 200 | # The plane positions from a TILED_FULL image should satisfy the 201 | # requirements 202 | plane_positions = compute_plane_position_slide_per_frame(sm_image) 203 | assert are_plane_positions_tiled_full( 204 | plane_positions, 205 | sm_image.Rows, 206 | sm_image.Columns, 207 | ) 208 | 209 | # If a plane is missing, it should not satisfy the requirements 210 | plane_positions_missing = plane_positions[:5] + plane_positions[6:] 211 | assert not are_plane_positions_tiled_full( 212 | plane_positions_missing, 213 | sm_image.Rows, 214 | sm_image.Columns, 215 | ) 216 | 217 | # If a plane is misordered, it should not satisfy the requirements 218 | plane_positions_misordered = [ 219 | plane_positions[1], 220 | plane_positions[0], 221 | *plane_positions[2:] 222 | ] 223 | assert not are_plane_positions_tiled_full( 224 | plane_positions_misordered, 225 | sm_image.Rows, 226 | sm_image.Columns, 227 | ) 228 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | import pytest 4 | import unittest 5 | 6 | from pydicom import dcmread 7 | from pydicom.uid import ( 8 | ExplicitVRBigEndian, 9 | ExplicitVRLittleEndian, 10 | ImplicitVRLittleEndian, 11 | ) 12 | from pydicom.data import get_testdata_file 13 | from highdicom import SOPClass, UID 14 | from highdicom.base import _check_little_endian 15 | 16 | 17 | class TestBase(unittest.TestCase): 18 | 19 | def test_type_2_attributes(self): 20 | # Series Number and Instance Number are type 1 for several IODs. 21 | # Therefore, we decided that we require them even on the base class. 22 | with pytest.raises(TypeError): 23 | SOPClass( 24 | study_instance_uid=UID(), 25 | series_instance_uid=UID(), 26 | series_number=1, 27 | sop_instance_uid=UID(), 28 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 29 | instance_number=None, 30 | modality='SR', 31 | manufacturer='highdicom', 32 | transfer_syntax_uid=ExplicitVRLittleEndian, 33 | ) 34 | with pytest.raises(TypeError): 35 | SOPClass( 36 | study_instance_uid=UID(), 37 | series_instance_uid=UID(), 38 | series_number=None, 39 | sop_instance_uid=UID(), 40 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 41 | instance_number=1, 42 | modality='SR', 43 | manufacturer='highdicom', 44 | transfer_syntax_uid=ExplicitVRLittleEndian, 45 | ) 46 | 47 | def test_type_3_attributes(self): 48 | instance = SOPClass( 49 | study_instance_uid=UID(), 50 | series_instance_uid=UID(), 51 | series_number=1, 52 | sop_instance_uid=UID(), 53 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 54 | instance_number=1, 55 | modality='SR', 56 | manufacturer='highdicom', 57 | manufacturer_model_name='foo-bar', 58 | software_versions='v1.0.0', 59 | transfer_syntax_uid=ExplicitVRLittleEndian, 60 | ) 61 | assert instance.SoftwareVersions is not None 62 | assert instance.ManufacturerModelName is not None 63 | assert hasattr(instance, 'ContentDate') 64 | assert hasattr(instance, 'ContentTime') 65 | assert not hasattr(instance, 'SeriesDate') 66 | assert not hasattr(instance, 'SeriesTime') 67 | 68 | def test_content_time_without_date(self): 69 | msg = ( 70 | "'content_time' may not be specified without " 71 | "'content_date'." 72 | ) 73 | with pytest.raises(TypeError, match=msg): 74 | SOPClass( 75 | study_instance_uid=UID(), 76 | series_instance_uid=UID(), 77 | series_number=1, 78 | sop_instance_uid=UID(), 79 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 80 | instance_number=1, 81 | modality='SR', 82 | manufacturer='highdicom', 83 | manufacturer_model_name='foo-bar', 84 | software_versions='v1.0.0', 85 | transfer_syntax_uid=ExplicitVRLittleEndian, 86 | content_time=datetime.time(12, 34, 56), 87 | ) 88 | 89 | def test_series_datetime(self): 90 | instance = SOPClass( 91 | study_instance_uid=UID(), 92 | series_instance_uid=UID(), 93 | series_number=1, 94 | sop_instance_uid=UID(), 95 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 96 | instance_number=1, 97 | modality='SR', 98 | manufacturer='highdicom', 99 | manufacturer_model_name='foo-bar', 100 | software_versions='v1.0.0', 101 | transfer_syntax_uid=ExplicitVRLittleEndian, 102 | series_date=datetime.date(2000, 12, 1), 103 | series_time=datetime.time(12, 34, 56), 104 | ) 105 | assert hasattr(instance, 'SeriesDate') 106 | assert hasattr(instance, 'SeriesTime') 107 | 108 | def test_series_date_without_time(self): 109 | msg = ( 110 | "'series_time' may not be specified without " 111 | "'series_date'." 112 | ) 113 | with pytest.raises(TypeError, match=msg): 114 | SOPClass( 115 | study_instance_uid=UID(), 116 | series_instance_uid=UID(), 117 | series_number=1, 118 | sop_instance_uid=UID(), 119 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 120 | instance_number=1, 121 | modality='SR', 122 | manufacturer='highdicom', 123 | manufacturer_model_name='foo-bar', 124 | software_versions='v1.0.0', 125 | transfer_syntax_uid=ExplicitVRLittleEndian, 126 | series_time=datetime.time(12, 34, 56), 127 | ) 128 | 129 | def test_series_date_after_content(self): 130 | msg = ( 131 | "'series_date' must not be later than 'content_date'." 132 | ) 133 | with pytest.raises(ValueError, match=msg): 134 | SOPClass( 135 | study_instance_uid=UID(), 136 | series_instance_uid=UID(), 137 | series_number=1, 138 | sop_instance_uid=UID(), 139 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 140 | instance_number=1, 141 | modality='SR', 142 | manufacturer='highdicom', 143 | manufacturer_model_name='foo-bar', 144 | software_versions='v1.0.0', 145 | transfer_syntax_uid=ExplicitVRLittleEndian, 146 | content_date=datetime.date(2000, 12, 1), 147 | series_date=datetime.date(2000, 12, 2), 148 | ) 149 | 150 | def test_big_endian(self): 151 | with pytest.raises(ValueError): 152 | SOPClass( 153 | study_instance_uid=UID(), 154 | series_instance_uid=UID(), 155 | series_number=1, 156 | sop_instance_uid=UID(), 157 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 158 | instance_number=1, 159 | modality='SR', 160 | manufacturer='highdicom', 161 | transfer_syntax_uid=ExplicitVRBigEndian, 162 | ) 163 | 164 | def test_explicit_vr(self): 165 | _ = SOPClass( 166 | study_instance_uid=UID(), 167 | series_instance_uid=UID(), 168 | series_number=1, 169 | sop_instance_uid=UID(), 170 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 171 | instance_number=1, 172 | modality='SR', 173 | manufacturer='highdicom', 174 | transfer_syntax_uid=ExplicitVRLittleEndian, 175 | ) 176 | 177 | def test_implicit_vr(self): 178 | _ = SOPClass( 179 | study_instance_uid=UID(), 180 | series_instance_uid=UID(), 181 | series_number=1, 182 | sop_instance_uid=UID(), 183 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 184 | instance_number=1, 185 | modality='SR', 186 | manufacturer='highdicom', 187 | transfer_syntax_uid=ImplicitVRLittleEndian, 188 | ) 189 | 190 | def test_series_description_too_long(self): 191 | msg = ( 192 | "Values of DICOM value representation Long " 193 | "String (LO) must not exceed 64 characters." 194 | ) 195 | with pytest.raises(ValueError, match=re.escape(msg)): 196 | SOPClass( 197 | study_instance_uid=UID(), 198 | series_instance_uid=UID(), 199 | series_number=1, 200 | sop_instance_uid=UID(), 201 | sop_class_uid='1.2.840.10008.5.1.4.1.1.88.33', 202 | instance_number=1, 203 | modality='SR', 204 | manufacturer='highdicom', 205 | series_description="abc" * 100, 206 | ) 207 | 208 | 209 | class TestEndianCheck(unittest.TestCase): 210 | 211 | def test_big_endian(self): 212 | ds = dcmread(get_testdata_file('MR_small_bigendian.dcm')) 213 | with pytest.raises(ValueError): 214 | _check_little_endian(ds) 215 | -------------------------------------------------------------------------------- /docs/release_notes.rst: -------------------------------------------------------------------------------- 1 | .. _releasenotes: 2 | 3 | Release Notes 4 | ============= 5 | 6 | Brief release notes may be found on `on Github 7 | `_. This page 8 | contains migration notes for major breaking changes to the library's API. 9 | 10 | .. _add-segments-deprecation: 11 | 12 | Deprecation of `add_segments` method 13 | ------------------------------------ 14 | 15 | Prior to highdicom 0.8.0, it was possible to add further segments to 16 | :class:`highdicom.seg.Segmentation` image after its construction using the 17 | `add_segments` method. This was found to produce incorrect Dimension Index 18 | Values if the empty frames did not match within all segments added. 19 | 20 | To create the Dimension Index Values correctly, the constructor needs access to 21 | all segments in the image when it is first created. Therefore, the 22 | `add_segments` method was removed in highdicom 0.8.0. Instead, in highdicom 23 | 0.8.0 and later, multiple segments can be passed to the constructor by stacking 24 | their arrays along the fourth dimension. 25 | 26 | Given code that adds segments like this, in highdicom 0.7.0 and earlier: 27 | 28 | .. code-block:: python 29 | 30 | import numpy as np 31 | import highdicom as hd 32 | 33 | # Create initial segment mask and description 34 | mask_1 = np.array( 35 | # ... 36 | ) 37 | description_1 = hd.seg.SegmentDescription( 38 | # ... 39 | ) 40 | seg = hd.seg.Segmentation( 41 | # ... 42 | pixel_array=mask_1, 43 | segment_descriptions=[description_1], 44 | # ... 45 | ) 46 | 47 | # Create a second segment and add to the existing segmentation 48 | mask_2 = np.array( 49 | # ... 50 | ) 51 | description_2 = hd.seg.SegmentDescription( 52 | # ... 53 | ) 54 | 55 | seg.add_segments( 56 | # ... 57 | pixel_array=mask_2, 58 | segment_descriptions=[description_2], 59 | # ... 60 | ) 61 | 62 | 63 | This can be migrated to highdicom 0.8.0 and later by concatenating the arrays 64 | along the fourth dimension and calling the constructor at the end. 65 | 66 | .. code-block:: python 67 | 68 | import numpy as np 69 | import highdicom as hd 70 | 71 | # Create initial segment mask and description 72 | mask_1 = np.array( 73 | # ... 74 | ) 75 | description_1 = hd.seg.SegmentDescription( 76 | # ... 77 | ) 78 | 79 | # Create a second segment and description 80 | mask_2 = np.array( 81 | # ... 82 | ) 83 | description_2 = hd.seg.SegmentDescription( 84 | # ... 85 | ) 86 | 87 | combined_segments = np.concatenate([mask_1, mask_2], axis=-1) 88 | combined_descriptions = [description_1, description_2] 89 | 90 | seg = hd.seg.Segmentation( 91 | # ... 92 | pixel_array=combined_segments, 93 | segment_descriptions=combined_descriptions, 94 | # ... 95 | ) 96 | 97 | 98 | Note that segments must always be stacked down the fourth dimension (with index 99 | 3) of the ``pixel_array``. In order to create a segmentation with multiple 100 | segments for a single source frame, it is required to add a new dimension 101 | (with length 1) as the first dimension (index 0) of the array. 102 | 103 | 104 | .. _correct-coordinate-mapping: 105 | 106 | Correct coordinate mapping 107 | -------------------------- 108 | 109 | Prior to highdicom 0.14.1, mappings between image coordinates and reference 110 | coordinates did not take into account that there are two image coordinate 111 | systems, which are shifted by 0.5 pixels. 112 | 113 | 1. **Pixel indices**: (column, row) indices into the pixel matrix. The values 114 | are zero-based integers in the range [0, Columns - 1] and [0, Rows - 1]. 115 | Pixel indices are defined relative to the centers of pixels and the (0, 0) 116 | index is located at the center of the top left corner hand pixel of the 117 | total pixel matrix. 118 | 2. **Image coordinates**: (column, row) coordinates in the pixel matrix at 119 | sub-pixel resolution. The values are floating-point numbers in the range 120 | [0, Columns] and [0, Rows]. Image coordinates are defined relative to the 121 | top left corner of the pixels and the (0.0, 0.0) point is located at the top 122 | left corner of the top left corner hand pixel of the total pixel matrix. 123 | 124 | To account for these differences, introduced two additional transformer classes 125 | in highdicom 0.14.1. and made changes to the existing ones. 126 | The existing transformer class now map between image coordinates and reference 127 | coordinates (:class:`highdicom.spatial.ImageToReferenceTransformer` and 128 | :class:`highdicom.spatial.ReferenceToImageTransformer`). 129 | While the new transformer classes map between pixel indices and reference 130 | coordinates (:class:`highdicom.spatial.PixelToReferenceTransformer` and 131 | :class:`highdicom.spatial.ReferenceToPixelTransformer`). 132 | Note that you want to use the former classes for converting between spatial 133 | coordinates (SCOORD) (:class:`highdicom.sr.ScoordContentItem`) and 3D spatial 134 | coordinates (SCOORD3D) (:class:`highdicom.sr.Scoord3DContentItem`) and the 135 | latter for determining the position of a pixel in the frame of reference or for 136 | projecting a coordinate in the frame of reference onto the image plane. 137 | 138 | To make the distinction between pixel indices and image coordinates as clear as 139 | possible, we renamed the parameter of the 140 | :func:`highdicom.spatial.map_pixel_into_coordinate_system` function from 141 | ``coordinate`` to ``index`` and enforce that the values that are provided via 142 | the argument are integers rather than floats. 143 | In addition, the return value of 144 | :func:`highdicom.spatial.map_coordinate_into_pixel_matrix` is now a tuple of 145 | integers. 146 | 147 | .. _processing-type-deprecation: 148 | 149 | Deprecation of `processing_type` parameter 150 | ------------------------------------------ 151 | 152 | In highdicom 0.15.0, the ``processing_type`` parameter was removed from the 153 | constructor of :class:`highdicom.content.SpecimenPreparationStep`. 154 | The parameter turned out to be superfluous, because the argument could be 155 | derived from the type of the ``processing_procedure`` argument. 156 | 157 | .. _specimen-preparation-step-refactoring: 158 | 159 | Refactoring of `SpecimenPreparationStep` class 160 | ---------------------------------------------- 161 | 162 | In highdicom 0.16.0 and later versions, 163 | :class:`highdicom.content.SpecimenPreparationStep` represents an item of the 164 | Specimen Preparation Sequence rather than the Specimen Preparation Step Content 165 | Item Sequence and the class is consequently derived from 166 | ``pydicom.dataset.Dataset`` instead of ``pydicom.sequence.Sequence``. 167 | As a consequence, alternative construction of an instance of 168 | :class:`highdicom.content.SpecimenPreparationStep` needs to be performed using 169 | the ``from_dataset()`` instead of the ``from_sequence()`` class method. 170 | 171 | .. _big-endian-deprecation: 172 | 173 | Deprecation of Big Endian Transfer Syntaxes 174 | ------------------------------------------- 175 | 176 | The use of "Big Endian" transfer syntaxes such as `ExplicitVRBigEndian` is 177 | disallowed from highdicom 0.18.0 onwards. The use of Big Endian transfer 178 | syntaxes has been retired in the standard for some time. To discourage the use 179 | of retired transfer syntaxes and to simplify the logic when encoding and 180 | decoding objects in which byte order is relevant, in version 0.17.0 and onwards 181 | passing a big endian transfer syntax to the constructor of 182 | :class:`highdicom.SOPClass` or any of its subclasses will result in a value 183 | error. 184 | 185 | Similarly, as of highdicom 0.18.0, it is no longer possible to pass datasets 186 | with a Big Endian transfer syntax to the `from_dataset` methods of any of the 187 | :class:`highdicom.SOPClass` subclasses. 188 | 189 | .. _update-image-library: 190 | 191 | Change in MeasurementReport constructor for TID 1601 enhancement 192 | ---------------------------------------------------------------- 193 | 194 | A breaking change was made after highdicom 0.18.4 in the creation of Image 195 | Library TID 1601 objects. 196 | Previously the Image Library was constructed by explicitly 197 | passing a ``pydicom.sequence.Sequence`` of 198 | :class:`highdicom.sr.ImageLibraryEntryDescriptors` 199 | objects to the :class:`highdicom.sr.MeasurementReport` constructor in the 200 | ``image_library_groups`` argument. 201 | Now a ``pydicom.sequence.Sequence`` of ``pydicom.dataset.Dataset`` 202 | objects is passed in the ``referenced_images`` argument and the 203 | ImageLibrary components are created internally by highdicom. 204 | This standardizes the content of the Image Library subcomponents. 205 | -------------------------------------------------------------------------------- /src/highdicom/enum.py: -------------------------------------------------------------------------------- 1 | """Enumerated halues.""" 2 | from enum import Enum 3 | 4 | 5 | class RGBColorChannels(Enum): 6 | R = 'R' 7 | """Red color channel.""" 8 | 9 | G = 'G' 10 | """Green color channel.""" 11 | 12 | B = 'B' 13 | """Blue color channel.""" 14 | 15 | 16 | class CoordinateSystemNames(Enum): 17 | 18 | """Enumerated values for coordinate system names.""" 19 | 20 | PATIENT = 'PATIENT' 21 | SLIDE = 'SLIDE' 22 | 23 | 24 | class PixelIndexDirections(Enum): 25 | 26 | """ 27 | 28 | Enumerated values used to describe indexing conventions of pixel arrays. 29 | 30 | """ 31 | 32 | L = 'L' 33 | """ 34 | 35 | Left: Pixel index that increases moving across the rows from right to left. 36 | 37 | """ 38 | 39 | R = 'R' 40 | """ 41 | 42 | Right: Pixel index that increases moving across the rows from left to right. 43 | 44 | """ 45 | 46 | U = 'U' 47 | """ 48 | 49 | Up: Pixel index that increases moving up the columns from bottom to top. 50 | 51 | """ 52 | 53 | D = 'D' 54 | """ 55 | 56 | Down: Pixel index that increases moving down the columns from top to bottom. 57 | 58 | """ 59 | 60 | 61 | class ContentQualificationValues(Enum): 62 | 63 | """Enumerated values for Content Qualification attribute.""" 64 | 65 | PRODUCT = 'PRODUCT' 66 | RESEARCH = 'RESEARCH' 67 | SERVICE = 'SERVICE' 68 | 69 | 70 | class DimensionOrganizationTypeValues(Enum): 71 | 72 | """Enumerated values for Dimension Organization Type attribute.""" 73 | 74 | THREE_DIMENSIONAL = '3D' 75 | THREE_DIMENSIONAL_TEMPORAL = '3D_TEMPORAL' 76 | TILED_FULL = 'TILED_FULL' 77 | TILED_SPARSE = 'TILED_SPARSE' 78 | 79 | 80 | class PatientSexValues(Enum): 81 | 82 | """Enumerated values for Patient's Sex attribute.""" 83 | 84 | M = 'M' 85 | """Male""" 86 | 87 | F = 'F' 88 | """Female""" 89 | 90 | O = 'O' # noqa: E741 91 | """Other""" 92 | 93 | 94 | class PhotometricInterpretationValues(Enum): 95 | 96 | """Enumerated values for Photometric Interpretation attribute. 97 | 98 | See :dcm:`Section C.7.6.3.1.2` 99 | for more information. 100 | 101 | """ 102 | 103 | MONOCHROME1 = 'MONOCHROME1' 104 | MONOCHROME2 = 'MONOCHROME2' 105 | PALETTE_COLOR = 'PALETTE COLOR' 106 | RGB = 'RGB' 107 | YBR_FULL = 'YBR_FULL' 108 | YBR_FULL_422 = 'YBR_FULL_422' 109 | YBR_PARTIAL_420 = 'YBR_PARTIAL_420' 110 | YBR_ICT = 'YBR_ICT' 111 | YBR_RCT = 'YBR_RCT' 112 | 113 | 114 | class PlanarConfigurationValues(Enum): 115 | 116 | """Enumerated values for Planar Representation attribute.""" 117 | 118 | COLOR_BY_PIXEL = 0 119 | COLOR_BY_PLANE = 1 120 | 121 | 122 | class PixelRepresentationValues(Enum): 123 | 124 | """Enumerated values for Planar Representation attribute.""" 125 | 126 | UNSIGNED_INTEGER = 0 127 | COMPLEMENT = 1 128 | 129 | 130 | class RescaleTypeValues(Enum): 131 | 132 | """Enumerated values for attribute Rescale Type. 133 | 134 | This specifies the units of the result of the rescale operation. 135 | Other values may be used, but they are not defined by the DICOM standard. 136 | 137 | """ 138 | 139 | OD = 'OD' 140 | """The number in the LUT represents thousands of optical density. 141 | 142 | That is, a value of 2140 represents an optical density of 2.140. 143 | 144 | """ 145 | 146 | HU = 'HU' 147 | """Hounsfield Units (CT).""" 148 | 149 | US = 'US' 150 | """Unspecified.""" 151 | 152 | MGML = 'MGML' 153 | """Milligrams per milliliter.""" 154 | 155 | Z_EFF = 'Z_EFF' 156 | """Effective Atomic Number (i.e., Effective-Z).""" 157 | 158 | ED = 'ED' 159 | """Electron density in 1023 electrons/ml.""" 160 | 161 | EDW = 'EDW' 162 | """Electron density normalized to water. 163 | 164 | Units are N/Nw where N is number of electrons per unit volume, and Nw is 165 | number of electrons in the same unit of water at standard temperature and 166 | pressure. 167 | 168 | """ 169 | 170 | HU_MOD = 'HU_MOD' 171 | """Modified Hounsfield Unit.""" 172 | 173 | PCT = 'PCT' 174 | """Percentage (%)""" 175 | 176 | 177 | class VOILUTFunctionValues(Enum): 178 | 179 | """Enumerated values for attribute VOI LUT Function.""" 180 | 181 | LINEAR = 'LINEAR' 182 | LINEAR_EXACT = 'LINEAR_EXACT' 183 | SIGMOID = 'SIGMOID' 184 | 185 | 186 | class PresentationLUTShapeValues(Enum): 187 | 188 | """Enumerated values for the Presentation LUT Shape attribute.""" 189 | 190 | IDENTITY = 'IDENTITY' 191 | """No further translation of values is performed.""" 192 | 193 | INVERSE = 'INVERSE' 194 | """ 195 | 196 | A value of INVERSE shall mean the same as a value of IDENTITY, except that 197 | the minimum output value shall convey the meaning of the maximum available 198 | luminance, and the maximum value shall convey the minimum available 199 | luminance. 200 | 201 | """ 202 | 203 | 204 | class LateralityValues(Enum): 205 | 206 | """Enumerated values for Laterality attribute.""" 207 | 208 | R = 'R' 209 | """Right""" 210 | 211 | L = 'L' 212 | """Left""" 213 | 214 | 215 | class AnatomicalOrientationTypeValues(Enum): 216 | 217 | """Enumerated values for Anatomical Orientation Type attribute.""" 218 | 219 | BIPED = 'BIPED' 220 | QUADRUPED = 'QUADRUPED' 221 | 222 | 223 | class PatientOrientationValuesBiped(Enum): 224 | 225 | """Enumerated values for Patient Orientation attribute 226 | if Anatomical Orientation Type attribute has value ``"BIPED"``. 227 | """ 228 | 229 | A = 'A' 230 | """Anterior""" 231 | 232 | P = 'P' 233 | """Posterior""" 234 | 235 | R = 'R' 236 | """Right""" 237 | 238 | L = 'L' 239 | """Left""" 240 | 241 | H = 'H' 242 | """Head""" 243 | 244 | F = 'F' 245 | """Foot""" 246 | 247 | 248 | class PatientOrientationValuesQuadruped(Enum): 249 | 250 | """Enumerated values for Patient Orientation attribute 251 | if Anatomical Orientation Type attribute has value ``"QUADRUPED"``. 252 | """ 253 | 254 | LE = 'LE' 255 | """Left""" 256 | 257 | RT = 'RT' 258 | """Right""" 259 | 260 | D = 'D' 261 | """Dorsal""" 262 | 263 | V = 'V' 264 | """Ventral""" 265 | 266 | CR = 'CR' 267 | """Cranial""" 268 | 269 | CD = 'CD' 270 | """Caudal""" 271 | 272 | R = 'R' 273 | """Rostral""" 274 | 275 | M = 'M' 276 | """Medial""" 277 | 278 | L = 'L' 279 | """Lateral""" 280 | 281 | PR = 'PR' 282 | """Proximal""" 283 | 284 | DI = 'DI' 285 | """Distal""" 286 | 287 | PA = 'PA' 288 | """Palmar""" 289 | 290 | PL = 'PL' 291 | """Plantar""" 292 | 293 | 294 | class UniversalEntityIDTypeValues(Enum): 295 | 296 | """Enumerated values for Universal Entity ID Type attribute.""" 297 | 298 | DNS = 'DNS' 299 | """An Internet dotted name. Either in ASCII or as integers.""" 300 | 301 | EUI64 = 'EUI64' 302 | """An IEEE Extended Unique Identifier.""" 303 | 304 | ISO = 'ISO' 305 | """An International Standards Organization Object Identifier.""" 306 | 307 | URI = 'URI' 308 | """Uniform Resource Identifier.""" 309 | 310 | UUID = 'UUID' 311 | """The DCE Universal Unique Identifier.""" 312 | 313 | X400 = 'X400' 314 | """An X.400 MHS identifier.""" 315 | 316 | X500 = 'X500' 317 | """An X.500 directory name.""" 318 | 319 | 320 | class PadModes(Enum): 321 | 322 | """Enumerated values of modes to pad an array.""" 323 | 324 | CONSTANT = 'CONSTANT' 325 | """Pad with a specified constant value.""" 326 | 327 | EDGE = 'EDGE' 328 | """Pad with the edge value.""" 329 | 330 | MINIMUM = 'MINIMUM' 331 | """Pad with the minimum value.""" 332 | 333 | MAXIMUM = 'MAXIMUM' 334 | """Pad with the maximum value.""" 335 | 336 | MEAN = 'MEAN' 337 | """Pad with the mean value.""" 338 | 339 | MEDIAN = 'MEDIAN' 340 | """Pad with the median value.""" 341 | 342 | 343 | class AxisHandedness(Enum): 344 | 345 | """Enumerated values for axis handedness. 346 | 347 | Axis handedness refers to a property of a mapping between voxel indices and 348 | their corresponding coordinates in the frame-of-reference coordinate 349 | system, as represented by the affine matrix. 350 | 351 | """ 352 | 353 | LEFT_HANDED = "LEFT_HANDED" 354 | """ 355 | 356 | The unit vectors of the first, second and third axes form a left hand when 357 | drawn in the frame-of-reference coordinate system with the thumb 358 | representing the first vector, the index finger representing the second 359 | vector, and the middle finger representing the third vector. 360 | 361 | """ 362 | 363 | RIGHT_HANDED = "RIGHT_HANDED" 364 | """ 365 | 366 | The unit vectors of the first, second and third axes form a right hand when 367 | drawn in the frame-of-reference coordinate system with the thumb 368 | representing the first vector, the index finger representing the second 369 | vector, and the middle finger representing the third vector. 370 | 371 | """ 372 | -------------------------------------------------------------------------------- /docs/remote.rst: -------------------------------------------------------------------------------- 1 | .. _remote: 2 | 3 | Reading from Remote Filesystems 4 | =============================== 5 | 6 | Functions like ``dcmread`` from pydicom and :meth:`highdicom.imread`, 7 | :meth:`highdicom.seg.segread`, :meth:`highdicom.sr.srread`, and 8 | :meth:`highdicom.ann.annread` from highdicom can read from any object that 9 | exposes a "file-like" interface. Many alternative and remote filesystems have 10 | Python clients that expose such an interface, and therefore can be read from 11 | directly. 12 | 13 | Coupling this with the :ref:`"lazy" frame retrieval ` option is 14 | especially powerful, allowing frames to be retrieved from the remote filesystem 15 | only as and when they are needed. This is particularly useful for large 16 | multiframe files such as those found in slide microscopy or multi-segment 17 | binary or fractional segmentations. However, the presence of offset tables in 18 | the files is important for this to be effective (see explanation 19 | in :ref:`lazy`). 20 | 21 | Here we give some simple examples of how to do this using two popular cloud 22 | storage providers: Google Cloud Storage (GCS) and Amazon Web Services (AWS) S3 23 | storage. These are the two storage mechanisms underlying the Imaging Data 24 | Commons (`IDC`_), a large repository of public DICOM images. It should be 25 | possible to achieve the same effect with other filesystems, as long as there is 26 | a Python client library that exposes a "file-like" interface. 27 | 28 | Google Cloud Storage (GCS) 29 | -------------------------- 30 | 31 | Blobs within Google Cloud Storage buckets can be accessed through a "file-like" 32 | interface using the official Python SDK (installed through the 33 | ``google-cloud-storage`` PyPI package). 34 | 35 | In this first example, we use lazy frame retrieval to load only a specific 36 | spatial patch from a large whole slide image from the IDC. 37 | 38 | .. code-block:: python 39 | 40 | import numpy as np 41 | import highdicom as hd 42 | 43 | # Additional libraries (install these separately) 44 | import matplotlib.pyplot as plt 45 | from google.cloud import storage 46 | 47 | 48 | # Create a storage client and use it to access the IDC's public data package 49 | client = storage.Client.create_anonymous_client() 50 | bucket = client.bucket("idc-open-data") 51 | 52 | # This is the path (within the above bucket) to a whole slide image from the 53 | # IDC collection called "CCDI MCI" 54 | blob = bucket.blob( 55 | "763fe058-7d25-4ba7-9b29-fd3d6c41dc4b/210f0529-c767-4795-9acf-bad2f4877427.dcm" 56 | ) 57 | 58 | # Read directly from the blob object using lazy frame retrieval 59 | with blob.open(mode="rb", chunk_size=500_000) as reader: 60 | im = hd.imread(reader, lazy_frame_retrieval=True) 61 | 62 | # Grab an arbitrary region of tile full pixel matrix 63 | region = im.get_total_pixel_matrix( 64 | row_start=15000, 65 | row_end=15512, 66 | column_start=17000, 67 | column_end=17512, 68 | dtype=np.uint8 69 | ) 70 | 71 | # Show the region 72 | plt.imshow(region) 73 | plt.show() 74 | 75 | .. figure:: images/slide_screenshot.png 76 | :width: 512px 77 | :alt: Image of retrieved slide region 78 | :align: center 79 | 80 | Figure produced by the above code snippet showing an arbitrary spatial 81 | region of a slide loaded directly from a Google Cloud bucket 82 | 83 | It is important to set the `chunk_size` parameter carefully. This value is the 84 | number of bytes that are retrieved in a single request (set to around 500kB in 85 | the above example). Ideally this should be just large enough to retrieve a 86 | single frame of the image in one request, but any larger leads to unnecessary 87 | data being retrieved. The default value is 40MiB, which is orders of magnitude 88 | larger than the size of most image frames and therefore will be very inefficient. 89 | 90 | As a further example, we use lazy frame retrieval to load only a specific set 91 | of segments from a large multi-organ segmentation of a CT image in the IDC 92 | stored in binary format (meaning each segment is stored using a separate set of 93 | frames). See :ref:`seg` for more information on working with DICOM 94 | segmentations. 95 | 96 | .. code-block:: python 97 | 98 | import highdicom as hd 99 | 100 | # Additional libraries (install these separately) 101 | from google.cloud import storage 102 | 103 | 104 | # Create a storage client and use it to access the IDC's public data package 105 | client = storage.Client.create_anonymous_client() 106 | bucket = client.bucket("idc-open-data") 107 | 108 | # This is the path (within the above bucket) to a segmentation of a CT series 109 | # containing a large number of different organs 110 | blob = bucket.blob( 111 | "3f38511f-fd09-4e2f-89ba-bc0845fe0005/c8ea3be0-15d7-4a04-842d-00b183f53b56.dcm" 112 | ) 113 | 114 | # Open the blob with "segread" using the "lazy frame retrieval" option 115 | with blob.open(mode="rb") as reader: 116 | seg = hd.seg.segread(reader, lazy_frame_retrieval=True) 117 | 118 | # Find the segment number corresponding to the liver segment 119 | selected_segment_numbers = seg.get_segment_numbers(segment_label="Liver") 120 | 121 | # Read in the selected segments lazily 122 | volume = seg.get_volume( 123 | segment_numbers=selected_segment_numbers, 124 | combine_segments=True, 125 | ) 126 | 127 | This works because running the ``.open("rb")`` method on a Blob object returns 128 | a `BlobReader`_ object, which has a "file-like" interface 129 | (specifically the ``seek``, ``read``, and ``tell`` methods). If you can provide 130 | examples for reading from storage provided by other cloud providers, please 131 | consider contributing them to this documentation. 132 | 133 | Amazon Web Services S3 134 | ---------------------- 135 | 136 | The `s3fs`_ package wraps an S3 client to expose a "file-like" 137 | interface for accessing blobs. It can be installed with ``pip install 138 | s3fs``. 139 | 140 | In order to be able to access open IDC data without providing AWS credentials, 141 | it is necessary to configure your own client object such that it does not 142 | require signing. This is demonstrated in the following example, which repeats 143 | the GCS from above using the counterpart of the same blob on AWS S3 (each DICOM 144 | file in the IDC is stored in two places, one on GSC and the other on S3). If 145 | you are accessing private files on S3, these steps will be different (consult 146 | the ``s3fs`` documentation for details). 147 | 148 | .. code-block:: python 149 | 150 | import numpy as np 151 | import highdicom as hd 152 | import matplotlib.pyplot as plt 153 | import s3fs 154 | 155 | 156 | # Configure a client to avoid the need for AWS credentials 157 | s3_client = s3fs.S3FileSystem( 158 | anon=True, # no credentials needed to access public data 159 | default_block_size=500_000, # see note below 160 | use_ssl=False # disable encryption for a further speed boost 161 | ) 162 | 163 | # URL to a whole slide image from the IDC "CCDS MCI" collection on AWS S3 164 | url = 's3://idc-open-data/763fe058-7d25-4ba7-9b29-fd3d6c41dc4b/210f0529-c767-4795-9acf-bad2f4877427.dcm' 165 | 166 | # Read the imge directly from the blob 167 | with s3_client.open(url, mode="rb") as reader: 168 | im = hd.imread(reader, lazy_frame_retrieval=True) 169 | 170 | # Grab an arbitrary region of tile full pixel matrix 171 | region = im.get_total_pixel_matrix( 172 | row_start=15000, 173 | row_end=15512, 174 | column_start=17000, 175 | column_end=17512, 176 | dtype=np.uint8 177 | ) 178 | 179 | # Show the region 180 | plt.imshow(region) 181 | plt.show() 182 | 183 | It is important to tune the ``default_block_size`` parameter to optimize performance. Ideally this value (in bytes) should be large enough to match the size of the raw (probably compressed) data for individual frames of the images, ensuring that each can be retrieved in a single request. However, any larger and unnecessary data will be retrieved, reducing efficiency. The default block size is around 50MB, which is orders of magnitude too large for most images. Above we set it to approximately 500kB, which is probably a reasonable choice for many types of DICOM image. 184 | 185 | The ``s3fs`` package is based on `fsspec`_, which provides abstractions over 186 | various file systems. There are a large number of other filesystems covered by 187 | either the `built-in`_ or `third-party`_ implementations (such as Azure, 188 | Hadoop, SFTP, HTTP, etc). The `smart_open`_ package also provides many similar 189 | wrappers for various filesystems, but is generally optimized for streaming use 190 | cases, not random-access use cases needed for this application. 191 | 192 | In all cases, be aware that the mechanics of the underlying retrieval, as well 193 | as configuration such as buffering and chunk size, can have a significant 194 | impact on the performance of lazy frame retrieval. 195 | 196 | 197 | .. _IDC: https://portal.imaging.datacommons.cancer.gov/ 198 | .. _BlobReader: https://cloud.google.com/python/docs/reference/storage/latest/google.cloud.storage.fileio.BlobReader 199 | .. _smart_open: https://github.com/piskvorky/smart_open 200 | .. _s3fs: https://s3fs.readthedocs.io/en/latest/ 201 | .. _fsspec: https://filesystem-spec.readthedocs.io/en/latest/ 202 | .. _built-in: https://filesystem-spec.readthedocs.io/en/latest/api.html#built-in-implementations 203 | .. _third-party: https://filesystem-spec.readthedocs.io/en/latest/api.html#other-known-implementations 204 | -------------------------------------------------------------------------------- /tests/test_ko.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | import unittest 3 | from pathlib import Path 4 | 5 | from pydicom.dataset import Dataset 6 | from pydicom.filereader import dcmread 7 | from pydicom.sr.codedict import codes 8 | import pytest 9 | 10 | from highdicom.ko.content import KeyObjectSelection 11 | from highdicom.ko.sop import KeyObjectSelectionDocument 12 | from highdicom.sr.enum import ValueTypeValues 13 | from highdicom.sr.templates import ( 14 | DeviceObserverIdentifyingAttributes, 15 | ObserverContext, 16 | PersonObserverIdentifyingAttributes, 17 | ) 18 | from highdicom.sr.value_types import ( 19 | ContainerContentItem, 20 | ContentSequence, 21 | CompositeContentItem, 22 | ImageContentItem, 23 | ) 24 | from highdicom.uid import UID 25 | 26 | 27 | class TestKeyObjectSelection(unittest.TestCase): 28 | 29 | def setUp(self): 30 | super().setUp() 31 | self._document_title = codes.DCM.Manifest 32 | 33 | self._sm_object = Dataset() 34 | self._sm_object.Modality = 'SM' 35 | self._sm_object.SOPClassUID = '1.2.840.10008.5.1.4.1.1.77.1.6' 36 | self._sm_object.SOPInstanceUID = UID() 37 | self._sm_object.Rows = 512 38 | self._sm_object.Columns = 512 39 | 40 | self._seg_object = Dataset() 41 | self._seg_object.Modality = 'SEG' 42 | self._seg_object.SOPClassUID = '1.2.840.10008.5.1.4.1.1.66.4' 43 | self._seg_object.SOPInstanceUID = UID() 44 | self._seg_object.Rows = 512 45 | self._seg_object.Columns = 512 46 | 47 | self._pm_object = Dataset() 48 | self._pm_object.Modality = 'OT' 49 | self._pm_object.SOPClassUID = '1.2.840.10008.5.1.4.1.1.30' 50 | self._pm_object.SOPInstanceUID = UID() 51 | self._pm_object.Rows = 512 52 | self._pm_object.Columns = 512 53 | 54 | self._sr_object = Dataset() 55 | self._sr_object.Modality = 'SR' 56 | self._sr_object.SOPClassUID = '1.2.840.10008.5.1.4.1.1.88.34' 57 | self._sr_object.SOPInstanceUID = UID() 58 | 59 | self._observer_person_context = ObserverContext( 60 | observer_type=codes.DCM.Person, 61 | observer_identifying_attributes=PersonObserverIdentifyingAttributes( 62 | name='Foo^Bar' 63 | ) 64 | ) 65 | self._observer_device_context = ObserverContext( 66 | observer_type=codes.DCM.Device, 67 | observer_identifying_attributes=DeviceObserverIdentifyingAttributes( 68 | uid=UID(), 69 | name='Device' 70 | ) 71 | ) 72 | 73 | def test_construction(self): 74 | content = KeyObjectSelection( 75 | document_title=self._document_title, 76 | referenced_objects=[ 77 | self._sm_object, 78 | self._seg_object, 79 | self._pm_object, 80 | self._sr_object, 81 | ], 82 | observer_person_context=self._observer_person_context, 83 | observer_device_context=self._observer_device_context, 84 | description='Special Selection' 85 | ) 86 | assert isinstance(content, KeyObjectSelection) 87 | assert isinstance(content, ContentSequence) 88 | assert len(content) == 1 89 | container = content[0] 90 | assert isinstance(container, ContainerContentItem) 91 | assert container.ContentTemplateSequence[0].TemplateIdentifier == '2010' 92 | # Observer Context (Person): 2 93 | # Observer Context (Device): 3 94 | # Description: 1 95 | # Referenced Objects: 4 96 | assert len(container.ContentSequence) == 10 97 | observer_type = container.ContentSequence[0] 98 | assert observer_type.name == codes.DCM.ObserverType 99 | assert observer_type.value == codes.DCM.Person 100 | observer_type = container.ContentSequence[2] 101 | assert observer_type.name == codes.DCM.ObserverType 102 | assert observer_type.value == codes.DCM.Device 103 | sm_reference = container.ContentSequence[6] 104 | assert isinstance(sm_reference, ImageContentItem) 105 | seg_reference = container.ContentSequence[7] 106 | assert isinstance(seg_reference, ImageContentItem) 107 | pm_reference = container.ContentSequence[8] 108 | assert isinstance(pm_reference, ImageContentItem) 109 | sr_reference = container.ContentSequence[9] 110 | assert isinstance(sr_reference, CompositeContentItem) 111 | 112 | observer_contexts = content.get_observer_contexts() 113 | assert len(observer_contexts) == 2 114 | observer_contexts = content.get_observer_contexts( 115 | observer_type=codes.DCM.Person 116 | ) 117 | assert len(observer_contexts) == 1 118 | observer_contexts = content.get_observer_contexts( 119 | observer_type=codes.DCM.Device 120 | ) 121 | assert len(observer_contexts) == 1 122 | 123 | references = content.get_references() 124 | assert len(references) == 4 125 | references = content.get_references( 126 | value_type=ValueTypeValues.IMAGE 127 | ) 128 | assert len(references) == 3 129 | references = content.get_references( 130 | value_type=ValueTypeValues.COMPOSITE 131 | ) 132 | assert len(references) == 1 133 | references = content.get_references( 134 | value_type=ValueTypeValues.WAVEFORM 135 | ) 136 | assert len(references) == 0 137 | 138 | def test_construction_with_missing_parameter(self): 139 | with pytest.raises(TypeError): 140 | KeyObjectSelection( 141 | document_title=self._document_title 142 | ) 143 | 144 | def test_construction_with_wrong_parameter_value(self): 145 | with pytest.raises(ValueError): 146 | KeyObjectSelection( 147 | document_title=self._document_title, 148 | referenced_objects=[] 149 | ) 150 | 151 | 152 | class TestKeyObjectSelectionDocument(unittest.TestCase): 153 | 154 | def setUp(self): 155 | super().setUp() 156 | file_path = Path(__file__) 157 | data_dir = file_path.parent.parent.joinpath('data') 158 | 159 | self._sm_image = dcmread( 160 | str(data_dir.joinpath('test_files', 'sm_image.dcm')) 161 | ) 162 | self._seg_image = dcmread( 163 | str(data_dir.joinpath('test_files', 'seg_image_sm_control.dcm')) 164 | ) 165 | 166 | self._evidence = [ 167 | self._sm_image, 168 | self._seg_image, 169 | ] 170 | 171 | self._content = KeyObjectSelection( 172 | document_title=codes.DCM.Manifest, 173 | referenced_objects=[ 174 | self._sm_image, 175 | self._seg_image, 176 | ] 177 | ) 178 | 179 | def test_construction(self): 180 | document = KeyObjectSelectionDocument( 181 | evidence=self._evidence, 182 | content=self._content, 183 | series_instance_uid=UID(), 184 | series_number=10, 185 | sop_instance_uid=UID(), 186 | instance_number=1, 187 | manufacturer='MGH Computational Pathology', 188 | institution_name='Massachusetts General Hospital', 189 | institutional_department_name='Pathology' 190 | ) 191 | assert isinstance(document, KeyObjectSelectionDocument) 192 | assert isinstance(document.content, KeyObjectSelection) 193 | 194 | assert document.Modality == 'KO' 195 | assert hasattr(document, 'CurrentRequestedProcedureEvidenceSequence') 196 | assert len(document.CurrentRequestedProcedureEvidenceSequence) > 0 197 | assert hasattr(document, 'ReferencedPerformedProcedureStepSequence') 198 | 199 | study_uid, series_uid, instance_uid = document.resolve_reference( 200 | self._sm_image.SOPInstanceUID 201 | ) 202 | assert study_uid == self._sm_image.StudyInstanceUID 203 | assert series_uid == self._sm_image.SeriesInstanceUID 204 | assert instance_uid == self._sm_image.SOPInstanceUID 205 | 206 | def test_construction_from_dataset(self): 207 | document = KeyObjectSelectionDocument( 208 | evidence=self._evidence, 209 | content=self._content, 210 | series_instance_uid=UID(), 211 | series_number=10, 212 | sop_instance_uid=UID(), 213 | instance_number=1, 214 | manufacturer='MGH Computational Pathology', 215 | institution_name='Massachusetts General Hospital', 216 | institutional_department_name='Pathology' 217 | ) 218 | assert isinstance(document, KeyObjectSelectionDocument) 219 | assert isinstance(document.content, KeyObjectSelection) 220 | 221 | with BytesIO() as fp: 222 | document.save_as(fp) 223 | fp.seek(0) 224 | document_reread = dcmread(fp) 225 | 226 | test_document = KeyObjectSelectionDocument.from_dataset(document_reread) 227 | assert isinstance(test_document, KeyObjectSelectionDocument) 228 | assert isinstance(test_document.content, KeyObjectSelection) 229 | assert test_document.Modality == 'KO' 230 | assert hasattr( 231 | test_document, 'CurrentRequestedProcedureEvidenceSequence' 232 | ) 233 | assert len(test_document.CurrentRequestedProcedureEvidenceSequence) > 0 234 | assert hasattr( 235 | test_document, 'ReferencedPerformedProcedureStepSequence' 236 | ) 237 | 238 | study_uid, series_uid, instance_uid = test_document.resolve_reference( 239 | self._sm_image.SOPInstanceUID 240 | ) 241 | assert study_uid == self._sm_image.StudyInstanceUID 242 | assert series_uid == self._sm_image.SeriesInstanceUID 243 | assert instance_uid == self._sm_image.SOPInstanceUID 244 | -------------------------------------------------------------------------------- /src/highdicom/utils.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from collections.abc import Sequence 3 | import warnings 4 | 5 | from pydicom.dataset import Dataset 6 | 7 | from highdicom.content import PlanePositionSequence 8 | from highdicom.enum import CoordinateSystemNames 9 | from highdicom.spatial import ( 10 | map_pixel_into_coordinate_system, 11 | tile_pixel_matrix, 12 | get_tile_array, 13 | iter_tiled_full_frame_data, 14 | is_tiled_image, 15 | ) 16 | 17 | 18 | # Several functions that were initially defined in this module were moved to 19 | # highdicom.spatial to consolidate similar functionality and prevent circular 20 | # dependencies. Therefore they are re-exported here for backwards compatibility 21 | __all__ = [ 22 | "tile_pixel_matrix", # backwards compatibility 23 | "get_tile_array", # backwards compatibility 24 | "iter_tiled_full_frame_data", # backwards compatibility 25 | "is_tiled_image", # backwards compatibility 26 | "compute_plane_position_slide_per_frame", 27 | "compute_plane_position_tiled_full", 28 | "are_plane_positions_tiled_full", 29 | ] 30 | 31 | 32 | def compute_plane_position_tiled_full( 33 | row_index: int, 34 | column_index: int, 35 | x_offset: float, 36 | y_offset: float, 37 | rows: int, 38 | columns: int, 39 | image_orientation: Sequence[float], 40 | pixel_spacing: Sequence[float], 41 | slice_thickness: float | None = None, # unused (deprecated) 42 | spacing_between_slices: float | None = None, 43 | slice_index: int | None = None 44 | ) -> PlanePositionSequence: 45 | """Compute the position of a frame (image plane) in the frame of reference 46 | defined by the three-dimensional slide coordinate system. 47 | 48 | This information is not provided in image instances with Dimension 49 | Orientation Type TILED_FULL and therefore needs to be computed. 50 | 51 | Parameters 52 | ---------- 53 | row_index: int 54 | One-based Row index value for a given frame (tile) along the column 55 | direction of the tiled Total Pixel Matrix, which is defined by 56 | the second triplet in `image_orientation` (values should be in the 57 | range [1, *n*], where *n* is the number of tiles per column) 58 | column_index: int 59 | One-based Column index value for a given frame (tile) along the row 60 | direction of the tiled Total Pixel Matrix, which is defined by 61 | the first triplet in `image_orientation` (values should be in the 62 | range [1, *n*], where *n* is the number of tiles per row) 63 | x_offset: float 64 | X offset of the Total Pixel Matrix in the slide coordinate system 65 | in millimeters 66 | y_offset: float 67 | Y offset of the Total Pixel Matrix in the slide coordinate system 68 | in millimeters 69 | rows: int 70 | Number of rows per Frame (tile) 71 | columns: int 72 | Number of columns per Frame (tile) 73 | image_orientation: Sequence[float] 74 | Cosines of the row direction (first triplet: horizontal, left to right, 75 | increasing Column index) and the column direction (second triplet: 76 | vertical, top to bottom, increasing Row index) direction for X, Y, and 77 | Z axis of the slide coordinate system defined by the Frame of Reference 78 | pixel_spacing: Sequence[float] 79 | Spacing between pixels in millimeter unit along the column direction 80 | (first value: spacing between rows, vertical, top to bottom, 81 | increasing Row index) and the row direction (second value: spacing 82 | between columns, horizontal, left to right, increasing Column index) 83 | slice_thickness: Union[float, None], optional 84 | This parameter is unused and passing anything other than None will 85 | cause a warning to be issued. Use spacing_between_slices to specify the 86 | spacing between neighboring slices. This parameter will be removed in a 87 | future version of the library. 88 | spacing_between_slices: Union[float, None], optional 89 | Distance between neighboring focal planes in micrometers 90 | slice_index: Union[int, None], optional 91 | Relative one-based index of the focal plane in the array of focal 92 | planes within the imaged volume from the slide to the coverslip 93 | 94 | Returns 95 | ------- 96 | highdicom.PlanePositionSequence 97 | Position, of the plane in the slide coordinate system 98 | 99 | Raises 100 | ------ 101 | TypeError 102 | When only one of `slice_index` and `spacing_between_slices` is provided 103 | 104 | """ 105 | if slice_thickness is not None: 106 | warnings.warn( 107 | "Passing a slice_thickness other than None has no effect and " 108 | "will be deprecated in a future version of the library.", 109 | UserWarning 110 | ) 111 | if row_index < 1 or column_index < 1: 112 | raise ValueError("Row and column indices must be positive integers.") 113 | row_offset_frame = ((row_index - 1) * rows) 114 | column_offset_frame = ((column_index - 1) * columns) 115 | 116 | provided_3d_params = ( 117 | slice_index is not None, 118 | spacing_between_slices is not None, 119 | ) 120 | if sum(provided_3d_params) not in (0, 2): 121 | raise TypeError( 122 | 'None or both of the following parameters need to be provided: ' 123 | '"slice_index", "spacing_between_slices"' 124 | ) 125 | # These checks are needed for mypy to be able to determine the correct type 126 | if (slice_index is not None and spacing_between_slices is not None): 127 | z_offset = float(slice_index - 1) * spacing_between_slices 128 | else: 129 | z_offset = 0.0 130 | 131 | # We should only be dealing with planar rotations. 132 | x, y, z = map_pixel_into_coordinate_system( 133 | index=(column_offset_frame, row_offset_frame), 134 | image_position=(x_offset, y_offset, z_offset), 135 | image_orientation=image_orientation, 136 | pixel_spacing=pixel_spacing, 137 | ) 138 | 139 | return PlanePositionSequence( 140 | coordinate_system=CoordinateSystemNames.SLIDE, 141 | image_position=(x, y, z), 142 | # Position of plane (tile) in Total Pixel Matrix: 143 | # First tile has position (1, 1) 144 | pixel_matrix_position=(column_offset_frame + 1, row_offset_frame + 1) 145 | ) 146 | 147 | 148 | def compute_plane_position_slide_per_frame( 149 | dataset: Dataset 150 | ) -> list[PlanePositionSequence]: 151 | """Computes the plane position for each frame in given dataset with 152 | respect to the slide coordinate system for an image using the TILED_FULL 153 | DimensionOrganizationType. 154 | 155 | Parameters 156 | ---------- 157 | dataset: pydicom.dataset.Dataset 158 | VL Whole Slide Microscopy Image or Segmentation Image using the 159 | "TILED_FULL" DimensionOrganizationType. 160 | 161 | Returns 162 | ------- 163 | List[highdicom.PlanePositionSequence] 164 | Plane Position Sequence per frame 165 | 166 | Raises 167 | ------ 168 | ValueError 169 | When `dataset` does not represent a VL Whole Slide Microscopy Image or 170 | Segmentation Image or the image does not use the "TILED_FULL" dimension 171 | organization type. 172 | 173 | """ 174 | return [ 175 | PlanePositionSequence( 176 | coordinate_system=CoordinateSystemNames.SLIDE, 177 | image_position=(x, y, z), 178 | pixel_matrix_position=(c, r), 179 | ) 180 | for _, _, c, r, x, y, z in iter_tiled_full_frame_data(dataset) 181 | ] 182 | 183 | 184 | def are_plane_positions_tiled_full( 185 | plane_positions: Sequence[PlanePositionSequence], 186 | rows: int, 187 | columns: int, 188 | ) -> bool: 189 | """Determine whether a list of plane positions matches "TILED_FULL". 190 | 191 | This takes a list of plane positions for each frame and determines whether 192 | the plane positions satisfy the requirements of "TILED_FULL". Plane 193 | positions match the TILED_FULL dimension organization type if they are 194 | non-overlapping, and cover the entire image plane in the order specified in 195 | the standard. 196 | 197 | The test implemented in this function is necessary and sufficient for the 198 | use of TILED_FULL in a newly created tiled image (thus allowing the plane 199 | positions to be omitted from the image and defined implicitly). 200 | 201 | Parameters 202 | ---------- 203 | plane_positions: Sequence[PlanePositionSequence] 204 | Plane positions of each frame. 205 | rows: int 206 | Number of rows in each frame. 207 | columns: int 208 | Number of columns in each frame. 209 | 210 | Returns 211 | ------- 212 | bool: 213 | True if the supplied plane positions satisfy the requirements for 214 | TILED_FULL. False otherwise. 215 | 216 | """ 217 | max_r = -1 218 | max_c = -1 219 | for plane_position in plane_positions: 220 | r = plane_position[0].RowPositionInTotalImagePixelMatrix 221 | c = plane_position[0].ColumnPositionInTotalImagePixelMatrix 222 | if r > max_r: 223 | max_r = r 224 | if c > max_c: 225 | max_c = c 226 | 227 | expected_positions = [ 228 | (r, c) for (r, c) in itertools.product( 229 | range(1, max_r + 1, rows), 230 | range(1, max_c + 1, columns), 231 | ) 232 | ] 233 | if len(expected_positions) != len(plane_positions): 234 | return False 235 | 236 | for (r_exp, c_exp), plane_position in zip( 237 | expected_positions, 238 | plane_positions 239 | ): 240 | r = plane_position[0].RowPositionInTotalImagePixelMatrix 241 | c = plane_position[0].ColumnPositionInTotalImagePixelMatrix 242 | if r != r_exp or c != c_exp: 243 | return False 244 | 245 | return True 246 | --------------------------------------------------------------------------------