├── AtlasEditor ├── AtlasEditor.png ├── AtlasEditor.py ├── CMakeLists.txt ├── README.md ├── Resources │ ├── Icons │ │ └── AtlasEditor.png │ └── UI │ │ └── AtlasEditor.ui ├── Testing │ ├── CMakeLists.txt │ └── Python │ │ └── CMakeLists.txt └── img │ ├── AtlasEditor.png │ ├── merge-cerebellum.png │ ├── merge-roi.png │ ├── original-merge.png │ ├── original-remove.png │ ├── output-merge.png │ └── output-remove.png ├── CMakeLists.txt ├── OpenAnatomyExport ├── CMakeLists.txt ├── OpenAnatomyExport.py ├── README.md ├── Resources │ ├── Icons │ │ └── OpenAnatomyExport.png │ └── UI │ │ └── OpenAnatomyExport.ui ├── Testing │ ├── CMakeLists.txt │ └── Python │ │ └── CMakeLists.txt └── img │ ├── Screenshot01.png │ ├── Screenshot02.png │ └── Screenshot03.png ├── README.md ├── SlicerOpenAnatomy.png └── SlicerOpenAnatomy.xcf /AtlasEditor/AtlasEditor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/AtlasEditor/AtlasEditor.png -------------------------------------------------------------------------------- /AtlasEditor/AtlasEditor.py: -------------------------------------------------------------------------------- 1 | import vtk 2 | import qt 3 | import json 4 | 5 | import slicer 6 | from slicer.ScriptedLoadableModule import * 7 | from slicer.util import VTKObservationMixin 8 | 9 | # 10 | # AtlasEditor 11 | # 12 | 13 | class AtlasEditor(ScriptedLoadableModule): 14 | """Uses ScriptedLoadableModule base class, available at: 15 | https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py 16 | """ 17 | 18 | def __init__(self, parent): 19 | ScriptedLoadableModule.__init__(self, parent) 20 | self.parent.title = "OpenAnatomy AtlasEditor" 21 | self.parent.categories = ["OpenAnatomy"] 22 | self.parent.dependencies = [] 23 | self.parent.contributors = ["Andy Huynh (ISML, University of Western Australia)"] 24 | self.parent.helpText = """""" 25 | self.parent.acknowledgementText = """""" 26 | 27 | # 28 | # AtlasEditorWidget 29 | # 30 | 31 | class AtlasEditorWidget(ScriptedLoadableModuleWidget, VTKObservationMixin): 32 | """Uses ScriptedLoadableModuleWidget base class, available at: 33 | https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py 34 | """ 35 | 36 | def __init__(self, parent=None): 37 | """ 38 | Called when the user opens the module the first time and the widget is initialized. 39 | """ 40 | ScriptedLoadableModuleWidget.__init__(self, parent) 41 | VTKObservationMixin.__init__(self) # needed for parameter node observation 42 | self.logic = None 43 | self._parameterNode = None 44 | self._updatingGUIFromParameterNode = False 45 | 46 | def setup(self): 47 | """ 48 | Called when the user opens the module the first time and the widget is initialized. 49 | """ 50 | ScriptedLoadableModuleWidget.setup(self) 51 | 52 | # Load widget from .ui file (created by Qt Designer). 53 | # Additional widgets can be instantiated manually and added to self.layout. 54 | uiWidget = slicer.util.loadUI(self.resourcePath('UI/AtlasEditor.ui')) 55 | self.layout.addWidget(uiWidget) 56 | self.ui = slicer.util.childWidgetVariables(uiWidget) 57 | 58 | # Set scene in MRML widgets. Make sure that in Qt designer the top-level qMRMLWidget's 59 | # "mrmlSceneChanged(vtkMRMLScene*)" signal in is connected to each MRML widget's. 60 | # "setMRMLScene(vtkMRMLScene*)" slot. 61 | uiWidget.setMRMLScene(slicer.mrmlScene) 62 | 63 | # Create logic class. Logic implements all computations that should be possible to run 64 | # in batch mode, without a graphical user interface. 65 | self.logic = AtlasEditorLogic() 66 | 67 | # Connections 68 | 69 | # These connections ensure that we update parameter node when scene is closed 70 | self.addObserver(slicer.mrmlScene, slicer.mrmlScene.StartCloseEvent, self.onSceneStartClose) 71 | self.addObserver(slicer.mrmlScene, slicer.mrmlScene.EndCloseEvent, self.onSceneEndClose) 72 | 73 | # These connections ensure that whenever user changes some settings on the GUI, that is saved in the MRML scene 74 | # (in the selected parameter node). 75 | 76 | self.ui.atlasLabelMapInputSelector.connect("currentNodeChanged(vtkMRMLNode*)", self.updateParameterNodeFromGUI) #vtkMRMLLabelMapVolumeNode 77 | self.ui.atlasLabelMapOutputSelector.connect("currentNodeChanged(vtkMRMLNode*)", self.updateParameterNodeFromGUI) #vtkMRMLLabelMapVolumeNode 78 | self.ui.atlasStructureInputPath.connect("currentPathChanged(QString)", self.updateParameterNodeFromGUI) 79 | 80 | # Buttons 81 | self.ui.downloadButton.connect('clicked(bool)', self.onDownloadButton) 82 | self.ui.mergeButton.connect('clicked(bool)', self.onMergeButton) 83 | self.ui.removeButton.connect('clicked(bool)', self.onRemoveButton) 84 | self.ui.updateButton.connect('clicked(bool)', self.onUpdateButton) 85 | 86 | # Make sure parameter node is initialized (needed for module reload) 87 | self.initializeParameterNode() 88 | 89 | def cleanup(self): 90 | """ 91 | Called when the application closes and the module widget is destroyed. 92 | """ 93 | self.removeObservers() 94 | 95 | def enter(self): 96 | """ 97 | Called each time the user opens this module. 98 | """ 99 | # Make sure parameter node exists and observed 100 | self.initializeParameterNode() 101 | 102 | def exit(self): 103 | """ 104 | Called each time the user opens a different module. 105 | """ 106 | # Do not react to parameter node changes (GUI wlil be updated when the user enters into the module) 107 | self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode) 108 | 109 | def onSceneStartClose(self, caller, event): 110 | """ 111 | Called just before the scene is closed. 112 | """ 113 | # Parameter node will be reset, do not use it anymore 114 | self.setParameterNode(None) 115 | 116 | def onSceneEndClose(self, caller, event): 117 | """ 118 | Called just after the scene is closed. 119 | """ 120 | # If this module is shown while the scene is closed then recreate a new parameter node immediately 121 | if self.parent.isEntered: 122 | self.initializeParameterNode() 123 | 124 | def initializeParameterNode(self): 125 | """ 126 | Ensure parameter node exists and observed. 127 | """ 128 | # Parameter node stores all user choices in parameter values, node selections, etc. 129 | # so that when the scene is saved and reloaded, these settings are restored. 130 | 131 | self.setParameterNode(self.logic.getParameterNode()) 132 | 133 | # Select default input nodes if nothing is selected yet to save a few clicks for the user 134 | 135 | 136 | def setParameterNode(self, inputParameterNode): 137 | """ 138 | Set and observe parameter node. 139 | Observation is needed because when the parameter node is changed then the GUI must be updated immediately. 140 | """ 141 | # Unobserve previously selected parameter node and add an observer to the newly selected. 142 | # Changes of parameter node are observed so that whenever parameters are changed by a script or any other module 143 | # those are reflected immediately in the GUI. 144 | if self._parameterNode is not None: 145 | self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode) 146 | self._parameterNode = inputParameterNode 147 | if self._parameterNode is not None: 148 | self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode) 149 | 150 | # Initial GUI update 151 | self.updateGUIFromParameterNode() 152 | 153 | def updateGUIFromParameterNode(self, caller=None, event=None): 154 | """ 155 | This method is called whenever parameter node is changed. 156 | The module GUI is updated to show the current state of the parameter node. 157 | """ 158 | 159 | if self._parameterNode is None or self._updatingGUIFromParameterNode: 160 | return 161 | 162 | # Make sure GUI changes do not call updateParameterNodeFromGUI (it could cause infinite loop) 163 | self._updatingGUIFromParameterNode = True 164 | 165 | # Update node selectors and sliders 166 | self.ui.atlasLabelMapInputSelector.setCurrentNode(self._parameterNode.GetNodeReference("InputLabelMap")) 167 | self.ui.atlasLabelMapOutputSelector.setCurrentNode(self._parameterNode.GetNodeReference("OutputLabelMap")) 168 | self.ui.atlasStructureInputPath.setCurrentPath(self._parameterNode.GetParameter("InputStructurePath")) 169 | 170 | # Update buttons states and tooltips 171 | # Update button 172 | if self._parameterNode.GetNodeReference("InputLabelMap") and len(self.ui.atlasStructureInputPath.currentPath) > 1: 173 | self.ui.updateButton.toolTip = "Update atlas structure tree widget." 174 | self.ui.updateButton.enabled = True 175 | else: 176 | self.ui.updateButton.toolTip = "Import input label map and atlas structure json file." 177 | self.ui.updateButton.enabled = False 178 | 179 | # Merge button 180 | if self._parameterNode.GetNodeReference("InputLabelMap") and self._parameterNode.GetNodeReference("OutputLabelMap"): 181 | self.ui.mergeButton.toolTip = "Merge input label map and output label map." 182 | self.ui.mergeButton.enabled = True 183 | else: 184 | self.ui.mergeButton.toolTip = "Select input label map and output label map." 185 | self.ui.mergeButton.enabled = False 186 | 187 | # Remove button 188 | if self._parameterNode.GetNodeReference("InputLabelMap") and self._parameterNode.GetNodeReference("OutputLabelMap"): 189 | self.ui.removeButton.toolTip = "Remove input label map and output label map." 190 | self.ui.removeButton.enabled = True 191 | else: 192 | self.ui.removeButton.toolTip = "Select input label map and output label map." 193 | self.ui.removeButton.enabled = False 194 | 195 | # All the GUI updates are done 196 | self._updatingGUIFromParameterNode = False 197 | 198 | def updateParameterNodeFromGUI(self, caller=None, event=None): 199 | """ 200 | This method is called when the user makes any change in the GUI. 201 | The changes are saved into the parameter node (so that they are restored when the scene is saved and loaded). 202 | """ 203 | 204 | if self._parameterNode is None or self._updatingGUIFromParameterNode: 205 | return 206 | 207 | wasModified = self._parameterNode.StartModify() # Modify all properties in a single batch 208 | 209 | self._parameterNode.SetNodeReferenceID("InputLabelMap", self.ui.atlasLabelMapInputSelector.currentNodeID) 210 | self._parameterNode.SetNodeReferenceID("OutputLabelMap", self.ui.atlasLabelMapOutputSelector.currentNodeID) 211 | self._parameterNode.SetParameter("InputStructurePath", self.ui.atlasStructureInputPath.currentPath) 212 | 213 | 214 | self._parameterNode.EndModify(wasModified) 215 | 216 | def onDownloadButton(self): 217 | """ 218 | Run processing when user clicks "Download" button. 219 | """ 220 | with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True): 221 | 222 | self.logic.downloadAtlas(self.ui.atlasInputSelector.currentIndex, self.ui.atlasLabelMapInputSelector, self.ui.atlasStructureInputPath, self.ui.structureTreeWidget, self.ui.atlasLabelMapOutputSelector) 223 | 224 | def onMergeButton(self): 225 | """ 226 | Run processing when user clicks "Merge" button. 227 | """ 228 | with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True): 229 | 230 | self.logic.merge(self.ui.atlasLabelMapInputSelector.currentNode(), self.ui.atlasLabelMapOutputSelector.currentNode()) 231 | 232 | def onRemoveButton(self): 233 | """ 234 | Run processing when user clicks "Remove" button. 235 | """ 236 | with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True): 237 | 238 | self.logic.remove(self.ui.atlasLabelMapInputSelector.currentNode(), self.ui.atlasLabelMapOutputSelector.currentNode()) 239 | 240 | def onUpdateButton(self): 241 | """ 242 | Run processing when user clicks "Update" button. 243 | """ 244 | with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True): 245 | 246 | self.logic.setup(self.ui.atlasLabelMapInputSelector.currentNode(), self.ui.atlasLabelMapOutputSelector.currentNode(), self.ui.atlasStructureInputPath.currentPath, self.ui.structureTreeWidget) 247 | 248 | self.logic.updateStructureView() 249 | 250 | 251 | # 252 | # AtlasEditorLogic 253 | # 254 | 255 | class AtlasEditorLogic(ScriptedLoadableModuleLogic): 256 | """This class should implement all the actual 257 | computation done by your module. The interface 258 | should be such that other python code can import 259 | this class and make use of the functionality without 260 | requiring an instance of the Widget. 261 | Uses ScriptedLoadableModuleLogic base class, available at: 262 | https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py 263 | """ 264 | 265 | def __init__(self): 266 | """ 267 | Called when the logic class is instantiated. Can be used for initializing member variables. 268 | """ 269 | ScriptedLoadableModuleLogic.__init__(self) 270 | self.atlasInputLabelMapVolumeNode = None 271 | self.atlasOutputLabelMapVolumeNode = None 272 | self.atlasStructureTreeWidget = None 273 | self.atlasStructureJSON = None 274 | self.atlasStructureTree = None 275 | """ 276 | Dictionary of atlas_data. Key is atlas ID, value is a list of URLs to download atlas data. 277 | Key: 278 | 0: SPL/NAC Brain Atlas 279 | 1: SPL Liver Atlas 280 | 281 | List Index: 282 | 0: Atlas Label Map (.nrrd) URL 283 | 1: Atlas Color Table (.ctbl) URL 284 | 2: Atlas Structure (.json) URL 285 | 286 | """ 287 | atlas_data = { 288 | 0: ["https://drive.google.com/uc?export=download&id=1sb_Syoi33pYwxCzCqITXyKrZAxRger5O", 289 | "https://drive.google.com/uc?export=download&id=1ed-OuzGz6DNJ9DsYmQT2nQHN8r0s8FV7", 290 | "https://drive.google.com/uc?export=download&id=17_NLdqOVEiAxQtOuhcFcOZTuEBlvbYHd"], 291 | 1: ["https://drive.google.com/uc?export=download&id=1ZdIiO7CjT5-27NtofimN16brsNbh2pjB", 292 | "https://drive.google.com/uc?export=download&id=1oQ8gXMN8wFA5fhl7J9xydRtUcH4bDGHV", 293 | "https://drive.google.com/uc?export=download&id=1TGzNZO-j5V1gJ5m_R1RjJPXI-zwXcdov"] 294 | } 295 | 296 | def setup(self, atlasInputLabelMapVolumeNode, atlasOutputLabelMapVolumeNode, atlasStructureJsonPath, atlasStructureTreeWidget): 297 | """ 298 | Setup variables for atlas editor 299 | """ 300 | self.atlasInputLabelMapVolumeNode = atlasInputLabelMapVolumeNode 301 | self.atlasOutputLabelMapVolumeNode = atlasOutputLabelMapVolumeNode 302 | self.atlasStructureJSON = json.load(open(atlasStructureJsonPath)) 303 | self.atlasStructureTreeWidget = atlasStructureTreeWidget 304 | 305 | def downloadFromURL(self, url, filename): 306 | """ 307 | Download file from URL and save to filename (folder must exist) 308 | """ 309 | try: 310 | print("Downloading file from " + url + " ...") 311 | import urllib 312 | urllib.request.urlretrieve(url, filename) 313 | except Exception as e: 314 | print("Error: can not download file ...") 315 | print(e) 316 | return -1 317 | 318 | def downloadAtlas(self, atlasIndex, atlasInputNode, atlasStructureInputPath, structureTree, atlasOutputNode): 319 | """ 320 | Download atlas data from URL and load into Slicer 321 | """ 322 | # Checks if atlas is supported from the atlas selector 323 | if atlasIndex != 0: 324 | slicer.util.errorDisplay("Atlas not yet supported.", waitCursor=True) 325 | return 326 | 327 | # Set up paths for downloading atlas data using Slicer's cache directory 328 | cache_path = slicer.mrmlScene.GetCacheManager().GetRemoteCacheDirectory() 329 | atlas_path = cache_path + "/atlas.nrrd" 330 | atlas_lut_path = cache_path + "/atlas-lut.ctbl" 331 | atlas_structure_path = cache_path + "/atlas-structure.json" 332 | 333 | # Download atlas data from atlas_data dictionary 334 | self.downloadFromURL(self.atlas_data[atlasIndex][0], atlas_path) 335 | self.downloadFromURL(self.atlas_data[atlasIndex][1], atlas_lut_path) 336 | self.downloadFromURL(self.atlas_data[atlasIndex][2], atlas_structure_path) 337 | 338 | # Load atlas data into Slicer as a labelmap volume 339 | atlas_lut = slicer.util.loadColorTable(atlas_lut_path) 340 | atlas = slicer.util.loadVolume(atlas_path, properties={'labelmap': True, 'colorNodeID': atlas_lut.GetID()}) 341 | 342 | # Update the 'Manually Import Atlas' fields 343 | atlasInputNode.setCurrentNode(atlas) 344 | atlasOutputNode.setCurrentNode(atlas) 345 | 346 | atlasStructureInputPath.setCurrentPath(atlas_structure_path) 347 | 348 | # Sets up variables and Update the 'Atlas Structure' tree widget 349 | self.setup(atlasInputNode.currentNode(), atlasOutputNode.currentNode(), atlasStructureInputPath.currentPath, structureTree) 350 | self.updateStructureView() 351 | 352 | 353 | def buildHierarchy(self, currentTree = None, groups=None): 354 | """ 355 | Build the hierarchy of the atlas in the widget tree. 356 | """ 357 | # If currentTree is None -> we set up the root of the tree. 358 | if currentTree is None and groups is None: 359 | groups = [] 360 | root = [] 361 | for item in self.atlasStructureJSON: 362 | if item['@id'] == "#__header__": 363 | for member in item['root']: 364 | root.append(member) 365 | 366 | for item in self.atlasStructureJSON: 367 | if item['@id'] in root: 368 | self.atlasStructureTree = qt.QTreeWidgetItem(self.atlasStructureTreeWidget) 369 | self.atlasStructureTree.setFlags(self.atlasStructureTree.flags() | qt.Qt.ItemIsTristate | qt.Qt.ItemIsUserCheckable) 370 | self.atlasStructureTree.setText(0, item['annotation']['name']) 371 | currentTree = self.atlasStructureTree 372 | for member in item['member']: 373 | groups.append(member) 374 | 375 | # If currentTree is not None -> We are set up the children of the tree. 376 | for group in groups: 377 | for item in self.atlasStructureJSON: 378 | if item['@id'] == group: 379 | child = qt.QTreeWidgetItem() 380 | currentTree.addChild(child) 381 | child.setText(0, item['annotation']['name']) 382 | child.setFlags(child.flags() | qt.Qt.ItemIsTristate | qt.Qt.ItemIsUserCheckable) 383 | child.setCheckState(0, qt.Qt.Unchecked) 384 | if item['@type'] == "Group": 385 | groups1 = [] 386 | for member in item['member']: 387 | groups1.append(member) 388 | self.buildHierarchy(child, groups1) 389 | 390 | def updateStructureView(self): 391 | """ 392 | Update the structure view of the atlas. 393 | """ 394 | # clear the tree 395 | self.atlasStructureTreeWidget.clear() 396 | self.buildHierarchy() 397 | self.atlasStructureTreeWidget.expandToDepth(0) 398 | 399 | def getCheckedItems(self, tree=None): 400 | """ 401 | Helper function to get the checked items of the structure view for mergined/removing functions. 402 | """ 403 | checked = dict() 404 | 405 | if tree is None: 406 | tree = self.atlasStructureTree 407 | signal_count = tree.childCount() 408 | 409 | for i in range(signal_count): 410 | if tree.child(i).checkState(0) == qt.Qt.Checked: 411 | 412 | signal = tree.child(i) 413 | checked_sweeps = list() 414 | num_children = signal.childCount() 415 | 416 | for n in range(num_children): 417 | child = signal.child(n) 418 | 419 | if child.checkState(0) == qt.Qt.Checked: 420 | checked_sweeps.append(child.text(0)) 421 | 422 | checked[signal.text(0)] = checked_sweeps 423 | 424 | elif tree.child(i).checkState(0) == qt.Qt.PartiallyChecked: 425 | checked.update(self.getCheckedItems(tree.child(i))) 426 | 427 | return checked 428 | 429 | def getStructureIdOfGroups(self, groups): 430 | """ 431 | Helper function to get the structure ids of the groups for merging/removing function. 432 | """ 433 | structureIds = [] 434 | for group in groups: 435 | for item in self.atlasStructureJSON: 436 | if item['@id'] == group: 437 | if item['@type'] == "Structure": 438 | if "-" in item['annotation']['name']: 439 | structureIds.append(item['annotation']['name'].replace("-", " ")) 440 | else: 441 | structureIds.append(item['annotation']['name']) 442 | if item['@type'] == "Group": 443 | groups.extend(item['member']) 444 | 445 | return structureIds 446 | 447 | def getIdfromName(self, name): 448 | """ 449 | Helper function to get the ids by name for merging/removing function. 450 | """ 451 | for item in self.atlasStructureJSON: 452 | if (item['@type'] == "Structure" or item['@type'] == "Group"): 453 | if item['annotation']['name'] == name: 454 | return item['@id'] 455 | 456 | def remove(self, inputLabelMap, outputLabelMap): 457 | """ 458 | Run the processing algorithm. 459 | Can be used without GUI widget. 460 | """ 461 | groupsToRemove = [] 462 | checkedItems = self.getCheckedItems() 463 | for checkedItem in checkedItems: 464 | group = checkedItems[checkedItem] 465 | if group: 466 | groupsToRemove.append(group) 467 | 468 | # flatten the list 469 | groupsToRemove = [item for sublist in groupsToRemove for item in sublist] 470 | 471 | groupIdsToRemove = [] 472 | for group in groupsToRemove: 473 | groupIdsToRemove.append(self.getIdfromName(group)) 474 | 475 | structureIds = self.getStructureIdOfGroups(groupIdsToRemove) 476 | 477 | # Create segmentation 478 | segmentationNode = slicer.vtkMRMLSegmentationNode() 479 | slicer.mrmlScene.AddNode(segmentationNode) 480 | segmentationNode.CreateDefaultDisplayNodes() # only needed for display 481 | segmentationNode.SetReferenceImageGeometryParameterFromVolumeNode(inputLabelMap) 482 | 483 | slicer.util.showStatusMessage("Import to segmentation") 484 | slicer.app.processEvents(qt.QEventLoop.ExcludeUserInputEvents) 485 | slicer.vtkSlicerSegmentationsModuleLogic.ImportLabelmapToSegmentationNode(inputLabelMap, segmentationNode) 486 | 487 | segmentsNotFound = [] 488 | for i, structureId in enumerate(structureIds): 489 | slicer.util.showStatusMessage(f"Merging segment ({i}/{len(structureIds)}): {structureId}") 490 | slicer.app.processEvents(qt.QEventLoop.ExcludeUserInputEvents) 491 | segmentID = segmentationNode.GetSegmentation().GetSegmentIdBySegmentName(structureId) 492 | if not segmentID: 493 | segmentsNotFound.append(structureId) 494 | continue 495 | segmentationNode.RemoveSegment(segmentID) 496 | 497 | slicer.util.showStatusMessage("Export to labelmap") 498 | slicer.app.processEvents(qt.QEventLoop.ExcludeUserInputEvents) 499 | slicer.app.pauseRender() 500 | slicer.vtkSlicerSegmentationsModuleLogic.ExportAllSegmentsToLabelmapNode(segmentationNode, outputLabelMap) 501 | 502 | slicer.util.showStatusMessage("Cleanup", 1000) 503 | slicer.app.processEvents(qt.QEventLoop.ExcludeUserInputEvents) 504 | 505 | slicer.mrmlScene.RemoveNode(segmentationNode) 506 | slicer.app.resumeRender() 507 | if segmentsNotFound: 508 | raise RuntimeError(f"Failed to remove segments (they were not found in the segmentation): {segmentsNotFound}") 509 | 510 | slicer.util.showStatusMessage("Done", 1000) 511 | slicer.app.processEvents(qt.QEventLoop.ExcludeUserInputEvents) 512 | 513 | 514 | def mergeSegments(self, segmentationNode, segmentsToMerge, mergedSegmentName): 515 | 516 | groupIdsToMerge = [] 517 | for group in segmentsToMerge: 518 | groupIdsToMerge.append(self.getIdfromName(group)) 519 | structureIds = self.getStructureIdOfGroups(groupIdsToMerge) 520 | 521 | # Create temporary segment editor to get access to effects 522 | segmentEditorWidget = slicer.qMRMLSegmentEditorWidget() 523 | segmentEditorWidget.setMRMLScene(slicer.mrmlScene) 524 | self.segmentEditorNode = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLSegmentEditorNode") 525 | segmentEditorWidget.setMRMLSegmentEditorNode(self.segmentEditorNode) 526 | segmentEditorWidget.setSegmentationNode(segmentationNode) 527 | self.segmentEditorNode.SetOverwriteMode(slicer.vtkMRMLSegmentEditorNode.OverwriteAllSegments) 528 | self.segmentEditorNode.SetMaskMode(slicer.vtkMRMLSegmentationNode.EditAllowedEverywhere) 529 | 530 | segmentsNotFound = [] 531 | firstSegment = structureIds[0] 532 | selectedSegmentID = segmentationNode.GetSegmentation().GetSegmentIdBySegmentName(firstSegment) 533 | if not selectedSegmentID: 534 | segmentsNotFound.append(structureId) 535 | return segmentsNotFound 536 | self.segmentEditorNode.SetSelectedSegmentID(selectedSegmentID) 537 | 538 | segmentEditorWidget.setActiveEffectByName("Logical operators") 539 | effect = segmentEditorWidget.activeEffect() 540 | effect.setParameter("BypassMasking","0") 541 | effect.setParameter("Operation","UNION") 542 | 543 | for i, structureId in enumerate(structureIds): 544 | if i == 0: 545 | # We add all other segments to the first one 546 | continue 547 | slicer.util.showStatusMessage(f"Merging segment ({i}/{len(structureIds)}): {structureId}", 1000) 548 | slicer.app.processEvents(qt.QEventLoop.ExcludeUserInputEvents) 549 | modifierSegmentID = segmentationNode.GetSegmentation().GetSegmentIdBySegmentName(structureId) 550 | if not modifierSegmentID: 551 | segmentsNotFound.append(structureId) 552 | continue 553 | effect.setParameter("ModifierSegmentID",modifierSegmentID) 554 | effect.self().onApply() 555 | 556 | segmentationNode.GetSegmentation().GetSegment(selectedSegmentID).SetName(mergedSegmentName) 557 | 558 | return segmentsNotFound 559 | 560 | def merge(self, inputLabelMap, outputLabelMap): 561 | """ 562 | Run the processing algorithm. 563 | Can be used without GUI widget. 564 | """ 565 | # Create segmentation 566 | segmentationNode = slicer.vtkMRMLSegmentationNode() 567 | slicer.mrmlScene.AddNode(segmentationNode) 568 | segmentationNode.CreateDefaultDisplayNodes() # only needed for display 569 | segmentationNode.SetReferenceImageGeometryParameterFromVolumeNode(inputLabelMap) 570 | 571 | # Convert labelmap to Segmentation Node 572 | slicer.vtkSlicerSegmentationsModuleLogic.ImportLabelmapToSegmentationNode(inputLabelMap, segmentationNode) 573 | 574 | # Get checked items 575 | checkedItems = self.getCheckedItems(self.atlasStructureTree) 576 | 577 | itemsToMerge = dict() 578 | for checkedItem in checkedItems: 579 | item = checkedItems[checkedItem] 580 | if item: 581 | itemsToMerge[checkedItem] = item 582 | 583 | for i in itemsToMerge.items(): 584 | segmentsNotFound = self.mergeSegments(segmentationNode, i[1], i[0]) 585 | 586 | slicer.vtkSlicerSegmentationsModuleLogic.ExportAllSegmentsToLabelmapNode(segmentationNode, outputLabelMap) 587 | 588 | slicer.mrmlScene.RemoveNode(segmentationNode) 589 | 590 | if segmentsNotFound: 591 | raise RuntimeError(f"Failed to merge segments (they were not found in the segmentation): {segmentsNotFound}") 592 | -------------------------------------------------------------------------------- /AtlasEditor/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | #----------------------------------------------------------------------------- 2 | set(MODULE_NAME AtlasEditor) 3 | 4 | #----------------------------------------------------------------------------- 5 | set(MODULE_PYTHON_SCRIPTS 6 | ${MODULE_NAME}.py 7 | ) 8 | 9 | set(MODULE_PYTHON_RESOURCES 10 | Resources/Icons/${MODULE_NAME}.png 11 | Resources/UI/${MODULE_NAME}.ui 12 | ) 13 | 14 | #----------------------------------------------------------------------------- 15 | slicerMacroBuildScriptedModule( 16 | NAME ${MODULE_NAME} 17 | SCRIPTS ${MODULE_PYTHON_SCRIPTS} 18 | RESOURCES ${MODULE_PYTHON_RESOURCES} 19 | WITH_GENERIC_TESTS 20 | ) 21 | 22 | #----------------------------------------------------------------------------- 23 | if(BUILD_TESTING) 24 | 25 | # Register the unittest subclass in the main script as a ctest. 26 | # Note that the test will also be available at runtime. 27 | slicer_add_python_unittest(SCRIPT ${MODULE_NAME}.py) 28 | 29 | # Additional build-time testing 30 | add_subdirectory(Testing) 31 | endif() 32 | -------------------------------------------------------------------------------- /AtlasEditor/README.md: -------------------------------------------------------------------------------- 1 | # Atlas Editor module 2 | 3 | “” 4 | 5 | This is a 3D Slicer module for editing and simplifying The Open Anatomy Project's atlas labelmaps by anatomical groups. 6 | 7 | The Open Anatomy Project aims to revolutionize the anatomy atlas by utilizing open data, fostering community-based collaborative development, and freely distributing medical knowledge. The project has several different digital anatomy atlases, including: 8 | - Mauritanian Anatomy Laboratory Thoracic Atlas 9 | - SPL/NAC Brain Atlas - :white_check_mark: (tested) 10 | - SPL Liver Atlas 11 | - SPL Head and Neck Atlas 12 | - SPL Inner Ear Atlas 13 | - SPL Knee Atlas 14 | - SPL Abdominal Atlas 15 | 16 | The Atlas Editor module is useful for editing and simplifying these comprehensive digital anatomy atlases for customization and tailoring of the atlas content to specific needs. Users can select and organize the structures/labels into groups that is most relevant to their particular field or area of study. 17 | 18 | ## Examples 19 | ### Merging 20 | #### Merging entire brain: 21 | ![](img/original-merge.png) 22 | ![](img/output-merge.png) 23 | 24 | #### Merging cerebellum: 25 | ![](img/merge-cerebellum.png) 26 | 27 | ### Removing 28 | #### Removing skin, head and neck muscles: 29 | ![](img/original-remove.png) 30 | ![](img/output-remove.png) 31 | 32 | ## Installation 33 | 34 | * Download and install a latest stable version of 3D Slicer (https://download.slicer.org). 35 | * Start 3D Slicer application, open the Extension Manager (menu: View / Extension manager) 36 | * Install AtlasEditor extension. 37 | 38 | ## Tutorial 39 | 40 | * Start 3D Slicer 41 | * Load an atlas labelmap: Add Data -> Colortable (e.g. hncma-atlas-lut.ctbl) -> OK -> Add Data -> Labelmap (e.g. hncma-atlas.nrrd) -> Check Show Options -> Check 'Label Map' -> Change colortable to the one imported -> OK 42 | * Switch to "Atlas Editor" module 43 | * Set 'Input Atlas Label Map' as the atlas label map imported. 44 | * Set 'Input Atlas Structure (.json)' - available in Open Anatomy atlas repository (e.g. atlasStructure.json) 45 | * Set 'Output Atlas Label Map' as 'Create a LabelMapVolume' or the imported atlas if you want to edit the original. 46 | * Click 'Update' to show the hierachy tree structure from the json file. 47 | * Check items that is to be merged or removed. 48 | * Click 'Merge' or 'Remove'. 49 | 50 | ## Visualize and save results 51 | * Open "Data" and turn on the visibility of the new labelmapvolume. 52 | * Convert labelmap to segmentation node to edit/visualise further. 53 | 54 | ## For Developers 55 | Open Anatomy's Atlas Browser 56 | https://github.com/mhalle/oabrowser/ 57 | 58 | SPL/NAC Brain Atlas 59 | https://github.com/mhalle/spl-brain-atlas 60 | 61 | Atlas Structure Schema 62 | https://github.com/stity/mrmlToJson 63 | https://github.com/stity/atlas-schema 64 | 65 | ## Future Work 66 | 67 | * Preview selected items/group in the viewer before clicking merging/remove. 68 | * Allow user to rearrange/categories different anatomical groups and create their own hierachal structure. 69 | * Display results in viewer. 70 | * Store hierachy information as segment tages (as list of key/value pairs for each segment) or store some ID in the segment tag to link the segment to OpenAnatomy metadata (Andras Lasso). 71 | * Display atlas in 3D using segmentation nodes and allow more segment editing operations (Andras Lasso). 72 | * Move/merge this extension with the SlicerOpenAnatomy extension (Andras Lasso). 73 | 74 | ## Acknowledgments 75 | This research was supported by an Australian Government Research Training Program (RTP) Scholarship. 76 | 77 | The Open Anatomy Browser: A Collaborative Web-Based Viewer for Interoperable Anatomy Atlases, Halle M, Demeusy V, Kikinis R. Front Neuroinform. 2017 Mar 27;11:22. doi:10.3389/fninf.2017.00022. 78 | 79 | ## License 80 | The code presented here is distributed under the Apache license (https://www.apache.org/licenses/LICENSE-2.0). 81 | 82 | ## Citing 83 | 84 | If you use SlicerAtlasEditor for your research, please consider adding the following citation: 85 | 86 | Huynh, A.H. (2023) Slicer Atlas Editor. Available at: https://github.com/andy9t7/SlicerAtlasEditor 87 | 88 | BibTeX: 89 | 90 | @Misc{huynh_2023_sliceratlaseditor, 91 | author = {Andy T. Huynh}, 92 | title = {{SlicerAtlasEditor} {A}tlas {E}ditor}, 93 | howpublished = {\url{https://github.com/andy9t7/SlicerAtlasEditor}}, 94 | year = 2023 95 | } 96 | 97 | ## Contact 98 | 99 | Please post any questions to the [Slicer Forum](https://discourse.slicer.org). 100 | 101 | Andy Trung Huynh - andy.huynh@research.uwa.edu.au -------------------------------------------------------------------------------- /AtlasEditor/Resources/Icons/AtlasEditor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/AtlasEditor/Resources/Icons/AtlasEditor.png -------------------------------------------------------------------------------- /AtlasEditor/Resources/UI/AtlasEditor.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | AtlasEditor 4 | 5 | 6 | 7 | 0 8 | 0 9 | 268 10 | 591 11 | 12 | 13 | 14 | 15 | 16 | 17 | Import Atlas via Open Anatomy: 18 | 19 | 20 | false 21 | 22 | 23 | 24 | 25 | 26 | Atlas: 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | SPL/NAC Brain Atlas 35 | 36 | 37 | 38 | 39 | SPL Liver Atlas 40 | 41 | 42 | 43 | 44 | SPL Head and Neck Atlas 45 | 46 | 47 | 48 | 49 | SPL Inner Ear Atlas 50 | 51 | 52 | 53 | 54 | SPL Knee Atlas 55 | 56 | 57 | 58 | 59 | SPL Abdominal Atlas 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | Download and Import 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | Manually Import Atlas: 78 | 79 | 80 | true 81 | 82 | 83 | 84 | 85 | 86 | Atlas Label Map: 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | vtkMRMLLabelMapVolumeNode 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | true 105 | 106 | 107 | 108 | 109 | 110 | 111 | Atlas Structure (.json): 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | Update 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | Atlas Structure 132 | 133 | 134 | 135 | 136 | 137 | 138 | false 139 | 140 | 141 | 142 | 1 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | Output 151 | 152 | 153 | 154 | 155 | 156 | Output Atlas LabelMap: 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | vtkMRMLLabelMapVolumeNode 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | Qt::Vertical 182 | 183 | 184 | 185 | 20 186 | 100 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | Merge 195 | 196 | 197 | 198 | 199 | 200 | 201 | Remove 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | ctkCollapsibleButton 210 | QWidget 211 |
ctkCollapsibleButton.h
212 | 1 213 |
214 | 215 | ctkPathLineEdit 216 | QWidget 217 |
ctkPathLineEdit.h
218 |
219 | 220 | qMRMLCollapsibleButton 221 | ctkCollapsibleButton 222 |
qMRMLCollapsibleButton.h
223 | 1 224 |
225 | 226 | qMRMLNodeComboBox 227 | QWidget 228 |
qMRMLNodeComboBox.h
229 |
230 | 231 | qMRMLWidget 232 | QWidget 233 |
qMRMLWidget.h
234 | 1 235 |
236 |
237 | 238 | 239 | 240 | AtlasEditor 241 | mrmlSceneChanged(vtkMRMLScene*) 242 | atlasLabelMapInputSelector 243 | setMRMLScene(vtkMRMLScene*) 244 | 245 | 246 | 189 247 | 236 248 | 249 | 250 | 189 251 | 36 252 | 253 | 254 | 255 | 256 | atlasStructureInputPath 257 | currentPathChanged(QString) 258 | atlasStructureInputPath 259 | setCurrentPath(QString) 260 | 261 | 262 | 133 263 | 196 264 | 265 | 266 | 189 267 | 82 268 | 269 | 270 | 271 | 272 | AtlasEditor 273 | mrmlSceneChanged(vtkMRMLScene*) 274 | atlasLabelMapOutputSelector 275 | setMRMLScene(vtkMRMLScene*) 276 | 277 | 278 | 189 279 | 236 280 | 281 | 282 | 189 283 | 128 284 | 285 | 286 | 287 | 288 |
289 | -------------------------------------------------------------------------------- /AtlasEditor/Testing/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | add_subdirectory(Python) 2 | -------------------------------------------------------------------------------- /AtlasEditor/Testing/Python/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | 2 | #slicer_add_python_unittest(SCRIPT ${MODULE_NAME}ModuleTest.py) 3 | -------------------------------------------------------------------------------- /AtlasEditor/img/AtlasEditor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/AtlasEditor/img/AtlasEditor.png -------------------------------------------------------------------------------- /AtlasEditor/img/merge-cerebellum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/AtlasEditor/img/merge-cerebellum.png -------------------------------------------------------------------------------- /AtlasEditor/img/merge-roi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/AtlasEditor/img/merge-roi.png -------------------------------------------------------------------------------- /AtlasEditor/img/original-merge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/AtlasEditor/img/original-merge.png -------------------------------------------------------------------------------- /AtlasEditor/img/original-remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/AtlasEditor/img/original-remove.png -------------------------------------------------------------------------------- /AtlasEditor/img/output-merge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/AtlasEditor/img/output-merge.png -------------------------------------------------------------------------------- /AtlasEditor/img/output-remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/AtlasEditor/img/output-remove.png -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.5) 2 | 3 | project(SlicerOpenAnatomy) 4 | 5 | #----------------------------------------------------------------------------- 6 | # Extension meta-information 7 | set(EXTENSION_HOMEPAGE "https://github.com/PerkLab/SlicerOpenAnatomy") 8 | set(EXTENSION_CATEGORY "Segmentation") 9 | set(EXTENSION_CONTRIBUTORS "Andras Lasso (PerkLab), Csaba Pinter (PerkLab), Michael Halle (SPL)") 10 | set(EXTENSION_DESCRIPTION "3D Slicer extension for exporting Slicer scenes to use in the OpenAnatomy.org browser") 11 | set(EXTENSION_ICONURL "https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/master/SlicerOpenAnatomy.png") 12 | set(EXTENSION_SCREENSHOTURLS "https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/master/OpenAnatomyExport/img/Screenshot01.png https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/master/OpenAnatomyExport/img/Screenshot02.png https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/master/OpenAnatomyExport/img/Screenshot03.png") 13 | set(EXTENSION_DEPENDS "NA") # Specified as a space separated string, a list or 'NA' if any 14 | 15 | #----------------------------------------------------------------------------- 16 | # Extension dependencies 17 | find_package(Slicer REQUIRED) 18 | include(${Slicer_USE_FILE}) 19 | 20 | #----------------------------------------------------------------------------- 21 | # Extension modules 22 | add_subdirectory(OpenAnatomyExport) 23 | add_subdirectory(AtlasEditor) 24 | ## NEXT_MODULE 25 | 26 | #----------------------------------------------------------------------------- 27 | include(${Slicer_EXTENSION_GENERATE_CONFIG}) 28 | include(${Slicer_EXTENSION_CPACK}) 29 | -------------------------------------------------------------------------------- /OpenAnatomyExport/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | #----------------------------------------------------------------------------- 2 | set(MODULE_NAME OpenAnatomyExport) 3 | 4 | #----------------------------------------------------------------------------- 5 | set(MODULE_PYTHON_SCRIPTS 6 | ${MODULE_NAME}.py 7 | ) 8 | 9 | set(MODULE_PYTHON_RESOURCES 10 | Resources/Icons/${MODULE_NAME}.png 11 | Resources/UI/${MODULE_NAME}.ui 12 | ) 13 | 14 | #----------------------------------------------------------------------------- 15 | slicerMacroBuildScriptedModule( 16 | NAME ${MODULE_NAME} 17 | SCRIPTS ${MODULE_PYTHON_SCRIPTS} 18 | RESOURCES ${MODULE_PYTHON_RESOURCES} 19 | WITH_GENERIC_TESTS 20 | ) 21 | 22 | #----------------------------------------------------------------------------- 23 | if(BUILD_TESTING) 24 | 25 | # Register the unittest subclass in the main script as a ctest. 26 | # Note that the test will also be available at runtime. 27 | slicer_add_python_unittest(SCRIPT ${MODULE_NAME}.py) 28 | 29 | # Additional build-time testing 30 | add_subdirectory(Testing) 31 | endif() 32 | -------------------------------------------------------------------------------- /OpenAnatomyExport/OpenAnatomyExport.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import unittest 4 | from unittest.runner import TextTestResult 5 | import vtk, qt, ctk, slicer 6 | from slicer.ScriptedLoadableModule import * 7 | import logging 8 | 9 | # 10 | # OpenAnatomyExport 11 | # 12 | 13 | class OpenAnatomyExport(ScriptedLoadableModule): 14 | """Uses ScriptedLoadableModule base class, available at: 15 | https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py 16 | """ 17 | 18 | def __init__(self, parent): 19 | ScriptedLoadableModule.__init__(self, parent) 20 | self.parent.title = "OpenAnatomy Export" 21 | self.parent.categories = ["OpenAnatomy"] 22 | self.parent.dependencies = [] 23 | self.parent.contributors = ["Andras Lasso (PerkLab), Csaba Pinter (PerkLab)"] 24 | self.parent.helpText = """ 25 | Export model hierarchy or segmentation to OpenAnatomy-compatible glTF file. 26 | """ 27 | self.parent.helpText += self.getDefaultModuleDocumentationLink() 28 | self.parent.acknowledgementText = """ 29 | """ # replace with organization, grant and thanks. 30 | 31 | # 32 | # OpenAnatomyExportWidget 33 | # 34 | 35 | class OpenAnatomyExportWidget(ScriptedLoadableModuleWidget): 36 | """Uses ScriptedLoadableModuleWidget base class, available at: 37 | https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py 38 | """ 39 | 40 | def setup(self): 41 | ScriptedLoadableModuleWidget.setup(self) 42 | 43 | self.logic = OpenAnatomyExportLogic() 44 | self.logic.logCallback = self.addLog 45 | 46 | # Load widget from .ui file (created by Qt Designer) 47 | uiWidget = slicer.util.loadUI(self.resourcePath('UI/OpenAnatomyExport.ui')) 48 | self.layout.addWidget(uiWidget) 49 | self.ui = slicer.util.childWidgetVariables(uiWidget) 50 | 51 | # Set scene in MRML widgets 52 | self.ui.inputSelector.setMRMLScene(slicer.mrmlScene) 53 | self.ui.imageInputSelector.setMRMLScene(slicer.mrmlScene) 54 | 55 | # Connections 56 | self.ui.exportButton.connect('clicked(bool)', self.onExportButton) 57 | self.ui.inputSelector.connect("currentItemChanged(vtkIdType)", self.onSelect) 58 | self.ui.outputFormatSelector.connect("currentIndexChanged(int)", self.onSelect) 59 | 60 | self.ui.imageExportButton.connect('clicked(bool)', self.onImageExportButton) 61 | self.ui.imageInputSelector.connect("currentNodeChanged(vtkMRMLNode*)", self.onSelect) 62 | self.ui.imageOutputFormatSelector.connect("currentIndexChanged(int)", self.onSelect) 63 | 64 | # Add vertical spacer 65 | self.layout.addStretch(1) 66 | 67 | # Refresh Export button state 68 | self.onSelect() 69 | 70 | def cleanup(self): 71 | pass 72 | 73 | def onSelect(self): 74 | currentItemId = self.ui.inputSelector.currentItem() 75 | shNode = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene) 76 | owner = shNode.GetItemOwnerPluginName(currentItemId) if currentItemId else "" 77 | self.ui.exportButton.enabled = (owner == "Folder" or owner == "Segmentations") 78 | 79 | currentFormat = self.ui.outputFormatSelector.currentText 80 | self.ui.outputModelHierarchyLabel.visible = (currentFormat == "scene") 81 | self.ui.outputFileFolderSelector.visible = (currentFormat != "scene") 82 | 83 | self.ui.imageExportButton.enabled = self.ui.imageInputSelector.currentNode() 84 | 85 | def onExportButton(self): 86 | slicer.app.setOverrideCursor(qt.Qt.WaitCursor) 87 | try: 88 | self.ui.statusLabel.plainText = '' 89 | self.addLog('Exporting...') 90 | self.ui.outputFileFolderSelector.addCurrentPathToHistory() 91 | reductionFactor = self.ui.reductionFactorSliderWidget.value 92 | outputFormat = self.ui.outputFormatSelector.currentText 93 | outputFolder = self.ui.inputSelector.currentItem() if outputFormat == "models" else self.ui.outputFileFolderSelector.currentPath 94 | self.logic.exportModel(self.ui.inputSelector.currentItem(), outputFolder, reductionFactor, outputFormat) 95 | self.addLog('Export successful.') 96 | except Exception as e: 97 | self.addLog("Error: {0}".format(str(e))) 98 | import traceback 99 | traceback.print_exc() 100 | self.addLog('Export failed.') 101 | slicer.app.restoreOverrideCursor() 102 | 103 | def onImageExportButton(self): 104 | slicer.app.setOverrideCursor(qt.Qt.WaitCursor) 105 | try: 106 | self.ui.imageOutputFileFolderSelector.addCurrentPathToHistory() 107 | imageOutputFormat = self.ui.imageOutputFormatSelector.currentText 108 | imageOutputFolder = self.ui.imageOutputFileFolderSelector.currentPath 109 | self.logic.exportImage(self.ui.imageInputSelector.currentNode(), imageOutputFormat, imageOutputFolder) 110 | slicer.util.delayDisplay('Export successful.') 111 | except Exception as e: 112 | logging.error("Error: {0}".format(str(e))) 113 | import traceback 114 | traceback.print_exc() 115 | slicer.util.errorDisplay('Export failed. See application log for details.') 116 | slicer.app.restoreOverrideCursor() 117 | 118 | def addLog(self, text): 119 | """Append text to log window 120 | """ 121 | self.ui.statusLabel.appendPlainText(text) 122 | slicer.app.processEvents() # force update 123 | 124 | # 125 | # OpenAnatomyExportLogic 126 | # 127 | 128 | class OpenAnatomyExportLogic(ScriptedLoadableModuleLogic): 129 | """This class should implement all the actual 130 | computation done by your module. The interface 131 | should be such that other python code can import 132 | this class and make use of the functionality without 133 | requiring an instance of the Widget. 134 | Uses ScriptedLoadableModuleLogic base class, available at: 135 | https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py 136 | """ 137 | 138 | def __init__(self): 139 | ScriptedLoadableModuleLogic.__init__(self) 140 | self.logCallback = None 141 | self._exportToFile = True # Save to files or just to the scene, normally on, maybe useful to turn off for debugging 142 | self.reductionFactor = 0.9 143 | 144 | # Slicer uses Gouraud lighting model by default, while glTF requires PBR. 145 | # Material properties conversion in VTK makes the model appear in glTF very dull, faded out, 146 | # therefore if we export models with Gouraud lighting we adjust the saturation and brightness. 147 | # By testing on a few anatomical atlases, saturation increase by 1.5x and no brightness 148 | # change seems to be working well. 149 | self.saturationBoost = 1.5 150 | self.brightnessBoost = 1.0 151 | 152 | self._outputShFolderItemId = None 153 | self._numberOfExpectedModels = 0 154 | self._numberOfProcessedModels = 0 155 | self._renderer = None 156 | self._renderWindow = None 157 | self._decimationParameterNode = None 158 | self._temporaryExportNodes = [] # temporary nodes used during exportModel 159 | self._gltfNodes = [] 160 | self._gltfMeshes = [] 161 | 162 | 163 | def addLog(self, text): 164 | logging.info(text) 165 | if self.logCallback: 166 | self.logCallback(text) 167 | 168 | 169 | def isValidInputOutputData(self, inputNode): 170 | """Validates if the output is not the same as input 171 | """ 172 | if not inputNode: 173 | logging.debug('isValidInputOutputData failed: no input node defined') 174 | return False 175 | return True 176 | 177 | 178 | def exportModel(self, inputItem, outputFolder=None, reductionFactor=None, outputFormat=None): 179 | if outputFormat is None: 180 | outputFormat = "glTF" 181 | if reductionFactor is not None: 182 | self.reductionFactor = reductionFactor 183 | self._exportToFile = (outputFormat != "scene") 184 | if outputFolder is None: 185 | if self._exportToFile: 186 | raise ValueError("Output folder must be specified if output format is not 'scene'") 187 | 188 | shNode = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene) 189 | inputName = shNode.GetItemName(inputItem) 190 | # Remove characters from name that cannot be used in file names 191 | inputName = slicer.app.ioManager().forceFileNameValidCharacters(inputName) 192 | 193 | # Get input as a subject hierarchy folder 194 | owner = shNode.GetItemOwnerPluginName(inputItem) 195 | if owner == "Folder": 196 | # Input is already a model hiearachy 197 | inputShFolderItemId = inputItem 198 | self._outputShFolderItemId = shNode.CreateFolderItem(shNode.GetSceneItemID(), inputName + " export") 199 | elif owner == "Segmentations": 200 | # Export segmentation to model hierarchy 201 | segLogic = slicer.modules.segmentations.logic() 202 | folderName = inputName + '_Models' 203 | inputShFolderItemId = shNode.CreateFolderItem(shNode.GetSceneItemID(), folderName) 204 | inputSegmentationNode = shNode.GetItemDataNode(inputItem) 205 | self.addLog('Export segmentation to models. This may take a few minutes.') 206 | success = segLogic.ExportAllSegmentsToModels(inputSegmentationNode, inputShFolderItemId) 207 | 208 | self._outputShFolderItemId = inputShFolderItemId 209 | else: 210 | raise ValueError("Input item must be a segmentation node or a folder containing model nodes") 211 | 212 | modelNodes = vtk.vtkCollection() 213 | shNode.GetDataNodesInBranch(inputShFolderItemId, modelNodes, "vtkMRMLModelNode") 214 | planeNodes = vtk.vtkCollection() 215 | shNode.GetDataNodesInBranch(inputShFolderItemId, planeNodes, "vtkMRMLMarkupsPlaneNode") 216 | self._numberOfExpectedModels = modelNodes.GetNumberOfItems() + planeNodes.GetNumberOfItems() 217 | self._numberOfProcessedModels = 0 218 | self._gltfNodes = [] 219 | self._gltfMeshes = [] 220 | 221 | # Add models to a self._renderer 222 | self.addModelsToRenderer(inputShFolderItemId, boostGouraudColor = (outputFormat == "glTF")) 223 | 224 | if self._exportToFile: 225 | outputFileName = inputName 226 | # import datetime 227 | # dateTimeStr = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 228 | # outputFileName += dateTimeStr 229 | outputFilePathBase = os.path.join(outputFolder, outputFileName) 230 | if outputFormat == "glTF": 231 | exporter = vtk.vtkGLTFExporter() 232 | outputFilePath = outputFilePathBase+'.gltf' 233 | exporter.SetFileName(outputFilePath) 234 | exporter.InlineDataOn() # save to single file 235 | exporter.SaveNormalOn() # save surface normals 236 | elif outputFormat == "OBJ": 237 | exporter = vtk.vtkOBJExporter() 238 | outputFilePath = outputFilePathBase + '.obj' 239 | exporter.SetFilePrefix(outputFilePathBase) 240 | else: 241 | raise ValueError("Output format must be scene, glTF, or OBJ") 242 | 243 | self.addLog(f"Writing file {outputFilePath}...") 244 | exporter.SetRenderWindow(self._renderWindow) 245 | exporter.Write() 246 | 247 | if outputFormat == "glTF": 248 | 249 | # Fix up the VTK-generated glTF file 250 | 251 | import json 252 | with open(outputFilePath, 'r') as f: 253 | jsonData = json.load(f) 254 | 255 | # Update mesh names 256 | for meshIndex, mesh in enumerate(self._gltfMeshes): 257 | jsonData['meshes'][meshIndex]['name'] = mesh['name'] 258 | 259 | # VTK uses "OPAQUE" alpha mode for all meshes, which would make all nodes appear opaque. 260 | # Replace alpha mode by "BLEND" for semi-transparent meshes. 261 | for material in jsonData['materials']: 262 | rgbaColor = material['pbrMetallicRoughness']['baseColorFactor'] 263 | if rgbaColor[3] < 1.0: 264 | material['alphaMode'] = 'BLEND' 265 | 266 | # Add camera nodes from the VTK-exported file 267 | for node in enumerate(self._gltfNodes): 268 | if 'camera' in node: 269 | self._gltfNodes.append(node) 270 | 271 | # Replace the entire hierarchy 272 | jsonData['nodes'] = self._gltfNodes 273 | 274 | # Set up root node 275 | rootNodeIndex = len(self._gltfNodes)-1 276 | 277 | # According to glTF specifications (3.4. Coordinate System and Units 278 | # https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#coordinate-system-and-units): 279 | # 280 | # glTF uses a right-handed coordinate system. glTF defines +Y as up, +Z as forward, and -X as right; the front of a glTF asset faces +Z. 281 | # The units for all linear distances are meters. 282 | 283 | # View up direction in glTF is +Y. 284 | # We map that to anatomical S direction by this transform (from LPS to LSA coordinate system). 285 | 286 | # Default coordinate system unit in Slicer is millimeters, therefore we need to scale the model 287 | # from the scene's length unit. Currently only "mm" and "m" units are supported. 288 | selectionNode = slicer.mrmlScene.GetNodeByID("vtkMRMLSelectionNodeSingleton") 289 | unitNode = slicer.mrmlScene.GetNodeByID(selectionNode.GetUnitNodeID("length")) 290 | lengthUnitSuffix = unitNode.GetSuffix() 291 | if lengthUnitSuffix == "mm": 292 | scaleToMeters = 0.001 293 | elif lengthUnitSuffix == "m": 294 | scaleToMeters = 1.0 295 | else: 296 | msg = f"Unsupported length unit ({lengthUnitSuffix}). Exported glTF file will not be scaled to meters!" 297 | self.addLog(msg) 298 | logging.warning(msg) 299 | scaleToMeters = 1.0 300 | 301 | # Transform from LPS coordinate system (in millimeters) to LSA coordinate system (in meters) 302 | jsonData['nodes'][rootNodeIndex]['matrix'] = [ 303 | scaleToMeters, 0.0, 0.0, 0.0, 304 | 0.0, 0.0, -scaleToMeters, 0.0, 305 | 0.0, scaleToMeters, 0.0, 0.0, 306 | 0.0, 0.0, 0.0, 1.0 307 | ] 308 | 309 | # The scene root is the last node in the self._gltfNodes list 310 | jsonData['scenes'][0]['nodes'] = [rootNodeIndex] 311 | 312 | jsonData['asset']['generator'] = f"{slicer.app.applicationName} {slicer.app.applicationVersion}" 313 | 314 | with open(outputFilePath, 'w') as f: 315 | f.write(json.dumps(jsonData, indent=3)) 316 | 317 | # TODO: 318 | # - Add scene view states as scenes 319 | # - Add option to change up vector (glTF defines the y axis as up, https://github.com/KhronosGroup/glTF/issues/1043 320 | # https://castle-engine.io/manual_up.php) 321 | 322 | # # Preview 323 | # iren = vtk.vtkRenderWindowInteractor() 324 | # iren.SetRenderWindow(renderWindow) 325 | # iren.Initialize() 326 | # renderer.ResetCamera() 327 | # renderer.GetActiveCamera().Zoom(1.5) 328 | # renderWindow.Render() 329 | # iren.Start() 330 | 331 | # Remove temporary nodes 332 | for node in self._temporaryExportNodes: 333 | slicer.mrmlScene.RemoveNode(node) 334 | self._temporaryExportNodes = [] 335 | 336 | self._numberOfExpectedModels = 0 337 | self._numberOfProcessedModels = 0 338 | self._renderer = None 339 | self._renderWindow = None 340 | self._decimationParameterNode = None 341 | 342 | if self._exportToFile: 343 | shNode.RemoveItem(self._outputShFolderItemId) 344 | 345 | def exportImage(self, volumeNode, outputFormat, outputFolder): 346 | writer=vtk.vtkXMLImageDataWriter() 347 | writer.SetFileName("{0}/{1}.vti".format(outputFolder, volumeNode.GetName())) 348 | writer.SetInputData(volumeNode.GetImageData()) 349 | writer.SetCompressorTypeToZLib() 350 | writer.Write() 351 | 352 | 353 | def addModelsToRenderer(self, shFolderItemId, boostGouraudColor=False): 354 | if not shFolderItemId: 355 | raise ValueError("Subject hierarchy folder does not exist.") 356 | 357 | gltfFolderNodeChildren = [] # gltf node indices of these item's children 358 | 359 | if self._exportToFile: 360 | if not self._renderer: 361 | self._renderer = vtk.vtkRenderer() 362 | if not self._renderWindow: 363 | self._renderWindow = vtk.vtkRenderWindow() 364 | self._renderWindow.AddRenderer(self._renderer) 365 | 366 | slicer.app.pauseRender() 367 | try: 368 | 369 | shNode = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene) 370 | folderName = shNode.GetItemName(shFolderItemId) 371 | # Remove characters from name that cannot be used in file names 372 | folderName = slicer.app.ioManager().forceFileNameValidCharacters(folderName) 373 | self.addLog(f"Writing {folderName}...") 374 | 375 | # Write all children of this item (recursively) 376 | childIds = vtk.vtkIdList() 377 | shNode.GetItemChildren(shFolderItemId, childIds) 378 | for itemIdIndex in range(childIds.GetNumberOfIds()): 379 | shItemId = childIds.GetId(itemIdIndex) 380 | dataNode = shNode.GetItemDataNode(shItemId) 381 | dataNotNone = dataNode is not None 382 | isModel = dataNotNone and dataNode.IsA("vtkMRMLModelNode") 383 | isMarkupsPlane = dataNotNone and dataNode.IsA("vtkMRMLMarkupsPlaneNode") 384 | dataIsValid = (isModel or isMarkupsPlane) 385 | if dataIsValid: 386 | if dataNode.IsA("vtkMRMLModelNode"): 387 | inputModelNode = dataNode 388 | else: 389 | inputModelNode = self.createPlaneModelFromMarkupsPlane(dataNode) 390 | meshName = dataNode.GetName() 391 | self._numberOfProcessedModels += 1 392 | self.addLog("Model {0}/{1}: {2}".format(self._numberOfProcessedModels, self._numberOfExpectedModels, meshName)) 393 | 394 | # Reuse existing model node if already exists 395 | existingOutputModelItemId = shNode.GetItemChildWithName(self._outputShFolderItemId, inputModelNode.GetName()) 396 | if existingOutputModelItemId: 397 | outputModelNode = shNode.GetItemDataNode(existingOutputModelItemId) 398 | else: 399 | outputModelNode = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLModelNode") 400 | outputModelNode.CreateDefaultDisplayNodes() 401 | outputModelNode.SetName(inputModelNode.GetName()) 402 | outputModelNode.GetDisplayNode().CopyContent(inputModelNode.GetDisplayNode()) 403 | if self._exportToFile: 404 | self._temporaryExportNodes.append(outputModelNode) 405 | 406 | if self.addModelToRenderer(inputModelNode, outputModelNode, boostGouraudColor): 407 | 408 | # Convert atlas model names (such as 'Model_505_left_lateral_geniculate_body') to simple names 409 | # by stripping the prefix and converting underscore to space. 410 | match = re.match(r'^Model_[0-9]+_(.+)', meshName) 411 | if match: 412 | meshName = match.groups()[0].replace('_', ' ') 413 | 414 | gltfMeshIndex = len(self._gltfMeshes) 415 | self._gltfMeshes.append({'name': meshName}) 416 | gltfMeshNodeIndex = len(self._gltfNodes) 417 | self._gltfNodes.append({'mesh': gltfMeshIndex, 'name': meshName}) 418 | gltfFolderNodeChildren.append(gltfMeshNodeIndex) 419 | 420 | if dataNode and dataNode.IsA("vtkMRMLMarkupsPlaneNode"): 421 | slicer.mrmlScene.RemoveNode(inputModelNode) 422 | 423 | # Write all children of this child item 424 | grandChildIds = vtk.vtkIdList() 425 | shNode.GetItemChildren(shItemId, grandChildIds) 426 | if grandChildIds.GetNumberOfIds() > 0: 427 | self.addModelsToRenderer(shItemId, boostGouraudColor) 428 | # added highest-level parent folder is the last node 429 | gltfFolderNodeIndex = len(self._gltfNodes)-1 430 | gltfFolderNodeChildren.append(gltfFolderNodeIndex) 431 | 432 | # Processed all items in the folder, now save the folder information 433 | self._gltfNodes.append({'name': folderName, 'children': gltfFolderNodeChildren}) 434 | 435 | finally: 436 | slicer.app.resumeRender() 437 | 438 | 439 | def addModelToRenderer(self, inputModelNode, outputModelNode, boostGouraudColor=False): 440 | '''Update output model in the scene and if valid add to self._renderer. 441 | :return: True if an actor is added to the renderer. 442 | ''' 443 | decimation = slicer.modules.decimation 444 | if not self._decimationParameterNode: 445 | self._decimationParameterNode = slicer.modules.decimation.logic().CreateNodeInScene() 446 | self._decimationParameterNode.SetParameterAsFloat("reductionFactor", self.reductionFactor) 447 | self._temporaryExportNodes.append(self._decimationParameterNode) 448 | 449 | # Quadric decimation 450 | # 451 | # Models with very small number of points are not decimated, as the memory saving is 452 | # negligible and the models may become severely distorted. 453 | # 454 | # Models that contain lines or vertices are not decimated either because the current 455 | # quadric decimation implementation would remove vertices and lines. 456 | 457 | if ((self.reductionFactor == 0.0) or (inputModelNode.GetPolyData().GetNumberOfPoints() < 50) 458 | or (inputModelNode.GetPolyData().GetLines().GetNumberOfCells() > 0) 459 | or (inputModelNode.GetPolyData().GetVerts().GetNumberOfCells() > 0)): 460 | 461 | # Skip decimation 462 | outputModelNode.CopyContent(inputModelNode) 463 | 464 | else: 465 | 466 | # Temporary workaround (part 1/2): 467 | # VTK 9.0 OBJ writer creates invalid OBJ file if there are triangle 468 | # strips and normals but no texture coords. 469 | # As a workaround, temporarily remove point normals in this case. 470 | # This workaround can be removed when Slicer's VTK includes this fix: 471 | # https://gitlab.kitware.com/vtk/vtk/-/merge_requests/8747 472 | if (inputModelNode.GetPolyData().GetNumberOfStrips() > 0 473 | and inputModelNode.GetPolyData().GetPointData() 474 | and inputModelNode.GetPolyData().GetPointData().GetNormals() 475 | and not inputModelNode.GetPolyData().GetPointData().GetTCoords()): 476 | # Save original normals and temporarily remove normals 477 | originalNormals = inputModelNode.GetPolyData().GetPointData().GetNormals() 478 | inputModelNode.GetPolyData().GetPointData().SetNormals(None) 479 | else: 480 | originalNormals = None 481 | 482 | self._decimationParameterNode.SetParameterAsNode("inputModel", inputModelNode) 483 | self._decimationParameterNode.SetParameterAsNode("outputModel", outputModelNode) 484 | slicer.cli.runSync(decimation, self._decimationParameterNode) 485 | 486 | # Temporary workaround (part 2/2): 487 | # Restore original normals. 488 | if originalNormals: 489 | inputModelNode.GetPolyData().GetPointData().SetNormals(originalNormals) 490 | 491 | # Compute normals 492 | decimatedNormals = vtk.vtkPolyDataNormals() 493 | decimatedNormals.SetInputData(outputModelNode.GetPolyData()) 494 | decimatedNormals.SplittingOff() 495 | decimatedNormals.Update() 496 | outputPolyData = decimatedNormals.GetOutput() 497 | 498 | if outputPolyData.GetNumberOfPoints()==0 or outputPolyData.GetNumberOfCells()==0: 499 | self.addLog(" Warning: empty model, not exported.") 500 | return False 501 | 502 | if not self._exportToFile: 503 | return True 504 | 505 | # Normal array name is hardcoded into glTF exporter to "NORMAL". 506 | normalArray = outputPolyData.GetPointData().GetNormals() 507 | if normalArray is not None: # polylines and vertices do not have normals 508 | normalArray.SetName("NORMAL") 509 | outputModelNode.SetAndObservePolyData(outputPolyData) 510 | 511 | ras2lps = vtk.vtkMatrix4x4() 512 | ras2lps.SetElement(0,0,-1) 513 | ras2lps.SetElement(1,1,-1) 514 | ras2lpsTransform = vtk.vtkTransform() 515 | ras2lpsTransform.SetMatrix(ras2lps) 516 | transformer = vtk.vtkTransformPolyDataFilter() 517 | transformer.SetTransform(ras2lpsTransform) 518 | transformer.SetInputConnection(outputModelNode.GetPolyDataConnection()) 519 | 520 | actor = vtk.vtkActor() 521 | mapper = vtk.vtkPolyDataMapper() 522 | mapper.SetInputConnection(transformer.GetOutputPort()) 523 | actor.SetMapper(mapper) 524 | displayNode = outputModelNode.GetDisplayNode() 525 | 526 | colorRGB = displayNode.GetColor() 527 | if displayNode.GetInterpolation() == slicer.vtkMRMLDisplayNode.PBRInterpolation: 528 | actor.GetProperty().SetColor(colorRGB[0], colorRGB[1], colorRGB[2]) 529 | actor.GetProperty().SetInterpolationToPBR() 530 | actor.GetProperty().SetMetallic(displayNode.GetMetallic()) 531 | actor.GetProperty().SetRoughness(displayNode.GetRoughness()) 532 | else: 533 | if boostGouraudColor: 534 | bf = colorRGB 535 | colorHSV = [0, 0, 0] 536 | vtk.vtkMath.RGBToHSV(colorRGB, colorHSV) 537 | colorHSV[1] = min(colorHSV[1] * self.saturationBoost, 1.0) # increase saturation 538 | colorHSV[2] = min(colorHSV[2] * self.brightnessBoost, 1.0) # increase brightness 539 | colorRGB = [0, 0, 0] 540 | vtk.vtkMath.HSVToRGB(colorHSV, colorRGB) 541 | actor.GetProperty().SetColor(colorRGB[0], colorRGB[1], colorRGB[2]) 542 | actor.GetProperty().SetInterpolationToGouraud() 543 | actor.GetProperty().SetAmbient(displayNode.GetAmbient()) 544 | actor.GetProperty().SetDiffuse(displayNode.GetDiffuse()) 545 | actor.GetProperty().SetSpecular(displayNode.GetSpecular()) 546 | actor.GetProperty().SetSpecularPower(displayNode.GetPower()) 547 | 548 | actor.GetProperty().SetOpacity(displayNode.GetOpacity()) 549 | self._renderer.AddActor(actor) 550 | 551 | return True 552 | 553 | def createPlaneModelFromMarkupsPlane(self,planeMarkup): 554 | planeBounds = planeMarkup.GetPlaneBounds() 555 | objectToWorld = vtk.vtkMatrix4x4() 556 | planeMarkup.GetObjectToWorldMatrix(objectToWorld) 557 | 558 | # Create plane polydata 559 | planeSource = vtk.vtkPlaneSource() 560 | planeSource.SetOrigin(objectToWorld.MultiplyPoint([planeBounds[0], planeBounds[2], 0.0, 1.0])[0:3]) 561 | planeSource.SetPoint1(objectToWorld.MultiplyPoint([planeBounds[1], planeBounds[2], 0.0, 1.0])[0:3]) 562 | planeSource.SetPoint2(objectToWorld.MultiplyPoint([planeBounds[0], planeBounds[3], 0.0, 1.0])[0:3]) 563 | planeModel = slicer.modules.models.logic().AddModel(planeSource.GetOutputPort()) 564 | 565 | # Copy props from markups to model 566 | planeMarkupDisplayNode = planeMarkup.GetDisplayNode() 567 | planeModelDisplayNode = planeModel.GetDisplayNode() 568 | planeModelDisplayNode.SetColor(planeMarkupDisplayNode.GetSelectedColor()) 569 | planeOpacity = planeMarkupDisplayNode.GetFillOpacity() 570 | planeModelDisplayNode.SetOpacity(planeOpacity) 571 | planeModel.SetName(planeMarkup.GetName()) 572 | 573 | return planeModel 574 | 575 | class OpenAnatomyExportTest(ScriptedLoadableModuleTest): 576 | """ 577 | This is the test case for your scripted module. 578 | Uses ScriptedLoadableModuleTest base class, available at: 579 | https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py 580 | """ 581 | 582 | def setUp(self): 583 | """ Do whatever is needed to reset the state - typically a scene clear will be enough. 584 | """ 585 | slicer.mrmlScene.Clear(0) 586 | 587 | def runTest(self): 588 | """Run as few or as many tests as needed here. 589 | """ 590 | self.setUp() 591 | self.test_OpenAnatomyExport1() 592 | 593 | def test_OpenAnatomyExport1(self): 594 | """ Ideally you should have several levels of tests. At the lowest level 595 | tests should exercise the functionality of the logic with different inputs 596 | (both valid and invalid). At higher levels your tests should emulate the 597 | way the user would interact with your code and confirm that it still works 598 | the way you intended. 599 | One of the most important features of the tests is that it should alert other 600 | developers when their changes will have an impact on the behavior of your 601 | module. For example, if a developer removes a feature that you depend on, 602 | your test should break so they know that the feature is needed. 603 | """ 604 | 605 | self.delayDisplay("Starting the test") 606 | # 607 | # first, get some data 608 | # 609 | import SampleData 610 | SampleData.downloadFromURL( 611 | nodeNames='FA', 612 | fileNames='FA.nrrd', 613 | uris='http://slicer.kitware.com/midas3/download?items=5767', 614 | checksums='SHA256:12d17fba4f2e1f1a843f0757366f28c3f3e1a8bb38836f0de2a32bb1cd476560') 615 | self.delayDisplay('Finished with download and loading') 616 | 617 | volumeNode = slicer.util.getNode(pattern="FA") 618 | logic = OpenAnatomyExportLogic() 619 | self.assertIsNotNone( logic.hasImageData(volumeNode) ) 620 | self.delayDisplay('Test passed!') 621 | -------------------------------------------------------------------------------- /OpenAnatomyExport/README.md: -------------------------------------------------------------------------------- 1 | # SlicerOpenAnatomy 2 | 3 | 3D Slicer extension for exporting Slicer scenes to use in the [OpenAnatomy.org](https://www.openanatomy.org/) browser and glTF and OBJ file viewers. 4 | 5 | ## OpenAnatomy Export module 6 | 7 | This module exports a model hierarchy or segmentation node from 3D Slicer into a single glTF or OBJ file, including all the model colors and opacities; and for glTF also model hierarchy and model names. 8 | 9 | ![OpenAnatomy Exporter module screenshot](img/Screenshot03.png) 10 | 11 | ## Quick start 12 | 13 | - Install `3D Slicer` and the `SlicerOpenAnatomy` extension 14 | - Create/edit a segmentation using `Segment Editor` module 15 | - Export the segmentation to glTF file using `OpenAnatomy Export` module 16 | - Upload your model to Dropbox or GitHub and get a download link 17 | - Create a view link by prepending `https://3dviewer.net/#model=` to the download link. The model can be viewed in 3D in the web browser on computers, tablets, and phones. 18 | 19 | Example view link: https://3dviewer.net/#model=https://www.dropbox.com/s/38arwo2uhzu0ydg/SPL-Abdominal-Atlas.gltf?dl=0 20 | 21 | ## glTF viewers 22 | 23 | - Online (in web browser): 24 | - [3dviewer.net](https://3dviewer.net/): simple, easy-to-use viewer 25 | - Free, open-source project ([GitHub page](https://github.com/kovacsv/Online3DViewer)) 26 | - Model hierarchy can be viewed as a tree, entire branch can be shown/hidden 27 | - Model can be picked by clicking on an object in the 3D view 28 | - Orbit and free rotation 29 | - No editing of material or lighting 30 | - Can open a URL and can be embedded into an iframe 31 | - [gltfviewer.com](https://www.gltfviewer.com/): more complex viewer, for advanced viewing and editing 32 | - Allows material and light editing and saving of modified glTF file 33 | - Shows nodes and meshes separately 34 | - Model hierarchy can be viewed as a tree, entire branch can be shown/hidden 35 | - Model can be picked by clicking on an object in the 3D view. Limitation: the item must be visible in the tree (branch must be expanded). 36 | - Search by name 37 | - [Sketchfab](https://sketchfab.com/): commercial project, allows sharing with password, advanced lighting and other rendering settings, virtual reality. 38 | - Android phones: 39 | - [OpenCascade CAD assistant](https://play.google.com/store/apps/details?id=org.opencascade.cadassistant): 40 | - Free application 41 | - Model hierarchy can be viewed as a tree, entire branch can be shown/hidden 42 | - Model can be picked by clicking on an object in the 3D view 43 | - Material and lighting editing 44 | 45 | ### Examples 46 | 47 | Open a glTF files stored in a github repository: 48 | 49 | https://3dviewer.net/#model=https://raw.githubusercontent.com/lassoan/Test/master/SPL-Abdominal-Atlas.gltf 50 | 51 | ![Exported glTF file viewed in 3dviewer.net](img/Screenshot02.png) 52 | 53 | Embed 3dviewer.net in a website: 54 | 55 | ``` 56 | 59 | ``` 60 | 61 | ## Export options 62 | 63 | - Segmentation to export: Select a segmentation or a subject hierarchy folder that contains models. If a folder is exported into glTF format then the folder hierarchy is preserved in the output file. 64 | - Reduction factor: Amount of size reduction. Larger value means more reduction therefore smaller file. Factor of 0.95 means the size is reduced by 95% (output file size is 5% of the original file size). 65 | - Output format 66 | - glTF: Export to glTF file format. Supported by many web viewers. Model names, hierarchy, color, and transparency information is preserved. Models that use Flat, Gouraud, or Phong interpolation in Slicer (see Models module / 3D display / Advanced) are converted to PBR interpolation during export (because glTF format uses PBR interpolation). Since these interpolation modes are not equivalent, the color and surface appearance will be slightly different in glTF viewers compared to what was shown in Slicer. For more accurate color correspondence, switch to PBR interpolation in Slicer (and it is recommended to enable `Image-based lighting` in `Lights` module in `SlicerSandbox` extension). 67 | - OBJ: Wavefront OBJ file format. Model color and transparency information is preserved. 68 | - scene: Export the models into the scene. 69 | - Output location: folder where the output file will be written to. Filename is determined automatically from the selected segmentation or subject hierarchy folder node name. 70 | -------------------------------------------------------------------------------- /OpenAnatomyExport/Resources/Icons/OpenAnatomyExport.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/OpenAnatomyExport/Resources/Icons/OpenAnatomyExport.png -------------------------------------------------------------------------------- /OpenAnatomyExport/Resources/UI/OpenAnatomyExport.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | UtilTest 4 | 5 | 6 | 7 | 0 8 | 0 9 | 405 10 | 418 11 | 12 | 13 | 14 | 15 | 16 | 17 | Segmentation and models export 18 | 19 | 20 | 21 | 22 | 23 | Segmentation to export: 24 | 25 | 26 | 27 | 28 | 29 | 30 | Select segmentation node or model folder 31 | 32 | 33 | 34 | vtkMRMLSegmentationNode 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | Decimation factor determining how much the mesh complexity will be reduced. Higher value means stronger reduction (smaller files, less details preserved). 43 | 44 | 45 | Reduction factor: 46 | 47 | 48 | 49 | 50 | 51 | 52 | Decimation factor determining how much the mesh complexity will be reduced. Higher value means stronger reduction (smaller files, less details preserved). 53 | 54 | 55 | 0.010000000000000 56 | 57 | 58 | 0.100000000000000 59 | 60 | 61 | 0.000000000000000 62 | 63 | 64 | 1.000000000000000 65 | 66 | 67 | 0.900000000000000 68 | 69 | 70 | 71 | 72 | 73 | 74 | Output format: 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | glTF 83 | 84 | 85 | 86 | 87 | OBJ 88 | 89 | 90 | 91 | 92 | scene 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | Output location: 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | ctkPathLineEdit::Dirs|ctkPathLineEdit::Drives|ctkPathLineEdit::Executable|ctkPathLineEdit::NoDot|ctkPathLineEdit::NoDotDot|ctkPathLineEdit::PermissionMask|ctkPathLineEdit::Readable|ctkPathLineEdit::Writable 110 | 111 | 112 | OpenAnatomy/OutputFolder 113 | 114 | 115 | 116 | 117 | 118 | 119 | (model hierarchy) 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | false 129 | 130 | 131 | Export selected data to Gltf 132 | 133 | 134 | Export 135 | 136 | 137 | 138 | 139 | 140 | 141 | true 142 | 143 | 144 | Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | Image export 155 | 156 | 157 | true 158 | 159 | 160 | 161 | 162 | 163 | Output format: 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | vti 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | Output location: 180 | 181 | 182 | 183 | 184 | 185 | 186 | false 187 | 188 | 189 | Export selected data to Gltf 190 | 191 | 192 | Export 193 | 194 | 195 | 196 | 197 | 198 | 199 | Image to export: 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | vtkMRMLScalarVolumeNode 208 | 209 | 210 | 211 | false 212 | 213 | 214 | true 215 | 216 | 217 | true 218 | 219 | 220 | 221 | 222 | 223 | 224 | ctkPathLineEdit::Dirs|ctkPathLineEdit::Drives|ctkPathLineEdit::Executable|ctkPathLineEdit::NoDot|ctkPathLineEdit::NoDotDot|ctkPathLineEdit::PermissionMask|ctkPathLineEdit::Readable|ctkPathLineEdit::Writable 225 | 226 | 227 | OpenAnatomy/OutputFolder 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | ctkCollapsibleButton 239 | QWidget 240 |
ctkCollapsibleButton.h
241 | 1 242 |
243 | 244 | ctkComboBox 245 | QComboBox 246 |
ctkComboBox.h
247 |
248 | 249 | ctkPathLineEdit 250 | QWidget 251 |
ctkPathLineEdit.h
252 |
253 | 254 | ctkSliderWidget 255 | QWidget 256 |
ctkSliderWidget.h
257 |
258 | 259 | qMRMLCollapsibleButton 260 | ctkCollapsibleButton 261 |
qMRMLCollapsibleButton.h
262 | 1 263 |
264 | 265 | qMRMLNodeComboBox 266 | QWidget 267 |
qMRMLNodeComboBox.h
268 |
269 | 270 | qMRMLSubjectHierarchyComboBox 271 | ctkComboBox 272 |
qMRMLSubjectHierarchyComboBox.h
273 |
274 |
275 | 276 | 277 |
278 | -------------------------------------------------------------------------------- /OpenAnatomyExport/Testing/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | add_subdirectory(Python) 2 | -------------------------------------------------------------------------------- /OpenAnatomyExport/Testing/Python/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | 2 | #slicer_add_python_unittest(SCRIPT ${MODULE_NAME}ModuleTest.py) 3 | -------------------------------------------------------------------------------- /OpenAnatomyExport/img/Screenshot01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/OpenAnatomyExport/img/Screenshot01.png -------------------------------------------------------------------------------- /OpenAnatomyExport/img/Screenshot02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/OpenAnatomyExport/img/Screenshot02.png -------------------------------------------------------------------------------- /OpenAnatomyExport/img/Screenshot03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/OpenAnatomyExport/img/Screenshot03.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slicer Open Anatomy extension 2 | 3 | “” 4 | 5 | Slicer Open Anatomy extension provides tools for loading, manipulating, exporting and visualizing the Open Anatomy Project's atlases in 3D Slicer. 6 | 7 | The Open Anatomy Project aims to revolutionize the anatomy atlas by utilizing open data, fostering community-based collaborative development, and freely distributing medical knowledge. The project has several different digital anatomy atlases, including: 8 | - Mauritanian Anatomy Laboratory Thoracic Atlas 9 | - SPL/NAC Brain Atlas 10 | - SPL Liver Atlas 11 | - SPL Head and Neck Atlas 12 | - SPL Inner Ear Atlas 13 | - SPL Knee Atlas 14 | - SPL Abdominal Atlas 15 | 16 | ## Modules 17 | ### [OpenAnatomy Export](OpenAnatomyExport/README.md) 18 | This module exports a model hierarchy or segmentation node from 3D Slicer into a single glTF or OBJ file, including all the model colors and opacities; and for glTF also model hierarchy and model names. [More details...](OpenAnatomyExport/README.md) 19 | 20 | ![OpenAnatomy Exporter module screenshot](OpenAnatomyExport/img/Screenshot03.png) 21 | 22 | ### [AtlasEditor](AtlasEditor/README.md) 23 | This module is for editing and simplifying Open Anatomy's atlases. Users can select and organize the structures/labels into groups that is most relevant to their particular field or area of study using a hierarchy JSON schema. [More details...](AtlasEditor/README.md) 24 | 25 | ![AtlasEditor module screenshot](AtlasEditor/img/merge-cerebellum.png) 26 | 27 | ## Installation 28 | 1. Install and open 3D Slicer (https://download.slicer.org). 29 | 2. Open the Extension Manager and download 'SlicerOpenAnatomy' extension. 30 | 3. Restart 3D Slicer. 31 | 32 | ## Acknowledgements 33 | Andras Lasso (PerkLab, Queen's University) 34 | Csaba Pinter (PerkLab, Queen's University) 35 | Michael Halle (SPL, Harvard Medical School) 36 | Andy Huynh (ISML, The University of Western Australia) 37 | -------------------------------------------------------------------------------- /SlicerOpenAnatomy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/SlicerOpenAnatomy.png -------------------------------------------------------------------------------- /SlicerOpenAnatomy.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerkLab/SlicerOpenAnatomy/4129ceb8f37471a17c222e541edfeb97ea28f461/SlicerOpenAnatomy.xcf --------------------------------------------------------------------------------