├── 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 | 
22 | 
23 |
24 | #### Merging cerebellum:
25 | 
26 |
27 | ### Removing
28 | #### Removing skin, head and neck muscles:
29 | 
30 | 
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 |
212 | 1
213 |
214 |
215 | ctkPathLineEdit
216 | QWidget
217 |
218 |
219 |
220 | qMRMLCollapsibleButton
221 | ctkCollapsibleButton
222 |
223 | 1
224 |
225 |
226 | qMRMLNodeComboBox
227 | QWidget
228 |
229 |
230 |
231 | qMRMLWidget
232 | QWidget
233 |
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 | 
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 | 
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 |
241 | 1
242 |
243 |
244 | ctkComboBox
245 | QComboBox
246 |
247 |
248 |
249 | ctkPathLineEdit
250 | QWidget
251 |
252 |
253 |
254 | ctkSliderWidget
255 | QWidget
256 |
257 |
258 |
259 | qMRMLCollapsibleButton
260 | ctkCollapsibleButton
261 |
262 | 1
263 |
264 |
265 | qMRMLNodeComboBox
266 | QWidget
267 |
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 | 
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 | 
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
--------------------------------------------------------------------------------