├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── README.md ├── apt.txt ├── binder ├── apt.txt ├── environment.yml └── postBuild ├── dicom2stl ├── Dicom2STL.py ├── __init__.py └── utils │ ├── __init__.py │ ├── dicomutils.py │ ├── parseargs.py │ ├── regularize.py │ ├── sitk2vtk.py │ ├── vtk2sitk.py │ └── vtkutils.py ├── environment.yml ├── examples ├── Data │ ├── ct_example.nii.gz │ ├── ct_head.nii.gz │ ├── head_diagram.jpg │ ├── head_mesh.png │ ├── head_volren.png │ ├── heads.jpg │ ├── marching_cubes.png │ ├── marching_squares.png │ └── mri_t1_example.nii.gz ├── Isosurface.ipynb ├── Tutorial.ipynb ├── environment.yml ├── gui.py └── myshow.py ├── pyproject.toml ├── requirements.txt ├── run_tests.sh └── tests ├── __init__.py ├── compare_stats.py ├── create_data.py ├── megatest.py ├── test_dicom2stl.py ├── test_dicomutils.py ├── test_sitk2vtk.py ├── test_sitkutils.py ├── test_vtk2sitk.py ├── test_vtkutils.py └── write_series.py /.github/workflows/python-app.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: Python application 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | linux: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python 3.9 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: 3.9 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install flake8 27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 28 | - name: Lint with flake8 29 | run: | 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. 33 | flake8 . --count --exit-zero --max-complexity=10 --statistics 34 | - name: Run tests 35 | run: | 36 | sh run_tests.sh 37 | macos: 38 | 39 | runs-on: macos-latest 40 | 41 | steps: 42 | - uses: actions/checkout@v3 43 | - name: Set up Python 3.9 44 | uses: actions/setup-python@v4 45 | with: 46 | python-version: 3.9 47 | - name: Install dependencies 48 | run: | 49 | python -m pip install --upgrade pip 50 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 51 | - name: Run tests 52 | run: | 53 | sh run_tests.sh 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # Vscode 56 | .vscode 57 | 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | dicom2stl 2 | ========= 3 | 4 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dave3d/dicom2stl/main?filepath=examples%2FIsosurface.ipynb) 5 | ![Python application](https://github.com/dave3d/dicom2stl/workflows/Python%20application/badge.svg) 6 | 7 | Tutorial: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dave3d/dicom2stl/main?filepath=examples%2FTutorial.ipynb) 8 | 9 | dicom2stl is a script that takes a [Dicom](https://www.dicomstandard.org/about/) 10 | series and generates a STL surface mesh. 11 | 12 | Written by David T. Chen from the National Institute of Allergy & Infectious Diseases (NIAID), 13 | dchen@mail.nih.gov It is covered by the Apache License, Version 2.0: 14 | > http://www.apache.org/licenses/LICENSE-2.0 15 | 16 | Getting Started 17 | =============== 18 | The script is written in Python and uses 4 external packages, [SimpleITK](https://simpleitk.readthedocs.io/en/master/), [SimpleITKUtilities](https://github.com/SimpleITK/SimpleITKUtilities), [VTK](https://vtk.org), and [pydicom](https://pydicom.github.io/). 19 | 20 | dicom2stl and its dependencies can be installed using pip: 21 | 22 | > pip install dicom2stl 23 | 24 | The options for the main script, **dicom2stl**, can be seen by running it: 25 | > dicom2stl --help 26 | 27 | Once you have a DICOM image series zip you can run your first script (Ensure that the ".zip" file is in the dicom2stl directory): 28 | > dicom2stl -t tissue -o output.stl dicom.zip 29 | 30 | This will create a .stl file named "output.stl" that extracted tissue from the DICOM image series. 31 | 32 | How dicom2stl works 33 | ====================== 34 | The script starts by reading in a series of 2-d images or a simple 3-d image. 35 | It can read any format supported by ITK. If the input name is a zip file or 36 | a directory name, the script expects a single series of DCM images, all with 37 | the ".dcm" suffix. 38 | 39 | Note: if this script is run with the individual Dicom slices provided on the 40 | command line, the slices might not be ordered in the correct order. It is 41 | better to provide a zip file or a directory, so ITK can determine the proper 42 | slice ordering. Dicom slices are not necessarily ordered the same 43 | alphabetically as they are physically. In the case of a zip file or directory, 44 | the script loads using the 45 | [SimpleITK ImageSeriesReader](https://simpleitk.readthedocs.io/en/master/Examples/DicomSeriesReader/Documentation.html) 46 | class, which orders the slices by their physical layout, not their alphabetical 47 | names. 48 | 49 | The primary image processing pipeline is as follows: 50 | * [Shrink](https://itk.org/SimpleITKDoxygen/html/classitk_1_1simple_1_1ShrinkImageFilter.html) 51 | ...the volume to 256 max dim (enabled by default) 52 | * [Anisotropic smoothing](https://itk.org/SimpleITKDoxygen/html/classitk_1_1simple_1_1CurvatureAnisotropicDiffusionImageFilter.html) 53 | ...(disabled by default) 54 | * [Double threshold filter](https://itk.org/SimpleITKDoxygen/html/classitk_1_1simple_1_1DoubleThresholdImageFilter.html) 55 | ...(enabled when tissue types are used) 56 | * [Median filter](https://itk.org/SimpleITKDoxygen/html/classitk_1_1simple_1_1MedianImageFilter.html) 57 | ...(enabled for 'soft' and 'fat' tissue types) 58 | * [Pad](https://itk.org/SimpleITKDoxygen/html/classitk_1_1simple_1_1ConstantPadImageFilter.html) 59 | ...the volume 60 | 61 | The script has built in double threshold values for the 4 different tissue 62 | types (bone, skin, muscle, soft). These values assume the input is DICOM with 63 | standard CT Hounsfield units. I determined these values experimentally on a 64 | few DICOM test sets, so the values might not work as well on other images. 65 | 66 | The volume is shrunk to 256 cubed or less for speed and polygon count reasons. 67 | 68 | After all the image processing is finished, the volume is converted to a VTK 69 | image using sitk2vtk from SimpleITKUtilities. 70 | 71 | Then the following VTK pipeline is executed: 72 | * [Extract a surface mesh](https://vtk.org/doc/nightly/html/classvtkContourFilter.html) 73 | ...from the VTK image 74 | * Apply the [clean mesh filter](https://vtk.org/doc/nightly/html/classvtkCleanPolyData.html) 75 | * [Remove small parts](https://vtk.org/doc/nightly/html/classvtkPolyDataConnectivityFilter.html) 76 | ...which connect to little other parts 77 | * Apply the [smooth mesh filter](https://vtk.org/doc/nightly/html/classvtkSmoothPolyDataFilter.html) 78 | * Apply the [reduce mesh filter](https://vtk.org/doc/nightly/html/classvtkQuadricDecimation.html) 79 | * [Write out an STL file](https://vtk.org/doc/nightly/html/classvtkSTLWriter.html) 80 | 81 | The amount of smoothing and mesh reduction can be adjusted via command line 82 | options. By default 25 iterations of smoothing is applied and the number of 83 | vertices is reduced by 90%. 84 | 85 | Basic Usage & Options 86 | ======== 87 | ``` 88 | usage: dicom2stl [-h] [--verbose] [--debug] [--output OUTPUT] [--meta META] [--ct] [--clean] [--temp TEMP] [--search SEARCH] 89 | [--type {skin,bone,soft_tissue,fat}] [--anisotropic] [--isovalue ISOVALUE] [--double DOUBLE_THRESHOLD] [--largest] 90 | [--rotaxis {X,Y,Z}] [--rotangle ROTANGLE] [--smooth SMOOTH] [--reduce REDUCE] [--clean-small SMALL] 91 | [--enable {anisotropic,shrink,median,largest,rotation}] [--disable {anisotropic,shrink,median,largest,rotation}] 92 | [filenames ...] 93 | ``` 94 | For a definitive list of options, run: 95 | > dicom2stl --help 96 | 97 | 98 | Examples 99 | ======== 100 | 101 | To extract the type "bone" from a zip of dicom images to an output file "bone.stl": 102 | > dicom2stl -t bone -o bone.stl dicom.zip 103 | 104 | To extract the skin from a NRRD volume: 105 | > dicom2stl -t skin -o skin.stl volume.nrrd 106 | 107 | To extract a specific iso-value (128) from a VTK volume: 108 | > dicom2stl -i 128 -o iso.stl volume.vtk 109 | 110 | To extract soft tissue from a dicom series in directory and 111 | apply a 180 degree Y axis rotation: 112 | > dicom2stl --enable rotation -t soft_tissue -o soft.stl dicom_dir 113 | 114 | The options for the script can be seen by running it: 115 | > dicom2stl --help 116 | 117 | You can try out an interactive Jupyter notebook via Binder: 118 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dave3d/dicom2stl/main?filepath=examples%2FIsosurface.ipynb) 119 | -------------------------------------------------------------------------------- /apt.txt: -------------------------------------------------------------------------------- 1 | libgl1-mesa-dev 2 | xvfb 3 | -------------------------------------------------------------------------------- /binder/apt.txt: -------------------------------------------------------------------------------- 1 | libgl1-mesa-dev 2 | xvfb 3 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | name: itkwidgets 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.10 6 | - numpy 7 | - itk 8 | - vtk 9 | - simpleitk 10 | - pip 11 | - pip: 12 | - ipywidgets>=7.1.2 13 | - jupyterlab 14 | - numpy 15 | - six 16 | - zstandard 17 | - ipywebrtc 18 | - bqplot 19 | - plotly>=3.3.0 20 | - simpleitkutilities 21 | - pydicom 22 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | # fix iopub issues 2 | mkdir $HOME/.jupyter 3 | echo "c.NotebookApp.iopub_data_rate_limit=1e22" >> $HOME/.jupyter/jupyter_notebook_config.py 4 | # install the package 5 | # git clone https://github.com/InsightSoftwareConsortium/itkwidgets.git 6 | # cd itkwidgets 7 | # pip install . 8 | pip install itkwidgets 9 | # add itkwidgets 10 | jupyter nbextension install --py --sys-prefix itkwidgets 11 | jupyter nbextension enable --py --sys-prefix itkwidgets 12 | cd /tmp/ 13 | # jupyter labextension install @jupyter-widgets/jupyterlab-manager 14 | # jupyter labextension install --minimize=False jupyter-matplotlib jupyterlab-datawidgets jupyter-webrtc itkwidgets 15 | -------------------------------------------------------------------------------- /dicom2stl/Dicom2STL.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | """ 4 | Script to take a Dicom series and generate an STL surface mesh. 5 | 6 | Written by David T. Chen from the National Institute of Allergy 7 | and Infectious Diseases, dchen@mail.nih.gov. 8 | It is covered by the Apache License, Version 2.0: 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Note: if you run this script with the individual Dicom slices provided on the 12 | command line, they might not be ordered in the correct order. You are better 13 | off providing a zip file or a directory. Dicom slices are not necessarily 14 | ordered the same alphabetically as they are physically. 15 | """ 16 | 17 | from __future__ import print_function 18 | 19 | import gc 20 | import math 21 | import os 22 | import sys 23 | import tempfile 24 | import time 25 | import zipfile 26 | import re 27 | from glob import glob 28 | import vtk 29 | import SimpleITK as sitk 30 | 31 | from SimpleITK.utilities.vtk import sitk2vtk 32 | 33 | 34 | from dicom2stl.utils import dicomutils 35 | from dicom2stl.utils import vtkutils 36 | from dicom2stl.utils import parseargs 37 | 38 | 39 | def roundThousand(x): 40 | """Round to the nearest thousandth""" 41 | y = int(1000.0 * x + 0.5) 42 | return str(float(y) * 0.001) 43 | 44 | 45 | def elapsedTime(start_time): 46 | """Print the elapsed time""" 47 | dt = time.perf_counter() - start_time 48 | print(f" {dt:4.3f} seconds") 49 | 50 | 51 | def loadVolume(fname, tempDir=None, verbose=0): 52 | """Load the volume image from a zip file, a directory of Dicom files, 53 | or a single volume image. Return the SimpleITK image and the modality.""" 54 | modality = None 55 | zipFlag = False 56 | dirFlag = False 57 | 58 | # Parse wildcards 59 | # sum() flatten nested list 60 | fname = sum([glob(f) for f in fname], []) 61 | 62 | if len(fname) == 0: 63 | print("Error: no valid input given.") 64 | sys.exit(4) 65 | 66 | zipFlag = zipfile.is_zipfile(fname[0]) 67 | 68 | dirFlag = os.path.isdir(fname[0]) 69 | 70 | img = sitk.Image(100, 100, 100, sitk.sitkUInt8) 71 | 72 | # Load our Dicom data 73 | # 74 | if zipFlag: 75 | # Case for a zip file of images 76 | if not tempDir: 77 | with tempfile.TemporaryDirectory() as defaultTempDir: 78 | img, modality = dicomutils.loadZipDicom(fname[0], defaultTempDir) 79 | else: 80 | img, modality = dicomutils.loadZipDicom(fname[0], tempDir) 81 | 82 | else: 83 | if dirFlag: 84 | img, modality = dicomutils.loadLargestSeries(fname[0]) 85 | 86 | else: 87 | # Case for a single volume image 88 | if len(fname) == 1: 89 | if verbose: 90 | print("Reading volume: ", fname[0]) 91 | img = sitk.ReadImage(fname[0]) 92 | modality = dicomutils.getModality(img) 93 | 94 | else: 95 | # Case for a series of image files 96 | # For files named like IM1, IM2, .. IM10 97 | # They would be ordered by default as IM1, IM10, IM2, ... 98 | # sort the fname list in correct serial number order 99 | RE_NUMBERS = re.compile(r"\d+") 100 | 101 | def extract_int(file_path): 102 | file_name = os.path.basename(file_path) 103 | return int(RE_NUMBERS.findall(file_name)[0]) 104 | 105 | fname = sorted(fname, key=extract_int) 106 | 107 | if verbose: 108 | if verbose > 1: 109 | print("Reading images: ", fname) 110 | else: 111 | print( 112 | "Reading images: ", 113 | fname[0], 114 | fname[1], 115 | "...", 116 | fname[len(fname) - 1], 117 | ) 118 | isr = sitk.ImageSeriesReader() 119 | isr.SetFileNames(fname) 120 | img = isr.Execute() 121 | firstslice = sitk.ReadImage(fname[0]) 122 | modality = dicomutils.getModality(firstslice) 123 | 124 | return img, modality 125 | 126 | 127 | def writeMetadataFile(img, metaName): 128 | """Write out the metadata to a text file""" 129 | with open(metaName, "wb") as fp: 130 | size = img.GetSize() 131 | spacing = img.GetSpacing() 132 | fp.write(b"xdimension " + str(size[0]).encode() + b"\n") 133 | fp.write(b"ydimension " + str(size[1]).encode() + b"\n") 134 | fp.write(b"zdimension " + str(size[2]).encode() + b"\n") 135 | fp.write(b"xspacing " + roundThousand(spacing[0]).encode() + b"\n") 136 | fp.write(b"yspacing " + roundThousand(spacing[1]).encode() + b"\n") 137 | fp.write(b"zspacing " + roundThousand(spacing[2]).encode() + b"\n") 138 | 139 | 140 | def shrinkVolume(input_image, newsize): 141 | """Shrink the volume to a new size""" 142 | size = input_image.GetSize() 143 | total = 0 144 | sfactor = [] 145 | for s in size: 146 | x = int(math.ceil(s / float(newsize))) 147 | sfactor.append(x) 148 | total = total + x 149 | 150 | if total > 3: 151 | # if total==3, no shrink happens 152 | t = time.perf_counter() 153 | print("Shrink factors: ", sfactor) 154 | img = sitk.Shrink(input_image, sfactor) 155 | newsize = img.GetSize() 156 | print(size, "->", newsize) 157 | elapsedTime(t) 158 | return img 159 | 160 | return input_image 161 | 162 | def volumeProcessingPipeline( 163 | img, shrinkFlag=True, anisotropicSmoothing=False, thresholds=None, medianFilter=False 164 | ): 165 | """Apply a series of filters to the volume image""" 166 | # 167 | # shrink the volume to 256 cubed 168 | if shrinkFlag: 169 | shrinkVolume(img, 256) 170 | 171 | gc.collect() 172 | 173 | # Apply anisotropic smoothing to the volume image. That's a smoothing 174 | # filter that preserves edges. 175 | # 176 | if anisotropicSmoothing: 177 | print("Anisotropic Smoothing") 178 | t = time.perf_counter() 179 | pixelType = img.GetPixelID() 180 | img = sitk.Cast(img, sitk.sitkFloat32) 181 | img = sitk.CurvatureAnisotropicDiffusion(img, 0.03) 182 | img = sitk.Cast(img, pixelType) 183 | elapsedTime(t) 184 | gc.collect() 185 | 186 | # Apply the double threshold filter to the volume 187 | # 188 | if isinstance(thresholds, list) and len(thresholds)==4: 189 | print("Double Threshold: ", thresholds) 190 | t = time.perf_counter() 191 | img = sitk.DoubleThreshold( 192 | img, thresholds[0], thresholds[1], thresholds[2], thresholds[3], 255, 0 193 | ) 194 | elapsedTime(t) 195 | gc.collect() 196 | 197 | # Apply a 3x3x1 median filter. I only use 1 in the Z direction so it's 198 | # not so slow. 199 | # 200 | if medianFilter: 201 | print("Median filter") 202 | t = time.perf_counter() 203 | img = sitk.Median(img, [3, 3, 1]) 204 | elapsedTime(t) 205 | gc.collect() 206 | 207 | # 208 | # Get the minimum image intensity for padding the image 209 | # 210 | stats = sitk.StatisticsImageFilter() 211 | stats.Execute(img) 212 | minVal = stats.GetMinimum() 213 | 214 | # Pad black to the boundaries of the image 215 | # 216 | pad = [5, 5, 5] 217 | img = sitk.ConstantPad(img, pad, pad, minVal) 218 | gc.collect() 219 | 220 | return img 221 | 222 | 223 | def meshProcessingPipeline( 224 | mesh, 225 | connectivityFilter=False, 226 | smallFactor=0.05, 227 | smoothN=25, 228 | reduceFactor=0.9, 229 | rotation=["X", 0.0], 230 | debug=False, 231 | ): 232 | """Apply a series of filters to the mesh""" 233 | if debug: 234 | print("Cleaning mesh") 235 | mesh2 = vtkutils.cleanMesh(mesh, connectivityFilter) 236 | mesh = None 237 | gc.collect() 238 | 239 | if debug: 240 | print(f"Cleaning small parts ratio{smallFactor}") 241 | mesh_cleaned_parts = vtkutils.removeSmallObjects(mesh2, smallFactor) 242 | mesh2 = None 243 | gc.collect() 244 | 245 | if debug: 246 | print("Smoothing mesh", smoothN, "iterations") 247 | mesh3 = vtkutils.smoothMesh(mesh_cleaned_parts, smoothN) 248 | mesh_cleaned_parts = None 249 | gc.collect() 250 | 251 | if debug: 252 | print("Simplifying mesh") 253 | mesh4 = vtkutils.reduceMesh(mesh3, reduceFactor) 254 | mesh3 = None 255 | gc.collect() 256 | 257 | print(rotation) 258 | axis_map = {"X": 0, "Y": 1, "Z": 2} 259 | try: 260 | rotAxis = axis_map[rotation[0]] 261 | if rotation[1] != 0.0: 262 | mesh5 = vtkutils.rotateMesh(mesh4, rotAxis, rotation[1]) 263 | else: 264 | mesh5 = mesh4 265 | except RuntimeError: 266 | mesh5 = mesh4 267 | mesh4 = None 268 | gc.collect() 269 | 270 | return mesh5 271 | 272 | 273 | def getTissueThresholds(tissueType): 274 | """Get the double threshold values for a given tissue type.""" 275 | thresholds = [] 276 | medianFilter = False 277 | 278 | # Convert tissue type name to threshold values 279 | print("Tissue type: ", tissueType) 280 | if tissueType.find("bone") > -1: 281 | thresholds = [200.0, 800.0, 1300.0, 1500.0] 282 | elif tissueType.find("skin") > -1: 283 | thresholds = [-200.0, 0.0, 500.0, 1500.0] 284 | elif tissueType.find("soft") > -1: 285 | thresholds = [-15.0, 30.0, 58.0, 100.0] 286 | medianFilter = True 287 | elif tissueType.find("fat") > -1: 288 | thresholds = [-122.0, -112.0, -96.0, -70.0] 289 | medianFilter = True 290 | else: 291 | thresholds = None 292 | 293 | return thresholds, medianFilter 294 | 295 | 296 | def Dicom2STL(args): 297 | """The primary dicom2stl function""" 298 | # Global variables 299 | # 300 | thresholds = None 301 | shrinkFlag = True 302 | connectivityFilter = False 303 | anisotropicSmoothing = False 304 | medianFilter = False 305 | 306 | # Handle enable/disable filters 307 | 308 | if args.filters: 309 | for x in args.filters: 310 | val = True 311 | y = x 312 | if x[:2] == "no": 313 | val = False 314 | y = x[2:] 315 | if y.startswith("shrink"): 316 | shrinkFlag = val 317 | if y.startswith("aniso"): 318 | anisotropicSmoothing = val 319 | if y.startswith("median"): 320 | medianFilter = val 321 | if y.startswith("large"): 322 | connectivityFilter = val 323 | 324 | print("") 325 | if args.temp is None: 326 | args.temp = tempfile.mkdtemp() 327 | print("Temp dir: ", args.temp) 328 | 329 | if args.tissue: 330 | thresholds, medianFilter = getTissueThresholds(args.tissue) 331 | 332 | if args.double_threshold: 333 | words = args.double_threshold.split(";") 334 | thresholds = [] 335 | for x in words: 336 | thresholds.append(float(x)) 337 | # check that there are 4 threshold values. 338 | print("Thresholds: ", thresholds) 339 | if len(thresholds) != 4: 340 | print("Error: Thresholds is not of len 4.", thresholds) 341 | sys.exit(3) 342 | else: 343 | print("Isovalue = ", args.isovalue) 344 | 345 | if args.debug: 346 | print("SimpleITK version: ", sitk.Version.VersionString()) 347 | print("SimpleITK: ", sitk, "\n") 348 | 349 | # 350 | # Load the volume image 351 | img, modality = loadVolume(args.filenames, args.temp, args.verbose) 352 | 353 | if args.ctonly: 354 | if modality.find("CT") == -1: 355 | print("Imaging modality is not CT. Exiting.") 356 | sys.exit(1) 357 | 358 | # Write out the metadata text file 359 | if args.meta: 360 | writeMetadataFile(img, args.meta) 361 | 362 | # 363 | # Filter the volume image 364 | img = volumeProcessingPipeline( 365 | img, shrinkFlag, anisotropicSmoothing, thresholds, medianFilter 366 | ) 367 | 368 | if isinstance(thresholds, list) and len(thresholds) == 4: 369 | args.isovalue = 64.0 370 | 371 | if args.verbose: 372 | print("\nImage for isocontouring") 373 | print(img.GetSize()) 374 | print(img.GetPixelIDTypeAsString()) 375 | print(img.GetSpacing()) 376 | print(img.GetOrigin()) 377 | if args.verbose > 1: 378 | print(img) 379 | print("") 380 | 381 | # Convert the SimpleITK image to a VTK image 382 | vtkimg = sitk2vtk(img) 383 | 384 | # Delete the SimpleITK image, free its memory 385 | img = None 386 | gc.collect() 387 | 388 | if args.debug: 389 | print("\nVTK version: ", vtk.vtkVersion.GetVTKVersion()) 390 | print("VTK: ", vtk, "\n") 391 | 392 | # Extract the iso-surface 393 | if args.debug: 394 | print("Extracting surface") 395 | mesh = vtkutils.extractSurface(vtkimg, args.isovalue) 396 | 397 | # Delete the VTK image, free its memory 398 | vtkimg = None 399 | gc.collect() 400 | 401 | # Filter the output mesh 402 | mesh = meshProcessingPipeline( 403 | mesh, 404 | connectivityFilter, 405 | args.small, 406 | args.smooth, 407 | args.reduce, 408 | [args.rotaxis, args.rotangle], 409 | args.debug, 410 | ) 411 | 412 | # We done! Write out the results 413 | vtkutils.writeMesh(mesh, args.output) 414 | 415 | # remove the temp directory 416 | if args.clean: 417 | # shutil.rmtree(args.temp) 418 | # with context manager the temp dir would be deleted any way 419 | pass 420 | 421 | print("") 422 | 423 | 424 | def main(): 425 | """Main function""" 426 | args = parseargs.parseargs() 427 | Dicom2STL(args) 428 | 429 | 430 | if __name__ == "__main__": 431 | main() 432 | -------------------------------------------------------------------------------- /dicom2stl/__init__.py: -------------------------------------------------------------------------------- 1 | """ dicom2stl - Convert DICOM files to STL files """ 2 | from dicom2stl import Dicom2STL 3 | import dicom2stl.utils.parseargs 4 | 5 | 6 | def main(): 7 | """Entry point for the application script""" 8 | args = dicom2stl.utils.parseargs.parseargs() 9 | Dicom2STL.Dicom2STL(args) 10 | -------------------------------------------------------------------------------- /dicom2stl/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # Birdie putt 2 | -------------------------------------------------------------------------------- /dicom2stl/utils/dicomutils.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | """ 4 | Function to load the largest Dicom series in a directory. 5 | 6 | It scans the directory recursively search for files with the ".dcm" 7 | suffix. Note that DICOM fails don't always have that suffix. In 8 | that case this function will fail. 9 | 10 | Written by David T. Chen from the National Institute of Allergy 11 | and Infectious Diseases, dchen@mail.nih.gov. 12 | It is covered by the Apache License, Version 2.0: 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | """ 15 | 16 | 17 | from __future__ import print_function 18 | import sys 19 | import os 20 | import fnmatch 21 | import zipfile 22 | import SimpleITK as sitk 23 | 24 | from pydicom.filereader import read_file_meta_info 25 | from pydicom.errors import InvalidDicomError 26 | 27 | 28 | def testDicomFile(file_path): 29 | """Test if given file is in DICOM format.""" 30 | try: 31 | read_file_meta_info(file_path) 32 | return True 33 | except InvalidDicomError: 34 | return False 35 | 36 | 37 | def scanDirForDicom(dicomdir): 38 | """Scan directory for dicom series.""" 39 | matches = [] 40 | found_dirs = [] 41 | try: 42 | for root, _, filenames in os.walk(dicomdir): 43 | for filename in fnmatch.filter(filenames, "*.dcm"): 44 | matches.append(os.path.join(root, filename)) 45 | if root not in found_dirs: 46 | found_dirs.append(root) 47 | except OSError as e: 48 | print("Error in scanDirForDicom: ", e) 49 | print("dicomdir = ", dicomdir) 50 | 51 | return (matches, found_dirs) 52 | 53 | 54 | def getAllSeries(target_dirs): 55 | """Get all the Dicom series in a set of directories.""" 56 | isr = sitk.ImageSeriesReader() 57 | found_series = [] 58 | for d in target_dirs: 59 | series = isr.GetGDCMSeriesIDs(d) 60 | for s in series: 61 | found_files = isr.GetGDCMSeriesFileNames(d, s) 62 | print(s, d, len(found_files)) 63 | found_series.append([s, d, found_files]) 64 | return found_series 65 | 66 | 67 | def getModality(img): 68 | """Get an image's modality, as stored in the Dicom meta data.""" 69 | modality = "" 70 | if (sitk.Version.MinorVersion() > 8) or (sitk.Version.MajorVersion() > 0): 71 | try: 72 | modality = img.GetMetaData("0008|0060") 73 | except RuntimeError: 74 | modality = "" 75 | return modality 76 | 77 | 78 | def loadLargestSeries(dicomdir): 79 | """ 80 | Load the largest Dicom series it finds in a recursive scan of 81 | a directory. 82 | 83 | Largest means has the most slices. It also returns the modality 84 | of the series. 85 | """ 86 | 87 | files, dirs = scanDirForDicom(dicomdir) 88 | 89 | if (len(files) == 0) or (len(dirs) == 0): 90 | print("Error in loadLargestSeries. No files found.") 91 | print("dicomdir = ", dicomdir) 92 | return None 93 | seriessets = getAllSeries(dirs) 94 | maxsize = 0 95 | maxindex = -1 96 | 97 | count = 0 98 | for ss in seriessets: 99 | size = len(ss[2]) 100 | if size > maxsize: 101 | maxsize = size 102 | maxindex = count 103 | count = count + 1 104 | if maxindex < 0: 105 | print("Error: no series found") 106 | return None 107 | isr = sitk.ImageSeriesReader() 108 | ss = seriessets[maxindex] 109 | files = ss[2] 110 | isr.SetFileNames(files) 111 | print("\nLoading series", ss[0], "in directory", ss[1]) 112 | img = isr.Execute() 113 | 114 | firstslice = sitk.ReadImage(files[0]) 115 | modality = getModality(firstslice) 116 | 117 | return img, modality 118 | 119 | 120 | def loadZipDicom(name, tempDir): 121 | """Unzip a zipfile of dicom images into a temp directory, then 122 | load the series that has the most slices. 123 | """ 124 | 125 | print("Reading Dicom zip file:", name) 126 | print("tempDir = ", tempDir) 127 | with zipfile.ZipFile(name, "r") as myzip: 128 | 129 | try: 130 | myzip.extractall(tempDir) 131 | except RuntimeError: 132 | print("Zip extract failed") 133 | 134 | return loadLargestSeries(tempDir) 135 | 136 | 137 | # 138 | # Main (test code) 139 | # 140 | 141 | if __name__ == "__main__": 142 | print("") 143 | print("dicomutils.py") 144 | print(sys.argv[1]) 145 | 146 | # img = loadLargestSeries(sys.argv[1]) 147 | # print (img) 148 | # sys.exit(0) 149 | 150 | dcm_files, dcm_dirs = scanDirForDicom(sys.argv[1]) 151 | print("") 152 | print("files") 153 | print(dcm_files) 154 | print("") 155 | print("dirs") 156 | print(dcm_dirs) 157 | 158 | print("series") 159 | series_found = getAllSeries(dcm_dirs) 160 | for sf in series_found: 161 | print(sf[0], " ", sf[1]) 162 | print(len(sf[2])) 163 | print("") 164 | -------------------------------------------------------------------------------- /dicom2stl/utils/parseargs.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | """ Command line argument parsing for dicom2stl """ 4 | import argparse 5 | 6 | from importlib.metadata import version, PackageNotFoundError 7 | 8 | __version__ = "unknown" 9 | 10 | try: 11 | __version__ = version("dicom2stl") 12 | except PackageNotFoundError: 13 | # package is not installed 14 | pass 15 | 16 | 17 | class disableFilter(argparse.Action): 18 | """Disable a filter""" 19 | 20 | def __call__(self, parser, args, values, option_string=None): 21 | # print("action, baby!", self.dest, values) 22 | # print(args, type(args)) 23 | noval = "no" + values 24 | if isinstance(args.filters, type(None)): 25 | args.filters = [noval] 26 | else: 27 | args.filters.append(noval) 28 | 29 | 30 | class enableAnisotropic(argparse.Action): 31 | """Enable anisotropic filtering""" 32 | 33 | def __init__(self, nargs=0, **kw): 34 | super().__init__(nargs=nargs, **kw) 35 | 36 | def __call__(self, parser, args, values, option_string=None): 37 | # x = getattr(args, 'filters') 38 | if isinstance(args.filters, type(None)): 39 | args.filters = ["anisotropic"] 40 | # args.filters.append('anisotropic') 41 | 42 | 43 | class enableLargest(argparse.Action): 44 | """Enable filtering for large objects""" 45 | 46 | def __init__(self, nargs=0, **kw): 47 | super().__init__(nargs=nargs, **kw) 48 | 49 | def __call__(self, parser, args, values, option_string=None): 50 | x = getattr(args, "filters") 51 | x.append("largest") 52 | 53 | 54 | def createParser(): 55 | """Create the command line argument parser""" 56 | parser = argparse.ArgumentParser() 57 | 58 | parser.add_argument("filenames", nargs="*") 59 | 60 | parser.add_argument( 61 | "--verbose", 62 | "-v", 63 | action="store_true", 64 | default=False, 65 | dest="verbose", 66 | help="Enable verbose messages", 67 | ) 68 | 69 | parser.add_argument( 70 | "--debug", 71 | "-D", 72 | action="store_true", 73 | default=False, 74 | dest="debug", 75 | help="""Enable debugging messages 76 | """, 77 | ) 78 | 79 | parser.add_argument( 80 | "--output", 81 | "-o", 82 | action="store", 83 | dest="output", 84 | default="result.stl", 85 | help="Output file name (default=result.stl)", 86 | ) 87 | 88 | parser.add_argument( 89 | "--meta", 90 | "-m", 91 | action="store", 92 | dest="meta", 93 | help="Output metadata file", 94 | ) 95 | 96 | parser.add_argument( 97 | "--ct", 98 | action="store_true", 99 | default=False, 100 | dest="ctonly", 101 | help="Only allow CT Dicom as input", 102 | ) 103 | 104 | parser.add_argument( 105 | "--clean", 106 | "-c", 107 | action="store_true", 108 | default=False, 109 | dest="clean", 110 | help="Clean up temp files", 111 | ) 112 | 113 | parser.add_argument( 114 | "--temp", "-T", action="store", dest="temp", help="Temporary directory" 115 | ) 116 | 117 | parser.add_argument( 118 | "--search", 119 | "-s", 120 | action="store", 121 | dest="search", 122 | help="Dicom series search string", 123 | ) 124 | 125 | parser.add_argument("--version", action="version", version=f"{__version__}") 126 | 127 | # Options that apply to the volumetric portion of the pipeline 128 | vol_group = parser.add_argument_group("Volume options") 129 | 130 | vol_group.add_argument( 131 | "--type", 132 | "-t", 133 | action="store", 134 | dest="tissue", 135 | choices=["skin", "bone", "soft_tissue", "fat"], 136 | help="CT tissue type", 137 | ) 138 | 139 | vol_group.add_argument( 140 | "--anisotropic", 141 | "-a", 142 | action=enableAnisotropic, 143 | help="Apply anisotropic smoothing to the volume", 144 | ) 145 | 146 | vol_group.add_argument( 147 | "--isovalue", 148 | "-i", 149 | action="store", 150 | dest="isovalue", 151 | type=float, 152 | default=0.0, 153 | help="Iso-surface value", 154 | ) 155 | 156 | vol_group.add_argument( 157 | "--double", 158 | "-d", 159 | action="store", 160 | dest="double_threshold", 161 | help="""Double threshold with 4 semicolon separated floats 162 | """, 163 | ) 164 | 165 | # Options that apply to the mesh processing portion of the pipeline 166 | mesh_group = parser.add_argument_group("Mesh options") 167 | mesh_group.add_argument( 168 | "--largest", 169 | "-l", 170 | action=enableLargest, 171 | help="Only keep the largest connected sub-mesh", 172 | ) 173 | 174 | mesh_group.add_argument( 175 | "--rotaxis", 176 | action="store", 177 | dest="rotaxis", 178 | default="Y", 179 | choices=["X", "Y", "Z"], 180 | help="Rotation axis (default=Y)", 181 | ) 182 | 183 | mesh_group.add_argument( 184 | "--rotangle", 185 | action="store", 186 | dest="rotangle", 187 | type=float, 188 | default=0.0, 189 | help="Rotation angle in degrees (default=180)", 190 | ) 191 | 192 | mesh_group.add_argument( 193 | "--smooth", 194 | action="store", 195 | dest="smooth", 196 | type=int, 197 | default=25, 198 | help="Mesh smoothing iterations (default=25)", 199 | ) 200 | 201 | mesh_group.add_argument( 202 | "--reduce", 203 | action="store", 204 | dest="reduce", 205 | type=float, 206 | default=0.9, 207 | help="Mesh reduction factor (default=.9)", 208 | ) 209 | 210 | mesh_group.add_argument( 211 | "--clean-small", 212 | "-x", 213 | action="store", 214 | dest="small", 215 | type=float, 216 | default=0.05, 217 | help="Clean small parts factor (default=.05)", 218 | ) 219 | 220 | # Filtering options 221 | filter_group = parser.add_argument_group("Filtering options") 222 | filter_group.add_argument( 223 | "--enable", 224 | action="append", 225 | dest="filters", 226 | choices=["anisotropic", "shrink", "median", "largest", "rotation"], 227 | help="Enable filtering options", 228 | ) 229 | filter_group.add_argument( 230 | "--disable", 231 | action=disableFilter, 232 | dest="filters", 233 | choices=["anisotropic", "shrink", "median", "largest", "rotation"], 234 | help="Disable filtering options", 235 | ) 236 | 237 | return parser 238 | 239 | 240 | def parseargs(): 241 | """Parse the command line arguments""" 242 | parser = createParser() 243 | args = parser.parse_args() 244 | return args 245 | -------------------------------------------------------------------------------- /dicom2stl/utils/regularize.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | """ Regularize a volume. I.e. resample it so that the voxels are cubic and the 4 | orientation matrix is identity. """ 5 | 6 | 7 | import getopt 8 | import sys 9 | import SimpleITK as sitk 10 | 11 | 12 | def regularize(img, maxdim=-1, verbose=False): 13 | """Regularize a volume. I.e. resample it so that the voxels are cubic and the 14 | orientation matrix is identity.""" 15 | dims = img.GetSize() 16 | 17 | if verbose: 18 | print("Input dims:", dims) 19 | print("Input origin:", img.GetOrigin()) 20 | print("Input direction:", img.GetDirection()) 21 | 22 | # corners of the volume in volume space 23 | vcorners = [ 24 | [0, 0, 0], 25 | [dims[0], 0, 0], 26 | [0, dims[1], 0], 27 | [dims[0], dims[1], 0], 28 | [0, 0, dims[2]], 29 | [dims[0], 0, dims[2]], 30 | [0, dims[1], dims[2]], 31 | [dims[0], dims[1], dims[2]], 32 | ] 33 | 34 | # compute corners of the volume on world space 35 | wcorners = [] 36 | mins = [1e32, 1e32, 1e32] 37 | maxes = [-1e32, -1e32, -1e32] 38 | for c in vcorners: 39 | wcorners.append(img.TransformContinuousIndexToPhysicalPoint(c)) 40 | 41 | # compute the bounding box of the volume 42 | for c in wcorners: 43 | for i in range(0, 3): 44 | if c[i] < mins[i]: 45 | mins[i] = c[i] 46 | if c[i] > maxes[i]: 47 | maxes[i] = c[i] 48 | 49 | if verbose: 50 | print("Bound min:", mins) 51 | print("Bound max:", maxes) 52 | 53 | # if no maxdim is specified, get the max dim of the input volume. 54 | # this is used as the max dimension of the new volume. 55 | if maxdim < 0: 56 | maxdim = max(dims[0], dims[1]) 57 | maxdim = max(maxdim, dims[2]) 58 | 59 | # compute the voxel spacing of the new volume. voxels 60 | # will be cubic, i.e. the spacing is the same in all directions. 61 | maxrange = 0.0 62 | for i in range(0, 3): 63 | r = maxes[i] - mins[i] 64 | maxrange = max(maxrange, r) 65 | newspacing = maxrange / maxdim 66 | if verbose: 67 | print("new spacing:", newspacing) 68 | 69 | # compute the dimensions of the new volume 70 | newdims = [] 71 | for i in range(0, 3): 72 | newdims.append(int((maxes[i] - mins[i]) / newspacing + 0.5)) 73 | if verbose: 74 | print("new dimensions:", newdims) 75 | 76 | # resample the input volume into our new volume 77 | newimg = sitk.Resample( 78 | img, 79 | newdims, 80 | sitk.Transform(), 81 | sitk.sitkLinear, 82 | mins, 83 | [newspacing, newspacing, newspacing], 84 | [1, 0, 0, 0, 1, 0, 0, 0, 1], 85 | img.GetPixelID(), 86 | ) 87 | 88 | return newimg 89 | 90 | 91 | def usage(): 92 | """Usage info for the command line script""" 93 | print("") 94 | print("regularize.py [options] input_volume output_volume") 95 | print("") 96 | print(" -v Verbose") 97 | print(" -d int Max dim") 98 | print("") 99 | 100 | 101 | if __name__ == "__main__": 102 | 103 | if len(sys.argv) == 1: 104 | # no input file. just do a test 105 | input_img = sitk.GaussianSource( 106 | sitk.sitkUInt8, 107 | size=[256, 256, 74], 108 | sigma=[20, 20, 5], 109 | mean=[128, 128, 37], 110 | scale=255, 111 | ) 112 | input_img.SetSpacing([0.4, 0.4, 2.0]) 113 | input_img.SetOrigin([25.0, 50.0, 75.0]) 114 | a = 0.70710678 115 | input_img.SetDirection([a, a, 0, a, -a, 0, 0, 0, 1]) 116 | sitk.WriteImage(input_img, "testimg.nrrd") 117 | 118 | outimg = regularize(input_img, 200) 119 | sitk.WriteImage(outimg, "testoutimg.nrrd") 120 | print(outimg) 121 | 122 | else: 123 | try: 124 | opts, args = getopt.getopt( 125 | sys.argv[1:], "vhd:", ["verbose", "help", "dim="] 126 | ) 127 | except getopt.GetoptError as err: 128 | print(str(err)) 129 | usage() 130 | sys.exit(1) 131 | 132 | md = 128 133 | verboseFlag = False 134 | 135 | for o, a in opts: 136 | if o in ("-h", "--help"): 137 | usage() 138 | sys.exit() 139 | elif o in ("-v", "--verbose"): 140 | verboseFlag = True 141 | elif o in ("-d", "--dim"): 142 | md = int(a) 143 | else: 144 | assert False, "unhandled option" 145 | 146 | if len(args) < 2: 147 | usage() 148 | sys.exit(1) 149 | 150 | inname = args[0] 151 | outname = args[1] 152 | 153 | input_img = sitk.ReadImage(inname) 154 | out_img = regularize(input_img, md, verboseFlag) 155 | sitk.WriteImage(out_img, outname) 156 | -------------------------------------------------------------------------------- /dicom2stl/utils/sitk2vtk.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | """ 4 | Function to convert a SimpleITK image to a VTK image. 5 | 6 | Written by David T. Chen from the National Institute of Allergy 7 | and Infectious Diseases, dchen@mail.nih.gov. 8 | It is covered by the Apache License, Version 2.0: 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | """ 11 | 12 | import SimpleITK as sitk 13 | import vtk 14 | from vtk.util import numpy_support 15 | 16 | 17 | def sitk2vtk(img, debugOn=False): 18 | """Convert a SimpleITK image to a VTK image, via numpy.""" 19 | 20 | size = list(img.GetSize()) 21 | origin = list(img.GetOrigin()) 22 | spacing = list(img.GetSpacing()) 23 | ncomp = img.GetNumberOfComponentsPerPixel() 24 | direction = img.GetDirection() 25 | 26 | # there doesn't seem to be a way to specify the image orientation in VTK 27 | 28 | # convert the SimpleITK image to a numpy array 29 | i2 = sitk.GetArrayFromImage(img) 30 | if debugOn: 31 | i2_string = i2.tostring() 32 | print("data string address inside sitk2vtk", hex(id(i2_string))) 33 | 34 | vtk_image = vtk.vtkImageData() 35 | 36 | # VTK expects 3-dimensional parameters 37 | if len(size) == 2: 38 | size.append(1) 39 | 40 | if len(origin) == 2: 41 | origin.append(0.0) 42 | 43 | if len(spacing) == 2: 44 | spacing.append(spacing[0]) 45 | 46 | if len(direction) == 4: 47 | direction = [ 48 | direction[0], 49 | direction[1], 50 | 0.0, 51 | direction[2], 52 | direction[3], 53 | 0.0, 54 | 0.0, 55 | 0.0, 56 | 1.0, 57 | ] 58 | 59 | vtk_image.SetDimensions(size) 60 | vtk_image.SetSpacing(spacing) 61 | vtk_image.SetOrigin(origin) 62 | vtk_image.SetExtent(0, size[0] - 1, 0, size[1] - 1, 0, size[2] - 1) 63 | 64 | if vtk.vtkVersion.GetVTKMajorVersion() < 9: 65 | print("Warning: VTK version <9. No direction matrix.") 66 | else: 67 | vtk_image.SetDirectionMatrix(direction) 68 | 69 | # depth_array = numpy_support.numpy_to_vtk(i2.ravel(), deep=True, 70 | # array_type = vtktype) 71 | depth_array = numpy_support.numpy_to_vtk(i2.ravel()) 72 | depth_array.SetNumberOfComponents(ncomp) 73 | vtk_image.GetPointData().SetScalars(depth_array) 74 | 75 | vtk_image.Modified() 76 | # 77 | if debugOn: 78 | print("Volume object inside sitk2vtk") 79 | print(vtk_image) 80 | # print("type = ", vtktype) 81 | print("num components = ", ncomp) 82 | print(size) 83 | print(origin) 84 | print(spacing) 85 | print(vtk_image.GetScalarComponentAsFloat(0, 0, 0, 0)) 86 | 87 | return vtk_image 88 | -------------------------------------------------------------------------------- /dicom2stl/utils/vtk2sitk.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ function for converting a VTK image to a SimpleITK image """ 4 | 5 | import SimpleITK as sitk 6 | import vtk 7 | import vtk.util.numpy_support as vtknp 8 | 9 | 10 | def vtk2sitk(vtkimg, debug=False): 11 | """Takes a VTK image, returns a SimpleITK image.""" 12 | sd = vtkimg.GetPointData().GetScalars() 13 | npdata = vtknp.vtk_to_numpy(sd) 14 | 15 | dims = list(vtkimg.GetDimensions()) 16 | origin = vtkimg.GetOrigin() 17 | spacing = vtkimg.GetSpacing() 18 | 19 | if debug: 20 | print("dims:", dims) 21 | print("origin:", origin) 22 | print("spacing:", spacing) 23 | 24 | print("numpy type:", npdata.dtype) 25 | print("numpy shape:", npdata.shape) 26 | 27 | dims.reverse() 28 | npdata.shape = tuple(dims) 29 | if debug: 30 | print("new shape:", npdata.shape) 31 | sitkimg = sitk.GetImageFromArray(npdata) 32 | sitkimg.SetSpacing(spacing) 33 | sitkimg.SetOrigin(origin) 34 | 35 | if vtk.vtkVersion.GetVTKMajorVersion() >= 9: 36 | direction = vtkimg.GetDirectionMatrix() 37 | d = [] 38 | for y in range(3): 39 | for x in range(3): 40 | d.append(direction.GetElement(y, x)) 41 | sitkimg.SetDirection(d) 42 | return sitkimg 43 | -------------------------------------------------------------------------------- /dicom2stl/utils/vtkutils.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | 4 | """ 5 | A collection of VTK functions for processing surfaces and volume. 6 | 7 | Written by David T. Chen from the National Institute of Allergy and 8 | Infectious Diseases, dchen@mail.nih.gov. 9 | It is covered by the Apache License, Version 2.0: 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | """ 12 | 13 | from __future__ import print_function 14 | 15 | # import gc 16 | import sys 17 | import time 18 | import traceback 19 | 20 | import vtk 21 | 22 | 23 | def elapsedTime(start_time): 24 | """time elapsed""" 25 | dt = time.perf_counter() - start_time 26 | print(f" {dt:4.3f} hseconds") 27 | 28 | 29 | # 30 | # Isosurface extraction 31 | # 32 | def extractSurface(vol, isovalue=0.0): 33 | """Extract an isosurface from a volume.""" 34 | try: 35 | t = time.perf_counter() 36 | iso = vtk.vtkContourFilter() 37 | if vtk.vtkVersion.GetVTKMajorVersion() >= 6: 38 | iso.SetInputData(vol) 39 | else: 40 | iso.SetInput(vol) 41 | iso.SetValue(0, isovalue) 42 | iso.Update() 43 | print("Surface extracted") 44 | mesh = iso.GetOutput() 45 | print(" ", mesh.GetNumberOfPolys(), "polygons") 46 | elapsedTime(t) 47 | iso = None 48 | return mesh 49 | except RuntimeError: 50 | print("Iso-surface extraction failed") 51 | exc_type, exc_value, exc_traceback = sys.exc_info() 52 | traceback.print_exception( 53 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 54 | ) 55 | return None 56 | 57 | 58 | # 59 | # Mesh filtering 60 | # 61 | def cleanMesh(mesh, connectivityFilter=False): 62 | """Clean a mesh using VTK's CleanPolyData filter.""" 63 | try: 64 | t = time.perf_counter() 65 | connect = vtk.vtkPolyDataConnectivityFilter() 66 | clean = vtk.vtkCleanPolyData() 67 | 68 | if connectivityFilter: 69 | if vtk.vtkVersion.GetVTKMajorVersion() >= 6: 70 | connect.SetInputData(mesh) 71 | else: 72 | connect.SetInput(mesh) 73 | connect.SetExtractionModeToLargestRegion() 74 | clean.SetInputConnection(connect.GetOutputPort()) 75 | else: 76 | if vtk.vtkVersion.GetVTKMajorVersion() >= 6: 77 | clean.SetInputData(mesh) 78 | else: 79 | clean.SetInput(mesh) 80 | 81 | clean.Update() 82 | print("Surface cleaned") 83 | m2 = clean.GetOutput() 84 | print(" ", m2.GetNumberOfPolys(), "polygons") 85 | elapsedTime(t) 86 | clean = None 87 | connect = None 88 | return m2 89 | except RuntimeError: 90 | print("Surface cleaning failed") 91 | exc_type, exc_value, exc_traceback = sys.exc_info() 92 | traceback.print_exception( 93 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 94 | ) 95 | return None 96 | 97 | 98 | def smoothMesh(mesh, nIterations=10): 99 | """Smooth a mesh using VTK's WindowedSincPolyData filter.""" 100 | try: 101 | t = time.perf_counter() 102 | smooth = vtk.vtkWindowedSincPolyDataFilter() 103 | smooth.SetNumberOfIterations(nIterations) 104 | if vtk.vtkVersion.GetVTKMajorVersion() >= 6: 105 | smooth.SetInputData(mesh) 106 | else: 107 | smooth.SetInput(mesh) 108 | smooth.Update() 109 | print("Surface smoothed") 110 | m2 = smooth.GetOutput() 111 | print(" ", m2.GetNumberOfPolys(), "polygons") 112 | elapsedTime(t) 113 | smooth = None 114 | return m2 115 | except RuntimeError: 116 | print("Surface smoothing failed") 117 | exc_type, exc_value, exc_traceback = sys.exc_info() 118 | traceback.print_exception( 119 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 120 | ) 121 | return None 122 | 123 | 124 | def rotateMesh(mesh, axis=1, angle=0): 125 | """Rotate a mesh about an arbitrary axis. Angle is in degrees.""" 126 | try: 127 | print("Rotating surface: axis=", axis, "angle=", angle) 128 | matrix = vtk.vtkTransform() 129 | if axis == 0: 130 | matrix.RotateX(angle) 131 | if axis == 1: 132 | matrix.RotateY(angle) 133 | if axis == 2: 134 | matrix.RotateZ(angle) 135 | tfilter = vtk.vtkTransformPolyDataFilter() 136 | tfilter.SetTransform(matrix) 137 | if vtk.vtkVersion.GetVTKMajorVersion() >= 6: 138 | tfilter.SetInputData(mesh) 139 | else: 140 | tfilter.SetInput(mesh) 141 | tfilter.Update() 142 | mesh2 = tfilter.GetOutput() 143 | return mesh2 144 | except RuntimeError: 145 | print("Surface rotating failed") 146 | exc_type, exc_value, exc_traceback = sys.exc_info() 147 | traceback.print_exception( 148 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 149 | ) 150 | return None 151 | 152 | 153 | # @profile 154 | 155 | 156 | def reduceMesh(mymesh, reductionFactor): 157 | """Reduce the number of triangles in a mesh using VTK's vtkDecimatePro 158 | filter.""" 159 | try: 160 | t = time.perf_counter() 161 | # deci = vtk.vtkQuadricDecimation() 162 | deci = vtk.vtkDecimatePro() 163 | deci.SetTargetReduction(reductionFactor) 164 | if vtk.vtkVersion.GetVTKMajorVersion() >= 6: 165 | deci.SetInputData(mymesh) 166 | else: 167 | deci.SetInput(mymesh) 168 | deci.Update() 169 | print("Surface reduced") 170 | m2 = deci.GetOutput() 171 | del deci 172 | # deci = None 173 | print(" ", m2.GetNumberOfPolys(), "polygons") 174 | elapsedTime(t) 175 | return m2 176 | except RuntimeError: 177 | print("Surface reduction failed") 178 | exc_type, exc_value, exc_traceback = sys.exc_info() 179 | traceback.print_exception( 180 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 181 | ) 182 | return None 183 | 184 | 185 | # from https://github.com/AOT-AG/DicomToMesh/blob/master/lib/src/meshRoutines.cpp#L109 186 | # MIT License 187 | def removeSmallObjects(mesh, ratio): 188 | """ 189 | Remove small parts which are not of interest 190 | @param ratio A floating-point value between 0.0 and 1.0, the higher the stronger effect 191 | """ 192 | 193 | # do nothing if ratio is 0 194 | if ratio == 0: 195 | return mesh 196 | 197 | try: 198 | t = time.perf_counter() 199 | conn_filter = vtk.vtkPolyDataConnectivityFilter() 200 | conn_filter.SetInputData(mesh) 201 | conn_filter.SetExtractionModeToAllRegions() 202 | conn_filter.Update() 203 | 204 | # remove objects consisting of less than ratio vertexes of the biggest object 205 | region_sizes = conn_filter.GetRegionSizes() 206 | 207 | # find object with most vertices 208 | max_size = 0 209 | for i in range(conn_filter.GetNumberOfExtractedRegions()): 210 | max_size = max(max_size, region_sizes.GetValue(i)) 211 | 212 | # append regions of sizes over the threshold 213 | conn_filter.SetExtractionModeToSpecifiedRegions() 214 | for i in range(conn_filter.GetNumberOfExtractedRegions()): 215 | if region_sizes.GetValue(i) > max_size * ratio: 216 | conn_filter.AddSpecifiedRegion(i) 217 | 218 | conn_filter.Update() 219 | processed_mesh = conn_filter.GetOutput() 220 | print("Small parts cleaned") 221 | print(" ", processed_mesh.GetNumberOfPolys(), "polygons") 222 | elapsedTime(t) 223 | return processed_mesh 224 | 225 | except RuntimeError: 226 | print("Remove small objects failed") 227 | exc_type, exc_value, exc_traceback = sys.exc_info() 228 | traceback.print_exception( 229 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 230 | ) 231 | return None 232 | 233 | 234 | # 235 | # Mesh I/O 236 | # 237 | 238 | 239 | def readMesh(name): 240 | """Read a mesh. Uses suffix to determine specific file type reader.""" 241 | if name.endswith(".vtk"): 242 | return readVTKMesh(name) 243 | if name.endswith(".ply"): 244 | return readPLY(name) 245 | if name.endswith(".stl"): 246 | return readSTL(name) 247 | print("Unknown file type: ", name) 248 | return None 249 | 250 | 251 | def readVTKMesh(name): 252 | """Read a VTK mesh file.""" 253 | try: 254 | reader = vtk.vtkPolyDataReader() 255 | reader.SetFileName(name) 256 | reader.Update() 257 | print("Input mesh:", name) 258 | mesh = reader.GetOutput() 259 | del reader 260 | # reader = None 261 | return mesh 262 | except RuntimeError: 263 | print("VTK mesh reader failed") 264 | exc_type, exc_value, exc_traceback = sys.exc_info() 265 | traceback.print_exception( 266 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 267 | ) 268 | return None 269 | 270 | 271 | def readSTL(name): 272 | """Read an STL mesh file.""" 273 | try: 274 | reader = vtk.vtkSTLReader() 275 | reader.SetFileName(name) 276 | reader.Update() 277 | print("Input mesh:", name) 278 | mesh = reader.GetOutput() 279 | del reader 280 | # reader = None 281 | return mesh 282 | except RuntimeError: 283 | print("STL Mesh reader failed") 284 | exc_type, exc_value, exc_traceback = sys.exc_info() 285 | traceback.print_exception( 286 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 287 | ) 288 | return None 289 | 290 | 291 | def readPLY(name): 292 | """Read a PLY mesh file.""" 293 | try: 294 | reader = vtk.vtkPLYReader() 295 | reader.SetFileName(name) 296 | reader.Update() 297 | print("Input mesh:", name) 298 | mesh = reader.GetOutput() 299 | del reader 300 | # reader = None 301 | return mesh 302 | except RuntimeError: 303 | print("PLY Mesh reader failed") 304 | exc_type, exc_value, exc_traceback = sys.exc_info() 305 | traceback.print_exception( 306 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 307 | ) 308 | return None 309 | 310 | 311 | def writeMesh(mesh, name): 312 | """Write a mesh. Uses suffix to determine specific file type writer.""" 313 | print("Writing", mesh.GetNumberOfPolys(), "polygons to", name) 314 | if name.endswith(".vtk"): 315 | writeVTKMesh(mesh, name) 316 | return 317 | if name.endswith(".ply"): 318 | writePLY(mesh, name) 319 | return 320 | if name.endswith(".stl"): 321 | writeSTL(mesh, name) 322 | return 323 | print("Unknown file type: ", name) 324 | 325 | 326 | def writeVTKMesh(mesh, name): 327 | """Write a VTK mesh file.""" 328 | try: 329 | writer = vtk.vtkPolyDataWriter() 330 | if vtk.vtkVersion.GetVTKMajorVersion() >= 6: 331 | writer.SetInputData(mesh) 332 | else: 333 | writer.SetInput(mesh) 334 | writer.SetFileTypeToBinary() 335 | writer.SetFileName(name) 336 | writer.Write() 337 | print("Output mesh:", name) 338 | writer = None 339 | except RuntimeError: 340 | print("VTK mesh writer failed") 341 | exc_type, exc_value, exc_traceback = sys.exc_info() 342 | traceback.print_exception( 343 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 344 | ) 345 | 346 | 347 | def writeSTL(mesh, name): 348 | """Write an STL mesh file.""" 349 | try: 350 | writer = vtk.vtkSTLWriter() 351 | if vtk.vtkVersion.GetVTKMajorVersion() >= 6: 352 | print("writeSTL 1") 353 | writer.SetInputData(mesh) 354 | else: 355 | print("writeSTL 2") 356 | writer.SetInput(mesh) 357 | writer.SetFileTypeToBinary() 358 | writer.SetFileName(name) 359 | writer.Write() 360 | print("Output mesh:", name) 361 | writer = None 362 | except RuntimeError: 363 | print("STL mesh writer failed") 364 | exc_type, exc_value, exc_traceback = sys.exc_info() 365 | traceback.print_exception( 366 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 367 | ) 368 | 369 | 370 | def writePLY(mesh, name): 371 | """Read a PLY mesh file.""" 372 | try: 373 | writer = vtk.vtkPLYWriter() 374 | if vtk.vtkVersion.GetVTKMajorVersion() >= 6: 375 | writer.SetInputData(mesh) 376 | else: 377 | writer.SetInput(mesh) 378 | writer.SetFileTypeToBinary() 379 | writer.SetFileName(name) 380 | writer.Write() 381 | print("Output mesh:", name) 382 | writer = None 383 | except RuntimeError: 384 | print("PLY mesh writer failed") 385 | exc_type, exc_value, exc_traceback = sys.exc_info() 386 | traceback.print_exception( 387 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 388 | ) 389 | 390 | 391 | # 392 | # Volume I/O 393 | # 394 | 395 | 396 | def readVTKVolume(name): 397 | """Read a VTK volume image file. Returns a vtkStructuredPoints object.""" 398 | try: 399 | reader = vtk.vtkStructuredPointsReader() 400 | reader.SetFileName(name) 401 | reader.Update() 402 | print("Input volume:", name) 403 | vol = reader.GetOutput() 404 | reader = None 405 | return vol 406 | except RuntimeError: 407 | print("VTK volume reader failed") 408 | exc_type, exc_value, exc_traceback = sys.exc_info() 409 | traceback.print_exception( 410 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 411 | ) 412 | return None 413 | 414 | 415 | def writeVTKVolume(vtkimg, name): 416 | """Write the old VTK Image file format""" 417 | try: 418 | writer = vtk.vtkStructuredPointsWriter() 419 | writer.SetFileName(name) 420 | writer.SetInputData(vtkimg) 421 | writer.SetFileTypeToBinary() 422 | writer.Update() 423 | except RuntimeError: 424 | print("VTK volume writer failed") 425 | exc_type, exc_value, exc_traceback = sys.exc_info() 426 | traceback.print_exception( 427 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 428 | ) 429 | 430 | 431 | def readVTIVolume(name): 432 | """Read a VTK XML volume image file. Returns a vtkStructuredPoints object.""" 433 | try: 434 | reader = vtk.vtkXMLImageDataReader() 435 | reader.SetFileName(name) 436 | reader.Update() 437 | print("Input volume:", name) 438 | vol = reader.GetOutput() 439 | reader = None 440 | return vol 441 | except RuntimeError: 442 | print("VTK XML volume reader failed") 443 | exc_type, exc_value, exc_traceback = sys.exc_info() 444 | traceback.print_exception( 445 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 446 | ) 447 | return None 448 | 449 | 450 | def writeVTIVolume(vtkimg, name): 451 | """Write the new XML VTK Image file format""" 452 | try: 453 | writer = vtk.vtkXMLImageDataWriter() 454 | writer.SetFileName(name) 455 | writer.SetInputData(vtkimg) 456 | writer.Update() 457 | except RuntimeError: 458 | print("VTK volume writer failed") 459 | exc_type, exc_value, exc_traceback = sys.exc_info() 460 | traceback.print_exception( 461 | exc_type, exc_value, exc_traceback, limit=2, file=sys.stdout 462 | ) 463 | 464 | 465 | # @profile 466 | 467 | 468 | def memquery1(): 469 | """memory query 1""" 470 | print("Hiya 1") 471 | 472 | 473 | # @profile 474 | 475 | 476 | def memquery2(): 477 | """memory query 2""" 478 | print("Hiya 2") 479 | 480 | 481 | # @profile 482 | 483 | 484 | def memquery3(): 485 | """memory query 3""" 486 | print("Hiya 3") 487 | 488 | 489 | # 490 | # Main (test code) 491 | # 492 | if __name__ == "__main__": 493 | print("vtkutils.py") 494 | 495 | print("VTK version:", vtk.vtkVersion.GetVTKVersion()) 496 | print("VTK:", vtk) 497 | 498 | try: 499 | inmesh = readMesh(sys.argv[1]) 500 | inmesh2 = reduceMesh(inmesh, 0.50) 501 | writeMesh(inmesh2, sys.argv[2]) 502 | except RuntimeError: 503 | print("Usage: vtkutils.py input_mesh output_mesh") 504 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: dicom2stl 2 | 3 | channels: 4 | - simpleitk 5 | - bioconda 6 | - conda-forge 7 | - defaults 8 | 9 | dependencies: 10 | - simpleitk 11 | - numpy 12 | - pip 13 | - pip: 14 | - pydicom 15 | - vtk 16 | - simpleitkutilities 17 | -------------------------------------------------------------------------------- /examples/Data/ct_example.nii.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dave3d/dicom2stl/edbee72ec6e843a9e0aa21a0d9cf92a75087d799/examples/Data/ct_example.nii.gz -------------------------------------------------------------------------------- /examples/Data/ct_head.nii.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dave3d/dicom2stl/edbee72ec6e843a9e0aa21a0d9cf92a75087d799/examples/Data/ct_head.nii.gz -------------------------------------------------------------------------------- /examples/Data/head_diagram.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dave3d/dicom2stl/edbee72ec6e843a9e0aa21a0d9cf92a75087d799/examples/Data/head_diagram.jpg -------------------------------------------------------------------------------- /examples/Data/head_mesh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dave3d/dicom2stl/edbee72ec6e843a9e0aa21a0d9cf92a75087d799/examples/Data/head_mesh.png -------------------------------------------------------------------------------- /examples/Data/head_volren.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dave3d/dicom2stl/edbee72ec6e843a9e0aa21a0d9cf92a75087d799/examples/Data/head_volren.png -------------------------------------------------------------------------------- /examples/Data/heads.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dave3d/dicom2stl/edbee72ec6e843a9e0aa21a0d9cf92a75087d799/examples/Data/heads.jpg -------------------------------------------------------------------------------- /examples/Data/marching_cubes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dave3d/dicom2stl/edbee72ec6e843a9e0aa21a0d9cf92a75087d799/examples/Data/marching_cubes.png -------------------------------------------------------------------------------- /examples/Data/marching_squares.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dave3d/dicom2stl/edbee72ec6e843a9e0aa21a0d9cf92a75087d799/examples/Data/marching_squares.png -------------------------------------------------------------------------------- /examples/Data/mri_t1_example.nii.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dave3d/dicom2stl/edbee72ec6e843a9e0aa21a0d9cf92a75087d799/examples/Data/mri_t1_example.nii.gz -------------------------------------------------------------------------------- /examples/Isosurface.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Using dicom2stl to extract an iso-surface from a volume" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "This notebook gives a basic introduction to using the `'dicom2stl'` script to extract an iso-surface from a volume image." 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "import os, sys\n", 24 | "\n", 25 | "# download dicom2stl if it's not here already\n", 26 | "if not os.path.isdir('dicom2stl'):\n", 27 | " !{'git clone https://github.com/dave3d/dicom2stl.git'}\n", 28 | "\n", 29 | "# Get the latest version\n", 30 | "!{'cd dicom2stl; git pull'}\n", 31 | " \n", 32 | "# Install required packages\n", 33 | "!{sys.executable} -m pip install SimpleITK\n", 34 | "!{sys.executable} -m pip install simpleitkutilities\n", 35 | "!{sys.executable} -m pip install vtk\n", 36 | "!{sys.executable} -m pip install itkwidgets\n", 37 | "!{sys.executable} -m pip install pydicom\n", 38 | "!{sys.executable} -m pip install dicom2stl\n" 39 | ] 40 | }, 41 | { 42 | "cell_type": "markdown", 43 | "metadata": {}, 44 | "source": [ 45 | "## Create a test volume that is 4 Gaussian blobs arranged in a tetrahedron" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "from dicom2stl.tests import create_data\n", 55 | "tetra = create_data.make_tetra()" 56 | ] 57 | }, 58 | { 59 | "cell_type": "markdown", 60 | "metadata": {}, 61 | "source": [ 62 | "## Display the tetra volume using [ITK Widgets](https://github.com/InsightSoftwareConsortium/itkwidgets)" 63 | ] 64 | }, 65 | { 66 | "cell_type": "code", 67 | "execution_count": null, 68 | "metadata": {}, 69 | "outputs": [], 70 | "source": [ 71 | "import itkwidgets" 72 | ] 73 | }, 74 | { 75 | "cell_type": "code", 76 | "execution_count": null, 77 | "metadata": {}, 78 | "outputs": [], 79 | "source": [ 80 | "itkwidgets.view(tetra, cmap='Grayscale', vmin=100)" 81 | ] 82 | }, 83 | { 84 | "cell_type": "markdown", 85 | "metadata": {}, 86 | "source": [ 87 | "## Write the tetra volume to a file" 88 | ] 89 | }, 90 | { 91 | "cell_type": "code", 92 | "execution_count": null, 93 | "metadata": {}, 94 | "outputs": [], 95 | "source": [ 96 | "import SimpleITK as sitk\n", 97 | "sitk.WriteImage(tetra, \"tetra.nii.gz\")" 98 | ] 99 | }, 100 | { 101 | "cell_type": "markdown", 102 | "metadata": {}, 103 | "source": [ 104 | "## Show the command line options for dicom2stl" 105 | ] 106 | }, 107 | { 108 | "cell_type": "code", 109 | "execution_count": null, 110 | "metadata": {}, 111 | "outputs": [], 112 | "source": [ 113 | "!{'dicom2stl -h'}" 114 | ] 115 | }, 116 | { 117 | "cell_type": "markdown", 118 | "metadata": {}, 119 | "source": [ 120 | "## Extract an iso-surface from the tetra volume\n", 121 | "The `'-i'` flag tells the script the intensity value to use for the iso-surface, `150` in this case. The `'-o'` flag specifies the output file, `tetra.stl`. The script can output STL, VTK or PLY files. And `tetra.nii.gz` is input volume." 122 | ] 123 | }, 124 | { 125 | "cell_type": "code", 126 | "execution_count": null, 127 | "metadata": {}, 128 | "outputs": [], 129 | "source": [ 130 | "!{'dicom2stl -i 150 -o tetra.stl tetra.nii.gz'}" 131 | ] 132 | }, 133 | { 134 | "cell_type": "markdown", 135 | "metadata": {}, 136 | "source": [ 137 | "## Load the mesh" 138 | ] 139 | }, 140 | { 141 | "cell_type": "code", 142 | "execution_count": null, 143 | "metadata": {}, 144 | "outputs": [], 145 | "source": [ 146 | "from dicom2stl.utils import vtkutils\n", 147 | "mesh = vtkutils.readMesh('tetra.stl')" 148 | ] 149 | }, 150 | { 151 | "cell_type": "markdown", 152 | "metadata": {}, 153 | "source": [ 154 | "## Display the mesh with the volume" 155 | ] 156 | }, 157 | { 158 | "cell_type": "code", 159 | "execution_count": null, 160 | "metadata": {}, 161 | "outputs": [], 162 | "source": [ 163 | "itkwidgets.view(tetra, cmap='Grayscale', geometries=[mesh], vmin=100)" 164 | ] 165 | }, 166 | { 167 | "cell_type": "code", 168 | "execution_count": null, 169 | "metadata": {}, 170 | "outputs": [], 171 | "source": [] 172 | } 173 | ], 174 | "metadata": { 175 | "kernelspec": { 176 | "display_name": "Python 3", 177 | "language": "python", 178 | "name": "python3" 179 | }, 180 | "language_info": { 181 | "codemirror_mode": { 182 | "name": "ipython", 183 | "version": 3 184 | }, 185 | "file_extension": ".py", 186 | "mimetype": "text/x-python", 187 | "name": "python", 188 | "nbconvert_exporter": "python", 189 | "pygments_lexer": "ipython3", 190 | "version": "3.6.10" 191 | } 192 | }, 193 | "nbformat": 4, 194 | "nbformat_minor": 4 195 | } 196 | -------------------------------------------------------------------------------- /examples/Tutorial.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "slideshow": { 7 | "slide_type": "slide" 8 | } 9 | }, 10 | "source": [ 11 | "# Creating a Printable Model from a 3D Medical Image\n", 12 | "\n", 13 | "## A Tutorial on dicom2stl.py\n", 14 | "\n", 15 | "[https://github.com/dave3d/dicom2stl](https://github.com/dave3d/dicom2stl)" 16 | ] 17 | }, 18 | { 19 | "cell_type": "markdown", 20 | "metadata": { 21 | "slideshow": { 22 | "slide_type": "slide" 23 | } 24 | }, 25 | "source": [ 26 | "![heads](https://github.com/dave3d/dicom2stl/blob/main/examples/Data/head_diagram.jpg?raw=true)\n" 27 | ] 28 | }, 29 | { 30 | "cell_type": "code", 31 | "execution_count": null, 32 | "metadata": { 33 | "slideshow": { 34 | "slide_type": "skip" 35 | } 36 | }, 37 | "outputs": [], 38 | "source": [ 39 | "import SimpleITK as sitk\n", 40 | "%matplotlib notebook" 41 | ] 42 | }, 43 | { 44 | "cell_type": "markdown", 45 | "metadata": { 46 | "slideshow": { 47 | "slide_type": "slide" 48 | } 49 | }, 50 | "source": [ 51 | "# Digital Imaging and Communications in Medicine (DICOM)\n", 52 | "\n", 53 | "DICOM is the standard for the communication and management of **medical imaging information** and related data.\n", 54 | "\n", 55 | "DICOM is most commonly used for storing and transmitting medical images enabling the **integration of medical imaging devices** such as scanners, servers, workstations, printers, network hardware, and **picture archiving and communication systems (PACS)** from multiple manufacturers\n", 56 | "\n", 57 | "[https://en.wikipedia.org/wiki/DICOM](https://en.wikipedia.org/wiki/DICOM)" 58 | ] 59 | }, 60 | { 61 | "cell_type": "markdown", 62 | "metadata": { 63 | "slideshow": { 64 | "slide_type": "slide" 65 | } 66 | }, 67 | "source": [ 68 | "# Imaging Modalities\n", 69 | "\n", 70 | " * CT (computed tomography)\n", 71 | " * MRI (magnetic resonance imaging)\n", 72 | " * ultrasound\n", 73 | " * X-ray\n", 74 | " * fluoroscopy\n", 75 | " * angiography\n", 76 | " * mammography\n", 77 | " * breast tomosynthesis\n", 78 | " * PET (positron emission tomography)\n", 79 | " * SPECT (single photon emission computed tomography)\n", 80 | " * Endoscopy\n", 81 | " * microscopy and whole slide imaging\n", 82 | " * OCT (optical coherence tomography)." 83 | ] 84 | }, 85 | { 86 | "cell_type": "code", 87 | "execution_count": null, 88 | "metadata": { 89 | "slideshow": { 90 | "slide_type": "slide" 91 | } 92 | }, 93 | "outputs": [], 94 | "source": [ 95 | "ct_image = sitk.ReadImage('Data/ct_example.nii.gz')\n", 96 | "mri_image = sitk.ReadImage('Data/mri_t1_example.nii.gz')\n", 97 | "\n", 98 | "import gui\n", 99 | "gui.MultiImageDisplay(image_list=[ct_image, mri_image], title_list=['CT Head', 'MRI T1 Head'])" 100 | ] 101 | }, 102 | { 103 | "cell_type": "markdown", 104 | "metadata": { 105 | "slideshow": { 106 | "slide_type": "slide" 107 | } 108 | }, 109 | "source": [ 110 | "# CT Houndsfield Units\n", 111 | "\n", 112 | "Hounsfield units (HU) are a dimensionless unit universally used in computed tomography (CT) scanning to express CT numbers in a standardized and convenient form. Hounsfield units are obtained from a linear transformation of the measured attenuation coefficients 1\n", 113 | "\n", 114 | " * Water is 0 HU\n", 115 | " * Air is -1000 HU\n", 116 | " * Very dense bone is 2000 HU\n", 117 | " * Metal is 3000 HU\n", 118 | " \n", 119 | " [Houndsfield Wikipedia page](https://en.wikipedia.org/wiki/Hounsfield_scale)" 120 | ] 121 | }, 122 | { 123 | "cell_type": "markdown", 124 | "metadata": { 125 | "slideshow": { 126 | "slide_type": "slide" 127 | } 128 | }, 129 | "source": [ 130 | "# Image Segmentation\n", 131 | "\n", 132 | "The process of partitioning an image into multiple segments.\n", 133 | "\n", 134 | "Typically used to locate objects and boundaries in images.\n", 135 | "\n", 136 | "We use thresholding (selecting a range of image intesities), but SimpleITK has a variety of algorithms\n", 137 | "\n", 138 | "[SimpleITK Notebooks](https://github.com/InsightSoftwareConsortium/SimpleITK-Notebooks/tree/master/Python)" 139 | ] 140 | }, 141 | { 142 | "cell_type": "code", 143 | "execution_count": null, 144 | "metadata": { 145 | "slideshow": { 146 | "slide_type": "slide" 147 | } 148 | }, 149 | "outputs": [], 150 | "source": [ 151 | "from myshow import myshow, myshow3d\n", 152 | "\n", 153 | "ct_bone = ct_image>200\n", 154 | "\n", 155 | "# To visualize the labels image in RGB with needs a image with 0-255 range\n", 156 | "ct255_image = sitk.Cast(sitk.IntensityWindowing(ct_bone,0,500.0,0.,255.), \n", 157 | " sitk.sitkUInt8)\n", 158 | "\n", 159 | "ct255_bone = sitk.Cast(ct_bone, sitk.sitkUInt8)\n", 160 | "\n", 161 | "myshow(sitk.LabelOverlay(ct255_image, ct255_bone), \"Basic Thresholding\")" 162 | ] 163 | }, 164 | { 165 | "cell_type": "markdown", 166 | "metadata": { 167 | "slideshow": { 168 | "slide_type": "slide" 169 | } 170 | }, 171 | "source": [ 172 | "# Iso-surface extraction\n", 173 | "\n", 174 | "Extract a polygonal surface from a 3D image. The most well known algorithm is Marching Cubes (Lorenson & Cline, SIGGRAPH 1987). The 2D version is Marching Squares, shown below\n", 175 | "\n", 176 | "![Marching Squares](https://github.com/dave3d/dicom2stl/blob/main/examples/Data/marching_squares.png?raw=true)\n" 177 | ] 178 | }, 179 | { 180 | "cell_type": "markdown", 181 | "metadata": { 182 | "slideshow": { 183 | "slide_type": "slide" 184 | } 185 | }, 186 | "source": [ 187 | "# Marching Cubes\n", 188 | "\n", 189 | "And here is the lookup table for Marching Cubes\n", 190 | "\n", 191 | "![Marching Cubes](https://github.com/dave3d/dicom2stl/blob/main/examples/Data/marching_cubes.png?raw=true)\n" 192 | ] 193 | }, 194 | { 195 | "cell_type": "markdown", 196 | "metadata": { 197 | "slideshow": { 198 | "slide_type": "slide" 199 | } 200 | }, 201 | "source": [ 202 | "# dicom2stl.py processing pipeline\n", 203 | "\n", 204 | "SimpleITK image processing pipeline\n", 205 | "\n", 206 | " * **Shrink** the volume to 256^3\n", 207 | " * Apply **anisotripic smoothing**\n", 208 | " * **Threshold**\n", 209 | " - Preset tissue types: skin, bone, fat, soft tissue\n", 210 | " - User specified iso-value\n", 211 | " * **Median filter**\n", 212 | " * **Pad** the volume with black\n", 213 | " \n", 214 | "VTK mesh pipeline\n", 215 | "\n", 216 | " * Run **Marching Cubes** to extract surface\n", 217 | " * Apply **CleanMesh** filter to merge vertices\n", 218 | " * Apply **SmoothMesh** filter\n", 219 | " * Run **polygon reduction**\n", 220 | " * Write STL\n", 221 | " \n" 222 | ] 223 | }, 224 | { 225 | "cell_type": "code", 226 | "execution_count": null, 227 | "metadata": { 228 | "slideshow": { 229 | "slide_type": "skip" 230 | } 231 | }, 232 | "outputs": [], 233 | "source": [ 234 | "import itkwidgets\n", 235 | "head = sitk.ReadImage(\"Data/ct_head.nii.gz\")\n", 236 | "itkwidgets.view(head)" 237 | ] 238 | }, 239 | { 240 | "cell_type": "code", 241 | "execution_count": null, 242 | "metadata": { 243 | "slideshow": { 244 | "slide_type": "slide" 245 | } 246 | }, 247 | "outputs": [], 248 | "source": [ 249 | "import sys, os\n", 250 | "\n", 251 | "# download dicom2stl if it's not here already\n", 252 | "if not os.path.isdir('dicom2stl'):\n", 253 | " !{'git clone https://github.com/dave3d/dicom2stl.git'}\n", 254 | " \n", 255 | "!{sys.executable} dicom2stl/dicom2stl.py -h" 256 | ] 257 | }, 258 | { 259 | "cell_type": "code", 260 | "execution_count": null, 261 | "metadata": { 262 | "slideshow": { 263 | "slide_type": "slide" 264 | } 265 | }, 266 | "outputs": [], 267 | "source": [ 268 | "!{sys.executable} dicom2stl/dicom2stl.py -i 400 -o bone.stl Data/ct_head.nii.gz" 269 | ] 270 | }, 271 | { 272 | "cell_type": "code", 273 | "execution_count": null, 274 | "metadata": { 275 | "slideshow": { 276 | "slide_type": "slide" 277 | } 278 | }, 279 | "outputs": [], 280 | "source": [ 281 | "from dicom2stl.utils import vtkutils\n", 282 | "mesh = vtkutils.readMesh('bone.stl')\n", 283 | "itkwidgets.view(head, geometries=[mesh])" 284 | ] 285 | }, 286 | { 287 | "cell_type": "code", 288 | "execution_count": null, 289 | "metadata": {}, 290 | "outputs": [], 291 | "source": [] 292 | } 293 | ], 294 | "metadata": { 295 | "celltoolbar": "Slideshow", 296 | "kernelspec": { 297 | "display_name": "Python 3", 298 | "language": "python", 299 | "name": "python3" 300 | }, 301 | "language_info": { 302 | "codemirror_mode": { 303 | "name": "ipython", 304 | "version": 3 305 | }, 306 | "file_extension": ".py", 307 | "mimetype": "text/x-python", 308 | "name": "python", 309 | "nbconvert_exporter": "python", 310 | "pygments_lexer": "ipython3", 311 | "version": "3.7.9" 312 | } 313 | }, 314 | "nbformat": 4, 315 | "nbformat_minor": 4 316 | } 317 | -------------------------------------------------------------------------------- /examples/environment.yml: -------------------------------------------------------------------------------- 1 | name: dicom2stl 2 | channels: 3 | - simpleitk 4 | - defaults 5 | dependencies: 6 | - numpy=1.18.1 7 | - simpleitk=1.2.4 8 | - vtk=8.2.0 9 | - pip: 10 | - itkwidgets==0.26.1 11 | - simpleitkutilities 12 | - pydicom 13 | -------------------------------------------------------------------------------- /examples/gui.py: -------------------------------------------------------------------------------- 1 | import SimpleITK as sitk 2 | import matplotlib.pyplot as plt 3 | import ipywidgets as widgets 4 | from IPython.display import display 5 | import numpy as np 6 | from matplotlib.widgets import RectangleSelector 7 | import matplotlib.patches as patches 8 | import matplotlib.cm as cm 9 | from matplotlib.ticker import MaxNLocator 10 | import copy 11 | 12 | class RegistrationPointDataAquisition(object): 13 | """ 14 | This class provides a GUI for localizing corresponding points in two images, and for evaluating registration results using a linked cursor 15 | approach, user clicks in one image and the corresponding point is added to the other image. 16 | """ 17 | 18 | def __init__(self, fixed_image, moving_image, fixed_window_level= None, moving_window_level= None, figure_size=(10,8), known_transformation=None): 19 | self.fixed_image = fixed_image 20 | self.fixed_npa, self.fixed_min_intensity, self.fixed_max_intensity = self.get_window_level_numpy_array(self.fixed_image, fixed_window_level) 21 | self.moving_image = moving_image 22 | self.moving_npa, self.moving_min_intensity, self.moving_max_intensity = self.get_window_level_numpy_array(self.moving_image, moving_window_level) 23 | self.fixed_point_indexes = [] 24 | self.moving_point_indexes = [] 25 | self.click_history = [] # Keep a history of user point localizations, enabling undo of last localization. 26 | self.known_transformation = known_transformation # If the transformation is valid (not None) then corresponding points are automatically added. 27 | self.text_and_marker_color = 'red' 28 | 29 | ui = self.create_ui() 30 | display(ui) 31 | 32 | # Create a figure with two axes for the fixed and moving images. 33 | self.fig, axes = plt.subplots(1,2,figsize=figure_size) 34 | #self.fig.canvas.set_window_title('Registration Points Acquisition') 35 | self.fixed_axes = axes[0] 36 | self.moving_axes = axes[1] 37 | # Connect the mouse button press to the canvas (__call__ method is the invoked callback). 38 | self.fig.canvas.mpl_connect('button_press_event', self) 39 | 40 | 41 | # Display the data and the controls, first time we display the images is outside the "update_display" method 42 | # as that method relies on the previous zoom factor which doesn't exist yet. 43 | self.fixed_axes.imshow(self.fixed_npa[self.fixed_slider.value,:,:] if self.fixed_slider else self.fixed_npa, 44 | cmap=plt.cm.Greys_r, 45 | vmin=self.fixed_min_intensity, 46 | vmax=self.fixed_max_intensity) 47 | self.moving_axes.imshow(self.moving_npa[self.moving_slider.value,:,:] if self.moving_slider else self.moving_npa, 48 | cmap=plt.cm.Greys_r, 49 | vmin=self.moving_min_intensity, 50 | vmax=self.moving_max_intensity) 51 | self.update_display() 52 | 53 | 54 | def create_ui(self): 55 | # Create the active UI components. Height and width are specified in 'em' units. This is 56 | # a html size specification, size relative to current font size. 57 | self.viewing_checkbox = widgets.RadioButtons(description= 'Interaction mode:', 58 | options= ['edit', 'view'], 59 | value = 'edit') 60 | 61 | self.clearlast_button = widgets.Button(description= 'Clear Last', 62 | width= '7em', 63 | height= '3em') 64 | self.clearlast_button.on_click(self.clear_last) 65 | 66 | self.clearall_button = widgets.Button(description= 'Clear All', 67 | width= '7em', 68 | height= '3em') 69 | self.clearall_button.on_click(self.clear_all) 70 | 71 | # Sliders are only created if a 3D image, otherwise no need. 72 | self.fixed_slider = self.moving_slider = None 73 | if self.fixed_npa.ndim == 3: 74 | self.fixed_slider = widgets.IntSlider(description='fixed image z slice:', 75 | min=0, 76 | max=self.fixed_npa.shape[0]-1, 77 | step=1, 78 | value = int((self.fixed_npa.shape[0]-1)/2), 79 | width='20em') 80 | self.fixed_slider.observe(self.on_slice_slider_value_change, names='value') 81 | 82 | self.moving_slider = widgets.IntSlider(description='moving image z slice:', 83 | min=0, 84 | max=self.moving_npa.shape[0]-1, 85 | step=1, 86 | value = int((self.moving_npa.shape[0]-1)/2), 87 | width='19em') 88 | self.moving_slider.observe(self.on_slice_slider_value_change, names='value') 89 | 90 | bx0 = widgets.Box(padding=7, children=[self.fixed_slider, self.moving_slider]) 91 | 92 | # Layout of UI components. This is pure ugliness because we are not using a UI toolkit. Layout is done 93 | # using the box widget and padding so that the visible UI components are spaced nicely. 94 | bx1 = widgets.Box(padding=7, children = [self.viewing_checkbox]) 95 | bx2 = widgets.Box(padding = 15, children = [self.clearlast_button]) 96 | bx3 = widgets.Box(padding = 15, children = [self.clearall_button]) 97 | return widgets.HBox(children=[widgets.HBox(children=[bx1, bx2, bx3]),bx0]) if self.fixed_npa.ndim==3 else widgets.HBox(children=[widgets.HBox(children=[bx1, bx2, bx3])]) 98 | 99 | def get_window_level_numpy_array(self, image, window_level): 100 | """ 101 | Get the numpy array representation of the image and the min and max of the intensities 102 | used for display. 103 | """ 104 | npa = sitk.GetArrayViewFromImage(image) 105 | if not window_level: 106 | return npa, npa.min(), npa.max() 107 | else: 108 | return npa, window_level[1]-window_level[0]/2.0, window_level[1]+window_level[0]/2.0 109 | 110 | def on_slice_slider_value_change(self, change): 111 | self.update_display() 112 | 113 | def update_display(self): 114 | """ 115 | Display the two images based on the slider values, if relevant, and the points which are on the 116 | displayed slices. 117 | """ 118 | # We want to keep the zoom factor which was set prior to display, so we log it before 119 | # clearing the axes. 120 | fixed_xlim = self.fixed_axes.get_xlim() 121 | fixed_ylim = self.fixed_axes.get_ylim() 122 | moving_xlim = self.moving_axes.get_xlim() 123 | moving_ylim = self.moving_axes.get_ylim() 124 | 125 | # Draw the fixed image in the first subplot and the localized points. 126 | self.fixed_axes.clear() 127 | self.fixed_axes.imshow(self.fixed_npa[self.fixed_slider.value,:,:] if self.fixed_slider else self.fixed_npa, 128 | cmap=plt.cm.Greys_r, 129 | vmin=self.fixed_min_intensity, 130 | vmax=self.fixed_max_intensity) 131 | # Positioning the text is a bit tricky, we position relative to the data coordinate system, but we 132 | # want to specify the shift in pixels as we are dealing with display. We therefore (a) get the data 133 | # point in the display coordinate system in pixel units (b) modify the point using pixel offset and 134 | # transform back to the data coordinate system for display. 135 | text_x_offset = -10 136 | text_y_offset = -10 137 | for i, pnt in enumerate(self.fixed_point_indexes): 138 | if (self.fixed_slider and int(pnt[2] + 0.5) == self.fixed_slider.value) or not self.fixed_slider: 139 | self.fixed_axes.scatter(pnt[0], pnt[1], s=90, marker='+', color=self.text_and_marker_color) 140 | # Get point in pixels. 141 | text_in_data_coords = self.fixed_axes.transData.transform([pnt[0],pnt[1]]) 142 | # Offset in pixels and get in data coordinates. 143 | text_in_data_coords = self.fixed_axes.transData.inverted().transform((text_in_data_coords[0]+text_x_offset, text_in_data_coords[1]+text_y_offset)) 144 | self.fixed_axes.text(text_in_data_coords[0], text_in_data_coords[1], str(i), color=self.text_and_marker_color) 145 | self.fixed_axes.set_title('fixed image - localized {0} points'.format(len(self.fixed_point_indexes))) 146 | self.fixed_axes.set_axis_off() 147 | 148 | # Draw the moving image in the second subplot and the localized points. 149 | self.moving_axes.clear() 150 | self.moving_axes.imshow(self.moving_npa[self.moving_slider.value,:,:] if self.moving_slider else self.moving_npa, 151 | cmap=plt.cm.Greys_r, 152 | vmin=self.moving_min_intensity, 153 | vmax=self.moving_max_intensity) 154 | for i, pnt in enumerate(self.moving_point_indexes): 155 | if (self.moving_slider and int(pnt[2] + 0.5) == self.moving_slider.value) or not self.moving_slider: 156 | self.moving_axes.scatter(pnt[0], pnt[1], s=90, marker='+', color=self.text_and_marker_color) 157 | text_in_data_coords = self.moving_axes.transData.transform([pnt[0],pnt[1]]) 158 | text_in_data_coords = self.moving_axes.transData.inverted().transform((text_in_data_coords[0]+text_x_offset, text_in_data_coords[1]+text_y_offset)) 159 | self.moving_axes.text(text_in_data_coords[0], text_in_data_coords[1], str(i), color=self.text_and_marker_color) 160 | self.moving_axes.set_title('moving image - localized {0} points'.format(len(self.moving_point_indexes))) 161 | self.moving_axes.set_axis_off() 162 | 163 | # Set the zoom factor back to what it was before we cleared the axes, and rendered our data. 164 | self.fixed_axes.set_xlim(fixed_xlim) 165 | self.fixed_axes.set_ylim(fixed_ylim) 166 | self.moving_axes.set_xlim(moving_xlim) 167 | self.moving_axes.set_ylim(moving_ylim) 168 | 169 | self.fig.canvas.draw_idle() 170 | 171 | def clear_all(self, button): 172 | """ 173 | Get rid of all the data. 174 | """ 175 | del self.fixed_point_indexes[:] 176 | del self.moving_point_indexes[:] 177 | del self.click_history[:] 178 | self.update_display() 179 | 180 | def clear_last(self, button): 181 | """ 182 | Remove last point or point-pair addition (depends on whether the interface is used for localizing point pairs or 183 | evaluation of registration). 184 | """ 185 | if self.click_history: 186 | if self.known_transformation: 187 | self.click_history.pop().pop() 188 | self.click_history.pop().pop() 189 | self.update_display() 190 | 191 | def get_points(self): 192 | """ 193 | Get the points in the image coordinate systems. 194 | """ 195 | if(len(self.fixed_point_indexes) != len(self.moving_point_indexes)): 196 | raise Exception('Number of localized points in fixed and moving images does not match.') 197 | fixed_point_list = [self.fixed_image.TransformContinuousIndexToPhysicalPoint(pnt) for pnt in self.fixed_point_indexes] 198 | moving_point_list = [self.moving_image.TransformContinuousIndexToPhysicalPoint(pnt) for pnt in self.moving_point_indexes] 199 | return fixed_point_list, moving_point_list 200 | 201 | def __call__(self, event): 202 | """ 203 | Callback invoked when the user clicks inside the figure. 204 | """ 205 | # We add points only in 'edit' mode. If the spatial transformation between the two images is known, self.known_transformation was set, 206 | # then every button_press_event will generate a point in each of the images. Finally, we enforce that all points have a corresponding 207 | # point in the other image by not allowing the user to add multiple points in the same image, they have to add points by switching between 208 | # the two images. 209 | if self.viewing_checkbox.value == 'edit': 210 | if event.inaxes==self.fixed_axes: 211 | if len(self.fixed_point_indexes) - len(self.moving_point_indexes)<=0: 212 | self.fixed_point_indexes.append((event.xdata, event.ydata, self.fixed_slider.value) if self.fixed_slider else (event.xdata, event.ydata)) 213 | self.click_history.append(self.fixed_point_indexes) 214 | if self.known_transformation: 215 | moving_point_physical = self.known_transformation.TransformPoint(self.fixed_image.TransformContinuousIndexToPhysicalPoint(self.fixed_point_indexes[-1])) 216 | moving_point_indexes = self.moving_image.TransformPhysicalPointToContinuousIndex(moving_point_physical) 217 | self.moving_point_indexes.append(moving_point_indexes) 218 | self.click_history.append(self.moving_point_indexes) 219 | if self.moving_slider: 220 | z_index = int(moving_point_indexes[2]+0.5) 221 | if self.moving_slider.max>=z_index and self.moving_slider.min<=z_index: 222 | self.moving_slider.value = z_index 223 | self.update_display() 224 | if event.inaxes==self.moving_axes: 225 | if len(self.moving_point_indexes) - len(self.fixed_point_indexes)<=0: 226 | self.moving_point_indexes.append((event.xdata, event.ydata, self.moving_slider.value) if self.moving_slider else (event.xdata, event.ydata)) 227 | self.click_history.append(self.moving_point_indexes) 228 | if self.known_transformation: 229 | inverse_transform = self.known_transformation.GetInverse() 230 | fixed_point_physical = inverse_transform.TransformPoint(self.moving_image.TransformContinuousIndexToPhysicalPoint(self.moving_point_indexes[-1])) 231 | fixed_point_indexes = self.fixed_image.TransformPhysicalPointToContinuousIndex(fixed_point_physical) 232 | self.fixed_point_indexes.append(fixed_point_indexes) 233 | self.click_history.append(self.fixed_point_indexes) 234 | if self.fixed_slider: 235 | z_index = int(fixed_point_indexes[2]+0.5) 236 | if self.fixed_slider.max>=z_index and self.fixed_slider.min<=z_index: 237 | self.fixed_slider.value = z_index 238 | self.update_display() 239 | 240 | 241 | class PointDataAquisition(object): 242 | 243 | def __init__(self, image, window_level= None, figure_size=(10,8)): 244 | self.image = image 245 | self.npa, self.min_intensity, self.max_intensity = self.get_window_level_numpy_array(self.image, window_level) 246 | self.point_indexes = [] 247 | 248 | ui = self.create_ui() 249 | display(ui) 250 | 251 | # Create a figure. 252 | self.fig, self.axes = plt.subplots(1,1,figsize=figure_size) 253 | # Connect the mouse button press to the canvas (__call__ method is the invoked callback). 254 | self.fig.canvas.mpl_connect('button_press_event', self) 255 | 256 | # Display the data and the controls, first time we display the image is outside the "update_display" method 257 | # as that method relies on the previous zoom factor which doesn't exist yet. 258 | self.axes.imshow(self.npa[self.slice_slider.value,:,:] if self.slice_slider else self.npa, 259 | cmap=plt.cm.Greys_r, 260 | vmin=self.min_intensity, 261 | vmax=self.max_intensity) 262 | self.update_display() 263 | 264 | 265 | def create_ui(self): 266 | # Create the active UI components. Height and width are specified in 'em' units. This is 267 | # a html size specification, size relative to current font size. 268 | self.viewing_checkbox = widgets.RadioButtons(description= 'Interaction mode:', 269 | options= ['edit', 'view'], 270 | value = 'edit') 271 | 272 | self.clearlast_button = widgets.Button(description= 'Clear Last', 273 | width= '7em', 274 | height= '3em') 275 | self.clearlast_button.on_click(self.clear_last) 276 | 277 | self.clearall_button = widgets.Button(description= 'Clear All', 278 | width= '7em', 279 | height= '3em') 280 | self.clearall_button.on_click(self.clear_all) 281 | 282 | # Slider is only created if a 3D image, otherwise no need. 283 | self.slice_slider = None 284 | if self.npa.ndim == 3: 285 | self.slice_slider = widgets.IntSlider(description='image z slice:', 286 | min=0, 287 | max=self.npa.shape[0]-1, 288 | step=1, 289 | value = int((self.npa.shape[0]-1)/2), 290 | width='20em') 291 | self.slice_slider.observe(self.on_slice_slider_value_change, names='value') 292 | bx0 = widgets.Box(padding=7, children=[self.slice_slider]) 293 | 294 | # Layout of UI components. This is pure ugliness because we are not using a UI toolkit. Layout is done 295 | # using the box widget and padding so that the visible UI components are spaced nicely. 296 | bx1 = widgets.Box(padding=7, children = [self.viewing_checkbox]) 297 | bx2 = widgets.Box(padding = 15, children = [self.clearlast_button]) 298 | bx3 = widgets.Box(padding = 15, children = [self.clearall_button]) 299 | return widgets.HBox(children=[widgets.HBox(children=[bx1, bx2, bx3]),bx0]) if self.slice_slider else widgets.HBox(children=[widgets.HBox(children=[bx1, bx2, bx3])]) 300 | 301 | def get_window_level_numpy_array(self, image, window_level): 302 | npa = sitk.GetArrayViewFromImage(image) 303 | if not window_level: 304 | return npa, npa.min(), npa.max() 305 | else: 306 | return npa, window_level[1]-window_level[0]/2.0, window_level[1]+window_level[0]/2.0 307 | 308 | def on_slice_slider_value_change(self, change): 309 | self.update_display() 310 | 311 | def update_display(self): 312 | # We want to keep the zoom factor which was set prior to display, so we log it before 313 | # clearing the axes. 314 | xlim = self.axes.get_xlim() 315 | ylim = self.axes.get_ylim() 316 | 317 | # Draw the image and localized points. 318 | self.axes.clear() 319 | self.axes.imshow(self.npa[self.slice_slider.value,:,:] if self.slice_slider else self.npa, 320 | cmap=plt.cm.Greys_r, 321 | vmin=self.min_intensity, 322 | vmax=self.max_intensity) 323 | # Positioning the text is a bit tricky, we position relative to the data coordinate system, but we 324 | # want to specify the shift in pixels as we are dealing with display. We therefore (a) get the data 325 | # point in the display coordinate system in pixel units (b) modify the point using pixel offset and 326 | # transform back to the data coordinate system for display. 327 | text_x_offset = -10 328 | text_y_offset = -10 329 | for i, pnt in enumerate(self.point_indexes): 330 | if(self.slice_slider and int(pnt[2] + 0.5) == self.slice_slider.value) or not self.slice_slider: 331 | self.axes.scatter(pnt[0], pnt[1], s=90, marker='+', color='yellow') 332 | # Get point in pixels. 333 | text_in_data_coords = self.axes.transData.transform([pnt[0],pnt[1]]) 334 | # Offset in pixels and get in data coordinates. 335 | text_in_data_coords = self.axes.transData.inverted().transform((text_in_data_coords[0]+text_x_offset, text_in_data_coords[1]+text_y_offset)) 336 | self.axes.text(text_in_data_coords[0], text_in_data_coords[1], str(i), color='yellow') 337 | self.axes.set_title('localized {0} points'.format(len(self.point_indexes))) 338 | self.axes.set_axis_off() 339 | 340 | 341 | # Set the zoom factor back to what it was before we cleared the axes, and rendered our data. 342 | self.axes.set_xlim(xlim) 343 | self.axes.set_ylim(ylim) 344 | 345 | self.fig.canvas.draw_idle() 346 | 347 | def add_point_indexes(self, point_index_data): 348 | self.validate_points(point_index_data) 349 | self.point_indexes.append(list(point_index_data)) 350 | self.update_display() 351 | 352 | def set_point_indexes(self, point_index_data): 353 | self.validate_points(point_index_data) 354 | del self.point_indexes[:] 355 | self.point_indexes = list(point_index_data) 356 | self.update_display() 357 | 358 | def validate_points(self, point_index_data): 359 | for p in point_index_data: 360 | if self.npa.ndim != len(p): 361 | raise ValueError('Given point (' + ', '.join(map(str,p)) + ') dimension does not match image dimension.') 362 | outside_2d_bounds = p[0]>=self.npa.shape[2] or p[0]<0 or p[1]>=self.npa.shape[1] or p[1]<0 363 | outside_bounds = outside_2d_bounds or (False if self.npa.ndim==2 else p[2]>=self.npa.shape[0] or p[2]<0) 364 | if outside_bounds: 365 | raise ValueError('Given point (' + ', '.join(map(str,p)) + ') is outside the image bounds.') 366 | 367 | def clear_all(self, button): 368 | del self.point_indexes[:] 369 | self.update_display() 370 | 371 | def clear_last(self, button): 372 | if self.point_indexes: 373 | self.point_indexes.pop() 374 | self.update_display() 375 | 376 | def get_points(self): 377 | return [self.image.TransformContinuousIndexToPhysicalPoint(pnt) for pnt in self.point_indexes] 378 | 379 | def get_point_indexes(self): 380 | ''' 381 | Return the point indexes, not the continous index we keep. 382 | ''' 383 | # Round and then cast to int, just rounding will return a float 384 | return [tuple(map(lambda x: int(round(x)), pnt)) for pnt in self.point_indexes] 385 | 386 | 387 | def __call__(self, event): 388 | if self.viewing_checkbox.value == 'edit': 389 | if event.inaxes==self.axes: 390 | self.point_indexes.append((event.xdata, event.ydata, self.slice_slider.value) if self.slice_slider else (event.xdata, event.ydata)) 391 | self.update_display() 392 | 393 | 394 | def multi_image_display2D(image_list, title_list=None, window_level_list= None, figure_size=(10,8), horizontal=True): 395 | 396 | if title_list: 397 | if len(image_list)!=len(title_list): 398 | raise ValueError('Title list and image list lengths do not match') 399 | else: 400 | title_list = ['']*len(image_list) 401 | 402 | # Create a figure. 403 | col_num, row_num = (len(image_list), 1) if horizontal else (1, len(image_list)) 404 | fig, axes = plt.subplots(row_num, col_num, figsize=figure_size) 405 | if len(image_list)==1: 406 | axes = [axes] 407 | 408 | # Get images as numpy arrays for display and the window level settings 409 | npa_list = list(map(sitk.GetArrayViewFromImage, image_list)) 410 | if not window_level_list: 411 | min_intensity_list = list(map(np.min, npa_list)) 412 | max_intensity_list = list(map(np.max, npa_list)) 413 | else: 414 | min_intensity_list = list(map(lambda x: x[1]-x[0]/2.0, window_level_list)) 415 | max_intensity_list = list(map(lambda x: x[1]+x[0]/2.0, window_level_list)) 416 | 417 | # Draw the image(s) 418 | for ax, npa, title, min_intensity, max_intensity in zip(axes, npa_list, title_list, min_intensity_list, max_intensity_list): 419 | ax.imshow(npa, 420 | cmap=plt.cm.Greys_r, 421 | vmin=min_intensity, 422 | vmax=max_intensity) 423 | ax.set_title(title) 424 | ax.set_axis_off() 425 | fig.tight_layout() 426 | return (fig, axes) 427 | 428 | 429 | class MultiImageDisplay(object): 430 | ''' 431 | This class provides a GUI for displaying 3D images. It supports display of 432 | multiple images in the same UI. The image slices are selected according to 433 | the axis specified by the user. Each image can have a title and a slider to 434 | scroll through the stack. The images can also share a single slider if they 435 | have the same number of slices along the given axis. Images are either 436 | grayscale or color. The intensity range used for display (window-level) can 437 | be specified by the user as input to the constructor or set via the displayed 438 | slider. For color images the intensity control slider will be disabled. This 439 | allows us to display both color and grayscale images in the same figure with 440 | a consistent look to the controls. The range of the intensity slider is set 441 | to be from top/bottom 2% of intensities (accomodating for outliers). Images 442 | are displayed either in horizontal or vertical layout, depending on the 443 | users choice. 444 | ''' 445 | def __init__(self, image_list, axis=0, shared_slider=False, title_list=None, window_level_list= None, intensity_slider_range_percentile = [2,98], figure_size=(10,8), horizontal=True): 446 | 447 | self.npa_list, wl_range, wl_init = self.get_window_level_numpy_array(image_list, window_level_list, intensity_slider_range_percentile) 448 | if title_list: 449 | if len(image_list)!=len(title_list): 450 | raise ValueError('Title list and image list lengths do not match') 451 | self.title_list = list(title_list) 452 | else: 453 | self.title_list = ['']*len(image_list) 454 | 455 | # Our dynamic slice, based on the axis the user specifies 456 | self.slc = [slice(None)]*3 457 | self.axis = axis 458 | 459 | ui = self.create_ui(shared_slider, wl_range, wl_init) 460 | display(ui) 461 | 462 | # Create a figure. 463 | col_num, row_num = (len(image_list), 1) if horizontal else (1, len(image_list)) 464 | self.fig, self.axes = plt.subplots(row_num,col_num,figsize=figure_size) 465 | if len(image_list)==1: 466 | self.axes = [self.axes] 467 | 468 | 469 | # Display the data and the controls, first time we display the image is outside the "update_display" method 470 | # as that method relies on the previous zoom factor which doesn't exist yet. 471 | for ax, npa, slider, wl_slider in zip(self.axes, self.npa_list, self.slider_list, self.wl_list): 472 | self.slc[self.axis] = slice(slider.value, slider.value+1) 473 | # Need to use squeeze to collapse degenerate dimension (e.g. RGB image size 124 124 1 3) 474 | ax.imshow(np.squeeze(npa[tuple(self.slc)]), 475 | cmap=plt.cm.Greys_r, 476 | vmin=wl_slider.value[0], 477 | vmax=wl_slider.value[1]) 478 | self.update_display() 479 | plt.tight_layout() 480 | 481 | 482 | def create_ui(self, shared_slider,wl_range, wl_init): 483 | # Create the active UI components. Height and width are specified in 'em' units. This is 484 | # a html size specification, size relative to current font size. 485 | 486 | if shared_slider: 487 | # Validate that all the images have the same size along the axis which we scroll through 488 | sz = self.npa_list[0].shape[self.axis] 489 | for npa in self.npa_list: 490 | if npa.shape[self.axis]!=sz: 491 | raise ValueError('Not all images have the same size along the specified axis, cannot share slider.') 492 | 493 | slider = widgets.IntSlider(description='image slice:', 494 | min=0, 495 | max=sz-1, 496 | step=1, 497 | value = int((sz-1)/2), 498 | width='20em') 499 | slider.observe(self.on_slice_slider_value_change, names='value') 500 | self.slider_list = [slider]*len(self.npa_list) 501 | slicer_box = widgets.Box(padding=7, children=[slider]) 502 | else: 503 | self.slider_list = [] 504 | for npa in self.npa_list: 505 | slider = widgets.IntSlider(description='image slice:', 506 | min=0, 507 | max=npa.shape[self.axis]-1, 508 | step=1, 509 | value = int((npa.shape[self.axis]-1)/2), 510 | width='20em') 511 | slider.observe(self.on_slice_slider_value_change, names='value') 512 | self.slider_list.append(slider) 513 | slicer_box = widgets.Box(padding=7, children=self.slider_list) 514 | self.wl_list = [] 515 | # Each image has a window-level slider, but it is disabled if the image 516 | # is a color image len(npa.shape)==4 . This allows us to display both 517 | # color and grayscale images in the same UI while retaining a reasonable 518 | # layout for the sliders. 519 | for r_values, i_values, npa in zip(wl_range, wl_init, self.npa_list): 520 | wl_range_slider = widgets.IntRangeSlider(description='intensity:', 521 | min=r_values[0], 522 | max=r_values[1], 523 | step=1, 524 | value = [i_values[0], i_values[1]], 525 | width='20em', 526 | disabled = len(npa.shape) == 4) 527 | wl_range_slider.observe(self.on_wl_slider_value_change, names='value') 528 | self.wl_list.append(wl_range_slider) 529 | wl_box = widgets.Box(padding=7, children=self.wl_list) 530 | return widgets.VBox(children=[slicer_box,wl_box]) 531 | 532 | def get_window_level_numpy_array(self, image_list, window_level_list, intensity_slider_range_percentile): 533 | # Using GetArray and not GetArrayView because we don't keep references 534 | # to the original images. If they are deleted outside the view would become 535 | # invalid, so we use a copy which guarantees that the gui is consistent. 536 | npa_list = list(map(sitk.GetArrayFromImage, image_list)) 537 | 538 | wl_range = [] 539 | wl_init = [] 540 | # We need to iterate over the images because they can be a mix of 541 | # grayscale and color images. If they are color we set the wl_range 542 | # to [0,255] and the wl_init is equal, ignoring the window_level_list 543 | # entry. 544 | for i, npa in enumerate(npa_list): 545 | if len(npa.shape) == 4: #color image 546 | wl_range.append((0,255)) 547 | wl_init.append((0,255)) 548 | # ignore any window_level_list entry 549 | else: 550 | # We don't necessarily take the minimum/maximum values, just in case there are outliers 551 | # user can specify how much to take off from top and bottom. 552 | min_max = np.percentile(npa.flatten(), intensity_slider_range_percentile) 553 | wl_range.append((min_max[0], min_max[1])) 554 | if not window_level_list: # No list was given. 555 | wl_init.append(wl_range[-1]) 556 | else: 557 | wl = window_level_list[i] 558 | if wl: 559 | wl_init.append((wl[1]-wl[0]/2.0, wl[1]+wl[0]/2.0)) 560 | else: # We have a list, but for this image the entry was left empty: [] 561 | wl_init.append(wl_range[-1]) 562 | return (npa_list, wl_range, wl_init) 563 | 564 | def on_slice_slider_value_change(self, change): 565 | self.update_display() 566 | 567 | def on_wl_slider_value_change(self, change): 568 | self.update_display() 569 | 570 | def update_display(self): 571 | 572 | # Draw the image(s) 573 | for ax, npa, title, slider, wl_slider in zip(self.axes, self.npa_list, self.title_list, self.slider_list, self.wl_list): 574 | # We want to keep the zoom factor which was set prior to display, so we log it before 575 | # clearing the axes. 576 | xlim = ax.get_xlim() 577 | ylim = ax.get_ylim() 578 | 579 | self.slc[self.axis] = slice(slider.value, slider.value+1) 580 | ax.clear() 581 | # Need to use squeeze to collapse degenerate dimension (e.g. RGB image size 124 124 1 3) 582 | ax.imshow(np.squeeze(npa[tuple(self.slc)]), 583 | cmap=plt.cm.Greys_r, 584 | vmin=wl_slider.value[0], 585 | vmax=wl_slider.value[1]) 586 | ax.set_title(title) 587 | ax.set_axis_off() 588 | 589 | # Set the zoom factor back to what it was before we cleared the axes, and rendered our data. 590 | ax.set_xlim(xlim) 591 | ax.set_ylim(ylim) 592 | 593 | self.fig.canvas.draw_idle() 594 | 595 | 596 | class ROIDataAquisition(object): 597 | ''' 598 | This class provides a GUI for selecting box shaped Regions Of Interest (ROIs). Each ROI is represented as a 599 | tuple: ((min_x,max_x),(min_y,max_y), and possibly (min_z,max_z)) if dealing with a 3D image. 600 | When using the zoom/pan tool from the toolbar ROI selection is disabled. Once you click again on the zoom/pan 601 | button zooming/panning will be disabled and ROI selection is enabled. 602 | Note that when you are marking the ROI on a slice that is outside the Z-range selected by the 603 | range slider, once you are done selecting the ROI, you will see no change on the current slice. This is the 604 | correct behavior, though initially you may be surprised by it. 605 | ''' 606 | def __init__(self, image, window_level= None, figure_size=(10,8)): 607 | self.image = image 608 | self.npa, self.min_intensity, self.max_intensity = self.get_window_level_numpy_array(self.image, window_level) 609 | self.rois = [] 610 | 611 | # ROI display settings 612 | self.roi_display_properties = dict(facecolor='red', edgecolor='black', alpha=0.2, fill=True) 613 | 614 | ui = self.create_ui() 615 | display(ui) 616 | 617 | # Create a figure. 618 | self.fig, self.axes = plt.subplots(1,1,figsize=figure_size) 619 | # Connect the mouse button press to the canvas (__call__ method is the invoked callback). 620 | self.fig.canvas.mpl_connect('button_press_event', self) 621 | self.roi_selector = RectangleSelector(self.axes, lambda eclick, erelease: None, 622 | drawtype='box', useblit=True, 623 | button=[1, 3], # Left, right buttons only. 624 | minspanx=5, minspany=5, # Ignore motion smaller than 5 pixels. 625 | spancoords='pixels', 626 | interactive=True, 627 | rectprops = self.roi_display_properties) 628 | self.roi_selector.set_visible(False) 629 | 630 | # Display the data and the controls, first time we display the image is outside the "update_display" method 631 | # as that method relies on the existence of a previous image which is removed from the figure. 632 | self.axes.imshow(self.npa[self.slice_slider.value,:,:] if self.slice_slider else self.npa, 633 | cmap=plt.cm.Greys_r, 634 | vmin=self.min_intensity, 635 | vmax=self.max_intensity) 636 | self.update_display() 637 | 638 | 639 | def create_ui(self): 640 | # Create the active UI components. Height and width are specified in 'em' units. This is 641 | # a html size specification, size relative to current font size. 642 | self.addroi_button = widgets.Button(description= 'Add ROI', 643 | width= '7em', 644 | height= '3em') 645 | self.addroi_button.on_click(self.add_roi) 646 | self.clearlast_button = widgets.Button(description= 'Clear Last', 647 | width= '7em', 648 | height= '3em') 649 | self.clearlast_button.on_click(self.clear_last) 650 | 651 | self.clearall_button = widgets.Button(description= 'Clear All', 652 | width= '7em', 653 | height= '3em') 654 | self.clearall_button.on_click(self.clear_all) 655 | 656 | # Create sliders only if 3D image 657 | self.slice_slider = self.roi_range_slider = None 658 | if self.npa.ndim == 3: 659 | self.roi_range_slider = widgets.IntRangeSlider(description= 'ROI z range:', 660 | min=0, 661 | max=self.npa.shape[0]-1, 662 | step=1, 663 | value=[0,self.npa.shape[0]-1], 664 | width='20em') 665 | bx4 = widgets.Box(padding = 15, children = [self.roi_range_slider]) 666 | self.slice_slider = widgets.IntSlider(description='image z slice:', 667 | min=0, 668 | max=self.npa.shape[0]-1, 669 | step=1, 670 | value = int((self.npa.shape[0]-1)/2), 671 | width='20em') 672 | self.slice_slider.observe(self.on_slice_slider_value_change, names='value') 673 | bx0 = widgets.Box(padding=7, children=[self.slice_slider]) 674 | 675 | # Layout of UI components. This is pure ugliness because we are not using a UI toolkit. Layout is done 676 | # using the box widget and padding so that the visible UI components are spaced nicely. 677 | bx1 = widgets.Box(padding=7, children = [self.addroi_button]) 678 | bx2 = widgets.Box(padding = 15, children = [self.clearlast_button]) 679 | bx3 = widgets.Box(padding = 15, children = [self.clearall_button]) 680 | return widgets.HBox(children=[widgets.HBox(children=[bx1, bx2, bx3]),widgets.VBox(children=[bx0,bx4])]) if self.npa.ndim==3 else widgets.HBox(children=[widgets.HBox(children=[bx1, bx2, bx3])]) 681 | 682 | 683 | def on_slice_slider_value_change(self, change): 684 | self.update_display() 685 | 686 | 687 | def get_window_level_numpy_array(self, image, window_level): 688 | npa = sitk.GetArrayViewFromImage(image) 689 | # We don't take the minimum/maximum values, just in case there are outliers (top/bottom 2%) 690 | if not window_level: 691 | min_max = np.percentile(npa.flatten(), [2,98]) 692 | return npa, min_max[0], min_max[1] 693 | else: 694 | return npa, window_level[1]-window_level[0]/2.0, window_level[1]+window_level[0]/2.0 695 | 696 | 697 | def update_display(self): 698 | # Draw the image and ROIs. 699 | # imshow adds an image to the axes, so we also remove the previous one. 700 | self.axes.imshow(self.npa[self.slice_slider.value,:,:] if self.slice_slider else self.npa, 701 | cmap=plt.cm.Greys_r, 702 | vmin=self.min_intensity, 703 | vmax=self.max_intensity) 704 | self.axes.images[0].remove() 705 | # Iterate over all of the ROIs and only display/undisplay those that are relevant. 706 | if self.slice_slider: 707 | for roi_data in self.rois: 708 | if self.slice_slider.value>= roi_data[3][0] and self.slice_slider.value<= roi_data[3][1]: 709 | roi_data[0].set_visible(True) 710 | else: 711 | roi_data[0].set_visible(False) 712 | self.axes.set_title('selected {0} ROIs'.format(len(self.rois))) 713 | self.axes.set_axis_off() 714 | 715 | self.fig.canvas.draw_idle() 716 | 717 | 718 | def add_roi_data(self, roi_data): 719 | ''' 720 | Add regions of interest to this GUI. 721 | Input is an iterable containing tuples where each tuple contains 722 | either two or three tuples (min_x,max_x),(min_y,max_y), (min_z,max_z). 723 | depending on the image dimensionality. The ROI 724 | is the box defined by these integer values and includes 725 | both min/max values. 726 | ''' 727 | self.validate_rois(roi_data) 728 | 729 | for roi in roi_data: 730 | self.rois.append((patches.Rectangle((roi[0][0], roi[1][0]), 731 | roi[0][1]-roi[0][0], 732 | roi[1][1]-roi[1][0], 733 | **self.roi_display_properties), 734 | roi[0], roi[1], roi[2] if self.npa.ndim==3 else None)) 735 | self.axes.add_patch(self.rois[-1][0]) 736 | self.update_display() 737 | 738 | 739 | def set_rois(self, roi_data): 740 | ''' 741 | Clear any existing ROIs and set the display to the given ones. 742 | Input is an iterable containing tuples where each tuple contains 743 | two or three tuples (min_x,max_x),(min_y,max_y), (min_z,max_z) depending 744 | on the image dimensionality. The ROI 745 | is the box defined by these integer values and includes 746 | both min/max values. 747 | ''' 748 | self.clear_all_data() 749 | self.add_roi_data(roi_data) 750 | 751 | 752 | def validate_rois(self, roi_data): 753 | for roi in roi_data: 754 | for i, bounds in enumerate(roi,1): 755 | if bounds[0] > bounds[1]: 756 | raise ValueError('First element in each tuple is expected to be smaller than second element, error in ROI (' + ', '.join(map(str,roi)) + ').') 757 | # Note that SimpleITK uses x-y-z specification vs. numpy's z-y-x 758 | if not (bounds[0]>=0 and bounds[1]TRE)', 902 | width= '7em', 903 | height= '3em') 904 | self.bias2_button.on_click(self.bias_2) 905 | 906 | self.clear_fiducial_button = widgets.Button(description= 'Clear Fiducials', 907 | width= '7em', 908 | height= '3em') 909 | self.clear_fiducial_button.on_click(self.clear_fiducials) 910 | 911 | self.clear_target_button = widgets.Button(description= 'Clear Targets', 912 | width= '7em', 913 | height= '3em') 914 | self.clear_target_button.on_click(self.clear_targets) 915 | 916 | self.reset_button = widgets.Button(description= 'Reset', 917 | width= '7em', 918 | height= '3em') 919 | self.reset_button.on_click(self.reset) 920 | 921 | self.register_button = widgets.Button(description= 'Register', 922 | width= '7em', 923 | height= '3em') 924 | self.register_button.on_click(self.register) 925 | 926 | 927 | # Layout of UI components. This is pure ugliness because we are not using a UI toolkit. Layout is done 928 | # using the box widget and padding so that the visible UI components are spaced nicely. 929 | bx0 = widgets.Box(padding = 2, children = [self.viewing_checkbox]) 930 | bx1 = widgets.Box(padding = 15, children = [self.noise_button]) 931 | bx2 = widgets.Box(padding = 15, children = [self.outlier_button]) 932 | bx3 = widgets.Box(padding = 15, children = [self.bias1_button]) 933 | bx4 = widgets.Box(padding = 15, children = [self.bias2_button]) 934 | bx5 = widgets.Box(padding = 15, children = [self.clear_fiducial_button]) 935 | bx6 = widgets.Box(padding = 15, children = [self.clear_target_button]) 936 | bx7 = widgets.Box(padding = 15, children = [self.reset_button]) 937 | bx8 = widgets.Box(padding = 15, children = [self.register_button]) 938 | return widgets.HBox(children=[bx0, widgets.VBox(children=[widgets.HBox([bx1, bx2, bx3, bx4]), widgets.HBox(children=[bx5, bx6, bx7, bx8])])]) 939 | 940 | 941 | def update_display(self): 942 | 943 | self.axes.clear() 944 | 945 | # Draw the fixed and moving fiducials and targets using the glyph specifications defined in 946 | # the class constructor. 947 | self.moving_fiducials_glyphs = [] 948 | self.moving_targets_glyphs = [] 949 | for fixed_fiducial, moving_fiducial in zip(self.fixed_fiducials, self.moving_fiducials): 950 | self.axes.plot(fixed_fiducial[0], fixed_fiducial[1], **(self.FIXED_FIDUCIAL_CONFIG)) 951 | self.moving_fiducials_glyphs += self.axes.plot(moving_fiducial[0], moving_fiducial[1], **(self.MOVING_FIDUCIAL_CONFIG)) 952 | for fixed_target, moving_target in zip(self.fixed_targets, self.moving_targets): 953 | self.axes.plot(fixed_target[0], fixed_target[1], **(self.FIXED_TARGET_CONFIG)) 954 | self.moving_targets_glyphs += self.axes.plot(moving_target[0], moving_target[1], **(self.MOVING_TARGET_CONFIG)) 955 | if self.centroid: 956 | self.axes.plot(self.centroid[0], self.centroid[1], **(self.CENTROID_CONFIG)) 957 | 958 | self.axes.set_title('Registration Error Demonstration') 959 | self.axes.get_xaxis().set_visible(False) 960 | self.axes.get_yaxis().set_visible(False) 961 | 962 | self.axes.set_facecolor((0, 0, 0)) 963 | 964 | # Set the data range back to what it was before we cleared the axes, and rendered our data. 965 | self.axes.set_xlim([0, self.scale]) 966 | self.axes.set_ylim([0, self.scale]) 967 | 968 | self.fig.canvas.draw_idle() 969 | 970 | 971 | def update_centroid_and_display(self, button): 972 | self.update_centroid() 973 | self.update_display() 974 | 975 | def update_centroid(self): 976 | if self.viewing_checkbox.value == 'rotate' and (self.moving_targets or self.moving_fiducials): 977 | n = len(self.moving_fiducials) + len(self.moving_targets) 978 | x,y = zip(*(self.moving_fiducials+self.moving_targets)) 979 | self.centroid = [sum(x)/n, sum(y)/n] 980 | else: 981 | self.centroid = [] 982 | 983 | 984 | def noise(self, button): 985 | if self.moving_fiducials: 986 | self.can_add_fiducials = False 987 | for fiducial,fle in zip(self.moving_fiducials, self.FLE): 988 | dx = float(np.random.normal(scale=0.02*self.scale)) 989 | dy = float(np.random.normal(scale=0.02*self.scale)) 990 | fiducial[0] += dx 991 | fiducial[1] += dy 992 | fle[0] += dx 993 | fle[1] += dy 994 | self.update_display() 995 | 996 | def outlier(self, button): 997 | if self.moving_fiducials: 998 | self.can_add_fiducials = False 999 | index = np.random.randint(low=0, high=len(self.moving_fiducials)) 1000 | new_x = max(min(self.moving_fiducials[index][0]+0.1*self.scale, self.scale), 0) 1001 | new_y = max(min(self.moving_fiducials[index][1]+0.1*self.scale, self.scale), 0) 1002 | self.FLE[index][0] += new_x - self.moving_fiducials[index][0] 1003 | self.FLE[index][1] += new_y - self.moving_fiducials[index][1] 1004 | self.moving_fiducials[index][0] = new_x 1005 | self.moving_fiducials[index][1] = new_y 1006 | self.update_display() 1007 | 1008 | def bias_1(self, button): 1009 | if self.moving_fiducials: 1010 | self.can_add_fiducials = False 1011 | for fiducial,fle in zip(self.moving_fiducials,self.FLE): 1012 | fiducial[0]+= 0.015*self.scale 1013 | fiducial[1]+= 0.015*self.scale 1014 | fle[0]+=0.015*self.scale 1015 | fle[1]+=0.015*self.scale 1016 | self.update_display() 1017 | 1018 | def bias_2(self, button): 1019 | if self.moving_fiducials: 1020 | self.can_add_fiducials = False 1021 | pol=1 1022 | for fiducial,fle in zip(self.moving_fiducials,self.FLE): 1023 | fiducial[0] += 0.015*pol*self.scale 1024 | fiducial[1] += 0.015*pol*self.scale 1025 | fle[0] += 0.015*pol*self.scale 1026 | fle[1] += 0.015*pol*self.scale 1027 | pol*=-1 1028 | self.update_display() 1029 | 1030 | def clear_fiducials(self, button): 1031 | self.fixed_fiducials = [] 1032 | self.moving_fiducials = [] 1033 | self.FLE = [] 1034 | self.can_add_fiducials = True 1035 | self.update_centroid() 1036 | self.update_display() 1037 | 1038 | def clear_targets(self, button): 1039 | self.fixed_targets = [] 1040 | self.moving_targets = [] 1041 | self.update_centroid() 1042 | self.update_display() 1043 | 1044 | def reset(self, button): 1045 | self.moving_fiducials = copy.deepcopy(self.fixed_fiducials) 1046 | self.moving_targets = copy.deepcopy(self.fixed_targets) 1047 | self.FLE = [[0.0,0.0]]*len(self.moving_fiducials) 1048 | self.can_add_fiducials = True 1049 | self.update_centroid() 1050 | self.update_display() 1051 | 1052 | def register(self, button): 1053 | fixed_points = [c for p in self.fixed_fiducials for c in p] 1054 | moving_points = [c for p in self.moving_fiducials for c in p] 1055 | transform = sitk.LandmarkBasedTransformInitializer(self.transform, fixed_points, moving_points) 1056 | 1057 | # For display purposes we want to transform the moving points to the 1058 | # fixed ones, so using the inverse transformation 1059 | inverse_transform = transform.GetInverse() 1060 | 1061 | for pnt in self.moving_fiducials + self.moving_targets: 1062 | transformed_pnt = inverse_transform.TransformPoint(pnt) 1063 | pnt[0] = transformed_pnt[0] 1064 | pnt[1] = transformed_pnt[1] 1065 | self.update_centroid() 1066 | self.update_display() 1067 | 1068 | 1069 | def on_press(self, event): 1070 | if self.viewing_checkbox.value == 'edit': 1071 | if self.can_add_fiducials: 1072 | if event.button ==1: #left click 1073 | if event.inaxes==self.axes: 1074 | self.fixed_fiducials.append([event.xdata, event.ydata]) 1075 | self.moving_fiducials.append([event.xdata, event.ydata]) 1076 | self.FLE.append([0.0,0.0]) 1077 | self.update_display() 1078 | elif event.button ==3: #right click 1079 | if event.inaxes==self.axes: 1080 | self.fixed_targets.append([event.xdata, event.ydata]) 1081 | self.moving_targets.append([event.xdata, event.ydata]) 1082 | self.update_display() 1083 | elif event.button == 1: #left click 1084 | if event.inaxes==self.axes: 1085 | if self.viewing_checkbox.value == 'translate': 1086 | self.previousx = event.xdata 1087 | self.previousy = event.ydata 1088 | elif self.viewing_checkbox.value == 'rotate' and self.centroid: 1089 | self.previous = [event.xdata - self.centroid[0], 1090 | event.ydata - self.centroid[1]] 1091 | 1092 | def on_motion(self, event): 1093 | if event.button == 1: #left click 1094 | if self.viewing_checkbox.value == 'translate': 1095 | dx = event.xdata-self.previousx 1096 | dy = event.ydata-self.previousy 1097 | for glyph in self.moving_fiducials_glyphs + self.moving_targets_glyphs: 1098 | glyph.set_data(glyph.get_xdata()+dx, 1099 | glyph.get_ydata()+dy) 1100 | self.previousx = event.xdata 1101 | self.previousy = event.ydata 1102 | self.fig.canvas.draw_idle() 1103 | self.fig.canvas.flush_events() 1104 | elif self.viewing_checkbox.value == 'rotate' and self.centroid: 1105 | ox = self.centroid[0] 1106 | oy = self.centroid[1] 1107 | v1 = self.previous 1108 | v2 = [event.xdata-ox, event.ydata-oy] 1109 | 1110 | cosang = v1[0]*v2[0]+v1[1]*v2[1] 1111 | sinang = v1[0]*v2[1]-v1[1]*v2[0] 1112 | angle = np.arctan2(sinang, cosang) 1113 | 1114 | for glyph in self.moving_fiducials_glyphs + self.moving_targets_glyphs: 1115 | px = glyph.get_xdata() 1116 | py = glyph.get_ydata() 1117 | glyph.set_data(ox + np.cos(angle) * (px - ox) - np.sin(angle) * (py - oy), 1118 | oy + np.sin(angle) * (px - ox) + np.cos(angle) * (py - oy)) 1119 | self.previous = v2 1120 | self.fig.canvas.draw_idle() 1121 | self.fig.canvas.flush_events() 1122 | 1123 | def on_release(self, event): 1124 | if event.button == 1: #left click 1125 | if self.viewing_checkbox.value == 'translate' or self.viewing_checkbox.value == 'rotate': 1126 | # Update the actual data using the glyphs (modified during translation/rotation) 1127 | for glyph,fiducial in zip(self.moving_fiducials_glyphs, self.moving_fiducials): 1128 | fiducial[0] = float(glyph.get_xdata()) 1129 | fiducial[1] = float(glyph.get_ydata()) 1130 | for glyph,target in zip(self.moving_targets_glyphs, self.moving_targets): 1131 | target[0] = float(glyph.get_xdata()) 1132 | target[1] = float(glyph.get_ydata()) 1133 | 1134 | 1135 | def get_fixed_fiducials(self): 1136 | return self.fixed_fiducials 1137 | 1138 | def get_fixed_targets(self): 1139 | return self.fixed_targets 1140 | 1141 | def get_moving_fiducials(self): 1142 | return self.moving_fiducials 1143 | 1144 | def get_moving_targets(self): 1145 | return self.moving_targets 1146 | 1147 | def get_FLE(self): 1148 | return [np.sqrt(fle_vec[0]**2 + fle_vec[1]**2) for fle_vec in self.FLE] 1149 | 1150 | def get_all_data(self): 1151 | return (self.fixed_fiducials, self.fixed_targets, self.moving_fiducials, 1152 | self.moving_targets, self.get_FLE()) 1153 | 1154 | def set_fiducials(self, fiducials): 1155 | self.set_points(fiducials) 1156 | self.FLE = [[0.0,0.0]]*len(self.moving_fiducials) 1157 | 1158 | def set_targets(self, targets): 1159 | self.set_points(targets, are_fiducials=False) 1160 | 1161 | def set_points(self, points, are_fiducials=True): 1162 | # Validate the points are inside the expected range. 1163 | all_coords = [coord for pnt in points for coord in pnt] 1164 | if min(all_coords)<0 or max(all_coords)>self.scale: 1165 | raise ValueError('One of the points is outside the image bounds, [0,{0}]X[0,{0}].'.format(self.scale)) 1166 | # Copy the data in and coerce points to lists. The coercion to list 1167 | # allows us to accept tuples, as internally we need the points to be mutable. 1168 | fill_lists = [self.fixed_fiducials, self.moving_fiducials] if are_fiducials else [self.fixed_targets, self.moving_targets] 1169 | for p in points: 1170 | fill_lists[0].append(list(p)) 1171 | fill_lists[1].append(list(p)) 1172 | self.update_display() 1173 | 1174 | 1175 | 1176 | def display_errors(fixed_fiducials, fixed_targets, FLE_errors, FRE_errors, TRE_errors, 1177 | min_err= None, max_err=None, title="Registration Errors"): 1178 | if not min_err: 1179 | min_err = min(FRE_errors[2], TRE_errors[2]) 1180 | if not max_err: 1181 | max_err = max(FRE_errors[3], TRE_errors[3]) 1182 | 1183 | print("Mean FLE %.6f\t STD FLE %.6f\t Min FLE %.6f\t Max FLE %.6f" %FLE_errors[0:4]) 1184 | print("Mean FRE %.6f\t STD FRE %.6f\t Min FRE %.6f\t Max FRE %.6f" %FRE_errors[0:4]) 1185 | print("Mean TRE %.6f\t STD TRE %.6f\t Min TRE %.6f\t Max TRE %.6f" %TRE_errors[0:4]) 1186 | plt.figure(figsize=(9, 3.5), num=title) 1187 | ax1 = plt.subplot(1, 2, 1) 1188 | ax1.set_title('Registration Errors Distributions') 1189 | ax1.yaxis.set_major_locator(MaxNLocator(integer=True)) 1190 | bins = np.linspace(min(FLE_errors[2], FRE_errors[2], TRE_errors[2]), max(FLE_errors[3], FRE_errors[3], TRE_errors[3]), 20) 1191 | plt.hist(FLE_errors[4], bins, alpha=0.3, label='FLE') 1192 | plt.hist(FRE_errors[4], bins, alpha=0.3, label='FRE') 1193 | plt.hist(TRE_errors[4], bins, alpha=0.3, label='TRE') 1194 | plt.legend(loc='upper right') 1195 | 1196 | ax2 = plt.subplot(1, 2, 2) 1197 | ax2.get_xaxis().set_visible(False) 1198 | ax2.get_yaxis().set_visible(False) 1199 | ax2.set_facecolor((0.8, 0.8, 0.8)) 1200 | ax2.set_title('Spatial Variability of Registration Errors') 1201 | collection = ax2.scatter(list(np.array(fixed_fiducials).T)[0], 1202 | list(np.array(fixed_fiducials).T)[1], 1203 | marker = 'o', 1204 | c = FRE_errors[4], 1205 | vmin = min_err, 1206 | vmax = max_err, 1207 | cmap = cm.hot) 1208 | ax2.scatter(list(np.array(fixed_targets).T)[0], 1209 | list(np.array(fixed_targets).T)[1], 1210 | marker = 's', 1211 | c = TRE_errors[4], 1212 | vmin = min_err, 1213 | vmax = max_err, 1214 | cmap = cm.hot) 1215 | plt.colorbar(collection, shrink=0.8) 1216 | plt.show() 1217 | -------------------------------------------------------------------------------- /examples/myshow.py: -------------------------------------------------------------------------------- 1 | import SimpleITK as sitk 2 | import matplotlib.pyplot as plt 3 | 4 | from ipywidgets import interact 5 | 6 | 7 | def myshow(img, title=None, margin=0.05, dpi=80): 8 | nda = sitk.GetArrayFromImage(img) 9 | 10 | spacing = img.GetSpacing() 11 | slicer = False 12 | 13 | if nda.ndim == 3: 14 | # fastest dim, either component or x 15 | c = nda.shape[-1] 16 | 17 | # the the number of components is 3 or 4 consider it an RGB image 18 | if c not in (3, 4): 19 | slicer = True 20 | 21 | elif nda.ndim == 4: 22 | c = nda.shape[-1] 23 | 24 | if c not in (3, 4): 25 | raise RuntimeError("Unable to show 3D-vector Image") 26 | 27 | # take a z-slice 28 | slicer = True 29 | 30 | if (slicer): 31 | ysize = nda.shape[1] 32 | xsize = nda.shape[2] 33 | else: 34 | ysize = nda.shape[0] 35 | xsize = nda.shape[1] 36 | 37 | # Make a figure big enough to accommodate an axis of xpixels by ypixels 38 | # as well as the ticklabels, etc... 39 | figsize = (1 + margin) * ysize / dpi, (1 + margin) * xsize / dpi 40 | 41 | def callback(z=None): 42 | 43 | extent = (0, xsize*spacing[1], ysize*spacing[0], 0) 44 | 45 | fig = plt.figure(figsize=figsize, dpi=dpi) 46 | 47 | # Make the axis the right size... 48 | ax = fig.add_axes([margin, margin, 1 - 2*margin, 1 - 2*margin]) 49 | 50 | plt.set_cmap("gray") 51 | 52 | if z is None: 53 | ax.imshow(nda, extent=extent, interpolation=None) 54 | else: 55 | ax.imshow(nda[z, ...], extent=extent, interpolation=None) 56 | 57 | if title: 58 | plt.title(title) 59 | 60 | plt.show() 61 | 62 | if slicer: 63 | interact(callback, z=(0, nda.shape[0]-1)) 64 | else: 65 | callback() 66 | 67 | 68 | def myshow3d(img, xslices=[], yslices=[], zslices=[], title=None, margin=0.05, 69 | dpi=80): 70 | 71 | img_xslices = [img[s, :, :] for s in xslices] 72 | img_yslices = [img[:, s, :] for s in yslices] 73 | img_zslices = [img[:, :, s] for s in zslices] 74 | 75 | maxlen = max(len(img_xslices), len(img_yslices), len(img_zslices)) 76 | 77 | img_null = sitk.Image([0, 0], img.GetPixelID(), 78 | img.GetNumberOfComponentsPerPixel()) 79 | 80 | img_slices = [] 81 | d = 0 82 | 83 | if len(img_xslices): 84 | img_slices += img_xslices + [img_null]*(maxlen-len(img_xslices)) 85 | d += 1 86 | 87 | if len(img_yslices): 88 | img_slices += img_yslices + [img_null]*(maxlen-len(img_yslices)) 89 | d += 1 90 | 91 | if len(img_zslices): 92 | img_slices += img_zslices + [img_null]*(maxlen-len(img_zslices)) 93 | d += 1 94 | 95 | if maxlen != 0: 96 | if img.GetNumberOfComponentsPerPixel() == 1: 97 | img = sitk.Tile(img_slices, [maxlen, d]) 98 | # TODO check in code to get Tile Filter working with VectorImages 99 | else: 100 | img_comps = [] 101 | for i in range(0, img.GetNumberOfComponentsPerPixel()): 102 | img_slices_c = [sitk.VectorIndexSelectionCast(s, i) 103 | for s in img_slices] 104 | img_comps.append(sitk.Tile(img_slices_c, [maxlen, d])) 105 | img = sitk.Compose(img_comps) 106 | 107 | myshow(img, title, margin, dpi) 108 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools_scm[toml]>=7.1"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "dicom2stl" 7 | authors = [ 8 | { name="David Chen", email="dchen@mail.nih.gov" }, 9 | ] 10 | description = "A script to extract an iso-surface from a DICOM series to produce an STL mesh." 11 | readme = "README.md" 12 | requires-python = ">=3.8" 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | ] 18 | dynamic = ["dependencies", "version"] 19 | 20 | [project.urls] 21 | "Homepage" = "https://github.com/dave3d/dicom2stl" 22 | "Bug Tracker" = "https://github.com/dave3d/dicom2stl" 23 | 24 | [tool.setuptools.dynamic] 25 | dependencies = {file = ["requirements.txt"]} 26 | 27 | [tool.setuptools.packages.find] 28 | exclude = ["docs*", "tests*", "binder*", "utils*", "examples*", "tmp*"] 29 | 30 | [project.scripts] 31 | dicom2stl = "dicom2stl.Dicom2STL:main" 32 | 33 | [tool.setuptools_scm] 34 | local_scheme = "dirty-tag" 35 | 36 | [tool.flake8] 37 | max-line-length = 88 38 | ignore = ['E203', 'W503'] 39 | max_complexity = 25 40 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.24.0 2 | SimpleITK>=2.2.1 3 | vtk>=9.2.0 4 | pydicom>=2.3 5 | simpleitkutilities>=0.3.0 6 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | python -m unittest discover -s tests 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Dave was here 2 | -------------------------------------------------------------------------------- /tests/compare_stats.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import SimpleITK as sitk 4 | import vtk 5 | 6 | 7 | def printStats(stats): 8 | print(" Min:", stats[0]) 9 | print(" Max:", stats[1]) 10 | print(" Mean:", stats[2]) 11 | print(" StdDev:", stats[3]) 12 | 13 | 14 | def compare_stats(sitkimg, vtkimg): 15 | """Compare the statistics of a SimpleITK image and a VTK image.""" 16 | 17 | # Compute the VTK image histogram statistics 18 | histo = vtk.vtkImageHistogramStatistics() 19 | histo.SetInputData(vtkimg) 20 | histo.Update() 21 | print(histo.GetStandardDeviation()) 22 | 23 | vtkstats = [ 24 | histo.GetMinimum(), 25 | histo.GetMaximum(), 26 | histo.GetMean(), 27 | histo.GetStandardDeviation(), 28 | ] 29 | 30 | print("\nvtk median = ", histo.GetMedian()) 31 | 32 | print("\nVTK source image stats") 33 | printStats(vtkstats) 34 | 35 | # Compute the SimpleITK image statistics 36 | stats = sitk.StatisticsImageFilter() 37 | stats.Execute(sitkimg) 38 | 39 | sitkstats = [ 40 | stats.GetMinimum(), 41 | stats.GetMaximum(), 42 | stats.GetMean(), 43 | stats.GetSigma(), 44 | ] 45 | 46 | print("\nSimpleITK image stats") 47 | printStats(sitkstats) 48 | 49 | # compare the statistics of the VTK and SimpleITK images 50 | ok = True 51 | for v, s in zip(vtkstats, sitkstats): 52 | x = v - s 53 | if v != 0.0: 54 | y = abs(x / v) 55 | else: 56 | y = abs(x) 57 | 58 | if y > 0.0001: 59 | print("Bad!", v, s, "\terror =", y) 60 | ok = False 61 | return ok 62 | 63 | 64 | if __name__ == "__main__": 65 | 66 | dims = [10, 10, 10] 67 | val = 0 68 | 69 | img = sitk.Image(dims, sitk.sitkUInt8) 70 | img = img + val 71 | 72 | img2 = vtk.vtkImageData() 73 | img2.SetDimensions(dims) 74 | 75 | img2.AllocateScalars(vtk.VTK_UNSIGNED_CHAR, 1) 76 | for z in range(dims[2]): 77 | for y in range(dims[1]): 78 | for x in range(dims[0]): 79 | img2.SetScalarComponentFromFloat(x, y, z, 0, val) 80 | 81 | ret = compare_stats(img, img2) 82 | 83 | if ret: 84 | print("PASS") 85 | else: 86 | print("FAIL") 87 | -------------------------------------------------------------------------------- /tests/create_data.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import argparse 4 | 5 | import SimpleITK as sitk 6 | 7 | 8 | def make_tetra(dim=128, scale=200.0, pixel_type=sitk.sitkUInt8): 9 | # vertices of a tetrahedron 10 | tverts = [ 11 | [0.732843, 0.45, 0.35], 12 | [0.308579, 0.694949, 0.35], 13 | [0.308579, 0.205051, 0.35], 14 | [0.45, 0.45, 0.75], 15 | ] 16 | sigma = [dim / 6, dim / 6, dim / 6] 17 | size = [dim, dim, dim] 18 | 19 | vol = sitk.Image(size, pixel_type) 20 | for v in tverts: 21 | pt = [v[0] * dim, v[1] * dim, v[2] * dim] 22 | vol = vol + sitk.GaussianSource( 23 | pixel_type, size, sigma=sigma, mean=pt, scale=scale 24 | ) 25 | 26 | return vol 27 | 28 | 29 | def make_cylinder(dim=64, scale=200.0, pixel_type=sitk.sitkUInt8): 30 | mean = [dim / 2, dim / 2] 31 | sigma = [dim / 4, dim / 4] 32 | img = sitk.GaussianSource( 33 | pixel_type, [dim, dim], sigma=sigma, mean=mean, scale=scale 34 | ) 35 | 36 | series = [] 37 | for i in range(dim): 38 | series.append(img) 39 | 40 | vol = sitk.JoinSeries(series) 41 | return vol 42 | 43 | 44 | if __name__ == "__main__": 45 | 46 | typemap = { 47 | "uint8": sitk.sitkUInt8, 48 | "uint16": sitk.sitkUInt16, 49 | "int16": sitk.sitkInt16, 50 | "int32": sitk.sitkInt32, 51 | "float32": sitk.sitkFloat32, 52 | "float64": sitk.sitkFloat64, 53 | } 54 | 55 | parser = argparse.ArgumentParser() 56 | 57 | parser.add_argument("output", help="Output file name") 58 | 59 | parser.add_argument( 60 | "--dim", 61 | "-d", 62 | action="store", 63 | dest="dim", 64 | type=int, 65 | default=32, 66 | help="Image dimensions (default=32)", 67 | ) 68 | parser.add_argument( 69 | "--pixel", 70 | "-p", 71 | action="store", 72 | dest="pixeltype", 73 | default="uint8", 74 | help="Pixel type (default='uint8')", 75 | ) 76 | parser.add_argument( 77 | "--scale", 78 | "-s", 79 | action="store", 80 | dest="scale", 81 | type=float, 82 | default=200.0, 83 | help="Intensity scale (default=200.0)", 84 | ) 85 | 86 | parser.add_argument( 87 | "--tetra", 88 | "-t", 89 | action="store_true", 90 | default=True, 91 | dest="tetra_flag", 92 | help="Make a tetrahedral volume", 93 | ) 94 | 95 | parser.add_argument( 96 | "--cylinder", 97 | "-c", 98 | action="store_false", 99 | default=True, 100 | dest="tetra_flag", 101 | help="Make a cylindrical volume", 102 | ) 103 | 104 | args = parser.parse_args() 105 | 106 | print("args:", args) 107 | ptype = typemap[args.pixeltype] 108 | print(ptype) 109 | 110 | if args.tetra_flag: 111 | print("Making tetra") 112 | vol = make_tetra(args.dim, args.scale, ptype) 113 | else: 114 | print("Making cylinder") 115 | vol = make_cylinder(args.dim, args.scale, ptype) 116 | print("Writing", args.output) 117 | sitk.WriteImage(vol, args.output) 118 | -------------------------------------------------------------------------------- /tests/megatest.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | from utils import sitk2vtk 4 | 5 | import glob 6 | import os 7 | import sys 8 | 9 | import SimpleITK as sitk 10 | import compare_stats 11 | 12 | thisdir = os.path.dirname(os.path.abspath(__file__)) 13 | parentdir = os.path.dirname(thisdir) 14 | sys.path.append(os.path.abspath(parentdir)) 15 | print(sys.path) 16 | 17 | suffixes = [".png", ".nrrd", ".dcm", ".nii.gz", ".dcm"] 18 | 19 | fnames = [] 20 | if len(sys.argv) == 1: 21 | img_dir = ( 22 | os.environ["HOME"] 23 | + "/SimpleITK-build/SimpleITK-build/" 24 | + "ExternalData/Testing/Data/Input" 25 | ) 26 | 27 | fnames = glob.glob(img_dir + "/*") 28 | fnames.extend(glob.glob(img_dir + "/**/*")) 29 | 30 | else: 31 | for x in sys.argv[1:]: 32 | if os.path.isfile(x): 33 | fnames.append(x) 34 | if os.path.isdir(x): 35 | fnames.extend(glob.glob(x + "/*")) 36 | fnames.extend(glob.glob(x + "/**/*")) 37 | 38 | img_names = [] 39 | for f in fnames: 40 | for s in suffixes: 41 | if f.endswith(s): 42 | img_names.append(f) 43 | 44 | print(len(img_names), "images") 45 | 46 | bad = [] 47 | pass_count = 0 48 | unsupport_count = 0 49 | unsupp = [] 50 | 51 | for n in img_names: 52 | print("\n", n) 53 | 54 | img = sitk.ReadImage(n) 55 | try: 56 | vtkimg = sitk2vtk.sitk2vtk(img) 57 | except BaseException: 58 | print("File", n, "didn't convert") 59 | continue 60 | 61 | if vtkimg is None: 62 | print("File", n, "didn't convert") 63 | continue 64 | try: 65 | ok = compare_stats.compare_stats(img, vtkimg) 66 | except BaseException: 67 | print("exception: probably wrong image type") 68 | print("UNSUPPORTED") 69 | unsupport_count = unsupport_count + 1 70 | unsupp.append(n) 71 | continue 72 | 73 | if not ok: 74 | bad.append(n) 75 | print("FAIL") 76 | else: 77 | print("PASS") 78 | pass_count = pass_count + 1 79 | 80 | print("\n", bad) 81 | print(len(bad), "bad result") 82 | 83 | print("\n", pass_count, "passed") 84 | 85 | print("\n", unsupport_count, "unsupported") 86 | print(unsupp) 87 | -------------------------------------------------------------------------------- /tests/test_dicom2stl.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | 4 | import SimpleITK as sitk 5 | from dicom2stl.utils import parseargs 6 | from dicom2stl.Dicom2STL import Dicom2STL 7 | 8 | from tests import create_data 9 | 10 | 11 | class TestDicom2STL(unittest.TestCase): 12 | @classmethod 13 | def setUpClass(self): 14 | print("Setting up dicom2stl tests") 15 | img = create_data.make_tetra() 16 | sitk.WriteImage(img, "tetra-test.nii.gz") 17 | 18 | @classmethod 19 | def tearDownClass(cls): 20 | print("Tearing down dicom2stl tests") 21 | os.remove("tetra-test.nii.gz") 22 | os.remove("testout.stl") 23 | 24 | def test_dicom2stl(self): 25 | print("\nDicom2stl test") 26 | print("cwd:", os.getcwd()) 27 | 28 | parser = parseargs.createParser() 29 | args = parser.parse_args( 30 | ["-i", "100", "-o", "testout.stl", "tetra-test.nii.gz"] 31 | ) 32 | 33 | print("\ndicom2stl arguments") 34 | print(args) 35 | 36 | try: 37 | Dicom2STL(args) 38 | except BaseException: 39 | self.fail("dicom2stl: exception thrown") 40 | 41 | if not os.path.exists("testout.stl"): 42 | self.fail("dicom2stl: no output file") 43 | 44 | 45 | if __name__ == "__main__": 46 | unittest.main() 47 | -------------------------------------------------------------------------------- /tests/test_dicomutils.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import os 4 | import shutil 5 | import unittest 6 | import zipfile 7 | 8 | import SimpleITK as sitk 9 | from tests import create_data 10 | from tests import write_series 11 | from dicom2stl.utils import dicomutils 12 | 13 | 14 | class TestDicomUtils(unittest.TestCase): 15 | TMPDIR = "testtmp" 16 | SIZE = 32 17 | 18 | @classmethod 19 | def setUpClass(cls): 20 | print("\nBuildin' it up!") 21 | cyl = create_data.make_cylinder(TestDicomUtils.SIZE, sitk.sitkUInt16) 22 | try: 23 | os.mkdir(TestDicomUtils.TMPDIR) 24 | except BaseException: 25 | print("Oopsie") 26 | write_series.write_series(cyl, TestDicomUtils.TMPDIR) 27 | 28 | @classmethod 29 | def tearDownClass(cls): 30 | print("\nTearin' it down!") 31 | shutil.rmtree(TestDicomUtils.TMPDIR) 32 | 33 | def test_scanDirForDicom(self): 34 | print("\nTesting DicomUtils.scanDirForDicom") 35 | matches, dirs = dicomutils.scanDirForDicom(TestDicomUtils.TMPDIR) 36 | print(matches, dirs) 37 | self.assertEqual(len(matches), TestDicomUtils.SIZE) 38 | 39 | def test_getAllSeries(self): 40 | print("\nTesting DicomUtils.getAllSeries") 41 | seriessets = dicomutils.getAllSeries([TestDicomUtils.TMPDIR]) 42 | print(seriessets) 43 | self.assertEqual(len(seriessets), 1) 44 | series_id = seriessets[0][0] 45 | if series_id.startswith("1.2.826.0.1.3680043"): 46 | print(" Series looks good") 47 | else: 48 | self.fail(" Bad series: " + series_id) 49 | 50 | def test_getModality(self): 51 | print("\nTesting DicomUtils.getModality") 52 | img = sitk.Image(10, 10, sitk.sitkUInt16) 53 | m1 = dicomutils.getModality(img) 54 | self.assertEqual(m1, "") 55 | img.SetMetaData("0008|0060", "dude") 56 | m2 = dicomutils.getModality(img) 57 | self.assertEqual(m2, "dude") 58 | 59 | def test_loadLargestSeries(self): 60 | print("\nTesting DicomUtils.loadLargestSeries") 61 | img, mod = dicomutils.loadLargestSeries(TestDicomUtils.TMPDIR) 62 | self.assertEqual( 63 | img.GetSize(), 64 | (TestDicomUtils.SIZE, TestDicomUtils.SIZE, TestDicomUtils.SIZE), 65 | ) 66 | self.assertEqual(mod, "CT") 67 | 68 | def test_loadZipDicom(self): 69 | print("\nTesting DicomUtils.loadZipDicom") 70 | zf = zipfile.ZipFile("tests/testzip.zip", "w") 71 | for z in range(TestDicomUtils.SIZE): 72 | zf.write(TestDicomUtils.TMPDIR + "/" + str(z) + ".dcm") 73 | zf.close() 74 | img, mod = dicomutils.loadZipDicom("tests/testzip.zip", "tests/ziptmp") 75 | print(img.GetSize()) 76 | print(mod) 77 | os.unlink("tests/testzip.zip") 78 | shutil.rmtree("tests/ziptmp") 79 | 80 | 81 | if __name__ == "__main__": 82 | unittest.main() 83 | -------------------------------------------------------------------------------- /tests/test_sitk2vtk.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import unittest 4 | import vtk 5 | import SimpleITK as sitk 6 | from SimpleITK.utilities.vtk import sitk2vtk 7 | import platform 8 | 9 | 10 | class TestSITK2VTK(unittest.TestCase): 11 | def test_sitk2vtk(self): 12 | print("Testing sitk2vtk") 13 | dims = [102, 102, 102] 14 | img = sitk.GaussianSource(sitk.sitkUInt8, dims) 15 | direction = [0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, -1.0] 16 | img.SetDirection(direction) 17 | 18 | vol = sitk2vtk(img) 19 | self.assertTupleEqual(vol.GetDimensions(), tuple(dims)) 20 | print("\nAccessing VTK image") 21 | val = vol.GetScalarComponentAsFloat(5, 5, 5, 0) 22 | print(val) 23 | self.assertAlmostEqual(val, 3.0) 24 | 25 | if vtk.vtkVersion.GetVTKMajorVersion() >= 9: 26 | print("\nDirection matrix") 27 | print(vol.GetDirectionMatrix()) 28 | else: 29 | print("VTK version < 9. No direction matrix") 30 | 31 | 32 | if __name__ == "__main__": 33 | unittest.main() 34 | -------------------------------------------------------------------------------- /tests/test_sitkutils.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import unittest 4 | from SimpleITK.utilities.vtk import * 5 | import vtk 6 | import SimpleITK as sitk 7 | import platform 8 | 9 | 10 | class TestSitkUtils(unittest.TestCase): 11 | def test_sitk2vtk(self): 12 | print("Testing SimpleITK Utilities") 13 | dims = [102, 102, 102] 14 | img = sitk.GaussianSource(sitk.sitkUInt8, dims) 15 | direction = [0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, -1.0] 16 | img.SetDirection(direction) 17 | 18 | vol = sitk2vtk(img) 19 | self.assertTupleEqual(vol.GetDimensions(), tuple(dims)) 20 | print("\nAccessing VTK image") 21 | val = vol.GetScalarComponentAsFloat(5, 5, 5, 0) 22 | print(val) 23 | self.assertAlmostEqual(val, 3.0) 24 | 25 | if vtk.vtkVersion.GetVTKMajorVersion() >= 9: 26 | print("\nDirection matrix") 27 | print(vol.GetDirectionMatrix()) 28 | else: 29 | print("VTK version < 9. No direction matrix") 30 | 31 | 32 | if __name__ == "__main__": 33 | unittest.main() 34 | -------------------------------------------------------------------------------- /tests/test_vtk2sitk.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import unittest 4 | 5 | import SimpleITK as sitk 6 | import vtk 7 | from tests import compare_stats 8 | from SimpleITK.utilities.vtk import vtk2sitk 9 | 10 | 11 | def printStats(stats): 12 | print(" Min:", stats[0]) 13 | print(" Max:", stats[1]) 14 | print(" Mean:", stats[2]) 15 | print(" StdDev:", stats[3]) 16 | 17 | 18 | class TestVTK2SITK(unittest.TestCase): 19 | def test_vtk2sitk(self): 20 | source = vtk.vtkImageSinusoidSource() 21 | source.Update() 22 | 23 | img = source.GetOutput() 24 | print("\nVTK source image") 25 | print(type(img)) 26 | print(img.GetScalarTypeAsString()) 27 | print(img.GetDimensions()) 28 | 29 | if vtk.vtkVersion.GetVTKMajorVersion() >= 9: 30 | img.SetDirectionMatrix(0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, -1.0) 31 | print(img.GetDirectionMatrix()) 32 | 33 | print("\nConverting VTK to SimpleITK") 34 | sitkimg = vtk2sitk(img) 35 | 36 | print("\nResulting SimpleITK Image") 37 | print(type(sitkimg)) 38 | print(sitkimg.GetPixelIDTypeAsString()) 39 | print(sitkimg.GetSize()) 40 | print(sitkimg.GetDirection()) 41 | 42 | self.assertIsInstance(sitkimg, sitk.Image) 43 | self.assertTupleEqual(img.GetDimensions(), sitkimg.GetSize()) 44 | 45 | # Create a VTK image of pixel type short 46 | cast = vtk.vtkImageCast() 47 | 48 | cast.SetInputConnection(0, source.GetOutputPort()) 49 | 50 | cast.SetOutputScalarTypeToShort() 51 | cast.Update() 52 | 53 | img2 = cast.GetOutput() 54 | print("\nVTK short image") 55 | print(type(img2)) 56 | print(img2.GetScalarTypeAsString()) 57 | print(img2.GetDimensions()) 58 | 59 | # Convert the short image to SimpleITK 60 | print("\nConverting short VTK to SimpleITK") 61 | sitkimg2 = vtk2sitk(img2) 62 | print("\nSimpleITK short image") 63 | print(type(sitkimg2)) 64 | print(sitkimg2.GetPixelIDTypeAsString()) 65 | print(sitkimg2.GetSize()) 66 | 67 | self.assertIsInstance(sitkimg2, sitk.Image) 68 | self.assertTupleEqual(img2.GetDimensions(), sitkimg2.GetSize()) 69 | 70 | # compare the statistics of the VTK and SimpleITK images 71 | ok = compare_stats.compare_stats(sitkimg2, img2) 72 | if ok: 73 | print("Statistics comparison passed") 74 | else: 75 | self.fail("Statistics comparison failed") 76 | 77 | 78 | if __name__ == "__main__": 79 | unittest.main() 80 | -------------------------------------------------------------------------------- /tests/test_vtkutils.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import os 4 | import unittest 5 | 6 | import SimpleITK as sitk 7 | import create_data 8 | import vtk 9 | from dicom2stl.utils import vtkutils 10 | 11 | 12 | class TestVTKUtils(unittest.TestCase): 13 | BALL = None 14 | 15 | @classmethod 16 | def setUpClass(cls): 17 | print("Setting it up") 18 | sphere = vtk.vtkSphereSource() 19 | sphere.SetPhiResolution(16) 20 | sphere.SetThetaResolution(16) 21 | sphere.Update() 22 | connect = vtk.vtkPolyDataConnectivityFilter() 23 | if vtk.vtkVersion.GetVTKMajorVersion() >= 6: 24 | connect.SetInputData(sphere.GetOutput()) 25 | else: 26 | connect.SetInput(sphere) 27 | connect.SetExtractionModeToLargestRegion() 28 | connect.Update() 29 | TestVTKUtils.BALL = connect.GetOutput() 30 | # print(TestVTKUtils.BALL) 31 | 32 | @classmethod 33 | def tearDownClass(cls): 34 | print("Tearing it down") 35 | try: 36 | os.remove("ball.stl") 37 | os.remove("ball.vtk") 38 | os.remove("ball.ply") 39 | except BaseException: 40 | print("") 41 | 42 | def test_cleanMesh(self): 43 | print("Testing cleanMesh") 44 | result = vtkutils.cleanMesh(TestVTKUtils.BALL, False) 45 | print(result.GetNumberOfPolys()) 46 | result = vtkutils.cleanMesh(TestVTKUtils.BALL, True) 47 | print(result.GetNumberOfPolys()) 48 | 49 | def test_smoothMesh(self): 50 | print("Testing smoothMesh") 51 | result = vtkutils.smoothMesh(TestVTKUtils.BALL) 52 | print(result.GetNumberOfPolys()) 53 | 54 | def test_rotateMesh(self): 55 | print("Testing rotateMesh") 56 | result = vtkutils.rotateMesh(TestVTKUtils.BALL, 0, 30) 57 | print(result.GetNumberOfPolys()) 58 | 59 | def test_reduceMesh(self): 60 | print("Testing reduceMesh") 61 | result = vtkutils.reduceMesh(TestVTKUtils.BALL, 0.5) 62 | print(result.GetNumberOfPolys()) 63 | 64 | def test_meshIO(self): 65 | print("Testing Mesh I/O") 66 | try: 67 | vtkutils.writeMesh(TestVTKUtils.BALL, "ball.stl") 68 | vtkutils.writeMesh(TestVTKUtils.BALL, "ball.vtk") 69 | vtkutils.writeMesh(TestVTKUtils.BALL, "ball.ply") 70 | except BaseException: 71 | print("Bad write") 72 | self.fail("writeMesh failed") 73 | 74 | try: 75 | m = vtkutils.readMesh("ball.stl") 76 | print("Read", m.GetNumberOfPolys(), "polygons") 77 | m = vtkutils.readMesh("ball.vtk") 78 | print("Read", m.GetNumberOfPolys(), "polygons") 79 | m = vtkutils.readMesh("ball.ply") 80 | print("Read", m.GetNumberOfPolys(), "polygons") 81 | except BaseException: 82 | print("Bad read") 83 | self.fail("readMesh failed") 84 | 85 | def test_readVTKVolume(self): 86 | print("Testing readVTKVolume") 87 | tetra = create_data.make_tetra(32) 88 | sitk.WriteImage(tetra, "tetra.vtk") 89 | try: 90 | vtkvol = vtkutils.readVTKVolume("tetra.vtk") 91 | print(type(vtkvol)) 92 | print(vtkvol.GetDimensions()) 93 | except BaseException: 94 | sitk.fail("readVTKVolume failed") 95 | 96 | try: 97 | os.remove("tetra.vtk") 98 | except BaseException: 99 | print("remove tetra.vtk failed") 100 | 101 | 102 | if __name__ == "__main__": 103 | unittest.main() 104 | -------------------------------------------------------------------------------- /tests/write_series.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import time 6 | 7 | import SimpleITK as sitk 8 | import numpy as np 9 | 10 | pixel_dtypes = {"int16": np.int16, "float64": np.float64} 11 | 12 | 13 | def writeSlices(series_tag_values, new_img, out_dir, writer, i): 14 | image_slice = new_img[:, :, i] 15 | 16 | # Tags shared by the series. 17 | list( 18 | map( 19 | lambda tag_value: image_slice.SetMetaData(tag_value[0], tag_value[1]), 20 | series_tag_values, 21 | ) 22 | ) 23 | 24 | # Slice specific tags. 25 | # Instance Creation Date 26 | image_slice.SetMetaData("0008|0012", time.strftime("%Y%m%d")) 27 | # Instance Creation Time 28 | image_slice.SetMetaData("0008|0013", time.strftime("%H%M%S")) 29 | 30 | # Setting the type to CT preserves the slice location. 31 | # set the type to CT so the thickness is carried over 32 | image_slice.SetMetaData("0008|0060", "CT") 33 | 34 | # (0020, 0032) image position patient determines the 3D spacing between 35 | # slices. 36 | image_slice.SetMetaData( 37 | "0020|0032", 38 | "\\".join( 39 | # Image Position (Patient) 40 | map(str, new_img.TransformIndexToPhysicalPoint((0, 0, i))) 41 | ), 42 | ) 43 | # Instance Number 44 | image_slice.SetMetaData("0020,0013", str(i)) 45 | 46 | # Write to the output directory and add the extension dcm, to force writing 47 | # in DICOM format. 48 | writer.SetFileName(os.path.join(out_dir, str(i) + ".dcm")) 49 | writer.Execute(image_slice) 50 | 51 | 52 | # Write the 3D image as a series 53 | # IMPORTANT: There are many DICOM tags that need to be updated when you modify 54 | # an original image. This is a delicate operation and requires 55 | # knowledge of the DICOM standard. This example only modifies some. 56 | # For a more complete list of tags that need to be modified see: 57 | # http://gdcm.sourceforge.net/wiki/index.php/Writing_DICOM 58 | # If it is critical for your work to generate valid DICOM files, 59 | # it is recommended to use David Clunie's Dicom3tools to validate 60 | # the files: 61 | # (http://www.dclunie.com/dicom3tools.html). 62 | 63 | 64 | def write_series(new_img, data_directory, pixel_dtype=np.int16): 65 | writer = sitk.ImageFileWriter() 66 | # Use the study/series/frame of reference information given in the 67 | # meta-data dictionary and not the automatically generated information 68 | # from the file IO 69 | writer.KeepOriginalImageUIDOn() 70 | 71 | modification_time = time.strftime("%H%M%S") 72 | modification_date = time.strftime("%Y%m%d") 73 | 74 | # Copy some of the tags and add the relevant tags indicating the change. 75 | # For the series instance UID (0020|000e), each of the components is a 76 | # number, cannot start with zero, and separated by a '.' We create a unique 77 | # series ID using the date and time. 78 | # tags of interest: 79 | direction = new_img.GetDirection() 80 | series_tag_values = [ 81 | ("0008|0031", modification_time), # Series Time 82 | ("0008|0021", modification_date), # Series Date 83 | ("0008|0008", "DERIVED\\SECONDARY"), # Image Type 84 | # Series Instance UID 85 | ( 86 | "0020|000e", 87 | "1.2.826.0.1.3680043.2.1125." 88 | + modification_date 89 | + ".1" 90 | + modification_time, 91 | ), 92 | # Image Orientation (Patient) 93 | ( 94 | "0020|0037", 95 | "\\".join( 96 | map( 97 | str, 98 | ( 99 | direction[0], 100 | direction[3], 101 | direction[6], 102 | direction[1], 103 | direction[4], 104 | direction[7], 105 | ), 106 | ) 107 | ), 108 | ), 109 | # Series Description 110 | ("0008|103e", "Created-SimpleITK"), 111 | ] 112 | 113 | if pixel_dtype == np.float64: 114 | # If we want to write floating point values, we need to use the rescale 115 | # slope, "0028|1053", to select the number of digits we want to keep. 116 | # We also need to specify additional pixel storage and representation 117 | # information. 118 | rescale_slope = 0.001 # keep three digits after the decimal point 119 | series_tag_values = series_tag_values + [ 120 | ("0028|1053", str(rescale_slope)), # rescale slope 121 | ("0028|1052", "0"), # rescale intercept 122 | ("0028|0100", "16"), # bits allocated 123 | ("0028|0101", "16"), # bits stored 124 | ("0028|0102", "15"), # high bit 125 | ("0028|0103", "1"), 126 | ] # pixel representation 127 | 128 | # Write slices to output directory 129 | list( 130 | map( 131 | lambda i: writeSlices( 132 | series_tag_values, new_img, data_directory, writer, i 133 | ), 134 | range(new_img.GetDepth()), 135 | ) 136 | ) 137 | 138 | 139 | def do_test(data_directory): 140 | # Re-read the series 141 | # Read the original series. First obtain the series file names using the 142 | # image series reader. 143 | series_IDs = sitk.ImageSeriesReader.GetGDCMSeriesIDs(data_directory) 144 | if not series_IDs: 145 | print( 146 | 'ERROR: given directory "' 147 | + data_directory 148 | + '" does not contain a DICOM series.' 149 | ) 150 | sys.exit(1) 151 | series_file_names = sitk.ImageSeriesReader.GetGDCMSeriesFileNames( 152 | data_directory, series_IDs[0] 153 | ) 154 | 155 | series_reader = sitk.ImageSeriesReader() 156 | series_reader.SetFileNames(series_file_names) 157 | 158 | # Configure the reader to load all of the DICOM tags (public+private): 159 | # By default tags are not loaded (saves time). 160 | # By default if tags are loaded, the private tags are not loaded. 161 | # We explicitly configure the reader to load tags, including the 162 | # private ones. 163 | series_reader.LoadPrivateTagsOn() 164 | image3D = series_reader.Execute() 165 | print(image3D.GetSpacing(), "vs", new_img.GetSpacing()) 166 | sys.exit(0) 167 | 168 | 169 | if __name__ == "__main__": 170 | 171 | if len(sys.argv) < 3: 172 | print( 173 | "Usage: python " 174 | + __file__ 175 | + " [" 176 | + ", ".join(pixel_dtypes) 177 | + "]" 178 | ) 179 | print(" or ") 180 | print( 181 | " python " 182 | + __file__ 183 | + " input_volume [" 184 | + ", ".join(pixel_dtypes) 185 | + "]" 186 | ) 187 | sys.exit(1) 188 | 189 | inname = "" 190 | 191 | if len(sys.argv) > 3: 192 | inname = sys.argv.pop(1) 193 | print("Reading volume", inname) 194 | 195 | # Create a new series from a numpy array 196 | try: 197 | pixel_dtype = pixel_dtypes[sys.argv[2]] 198 | except KeyError: 199 | pixel_dtype = pixel_dtypes["int16"] 200 | 201 | data_directory = sys.argv[1] 202 | 203 | if len(inname) > 0: 204 | new_img = sitk.ReadImage(inname) 205 | if pixel_dtype == "float64": 206 | new_img = sitk.Cast(new_img, sitk.sitkFloat64) 207 | else: 208 | new_img = sitk.Cast(new_img, sitk.sitkInt16) 209 | 210 | else: 211 | new_arr = np.random.uniform(-10, 10, size=(3, 4, 5)).astype(pixel_dtype) 212 | new_img = sitk.GetImageFromArray(new_arr) 213 | new_img.SetSpacing([2.5, 3.5, 4.5]) 214 | 215 | write_series(new_img, pixel_dtype, data_directory) 216 | 217 | if len(inname) == 0: 218 | do_test(data_directory) 219 | --------------------------------------------------------------------------------