├── .github └── workflows │ ├── publish-on-pypi.yml │ └── test.yml ├── .gitignore ├── CITATION.cff ├── LICENSE ├── LabelFusion ├── __init__.py ├── fusion_wrappers.py ├── fusionator.py ├── itkUtils.py ├── majority_voting.py ├── simple.py ├── utils.py └── wrapper.py ├── MANIFEST.in ├── README.md ├── __init__.py ├── azure-pipelines.yml ├── data ├── baseline_itkvoting.nii.gz ├── baseline_majorityvoting.nii.gz ├── baseline_simple.nii.gz ├── baseline_staple.nii.gz ├── resunet.nii.gz └── unet.nii.gz ├── fusion_run └── setup.py /.github/workflows/publish-on-pypi.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | workflow_dispatch: 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | deploy: 21 | 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up Python 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: '3.12' 30 | # This second step is unnecessary but highly recommended because 31 | # It will cache database and saves time re-downloading it if database isn't stale. 32 | - name: Cache pip 33 | uses: actions/cache@v3 34 | with: 35 | path: ~/.cache/pip 36 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 37 | restore-keys: | 38 | ${{ runner.os }}-pip- 39 | - name: Install dependencies 40 | run: | 41 | python -m pip install --upgrade pip 42 | pip install uv 43 | uv pip install scikit-build build --system 44 | uv pip install -e . --system 45 | - name: Build package 46 | run: python -m build 47 | - name: Publish package 48 | uses: pypa/gh-action-pypi-publish@master 49 | with: 50 | user: __token__ 51 | password: ${{ secrets.PYPI_API_TOKEN }} 52 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Build & Tests 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macos-latest, windows-latest] 19 | python-version: ["3.9", "3.10", "3.11", "3.12"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install uv 31 | uv pip install scikit-build build --system 32 | uv pip install -e . --system 33 | - name: Run through staple 34 | run: | 35 | python fusion_run -inputs ./data/unet.nii.gz,./data/resunet.nii.gz -output ./data/test_staple.nii.gz -classes 0,1,2,4 -method staple 36 | python -c "from LabelFusion.itkUtils import *; sys.exit('fail') if(not (imageComparision('./data/baseline_staple.nii.gz', './data/test_staple.nii.gz'))) else print('pass')" 37 | - name: Run through itkvoting 38 | run: | 39 | python fusion_run -inputs ./data/unet.nii.gz,./data/resunet.nii.gz -output ./data/test_itkvoting.nii.gz -classes 0,1,2,4 -method itkvoting 40 | python -c "from LabelFusion.itkUtils import *; sys.exit('fail') if(not (imageComparision('./data/baseline_itkvoting.nii.gz', './data/test_itkvoting.nii.gz'))) else print('pass')" 41 | - name: Run through majorityvoting 42 | run: | 43 | python fusion_run -inputs ./data/unet.nii.gz,./data/resunet.nii.gz -output ./data/test_majorityvoting.nii.gz -classes 0,1,2,4 -method majorityvoting 44 | python -c "from LabelFusion.itkUtils import *; sys.exit('fail') if(not (imageComparision('./data/baseline_majorityvoting.nii.gz', './data/test_majorityvoting.nii.gz'))) else print('pass')" 45 | - name: Run through simple 46 | run: | 47 | python fusion_run -inputs ./data/unet.nii.gz,./data/resunet.nii.gz -output ./data/test_simple.nii.gz -classes 0,1,2,4 -method simple 48 | python -c "from LabelFusion.itkUtils import *; sys.exit('fail') if(not (imageComparision('./data/baseline_simple.nii.gz', './data/test_simple.nii.gz'))) else print('pass')" 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .vscode 131 | 132 | # data 133 | data/test_*.nii.gz 134 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite it as below." 3 | authors: 4 | - family-names: "Pati" 5 | given-names: "Sarthak" 6 | orcid: "https://orcid.org/0000-0003-2243-8487" 7 | - family-names: "Bakas" 8 | given-names: "Spyridon" 9 | orcid: "https://orcid.org/0000-0001-8734-6482" 10 | title: "LabelFusion" 11 | version: 1.0.11 12 | doi: 10.5281/zenodo.4633206 13 | date-released: 2021-03-24 14 | url: "https://github.com/CBICA/LabelFusion" 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /LabelFusion/__init__.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | 3 | __version__ = pkg_resources.require("LabelFusion")[0].version 4 | -------------------------------------------------------------------------------- /LabelFusion/fusion_wrappers.py: -------------------------------------------------------------------------------- 1 | from .majority_voting import * 2 | from .simple import * 3 | from .fusionator import Fusionator 4 | 5 | import SimpleITK as sitk 6 | 7 | direct_itk_LabelFusion = [ 8 | "staple", 9 | "itkvoting", 10 | "voting", 11 | "majorityvoting", 12 | ] # variable that checks which LabelFusion can directly use itk images 13 | 14 | 15 | def fuse_segmentations_nonITK(list_of_oneHotEncodedSegmentations, method, class_list): 16 | """ 17 | This function takes a list of one-hot encoded segmentations and the method as input and returns the one-hot encoded fused segmentation for non-ITK implementations 18 | """ 19 | if "majority" in method: 20 | return majority_voting(list_of_oneHotEncodedSegmentations) 21 | elif "simple" in method: 22 | fusion = Fusionator() 23 | return fusion.simple(list_of_oneHotEncodedSegmentations, labels=class_list) 24 | 25 | 26 | def fuse_segmentations_itk(list_of_segmentations_images, method): 27 | """ 28 | This function takes a list of segmentations images and the method as input and returns the fused segmentation for ITK implementations 29 | """ 30 | if "staple" in method: 31 | filter = sitk.MultiLabelSTAPLEImageFilter() 32 | filter.SetLabelForUndecidedPixels(0) 33 | 34 | return filter.Execute( 35 | list_of_segmentations_images 36 | ) # sitk.MultiLabelSTAPLE(list_of_segmentations_images) # DOI: 10.1109/TMI.2004.830803 37 | elif "voting" in method: 38 | votingFilter = sitk.LabelVotingImageFilter() 39 | votingFilter.SetLabelForUndecidedPixels(0) 40 | return votingFilter.Execute( 41 | list_of_segmentations_images 42 | ) # DOI: 10.1016/j.patrec.2005.03.017 43 | -------------------------------------------------------------------------------- /LabelFusion/fusionator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Author: Christoph Berger 3 | # Script for the fusion of segmentation labels 4 | # 5 | # Please refer to README.md and LICENSE.md for further documentation 6 | # This software is not certified for clinical use. 7 | # adapted from https://github.com/neuronflow/BraTS-Toolkit-Source 8 | import os 9 | import logging 10 | import itertools 11 | import math 12 | 13 | import numpy as np 14 | import os.path as op 15 | 16 | # from .util import own_itk as oitk 17 | # from .util import filemanager as fm 18 | 19 | 20 | class Fusionator(object): 21 | def __init__(self, verbose=False): 22 | self.verbose = verbose 23 | 24 | def binaryMav(self, candidates, weights=None): 25 | """ 26 | binaryMav performs majority vote fusion on an arbitrary number of input segmentations with 27 | only two classes each (1 and 0). 28 | 29 | Args: 30 | candidates (list): the candidate segmentations as binary numpy arrays of same shape 31 | weights (list, optional): associated weights for each segmentation in candidates. Defaults to None. 32 | 33 | Return 34 | array: a numpy array with the majority vote result 35 | """ 36 | num = len(candidates) 37 | if weights == None: 38 | weights = itertools.repeat(1, num) 39 | # manage empty calls 40 | if num == 0: 41 | print("ERROR! No segmentations to fuse.") 42 | elif num == 1: 43 | return candidates[0] 44 | if self.verbose: 45 | print( 46 | "Number of segmentations to be fused using compound majority vote is: ", 47 | num, 48 | ) 49 | for c in candidates: 50 | print( 51 | "Candidate with shape {} and values {} and sum {}".format( 52 | c.shape, np.unique(c), np.sum(c) 53 | ) 54 | ) 55 | # load first segmentation and use it to create initial numpy arrays 56 | temp = candidates[0] 57 | result = np.zeros(temp.shape) 58 | # loop through all available segmentations and tally votes for each class 59 | label = np.zeros(temp.shape) 60 | for c, w in zip(candidates, weights): 61 | if c.max() != 1 or c.min() != 0: 62 | if self.verbose: 63 | logging.warning( 64 | "The passed segmentation contains labels other than 1 and 0." 65 | ) 66 | if self.verbose: 67 | print("weight is: " + str(w)) 68 | label[c == 1] += 1.0 * w 69 | num = sum(weights) 70 | result[label >= (num / 2.0)] = 1 71 | if self.verbose: 72 | print("Shape of result:", result.shape) 73 | print("Shape of current input array:", temp.shape) 74 | print( 75 | "Labels and datatype of current output:", 76 | result.max(), 77 | result.min(), 78 | result.dtype, 79 | ) 80 | return result 81 | 82 | def mav(self, candidates, labels=None, weights=None): 83 | """ 84 | mav performs majority vote fusion on an arbitrary number of input segmentations with 85 | an arbitrary number of labels. 86 | 87 | Args: 88 | candidates (list): the candidate segmentations as binary numpy arrays of same shape 89 | labels (list, optional): a list of labels present in the candidates. Defaults to None. 90 | weights (list, optional): weights for the fusion. Defaults to None. 91 | 92 | Returns: 93 | array: a numpy array with the majority vote result 94 | """ 95 | num = len(candidates) 96 | if weights == None: 97 | weights = itertools.repeat(1, num) 98 | # manage empty calls 99 | if num == 0: 100 | print("ERROR! No segmentations to fuse.") 101 | if self.verbose: 102 | print( 103 | "Number of segmentations to be fused using compound majority vote is: ", 104 | num, 105 | ) 106 | # if no labels are passed, get the labels from the first input file (might lead to missing labels!) 107 | if labels == None: 108 | labels = np.unique(candidates[0]) 109 | for c in candidates: 110 | labels = np.append(labels, np.unique(c)) 111 | print( 112 | "Labels of current candidate: {}, dtype: {}".format( 113 | np.unique(c), c.dtype 114 | ) 115 | ) 116 | labels = np.unique(labels).astype(int) 117 | logging.warning( 118 | "No labels passed, choosing those labels automatically: {}".format( 119 | labels 120 | ) 121 | ) 122 | # remove background label 123 | if 0 in labels: 124 | labels = np.delete(labels, 0) 125 | # load first segmentation and use it to create initial numpy arrays 126 | temp = candidates[0] 127 | result = np.zeros(temp.shape) 128 | # loop through all available segmentations and tally votes for each class 129 | print("Labels: {}".format(labels)) 130 | for l in sorted(labels, reverse=True): 131 | label = np.zeros(temp.shape) 132 | num = 0 133 | for c, w in zip(candidates, weights): 134 | print("weight is: " + str(w)) 135 | label[c == l] += 1.0 * w 136 | num = sum(weights) 137 | print(num) 138 | result[label >= (num / 2.0)] = l 139 | if self.verbose: 140 | print("Shape of result:", result.shape) 141 | print( 142 | "Labels and datatype of result:", 143 | result.max(), 144 | result.min(), 145 | result.dtype, 146 | ) 147 | return result 148 | 149 | def brats_simple( 150 | self, 151 | candidates, 152 | weights=None, 153 | t=0.05, 154 | stop=25, 155 | inc=0.07, 156 | method="dice", 157 | iterations=25, 158 | ): 159 | """ 160 | BRATS DOMAIN ADAPTED!!!!! simple implementation using DICE scoring 161 | Iteratively estimates the accuracy of the segmentations and dynamically assigns weights 162 | for the next iteration. Continues for each label until convergence is reached. 163 | 164 | Args: 165 | candidates (list): [description] 166 | weights (list, optional): [description]. Defaults to None. 167 | t (float, optional): [description]. Defaults to 0.05. 168 | stop (int, optional): [description]. Defaults to 25. 169 | inc (float, optional): [description]. Defaults to 0.07. 170 | method (str, optional): [description]. Defaults to 'dice'. 171 | iterations (int, optional): [description]. Defaults to 25. 172 | labels (list, optional): [description]. Defaults to None. 173 | 174 | Raises: 175 | IOError: If no segmentations to be fused are passed 176 | 177 | Returns: 178 | array: a numpy array with the SIMPLE fusion result 179 | """ 180 | # manage empty calls 181 | num = len(candidates) 182 | if num == 0: 183 | print("ERROR! No segmentations to fuse.") 184 | raise IOError("No valid segmentations passed for SIMPLE Fusion") 185 | if self.verbose: 186 | print("Number of segmentations to be fused using SIMPLE is: ", num) 187 | # handle unpassed weights 188 | if weights == None: 189 | weights = itertools.repeat(1, num) 190 | backup_weights = weights # ugly save to reset weights after each round 191 | # get unique labels for multi-class fusion 192 | 193 | result = np.zeros(candidates[0].shape) 194 | labels = [2, 1, 4] 195 | logging.info("Fusing a segmentation with the labels: {}".format(labels)) 196 | # loop over each label 197 | for l in labels: 198 | if self.verbose: 199 | print("Currently fusing label {}".format(l)) 200 | # load first segmentation and use it to create initial numpy arrays IFF it contains labels 201 | if l == 2: 202 | # whole tumor 203 | bin_candidates = [(c > 0).astype(int) for c in candidates] 204 | elif l == 1: 205 | # tumor core 206 | bin_candidates = [((c == 1) | (c == 4)).astype(int) for c in candidates] 207 | else: 208 | # active tumor 209 | bin_candidates = [(c == 4).astype(int) for c in candidates] 210 | if self.verbose: 211 | print(bin_candidates[0].shape) 212 | # baseline estimate 213 | estimate = self.binaryMav(bin_candidates, weights) 214 | # initial convergence baseline 215 | conv = np.sum(estimate) 216 | # check if the estimate was reasonable 217 | if conv == 0: 218 | logging.error("Majority Voting in SIMPLE returned an empty array") 219 | # return np.zeros(candidates[0].shape) 220 | # reset tau before each iteration 221 | tau = t 222 | for i in range(iterations): 223 | t_weights = [] # temporary weights 224 | for c in bin_candidates: 225 | # score all canidate segmentations 226 | t_weights.append( 227 | (self._score(c, estimate, method) + 1) ** 2 228 | ) # SQUARED DICE! 229 | weights = t_weights 230 | # save maximum score in weights 231 | max_phi = max(weights) 232 | # remove dropout estimates 233 | bin_candidates = [ 234 | c for c, w in zip(bin_candidates, weights) if (w > t * max_phi) 235 | ] 236 | # calculate new estimate 237 | estimate = self.binaryMav(bin_candidates, weights) 238 | # increment tau 239 | tau = tau + inc 240 | # check if it converges 241 | if np.abs(conv - np.sum(estimate)) < stop: 242 | if self.verbose: 243 | print( 244 | "Convergence for label {} after {} iterations reached.".format( 245 | l, i 246 | ) 247 | ) 248 | break 249 | conv = np.sum(estimate) 250 | # assign correct label to result 251 | result[estimate == 1] = l 252 | # reset weights 253 | weights = backup_weights 254 | if self.verbose: 255 | print("Shape of result:", result.shape) 256 | print("Shape of current input array:", bin_candidates[0].shape) 257 | print( 258 | "Labels and datatype of current output:", 259 | result.max(), 260 | result.min(), 261 | result.dtype, 262 | ) 263 | return result 264 | 265 | def simple( 266 | self, 267 | candidates, 268 | weights=None, 269 | t=0.05, 270 | stop=25, 271 | inc=0.07, 272 | method="dice", 273 | iterations=25, 274 | labels=None, 275 | ): 276 | """ 277 | simple implementation using DICE scoring 278 | Iteratively estimates the accuracy of the segmentations and dynamically assigns weights 279 | for the next iteration. Continues for each label until convergence is reached. 280 | 281 | Args: 282 | candidates (list): [description] 283 | weights (list, optional): [description]. Defaults to None. 284 | t (float, optional): [description]. Defaults to 0.05. 285 | stop (int, optional): [description]. Defaults to 25. 286 | inc (float, optional): [description]. Defaults to 0.07. 287 | method (str, optional): [description]. Defaults to 'dice'. 288 | iterations (int, optional): [description]. Defaults to 25. 289 | labels (list, optional): [description]. Defaults to None. 290 | 291 | Raises: 292 | IOError: If no segmentations to be fused are passed 293 | 294 | Returns: 295 | array: a numpy array with the SIMPLE fusion result 296 | """ 297 | # manage empty calls 298 | num = len(candidates) 299 | if num == 0: 300 | print("ERROR! No segmentations to fuse.") 301 | raise IOError("No valid segmentations passed for SIMPLE Fusion") 302 | if self.verbose: 303 | print("Number of segmentations to be fused using SIMPLE is: ", num) 304 | # handle unpassed weights 305 | if weights == None: 306 | weights = itertools.repeat(1, num) 307 | backup_weights = weights # ugly save to reset weights after each round 308 | # get unique labels for multi-class fusion 309 | if labels == None: 310 | labels = np.unique(candidates[0]) 311 | for c in candidates: 312 | labels = np.append(labels, np.unique(c)) 313 | print( 314 | "Labels of current candidate: {}, dtype: {}".format( 315 | np.unique(c), c.dtype 316 | ) 317 | ) 318 | labels = np.unique(labels).astype(int) 319 | logging.warning( 320 | "No labels passed, choosing those labels automatically: {}".format( 321 | labels 322 | ) 323 | ) 324 | result = np.zeros(candidates[0].shape) 325 | # remove background label 326 | if 0 in labels: 327 | labels = np.delete(labels, 0) 328 | logging.info("Fusing a segmentation with the labels: {}".format(labels)) 329 | # loop over each label 330 | for l in sorted(labels): 331 | if self.verbose: 332 | print("Currently fusing label {}".format(l)) 333 | # load first segmentation and use it to create initial numpy arrays IFF it contains labels 334 | bin_candidates = [(c == l).astype(int) for c in candidates] 335 | if self.verbose: 336 | print(bin_candidates[0].shape) 337 | # baseline estimate 338 | estimate = self.binaryMav(bin_candidates, weights) 339 | # initial convergence baseline 340 | conv = np.sum(estimate) 341 | # check if the estimate was reasonable 342 | if conv == 0: 343 | logging.error("Majority Voting in SIMPLE returned an empty array") 344 | # return np.zeros(candidates[0].shape) 345 | # reset tau before each iteration 346 | tau = t 347 | for i in range(iterations): 348 | t_weights = [] # temporary weights 349 | for c in bin_candidates: 350 | # score all canidate segmentations 351 | t_weights.append( 352 | (self._score(c, estimate, method) + 1) ** 2 353 | ) # SQUARED DICE! 354 | weights = t_weights 355 | # save maximum score in weights 356 | max_phi = max(weights) 357 | # remove dropout estimates 358 | bin_candidates = [ 359 | c for c, w in zip(bin_candidates, weights) if (w > t * max_phi) 360 | ] 361 | # calculate new estimate 362 | estimate = self.binaryMav(bin_candidates, weights) 363 | # increment tau 364 | tau = tau + inc 365 | # check if it converges 366 | if np.abs(conv - np.sum(estimate)) < stop: 367 | if self.verbose: 368 | print( 369 | "Convergence for label {} after {} iterations reached.".format( 370 | l, i 371 | ) 372 | ) 373 | break 374 | conv = np.sum(estimate) 375 | # assign correct label to result 376 | result[estimate == 1] = l 377 | # reset weights 378 | weights = backup_weights 379 | if self.verbose: 380 | print("Shape of result:", result.shape) 381 | print("Shape of current input array:", bin_candidates[0].shape) 382 | print( 383 | "Labels and datatype of current output:", 384 | result.max(), 385 | result.min(), 386 | result.dtype, 387 | ) 388 | return result 389 | 390 | # def dirFuse(self, directory, method='mav', outputPath=None, labels=None): 391 | # ''' 392 | # dirFuse [summary] 393 | 394 | # Args: 395 | # directory ([type]): [description] 396 | # method (str, optional): [description]. Defaults to 'mav'. 397 | # outputName ([type], optional): [description]. Defaults to None. 398 | # ''' 399 | # if method == 'all': 400 | # return 401 | # candidates = [] 402 | # weights = [] 403 | # temp = None 404 | # for file in os.listdir(directory): 405 | # if file.endswith('.nii.gz'): 406 | # # skip existing fusions 407 | # if 'fusion' in file: 408 | # continue 409 | # temp = op.join(directory, file) 410 | # try: 411 | # candidates.append(oitk.get_itk_array(oitk.get_itk_image(temp))) 412 | # weights.append(1) 413 | # print('Loaded: ' + os.path.join(directory, file)) 414 | # except Exception as e: 415 | # print('Could not load this file: ' + file + ' \nPlease check if this is a valid path and that the files exists. Exception: ' + e) 416 | # if method == 'mav': 417 | # print('Orchestra: Now fusing all .nii.gz files in directory {} using MAJORITY VOTING. For more output, set the -v or --verbose flag or instantiate the fusionator class with verbose=true'.format(directory)) 418 | # result = self.mav(candidates, labels, weights) 419 | # elif method == 'simple': 420 | # print('Orchestra: Now fusing all .nii.gz files in directory {} using SIMPLE. For more output, set the -v or --verbose flag or instantiate the fusionator class with verbose=true'.format(directory)) 421 | # result = self.simple(candidates, weights) 422 | # elif method == 'brats-simple': 423 | # print('Orchestra: Now fusing all .nii.gz files in directory {} using BRATS-SIMPLE. For more output, set the -v or --verbose flag or instantiate the fusionator class with verbose=true'.format(directory)) 424 | # result = self.brats_simple(candidates, weights) 425 | # try: 426 | # if outputPath == None: 427 | # oitk.write_itk_image(oitk.make_itk_image(result, proto_image=oitk.get_itk_image(temp)), op.join(directory, method + '_fusion.nii.gz')) 428 | # else: 429 | # outputDir = op.dirname(outputPath) 430 | # os.makedirs(outputDir, exist_ok=True) 431 | # oitk.write_itk_image(oitk.make_itk_image(result, proto_image=oitk.get_itk_image(temp)), outputPath) 432 | # logging.info('Segmentation Fusion with method {} saved in directory {}.'.format(method, directory)) 433 | # except Exception as e: 434 | # print('Very bad, this should also be logged somewhere: ' + str(e)) 435 | # logging.exception('Issues while saving the resulting segmentation: {}'.format(str(e))) 436 | 437 | # def fuse(self, segmentations, outputPath, method='mav', weights=None, labels=None): 438 | # ''' 439 | # fuse [summary] 440 | 441 | # Args: 442 | # segmentations ([type]): [description] 443 | # outputPath ([type]): [description] 444 | # method (str, optional): [description]. Defaults to 'mav'. 445 | # weights ([type], optional): [description]. Defaults to None. 446 | 447 | # Raises: 448 | # IOError: [description] 449 | # ''' 450 | # candidates = [] 451 | # if weights is not None: 452 | # if len(weights) != len(segmentations): 453 | # raise IOError('Please pass a matching number of weights and segmentation files') 454 | # w_weights = weights 455 | # else: 456 | # w_weights = [] 457 | # for seg in segmentations: 458 | # if seg.endswith('.nii.gz'): 459 | # try: 460 | # candidates.append(oitk.get_itk_array(oitk.get_itk_image(seg))) 461 | # if weights is None: 462 | # w_weights.append(1) 463 | # print('Loaded: ' + seg) 464 | # except Exception as e: 465 | # print('Could not load this file: ' + seg + ' \nPlease check if this is a valid path and that the files exists. Exception: ' + str(e)) 466 | # raise 467 | # if method == 'mav': 468 | # print('Orchestra: Now fusing all passed .nii.gz files using MAJORITY VOTING. For more output, set the -v or --verbose flag or instantiate the fusionator class with verbose=true') 469 | # result = self.mav(candidates, labels=labels, weights=w_weights) 470 | # elif method == 'simple': 471 | # print('Orchestra: Now fusing all passed .nii.gz files in using SIMPLE. For more output, set the -v or --verbose flag or instantiate the fusionator class with verbose=true') 472 | # result = self.simple(candidates, w_weights) 473 | # elif method == 'brats-simple': 474 | # print('Orchestra: Now fusing all .nii.gz files in directory {} using BRATS-SIMPLE. For more output, set the -v or --verbose flag or instantiate the fusionator class with verbose=true') 475 | # result = self.brats_simple(candidates, w_weights) 476 | # try: 477 | # outputDir = op.dirname(outputPath) 478 | # os.makedirs(outputDir, exist_ok=True) 479 | # oitk.write_itk_image(oitk.make_itk_image(result, proto_image=oitk.get_itk_image(seg)), outputPath) 480 | # logging.info('Segmentation Fusion with method {} saved as {}.'.format(method, outputPath)) 481 | # except Exception as e: 482 | # print('Very bad, this should also be logged somewhere: ' + str(e)) 483 | # logging.exception('Issues while saving the resulting segmentation: {}'.format(str(e))) 484 | 485 | def _score(self, seg, gt, method="dice"): 486 | """Calculates a similarity score based on the 487 | method specified in the parameters 488 | Input: Numpy arrays to be compared, need to have the 489 | same dimensions (shape) 490 | Default scoring method: DICE coefficient 491 | method may be: 'dice' 492 | 'auc' 493 | 'bdice' 494 | returns: a score [0,1], 1 for identical inputs 495 | """ 496 | try: 497 | np.seterr(all="ignore") 498 | # True Positive (TP): we predict a label of 1 (positive) and the true label is 1. 499 | TP = np.sum(np.logical_and(seg == 1, gt == 1)) 500 | # True Negative (TN): we predict a label of 0 (negative) and the true label is 0. 501 | TN = np.sum(np.logical_and(seg == 0, gt == 0)) 502 | # False Positive (FP): we predict a label of 1 (positive), but the true label is 0. 503 | FP = np.sum(np.logical_and(seg == 1, gt == 0)) 504 | # False Negative (FN): we predict a label of 0 (negative), but the true label is 1. 505 | FN = np.sum(np.logical_and(seg == 0, gt == 1)) 506 | FPR = FP / (FP + TN) 507 | FNR = FN / (FN + TP) 508 | TPR = TP / (TP + FN) 509 | TNR = TN / (TN + FP) 510 | except ValueError: 511 | print("Value error encountered!") 512 | return 0 513 | # faster dice? Oh yeah! 514 | if method == "dice": 515 | # default dice score 516 | score = 2 * TP / (2 * TP + FP + FN) 517 | elif method == "auc": 518 | # AUC scoring 519 | score = 1 - (FPR + FNR) / 2 520 | elif method == "bdice": 521 | # biased dice towards false negatives 522 | score = 2 * TP / (2 * TP + FN) 523 | elif method == "spec": 524 | # specificity 525 | score = TN / (TN + FP) 526 | elif method == "sens": 527 | # sensitivity 528 | score = TP / (TP + FN) 529 | elif method == "toterr": 530 | score = (FN + FP) / (155 * 240 * 240) 531 | elif method == "ppv": 532 | prev = np.sum(gt) / (155 * 240 * 240) 533 | temp = TPR * prev 534 | score = (temp) / (temp + (1 - TNR) * (1 - prev)) 535 | else: 536 | score = 0 537 | if np.isnan(score) or math.isnan(score): 538 | score = 0 539 | return score 540 | -------------------------------------------------------------------------------- /LabelFusion/itkUtils.py: -------------------------------------------------------------------------------- 1 | import SimpleITK as sitk 2 | import sys 3 | 4 | 5 | def imageSanityCheck(targetImageFile, inputImageFile) -> bool: 6 | """ 7 | This function does sanity checking of 2 images 8 | """ 9 | targetImage = sitk.ReadImage(targetImageFile) 10 | inputImage = sitk.ReadImage(inputImageFile) 11 | 12 | commonMessage = ( 13 | " mismatch for target image, '" 14 | + targetImageFile 15 | + "' and input image, '" 16 | + inputImageFile 17 | + "'" 18 | ) 19 | problemsIn = "" 20 | returnTrue = True 21 | 22 | if targetImage.GetDimension() != inputImage.GetDimension(): 23 | problemsIn += "Dimension" 24 | returnTrue = False 25 | 26 | if targetImage.GetSize() != inputImage.GetSize(): 27 | if not problemsIn: 28 | problemsIn += "Size" 29 | else: 30 | problemsIn += ", Size" 31 | returnTrue = False 32 | 33 | if targetImage.GetOrigin() != inputImage.GetOrigin(): 34 | if not problemsIn: 35 | problemsIn += "Origin" 36 | else: 37 | problemsIn += ", Origin" 38 | returnTrue = False 39 | 40 | if targetImage.GetSpacing() != inputImage.GetSpacing(): 41 | if not problemsIn: 42 | problemsIn += "Spacing" 43 | else: 44 | problemsIn += ", Spacing" 45 | returnTrue = False 46 | 47 | if returnTrue: 48 | return True 49 | else: 50 | print(problemsIn + commonMessage, file=sys.stderr) 51 | return False 52 | 53 | 54 | def imageComparision(targetImageFile, inputImageFile) -> bool: 55 | """ 56 | This function compares arrays of 2 images 57 | """ 58 | if imageSanityCheck( 59 | targetImageFile, inputImageFile 60 | ): # proceed only when sanity check passes 61 | target_array = sitk.GetArrayFromImage(sitk.ReadImage(targetImageFile)) 62 | input_array = sitk.GetArrayFromImage(sitk.ReadImage(inputImageFile)) 63 | 64 | if (target_array == input_array).all(): 65 | return True 66 | 67 | return False 68 | -------------------------------------------------------------------------------- /LabelFusion/majority_voting.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def majority_voting(segmentationArray_oneHot): 5 | """ 6 | This function takes an list of one-hot encoded masks as input and returns a 3D one-hot encoded mask 7 | """ 8 | seg_sum = segmentationArray_oneHot[0] # initialize 9 | for i in range(1, len(segmentationArray_oneHot)): # add all of them up 10 | seg_sum += segmentationArray_oneHot[i] 11 | 12 | init_seg = (seg_sum / len(segmentationArray_oneHot) > (0.5)).astype( 13 | int 14 | ) # Doing the majority voting and then round 15 | return init_seg 16 | -------------------------------------------------------------------------------- /LabelFusion/simple.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from .majority_voting import * 3 | from .utils import * 4 | 5 | 6 | def simple_iterative(segmentationArray_oneHot): 7 | """ 8 | This function takes an list of one-hot encoded masks as input and returns a 3D one-hot encoded mask 9 | 10 | Reference: DOI:10.1109/tmi.2010.2057442 11 | """ 12 | segmentationArray_oneHot_wrap = ( 13 | segmentationArray_oneHot # make a copy so that input isn't overwritten 14 | ) 15 | seg_for_comparision = majority_voting( 16 | segmentationArray_oneHot_wrap 17 | ) # use majority voting as initial 18 | 19 | dice_list = [] 20 | num_classes = segmentationArray_oneHot_wrap[0].shape[0] 21 | # calculate dice for each input compared to initial segmentation 22 | for i in range(0, len(segmentationArray_oneHot_wrap)): 23 | dice_list.append( 24 | 1 25 | - MCD_loss( 26 | segmentationArray_oneHot_wrap[i], seg_for_comparision, num_classes 27 | ) 28 | ) 29 | 30 | num_iter = ( 31 | len(segmentationArray_oneHot_wrap) - 1 32 | ) # initialize the number of iterations 33 | 34 | for i in range(num_iter): 35 | order = np.array(dice_list).argsort() # sort the dice 36 | # remove the best segmentation from comparision - part of SIMPLE algorithm 37 | del dice_list[order[0]] 38 | del segmentationArray_oneHot_wrap[order[0]] 39 | 40 | dice_list = [] # re-initilize dice list to get iteratively updated 41 | seg_sum = np.zeros( 42 | segmentationArray_oneHot[0].shape 43 | ) # initialize the first segmentation 44 | # loop through remaining and calculate updated segmentation 45 | for i in range(0, len(segmentationArray_oneHot_wrap)): 46 | current_dice = 1 - MCD_loss( 47 | segmentationArray_oneHot_wrap[i], seg_for_comparision, num_classes 48 | ) 49 | dice_list.append(current_dice) 50 | seg_sum += current_dice * segmentationArray_oneHot_wrap[i] 51 | 52 | seg_for_comparision = (seg_sum / (sum(dice_list)) > 0.5).astype( 53 | int 54 | ) # update the seg_for_comparision 55 | 56 | return seg_for_comparision 57 | -------------------------------------------------------------------------------- /LabelFusion/utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def convert_to_3D(segmentation_oneHot, class_list): 5 | """ 6 | This function takes a one-hot encoded mask and the class list as input and returns a 3D segmentation 7 | """ 8 | returnSeg = segmentation_oneHot[class_list[0]] * class_list[0] # initialize 9 | for i in range(1, len(class_list)): 10 | returnSeg += segmentation_oneHot[i] * class_list[i] 11 | 12 | return returnSeg 13 | 14 | 15 | def one_hot_nonoverlap(segmask_array, class_list): 16 | """ 17 | This function takes an N-D mask and a class list and returns a dictionary of one-hot encoded segmentations 18 | """ 19 | returnSeg = [] 20 | for i in range(0, len(class_list)): 21 | returnSeg.append((segmask_array == class_list[i]).astype(np.uint8)) 22 | 23 | return np.stack(returnSeg, axis=0) 24 | 25 | 26 | def dice_loss(inp, target): 27 | """ 28 | This function calculates the DICE loss for 2 segmentation arrays 29 | """ 30 | smooth = 1e-7 31 | iflat = inp.flatten() 32 | tflat = target.flatten() 33 | intersection = (iflat * tflat).sum() 34 | return 1 - ((2.0 * intersection + smooth) / (iflat.sum() + tflat.sum() + smooth)) 35 | 36 | 37 | def MCD_loss(pm, gt, num_class): 38 | """ 39 | This function calculates the DICE loss for 2 multi-class segmentation arrays 40 | """ 41 | acc_dice_loss = 0 42 | for i in range(0, num_class): 43 | acc_dice_loss += dice_loss(gt[i, :, :, :], pm[i, :, :, :]) 44 | acc_dice_loss /= num_class 45 | return acc_dice_loss 46 | -------------------------------------------------------------------------------- /LabelFusion/wrapper.py: -------------------------------------------------------------------------------- 1 | from .fusion_wrappers import * 2 | 3 | 4 | def fuse_images(list_of_simpleITK_images, method, class_list=None): 5 | """ 6 | This function takes a list of simpleITK images and pushes it to appropriate functions 7 | """ 8 | 9 | method = method.lower() 10 | 11 | if not ( 12 | method in direct_itk_LabelFusion 13 | ): # for non-itk LabelFusion, get image arrays 14 | inputListOfOneHotEncodedMasks = [] 15 | 16 | for image in list_of_simpleITK_images: 17 | current_immage_array = sitk.GetArrayFromImage( 18 | image 19 | ) # initialize the fused segmentation array 20 | 21 | inputListOfOneHotEncodedMasks.append( 22 | one_hot_nonoverlap(current_immage_array, class_list) 23 | ) 24 | 25 | # call the fusion 26 | fused_oneHot = fuse_segmentations_nonITK( 27 | inputListOfOneHotEncodedMasks, method, class_list 28 | ) 29 | fused_segmentation_image = sitk.GetImageFromArray( 30 | convert_to_3D(fused_oneHot, class_list) 31 | ) 32 | fused_segmentation_image.CopyInformation(list_of_simpleITK_images[0]) 33 | 34 | else: # for direct itk LabelFusion, we actually need the images themselves 35 | # call the fusion 36 | fused_segmentation_image = fuse_segmentations_itk( 37 | list_of_simpleITK_images, method 38 | ) 39 | 40 | return fused_segmentation_image 41 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include LICENSE 3 | exclude *.toml -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LabelFusion 2 | 3 |
9 | 10 | 11 | This repo contains implementation of various label fusion approaches that can be used to fuse multiple labels. 12 | 13 | ## Installation 14 | 15 | ### Default 16 | ```sh 17 | conda create -n venv_labelFusion python=3.12 -y 18 | conda activate venv_labelFusion 19 | pip install LabelFusion 20 | ``` 21 | 22 | ### For Development 23 | ```sh 24 | # fork to your own repo 25 | git clone ${yourFork_labelFusion_repo_link} 26 | cd LabelFusion 27 | conda create -p ./venv python=3.12 -y 28 | conda activate ./venv 29 | pip install -e . 30 | # develop, push 31 | # initiate pull request 32 | ``` 33 | 34 | ## Available fusion methods: 35 | 36 | - [Voting (ITK)](https://simpleitk.org/doxygen/latest/html/classitk_1_1simple_1_1LabelVotingImageFilter.html): [DOI:10.1016/j.patrec.2005.03.017](https://doi.org/10.1016/j.patrec.2005.03.017) 37 | - [STAPLE (ITK)](https://simpleitk.org/doxygen/latest/html/classitk_1_1simple_1_1MultiLabelSTAPLEImageFilter.html): [DOI:10.1109/TMI.2004.830803](https://doi.org/10.1109/TMI.2004.830803) 38 | - Majority Voting: [DOI:10.1007/978-3-319-20801-5_11](https://doi.org/10.1007/978-3-319-20801-5_11) 39 | - SIMPLE: [DOI:10.1109/tmi.2010.2057442](https://doi.org/10.1109/TMI.2010.2057442) 40 | 41 | ## Usage 42 | 43 | ### Command-Line interface 44 | 45 | ```sh 46 | # continue from previous shell 47 | python fusion_run -h 48 | -h, --help show this help message and exit 49 | -inputs INPUTS The absolute, comma-separated paths of labels that need to be fused 50 | -classes CLASSES The expected labels; for example, for BraTS, this should be '0,1,2,3' - not used for STAPLE or ITKVoting 51 | -method METHOD The method to apply; currently available: STAPLE | ITKVoting | MajorityVoting | SIMPLE 52 | -output OUTPUT The output file to write the results 53 | ``` 54 | 55 | Example: 56 | ```sh 57 | # continue from previous shell 58 | python fusion_run \ 59 | -inputs /path/to/seg_algo_0.nii.gz,/path/to/seg_algo_1.nii.gz,/path/to/seg_algo_2.nii.gz \ 60 | -classes 0,1,2,3 \ 61 | -method STAPLE \ 62 | -output /path/to/seg_fusion.nii.gz 63 | ``` 64 | 65 | ### Python interface 66 | 67 | ```python 68 | # assuming virtual environment containing LabelFusion is activated 69 | import SimpleITK as sitk 70 | from LabelFusion.wrapper import fuse_images 71 | 72 | label_to_fuse_0 = '/path/to/seg_algo_0.nii.gz' 73 | label_to_fuse_1 = '/path/to/seg_algo_1.nii.gz' 74 | 75 | images_to_fuse = [] 76 | images_to_fuse.append(sitk.ReadImage(label_to_fuse_0, sitk.sitkUInt8)) 77 | images_to_fuse.append(sitk.ReadImage(label_to_fuse_1, sitk.sitkUInt8)) 78 | fused_staple = fuse_images(images_to_fuse, 'staple') # class_list is not needed for staple/itkvoting 79 | sitk.WriteImage(fused_staple, '/path/to/output_staple.nii.gz') 80 | fused_simple = fuse_images(images_to_fuse, 'simple', class_list=[0,1,2,3]) 81 | sitk.WriteImage(fused_simple, '/path/to/output_simple.nii.gz') 82 | ``` 83 | 84 | ## Testing 85 | 86 | This repo has continuous integration enabled via [GitHub Actions](https://github.com/FeTS-AI/LabelFusion/actions/workflows/test.yml) for the following [operating systems](https://github.com/FeTS-AI/LabelFusion/blob/main/.github/workflows/test.yml#L18): 87 | 88 | - Windows 89 | - Ubuntu 90 | - macOS 91 | 92 | And for the following python versions: 93 | 94 | - 3.9 95 | - 3.10 96 | - 3.11 97 | - 3.12 98 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import LabelFusion 2 | from LabelFusion.wrapper import fuse_images 3 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Python package 2 | # Create and test a Python package on multiple Python versions. 3 | # Add steps that analyze code, save the dist with the build record, publish to a PyPI-compatible index, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/python 5 | 6 | trigger: 7 | - main 8 | 9 | strategy: 10 | matrix: 11 | linux_3.8: 12 | imageName: 'ubuntu-latest' 13 | python.version: '3.8' 14 | mac_3.8: 15 | imageName: 'macos-latest' 16 | python.version: '3.8' 17 | windows_3.8: 18 | imageName: 'windows-latest' 19 | python.version: '3.8' 20 | 21 | pool: 22 | vmImage: $(imageName) 23 | 24 | steps: 25 | - task: UsePythonVersion@0 26 | inputs: 27 | versionSpec: '$(python.version)' 28 | displayName: 'Use Python $(python.version)' 29 | 30 | - script: | 31 | python -m pip install --upgrade pip 32 | pip install . 33 | pip install artifacts-keyring 34 | displayName: 'Install dependencies' 35 | 36 | - script: | 37 | python fusion_run -inputs ./data/unet.nii.gz,./data/resunet.nii.gz -output ./data/test_staple.nii.gz -classes 0,1,2,4 -method staple 38 | python -c "from LabelFusion.itkUtils import *; sys.exit('fail') if(not (imageComparision('./data/baseline_staple.nii.gz', './data/test_staple.nii.gz'))) else print('pass')" 39 | displayName: 'Run through staple' 40 | 41 | - script: | 42 | python fusion_run -inputs ./data/unet.nii.gz,./data/resunet.nii.gz -output ./data/test_itkvoting.nii.gz -classes 0,1,2,4 -method itkvoting 43 | python -c "from LabelFusion.itkUtils import *; sys.exit('fail') if(not (imageComparision('./data/baseline_itkvoting.nii.gz', './data/test_itkvoting.nii.gz'))) else print('pass')" 44 | displayName: 'Run through itkvoting' 45 | 46 | - script: | 47 | python fusion_run -inputs ./data/unet.nii.gz,./data/resunet.nii.gz -output ./data/test_majorityvoting.nii.gz -classes 0,1,2,4 -method majorityvoting 48 | python -c "from LabelFusion.itkUtils import *; sys.exit('fail') if(not (imageComparision('./data/baseline_majorityvoting.nii.gz', './data/test_majorityvoting.nii.gz'))) else print('pass')" 49 | displayName: 'Run through majorityvoting' 50 | 51 | - script: | 52 | python fusion_run -inputs ./data/unet.nii.gz,./data/resunet.nii.gz -output ./data/test_simple.nii.gz -classes 0,1,2,4 -method simple 53 | python -c "from LabelFusion.itkUtils import *; sys.exit('fail') if(not (imageComparision('./data/baseline_simple.nii.gz', './data/test_simple.nii.gz'))) else print('pass')" 54 | displayName: 'Run through simple' 55 | -------------------------------------------------------------------------------- /data/baseline_itkvoting.nii.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FeTS-AI/LabelFusion/11269a461c6707295fcddc6f8663affb525c6049/data/baseline_itkvoting.nii.gz -------------------------------------------------------------------------------- /data/baseline_majorityvoting.nii.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FeTS-AI/LabelFusion/11269a461c6707295fcddc6f8663affb525c6049/data/baseline_majorityvoting.nii.gz -------------------------------------------------------------------------------- /data/baseline_simple.nii.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FeTS-AI/LabelFusion/11269a461c6707295fcddc6f8663affb525c6049/data/baseline_simple.nii.gz -------------------------------------------------------------------------------- /data/baseline_staple.nii.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FeTS-AI/LabelFusion/11269a461c6707295fcddc6f8663affb525c6049/data/baseline_staple.nii.gz -------------------------------------------------------------------------------- /data/resunet.nii.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FeTS-AI/LabelFusion/11269a461c6707295fcddc6f8663affb525c6049/data/resunet.nii.gz -------------------------------------------------------------------------------- /data/unet.nii.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FeTS-AI/LabelFusion/11269a461c6707295fcddc6f8663affb525c6049/data/unet.nii.gz -------------------------------------------------------------------------------- /fusion_run: -------------------------------------------------------------------------------- 1 | #!usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os, argparse, sys, pkg_resources 5 | from pathlib import Path 6 | from datetime import date 7 | 8 | import SimpleITK as sitk 9 | 10 | import LabelFusion 11 | from LabelFusion.itkUtils import * 12 | from LabelFusion.utils import * 13 | from LabelFusion.wrapper import * 14 | 15 | def main(): 16 | copyrightMessage = 'Contact: admin@fets.ai\n\n' + 'This program is NOT FDA/CE approved and NOT intended for clinical use.\nCopyright (c) ' + str(date.today().year) + ' University of Pennsylvania. All rights reserved.' 17 | parser = argparse.ArgumentParser(prog='LabelFusion', formatter_class=argparse.RawTextHelpFormatter, description = "Fusion of different labels together.\n\n" + copyrightMessage) 18 | parser.add_argument('-inputs', type=str, help = 'The absolute, comma-separated paths of labels that need to be fused', required=True) 19 | parser.add_argument('-classes', type=str, help = 'The expected labels; for example, for BraTS, this should be \'0,1,2,4\'; this is not needed for STAPLE|ITKVoting', default='0,1') 20 | parser.add_argument('-method', type=str, help = 'The method to apply; currently available: STAPLE | ITKVoting | MajorityVoting | SIMPLE', required=True) 21 | parser.add_argument('-output', type=str, help = 'The output file to write the results', required=True) 22 | 23 | args = parser.parse_args() 24 | 25 | inputs = list(args.inputs.split(',')) # list of input file paths 26 | 27 | if len(inputs) < 2: 28 | sys.exit('Cannot perform fusion for less than 2 input labels') 29 | 30 | for i in range(1, len(inputs)): 31 | if not imageSanityCheck(inputs[0], inputs[i]): 32 | sys.exit('There is a mismatch between the physical definitions of the input labels, please check') 33 | 34 | class_list = list(args.classes.split(',')) 35 | class_list_int = [int(i) for i in class_list] 36 | 37 | method = args.method.lower() 38 | 39 | inputSegmentationImages = [] 40 | for i in range(0, len(inputs)): 41 | inputSegmentationImages.append(sitk.ReadImage(inputs[i], sitk.sitkUInt8)) 42 | 43 | fused_segmentation_image = fuse_images(inputSegmentationImages, method, class_list_int) 44 | 45 | # finally, write the fused image 46 | sitk.WriteImage(fused_segmentation_image, args.output) 47 | exit(0) 48 | 49 | if __name__ == '__main__': 50 | main() 51 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """The setup script.""" 4 | 5 | from setuptools import setup, find_packages 6 | 7 | with open("README.md") as readme_file: 8 | readme = readme_file.read() 9 | 10 | requirements = [ 11 | "numpy>=1.19.2", 12 | "SimpleITK!=2.0.*", 13 | "SimpleITK!=2.2.1", # https://github.com/mlcommons/GaNDLF/issues/536 14 | "setuptools", 15 | "wheel", 16 | "twine", 17 | "keyring", 18 | "black", 19 | "psutil", 20 | "requests", 21 | ] 22 | 23 | setup( 24 | name="LabelFusion", 25 | version="1.0.15", # dev: development release; this should be changed when tagging 26 | author="Megh Bhalerao, Sarthak Pati", 27 | author_email="admin@fets.ai", 28 | python_requires=">=3.9", 29 | packages=find_packages(), 30 | scripts=["fusion_run"], 31 | classifiers=[ 32 | "Development Status :: 5 - Production/Stable", 33 | "Intended Audience :: Science/Research", 34 | "License :: OSI Approved :: Apache Software License", 35 | "Natural Language :: English", 36 | "Operating System :: OS Independent", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Programming Language :: Python :: 3.12", 41 | ], 42 | description=("Label fusion strategies for multi-class labels."), 43 | url="https://github.com/FETS-AI/LabelFusion", 44 | install_requires=requirements, 45 | license="Apache-2.0", 46 | long_description=readme, 47 | long_description_content_type="text/markdown", 48 | include_package_data=True, 49 | keywords="semantic, segmentation, label-fusion, fusion", 50 | zip_safe=False, 51 | ) 52 | --------------------------------------------------------------------------------