From b2252f562afb12f597b1c572f40229b6d5c5a9ed Mon Sep 17 00:00:00 2001 From: "Mauro I. Dominguez" Date: Fri, 9 Aug 2024 18:35:37 -0300 Subject: [PATCH 1/4] ENH: make boolean operations more robust by automatic retries and input meshes triangulation features Fixes zippy84/vtkbool#81 --- CombineModels/CombineModels.py | 201 +++++++++++++++++--- CombineModels/Resources/UI/CombineModels.ui | 87 ++++++++- 2 files changed, 258 insertions(+), 30 deletions(-) diff --git a/CombineModels/CombineModels.py b/CombineModels/CombineModels.py index 70afac3..f220c43 100644 --- a/CombineModels/CombineModels.py +++ b/CombineModels/CombineModels.py @@ -92,6 +92,12 @@ def setup(self): self.ui.operationDifferenceRadioButton.connect("toggled(bool)", lambda toggled, op="difference": self.operationButtonToggled(op)) self.ui.operationDifference2RadioButton.connect("toggled(bool)", lambda toggled, op="difference2": self.operationButtonToggled(op)) + self.ui.triangulateInputsCheckBox.connect("stateChanged(int)", self.updateParameterNodeFromGUI) + + # Spin Boxes + self.ui.numberOfRetriesSpinBox.valueChanged.connect(self.updateParameterNodeFromGUI) + self.ui.translateRandomlySpinBox.valueChanged.connect(self.updateParameterNodeFromGUI) + # Buttons self.ui.applyButton.connect('clicked(bool)', self.onApplyButton) self.ui.toggleVisibilityButton.connect('clicked(bool)', self.onToggleVisibilityButton) @@ -198,6 +204,24 @@ def updateGUIFromParameterNode(self, caller=None, event=None): self.ui.toggleVisibilityButton.enabled = (self._parameterNode.GetNodeReference("OutputModel") is not None) + # translate randomly order of magnitude (value is negative by default) + translateRandomly_order_abs_val = int(self._parameterNode.GetParameter("translateRandomly")) + self.ui.translateRandomlySpinBox.value = translateRandomly_order_abs_val + + numberOfRetries = int(self._parameterNode.GetParameter("numberOfRetries")) + self.ui.numberOfRetriesSpinBox.value = numberOfRetries + if numberOfRetries > 0: + self.ui.numberOfRetriesSpinBox.toolTip = "Model B will be randomized if operation fails" + self.ui.translateRandomlySpinBox.enabled = True + self.ui.translateRandomlySpinBox.toolTip = "If the operation fails, it will retry with a random translation of" + \ + f" {10**-translateRandomly_order_abs_val}mm" + else: + self.ui.numberOfRetriesSpinBox.toolTip = "No retries will be done" + self.ui.translateRandomlySpinBox.enabled = False + self.ui.translateRandomlySpinBox.toolTip = "Set a number of retries different than 0" + + self.ui.triangulateInputsCheckBox.checked = self._parameterNode.GetParameter("triangulateInputs") == "True" + # All the GUI updates are done self._updatingGUIFromParameterNode = False @@ -216,6 +240,14 @@ def updateParameterNodeFromGUI(self, caller=None, event=None): self._parameterNode.SetNodeReferenceID("InputModelB", self.ui.inputModelBSelector.currentNodeID) self._parameterNode.SetNodeReferenceID("OutputModel", self.ui.outputModelSelector.currentNodeID) + self._parameterNode.SetParameter("numberOfRetries", str(self.ui.numberOfRetriesSpinBox.value)) + self._parameterNode.SetParameter("translateRandomly", str(self.ui.translateRandomlySpinBox.value)) + + if self.ui.triangulateInputsCheckBox.checked: + self._parameterNode.SetParameter("triangulateInputs", "True") + else: + self._parameterNode.SetParameter("triangulateInputs", "False") + self._parameterNode.EndModify(wasModified) def operationButtonToggled(self, operation): @@ -237,7 +269,11 @@ def onApplyButton(self): self._parameterNode.GetNodeReference("InputModelA"), self._parameterNode.GetNodeReference("InputModelB"), self._parameterNode.GetNodeReference("OutputModel"), - self._parameterNode.GetParameter("Operation")) + self._parameterNode.GetParameter("Operation"), + int(self._parameterNode.GetParameter("numberOfRetries")), + int(self._parameterNode.GetParameter("translateRandomly")), + self._parameterNode.GetParameter("triangulateInputs") == "True" + ) except Exception as e: slicer.util.errorDisplay("Failed to compute results: "+str(e)) @@ -285,8 +321,21 @@ def setDefaultParameters(self, parameterNode): """ if not parameterNode.GetParameter("Operation"): parameterNode.SetParameter("Operation", "union") - - def process(self, inputModelA, inputModelB, outputModel, operation): + if not parameterNode.GetParameter("numberOfRetries"): + parameterNode.SetParameter("numberOfRetries", "2") + if not parameterNode.GetParameter("translateRandomly"): + parameterNode.SetParameter("translateRandomly", "4") + + def process( + self, + inputModelA, + inputModelB, + outputModel, + operation, + numberOfRetries = 2, + translateRandomly = 4, + triangulateInputs = True + ): """ Run the processing algorithm. Can be used without GUI widget. @@ -294,6 +343,9 @@ def process(self, inputModelA, inputModelB, outputModel, operation): :param inputModelB: second input model node :param outputModel: result model node, if empty then a new output node will be created :param operation: union, intersection, difference, difference2 + :param numberOfRetries: number of retries if operation fails + :param translateRandomly: order of magnitude of the random translation + :param triangulateInputs: triangulate input models before boolean operation """ if not inputModelA or not inputModelB or not outputModel: @@ -318,31 +370,136 @@ def process(self, inputModelA, inputModelB, outputModel, operation): else: raise ValueError("Invalid operation: "+operation) - if inputModelA.GetParentTransformNode() == outputModel.GetParentTransformNode(): - combine.SetInputConnection(0, inputModelA.GetPolyDataConnection()) - else: - transformToOutput = vtk.vtkGeneralTransform() - slicer.vtkMRMLTransformNode.GetTransformBetweenNodes(inputModelA.GetParentTransformNode(), outputModel.GetParentTransformNode(), transformToOutput) - transformer = vtk.vtkTransformPolyDataFilter() - transformer.SetTransform(transformToOutput) - transformer.SetInputConnection(inputModelA.GetPolyDataConnection()) - combine.SetInputConnection(0, transformer.GetOutputPort()) - - if inputModelB.GetParentTransformNode() == outputModel.GetParentTransformNode(): - combine.SetInputConnection(1, inputModelB.GetPolyDataConnection()) - else: - transformToOutput = vtk.vtkGeneralTransform() - slicer.vtkMRMLTransformNode.GetTransformBetweenNodes(inputModelB.GetParentTransformNode(), outputModel.GetParentTransformNode(), transformToOutput) - transformer = vtk.vtkTransformPolyDataFilter() - transformer.SetTransform(transformToOutput) - transformer.SetInputConnection(inputModelB.GetPolyDataConnection()) - combine.SetInputConnection(1, transformer.GetOutputPort()) + transformToOutput = vtk.vtkGeneralTransform() + slicer.vtkMRMLTransformNode.GetTransformBetweenNodes(inputModelA.GetParentTransformNode(), outputModel.GetParentTransformNode(), transformToOutput) + if transformToOutput is None: + transformToOutput = vtk.vtkTransform() + transformerA = vtk.vtkTransformPolyDataFilter() + transformerA.SetTransform(transformToOutput) + transformerA.SetInputData(inputModelA.GetPolyData()) + transformerA.Update() + combine.SetInputData(0, transformerA.GetOutput()) + + transformToOutput = vtk.vtkGeneralTransform() + slicer.vtkMRMLTransformNode.GetTransformBetweenNodes(inputModelB.GetParentTransformNode(), outputModel.GetParentTransformNode(), transformToOutput) + if transformToOutput is None: + transformToOutput = vtk.vtkTransform() + preTransformerB = vtk.vtkTransformPolyDataFilter() + preTransformerB.SetTransform(transformToOutput) + preTransformerB.SetInputData(inputModelB.GetPolyData()) + preTransformerB.Update() + identityTransform = vtk.vtkTransform() + transformerB = vtk.vtkTransformPolyDataFilter() + transformerB.SetTransform(identityTransform) + transformerB.SetInputData(preTransformerB.GetOutput()) + transformerB.Update() + combine.SetInputData(1, transformerB.GetOutput()) # These parameters might be useful to expose: # combine.MergeRegsOn() # default off # combine.DecPolysOff() # default on combine.Update() + collisionDetectionFilter = vtk.vtkCollisionDetectionFilter() + collisionDetectionFilter.SetInputData(0, transformerA.GetOutput()) + collisionDetectionFilter.SetInputData(1, transformerB.GetOutput()) + identityMatrix = vtk.vtkMatrix4x4() + collisionDetectionFilter.SetMatrix(0,identityMatrix) + collisionDetectionFilter.SetMatrix(1,identityMatrix) + collisionDetectionFilter.SetCollisionModeToFirstContact() + + for retry in range(numberOfRetries+1): + if ( + operation == 'union' + ): + if combine.GetOutput().GetNumberOfPoints() != 0: + # succeess + break + else: + if retry == 0: + # check if the models are already intersecting + collisionDetectionFilter.Update() + if collisionDetectionFilter.GetOutput().GetNumberOfPoints() == 0: + # models do not touch so we append them + appendFilter = vtk.vtkAppendPolyData() + appendFilter.AddInputData(transformerA.GetOutput()) + appendFilter.AddInputData(transformerB.GetOutput()) + appendFilter.Update() + combine = appendFilter + break + + if ( + operation == 'intersection' + ): + if combine.GetOutput().GetNumberOfPoints() != 0: + # succeess + break + else: + if retry == 0: + # check if the models are already intersecting + collisionDetectionFilter.Update() + if collisionDetectionFilter.GetOutput().GetNumberOfPoints() == 0: + # models do not touch so we return an empty model + break + + if ( + operation == 'difference' + ): + if combine.GetOutput().GetNumberOfPoints() != 0: + # succeess + break + else: + if retry == 0: + # check if the models are already intersecting + collisionDetectionFilter.Update() + if collisionDetectionFilter.GetOutput().GetNumberOfPoints() == 0: + # models do not touch so we return modelA + combine = transformerA + break + + if ( + operation == 'difference2' + ): + if combine.GetOutput().GetNumberOfPoints() != 0: + # succeess + break + else: + if retry == 0: + # check if the models are already intersecting + collisionDetectionFilter.Update() + if collisionDetectionFilter.GetOutput().GetNumberOfPoints() == 0: + # models do not touch so we return modelA + combine = transformerB + break + + if retry == 0 and triangulateInputs: + # in case inputs are not triangulated, triangulate them + triangulatedInputModelA = vtk.vtkTriangleFilter() + triangulatedInputModelA.SetInputData(inputModelA.GetPolyData()) + triangulatedInputModelA.Update() + transformerA.SetInputData(triangulatedInputModelA.GetOutput()) + transformerA.Update() + triangulatedInputModelB = vtk.vtkTriangleFilter() + triangulatedInputModelB.SetInputData(inputModelB.GetPolyData()) + triangulatedInputModelB.Update() + preTransformerB.SetInputData(triangulatedInputModelB.GetOutput()) + preTransformerB.Update() + transformerB.SetInputData(preTransformerB.GetOutput()) + + # retry with random translation if boolean operation fails + logging.info(f"Retrying boolean operation with random translation (retry {retry+1})") + transform = vtk.vtkTransform() + unitaryVector = [vtk.vtkMath.Random()-0.5 for _ in range(3)] + vtk.vtkMath.Normalize(unitaryVector) + import numpy as np + translationVector = np.array(unitaryVector) * (10**-translateRandomly) + transform.Translate(translationVector) + transformerB.SetTransform(transform) + transformerB.Update() + # recalculate the boolean operation + combine.SetInputData(1, transformerB.GetOutput()) + combine.Update() + outputModel.SetAndObservePolyData(combine.GetOutput()) outputModel.CreateDefaultDisplayNodes() # The filter creates a few scalars, don't show them by default, as they would be somewhat distracting diff --git a/CombineModels/Resources/UI/CombineModels.ui b/CombineModels/Resources/UI/CombineModels.ui index 2278914..306931b 100644 --- a/CombineModels/Resources/UI/CombineModels.ui +++ b/CombineModels/Resources/UI/CombineModels.ui @@ -6,8 +6,8 @@ 0 0 - 279 - 372 + 350 + 582 @@ -199,6 +199,77 @@ + + + + Advanced + + + true + + + + + + Number of retries: + + + + + + + + + + 5 + + + 2 + + + + + + + Translate +randomly: + + + + + + + mm + + + 1e- + + + 5 + + + 4 + + + + + + + Triangulate +inputs: + + + + + + + + + + + + + @@ -241,12 +312,6 @@ - - ctkCollapsibleButton - QWidget -
ctkCollapsibleButton.h
- 1 -
qMRMLNodeComboBox QWidget @@ -258,6 +323,12 @@
qMRMLWidget.h
1
+ + ctkCollapsibleButton + QWidget +
ctkCollapsibleButton.h
+ 1 +
From e8d578aaf582b8845c3f65750ae22e9a64fc5956 Mon Sep 17 00:00:00 2001 From: "Mauro I. Dominguez" Date: Sat, 10 Aug 2024 14:35:33 -0300 Subject: [PATCH 2/4] BUG: fix CombineModels behavior when models do not touch and a few typos --- CombineModels/CombineModels.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/CombineModels/CombineModels.py b/CombineModels/CombineModels.py index f220c43..b5932f7 100644 --- a/CombineModels/CombineModels.py +++ b/CombineModels/CombineModels.py @@ -325,6 +325,8 @@ def setDefaultParameters(self, parameterNode): parameterNode.SetParameter("numberOfRetries", "2") if not parameterNode.GetParameter("translateRandomly"): parameterNode.SetParameter("translateRandomly", "4") + if not parameterNode.GetParameter("triangulateInputs"): + parameterNode.SetParameter("triangulateInputs", "True") def process( self, @@ -413,13 +415,13 @@ def process( operation == 'union' ): if combine.GetOutput().GetNumberOfPoints() != 0: - # succeess + # success break else: if retry == 0: # check if the models are already intersecting collisionDetectionFilter.Update() - if collisionDetectionFilter.GetOutput().GetNumberOfPoints() == 0: + if collisionDetectionFilter.GetNumberOfContacts() == 0: # models do not touch so we append them appendFilter = vtk.vtkAppendPolyData() appendFilter.AddInputData(transformerA.GetOutput()) @@ -432,13 +434,13 @@ def process( operation == 'intersection' ): if combine.GetOutput().GetNumberOfPoints() != 0: - # succeess + # success break else: if retry == 0: # check if the models are already intersecting collisionDetectionFilter.Update() - if collisionDetectionFilter.GetOutput().GetNumberOfPoints() == 0: + if collisionDetectionFilter.GetNumberOfContacts() == 0: # models do not touch so we return an empty model break @@ -446,13 +448,13 @@ def process( operation == 'difference' ): if combine.GetOutput().GetNumberOfPoints() != 0: - # succeess + # success break else: if retry == 0: # check if the models are already intersecting collisionDetectionFilter.Update() - if collisionDetectionFilter.GetOutput().GetNumberOfPoints() == 0: + if collisionDetectionFilter.GetNumberOfContacts() == 0: # models do not touch so we return modelA combine = transformerA break @@ -461,14 +463,14 @@ def process( operation == 'difference2' ): if combine.GetOutput().GetNumberOfPoints() != 0: - # succeess + # success break else: if retry == 0: # check if the models are already intersecting collisionDetectionFilter.Update() - if collisionDetectionFilter.GetOutput().GetNumberOfPoints() == 0: - # models do not touch so we return modelA + if collisionDetectionFilter.GetNumberOfContacts() == 0: + # models do not touch so we return modelB combine = transformerB break From 3a33ade5ea7422c9678131e8f1d7b7fe7f118824 Mon Sep 17 00:00:00 2001 From: "Mauro I. Dominguez" Date: Sun, 11 Aug 2024 16:22:13 -0300 Subject: [PATCH 3/4] ENH: add handling of CombineModels' inputs when any of them is an empty polyData to avoid Slicer crash --- CombineModels/CombineModels.py | 157 +++++++++++++++++++++------------ 1 file changed, 100 insertions(+), 57 deletions(-) diff --git a/CombineModels/CombineModels.py b/CombineModels/CombineModels.py index b5932f7..1024dbf 100644 --- a/CombineModels/CombineModels.py +++ b/CombineModels/CombineModels.py @@ -397,6 +397,63 @@ def process( transformerB.Update() combine.SetInputData(1, transformerB.GetOutput()) + # first handle cases where inputs are not valid otherwise the boolean filter will crash + preBooleanOperationHandlingDone = False + + modelAIsValid = transformerA.GetOutput().GetNumberOfPoints() != 0 + modelBIsValid = transformerB.GetOutput().GetNumberOfPoints() != 0 + modelAIsEmpty = not modelAIsValid + modelBIsEmpty = not modelBIsValid + bothModelsAreEmpty = modelAIsEmpty and modelBIsEmpty + onlyModelAIsValid = modelAIsValid and not modelBIsValid + onlyModelBIsValid = modelBIsValid and not modelAIsValid + + if ( + bothModelsAreEmpty or + ((operation == 'union') and onlyModelBIsValid) or + ((operation == 'union') and onlyModelAIsValid) + ): + appendFilter = vtk.vtkAppendPolyData() + appendFilter.AddInputData(transformerA.GetOutput()) + appendFilter.AddInputData(transformerB.GetOutput()) + combine = appendFilter + preBooleanOperationHandlingDone = True + elif ( + ( + (operation == 'intersection') and + (onlyModelAIsValid or onlyModelBIsValid) + ) or + ( + (operation == 'difference') and + (onlyModelBIsValid) + ) or + ( + (operation == 'difference2') and + (onlyModelAIsValid) + ) + ): + # return empty model + appendFilter = vtk.vtkAppendPolyData() + emptyPolyData = vtk.vtkPolyData() + appendFilter.AddInputData(emptyPolyData) + combine = appendFilter + preBooleanOperationHandlingDone = True + elif ( + (operation == 'difference') and + (onlyModelAIsValid) + ): + combine = transformerA + preBooleanOperationHandlingDone = True + elif ( + (operation == 'difference2') and + (onlyModelBIsValid) + ): + combine = transformerB + preBooleanOperationHandlingDone = True + # else: + # combine is a vtkPolyDataBooleanFilter + + # These parameters might be useful to expose: # combine.MergeRegsOn() # default off # combine.DecPolysOff() # default on @@ -410,14 +467,12 @@ def process( collisionDetectionFilter.SetMatrix(1,identityMatrix) collisionDetectionFilter.SetCollisionModeToFirstContact() - for retry in range(numberOfRetries+1): - if ( - operation == 'union' - ): - if combine.GetOutput().GetNumberOfPoints() != 0: - # success - break - else: + combineFilterSuccessful = combine.GetOutput().GetNumberOfPoints() != 0 + if not combineFilterSuccessful and not preBooleanOperationHandlingDone: + for retry in range(numberOfRetries+1): + if ( + operation == 'union' + ): if retry == 0: # check if the models are already intersecting collisionDetectionFilter.Update() @@ -430,27 +485,19 @@ def process( combine = appendFilter break - if ( - operation == 'intersection' - ): - if combine.GetOutput().GetNumberOfPoints() != 0: - # success - break - else: + if ( + operation == 'intersection' + ): if retry == 0: # check if the models are already intersecting collisionDetectionFilter.Update() if collisionDetectionFilter.GetNumberOfContacts() == 0: # models do not touch so we return an empty model break - - if ( - operation == 'difference' - ): - if combine.GetOutput().GetNumberOfPoints() != 0: - # success - break - else: + + if ( + operation == 'difference' + ): if retry == 0: # check if the models are already intersecting collisionDetectionFilter.Update() @@ -459,13 +506,9 @@ def process( combine = transformerA break - if ( - operation == 'difference2' - ): - if combine.GetOutput().GetNumberOfPoints() != 0: - # success - break - else: + if ( + operation == 'difference2' + ): if retry == 0: # check if the models are already intersecting collisionDetectionFilter.Update() @@ -474,33 +517,33 @@ def process( combine = transformerB break - if retry == 0 and triangulateInputs: - # in case inputs are not triangulated, triangulate them - triangulatedInputModelA = vtk.vtkTriangleFilter() - triangulatedInputModelA.SetInputData(inputModelA.GetPolyData()) - triangulatedInputModelA.Update() - transformerA.SetInputData(triangulatedInputModelA.GetOutput()) - transformerA.Update() - triangulatedInputModelB = vtk.vtkTriangleFilter() - triangulatedInputModelB.SetInputData(inputModelB.GetPolyData()) - triangulatedInputModelB.Update() - preTransformerB.SetInputData(triangulatedInputModelB.GetOutput()) - preTransformerB.Update() - transformerB.SetInputData(preTransformerB.GetOutput()) - - # retry with random translation if boolean operation fails - logging.info(f"Retrying boolean operation with random translation (retry {retry+1})") - transform = vtk.vtkTransform() - unitaryVector = [vtk.vtkMath.Random()-0.5 for _ in range(3)] - vtk.vtkMath.Normalize(unitaryVector) - import numpy as np - translationVector = np.array(unitaryVector) * (10**-translateRandomly) - transform.Translate(translationVector) - transformerB.SetTransform(transform) - transformerB.Update() - # recalculate the boolean operation - combine.SetInputData(1, transformerB.GetOutput()) - combine.Update() + if retry == 0 and triangulateInputs: + # in case inputs are not triangulated, triangulate them + triangulatedInputModelA = vtk.vtkTriangleFilter() + triangulatedInputModelA.SetInputData(inputModelA.GetPolyData()) + triangulatedInputModelA.Update() + transformerA.SetInputData(triangulatedInputModelA.GetOutput()) + transformerA.Update() + triangulatedInputModelB = vtk.vtkTriangleFilter() + triangulatedInputModelB.SetInputData(inputModelB.GetPolyData()) + triangulatedInputModelB.Update() + preTransformerB.SetInputData(triangulatedInputModelB.GetOutput()) + preTransformerB.Update() + transformerB.SetInputData(preTransformerB.GetOutput()) + + # retry with random translation if boolean operation fails + logging.info(f"Retrying boolean operation with random translation (retry {retry+1})") + transform = vtk.vtkTransform() + unitaryVector = [vtk.vtkMath.Random()-0.5 for _ in range(3)] + vtk.vtkMath.Normalize(unitaryVector) + import numpy as np + translationVector = np.array(unitaryVector) * (10**-translateRandomly) + transform.Translate(translationVector) + transformerB.SetTransform(transform) + transformerB.Update() + # recalculate the boolean operation + combine.SetInputData(1, transformerB.GetOutput()) + combine.Update() outputModel.SetAndObservePolyData(combine.GetOutput()) outputModel.CreateDefaultDisplayNodes() From d41dc230c6d912bcaf94e1889588a5833e206a71 Mon Sep 17 00:00:00 2001 From: Andras Lasso Date: Mon, 26 Aug 2024 21:28:54 +0200 Subject: [PATCH 4/4] Cleaned up and simplified the code --- CombineModels/CombineModels.py | 319 ++++++++------------ CombineModels/Resources/UI/CombineModels.ui | 149 +++++---- 2 files changed, 224 insertions(+), 244 deletions(-) diff --git a/CombineModels/CombineModels.py b/CombineModels/CombineModels.py index 1024dbf..d4921bb 100644 --- a/CombineModels/CombineModels.py +++ b/CombineModels/CombineModels.py @@ -3,6 +3,8 @@ import logging import vtk, qt, ctk, slicer from slicer.ScriptedLoadableModule import * +from slicer.i18n import tr as _ +from slicer.i18n import translate from slicer.util import VTKObservationMixin # @@ -96,7 +98,7 @@ def setup(self): # Spin Boxes self.ui.numberOfRetriesSpinBox.valueChanged.connect(self.updateParameterNodeFromGUI) - self.ui.translateRandomlySpinBox.valueChanged.connect(self.updateParameterNodeFromGUI) + self.ui.randomTranslationMagnitudeSpinBox.valueChanged.connect(self.updateParameterNodeFromGUI) # Buttons self.ui.applyButton.connect('clicked(bool)', self.onApplyButton) @@ -205,20 +207,20 @@ def updateGUIFromParameterNode(self, caller=None, event=None): self.ui.toggleVisibilityButton.enabled = (self._parameterNode.GetNodeReference("OutputModel") is not None) # translate randomly order of magnitude (value is negative by default) - translateRandomly_order_abs_val = int(self._parameterNode.GetParameter("translateRandomly")) - self.ui.translateRandomlySpinBox.value = translateRandomly_order_abs_val + randomTranslationMagnitude = int(self._parameterNode.GetParameter("randomTranslationMagnitude")) + self.ui.randomTranslationMagnitudeSpinBox.value = randomTranslationMagnitude numberOfRetries = int(self._parameterNode.GetParameter("numberOfRetries")) self.ui.numberOfRetriesSpinBox.value = numberOfRetries if numberOfRetries > 0: self.ui.numberOfRetriesSpinBox.toolTip = "Model B will be randomized if operation fails" - self.ui.translateRandomlySpinBox.enabled = True - self.ui.translateRandomlySpinBox.toolTip = "If the operation fails, it will retry with a random translation of" + \ - f" {10**-translateRandomly_order_abs_val}mm" + self.ui.randomTranslationMagnitudeSpinBox.enabled = True + randomTranslationAmount = 10**-randomTranslationMagnitude + self.ui.randomTranslationMagnitudeSpinBox.toolTip = f"If the operation fails, it will retry with a random translation of {randomTranslationAmount}" else: - self.ui.numberOfRetriesSpinBox.toolTip = "No retries will be done" - self.ui.translateRandomlySpinBox.enabled = False - self.ui.translateRandomlySpinBox.toolTip = "Set a number of retries different than 0" + self.ui.numberOfRetriesSpinBox.toolTip = "Computation will be attempted only with exact inputs." + self.ui.randomTranslationMagnitudeSpinBox.enabled = False + self.ui.randomTranslationMagnitudeSpinBox.toolTip = "Set a number of retries to larger than 0" self.ui.triangulateInputsCheckBox.checked = self._parameterNode.GetParameter("triangulateInputs") == "True" @@ -241,13 +243,10 @@ def updateParameterNodeFromGUI(self, caller=None, event=None): self._parameterNode.SetNodeReferenceID("OutputModel", self.ui.outputModelSelector.currentNodeID) self._parameterNode.SetParameter("numberOfRetries", str(self.ui.numberOfRetriesSpinBox.value)) - self._parameterNode.SetParameter("translateRandomly", str(self.ui.translateRandomlySpinBox.value)) + self._parameterNode.SetParameter("randomTranslationMagnitude", str(self.ui.randomTranslationMagnitudeSpinBox.value)) + + self._parameterNode.SetParameter("triangulateInputs", "true" if self.ui.triangulateInputsCheckBox.checked else "false") - if self.ui.triangulateInputsCheckBox.checked: - self._parameterNode.SetParameter("triangulateInputs", "True") - else: - self._parameterNode.SetParameter("triangulateInputs", "False") - self._parameterNode.EndModify(wasModified) def operationButtonToggled(self, operation): @@ -271,8 +270,8 @@ def onApplyButton(self): self._parameterNode.GetNodeReference("OutputModel"), self._parameterNode.GetParameter("Operation"), int(self._parameterNode.GetParameter("numberOfRetries")), - int(self._parameterNode.GetParameter("translateRandomly")), - self._parameterNode.GetParameter("triangulateInputs") == "True" + int(self._parameterNode.GetParameter("randomTranslationMagnitude")), + self._parameterNode.GetParameter("triangulateInputs") == "true" ) except Exception as e: @@ -323,11 +322,11 @@ def setDefaultParameters(self, parameterNode): parameterNode.SetParameter("Operation", "union") if not parameterNode.GetParameter("numberOfRetries"): parameterNode.SetParameter("numberOfRetries", "2") - if not parameterNode.GetParameter("translateRandomly"): - parameterNode.SetParameter("translateRandomly", "4") + if not parameterNode.GetParameter("randomTranslationMagnitude"): + parameterNode.SetParameter("randomTranslationMagnitude", "4") if not parameterNode.GetParameter("triangulateInputs"): - parameterNode.SetParameter("triangulateInputs", "True") - + parameterNode.SetParameter("triangulateInputs", "true") + def process( self, inputModelA, @@ -335,7 +334,7 @@ def process( outputModel, operation, numberOfRetries = 2, - translateRandomly = 4, + randomTranslationMagnitude = 4, triangulateInputs = True ): """ @@ -346,7 +345,7 @@ def process( :param outputModel: result model node, if empty then a new output node will be created :param operation: union, intersection, difference, difference2 :param numberOfRetries: number of retries if operation fails - :param translateRandomly: order of magnitude of the random translation + :param randomTranslationMagnitude: order of magnitude of the random translation :param triangulateInputs: triangulate input models before boolean operation """ @@ -372,180 +371,122 @@ def process( else: raise ValueError("Invalid operation: "+operation) - transformToOutput = vtk.vtkGeneralTransform() - slicer.vtkMRMLTransformNode.GetTransformBetweenNodes(inputModelA.GetParentTransformNode(), outputModel.GetParentTransformNode(), transformToOutput) - if transformToOutput is None: - transformToOutput = vtk.vtkTransform() - transformerA = vtk.vtkTransformPolyDataFilter() - transformerA.SetTransform(transformToOutput) - transformerA.SetInputData(inputModelA.GetPolyData()) - transformerA.Update() - combine.SetInputData(0, transformerA.GetOutput()) - - transformToOutput = vtk.vtkGeneralTransform() - slicer.vtkMRMLTransformNode.GetTransformBetweenNodes(inputModelB.GetParentTransformNode(), outputModel.GetParentTransformNode(), transformToOutput) - if transformToOutput is None: - transformToOutput = vtk.vtkTransform() - preTransformerB = vtk.vtkTransformPolyDataFilter() - preTransformerB.SetTransform(transformToOutput) - preTransformerB.SetInputData(inputModelB.GetPolyData()) - preTransformerB.Update() - identityTransform = vtk.vtkTransform() - transformerB = vtk.vtkTransformPolyDataFilter() - transformerB.SetTransform(identityTransform) - transformerB.SetInputData(preTransformerB.GetOutput()) - transformerB.Update() - combine.SetInputData(1, transformerB.GetOutput()) - - # first handle cases where inputs are not valid otherwise the boolean filter will crash - preBooleanOperationHandlingDone = False - - modelAIsValid = transformerA.GetOutput().GetNumberOfPoints() != 0 - modelBIsValid = transformerB.GetOutput().GetNumberOfPoints() != 0 - modelAIsEmpty = not modelAIsValid - modelBIsEmpty = not modelBIsValid - bothModelsAreEmpty = modelAIsEmpty and modelBIsEmpty - onlyModelAIsValid = modelAIsValid and not modelBIsValid - onlyModelBIsValid = modelBIsValid and not modelAIsValid - - if ( - bothModelsAreEmpty or - ((operation == 'union') and onlyModelBIsValid) or - ((operation == 'union') and onlyModelAIsValid) - ): - appendFilter = vtk.vtkAppendPolyData() - appendFilter.AddInputData(transformerA.GetOutput()) - appendFilter.AddInputData(transformerB.GetOutput()) - combine = appendFilter - preBooleanOperationHandlingDone = True - elif ( - ( - (operation == 'intersection') and - (onlyModelAIsValid or onlyModelBIsValid) - ) or - ( - (operation == 'difference') and - (onlyModelBIsValid) - ) or - ( - (operation == 'difference2') and - (onlyModelAIsValid) - ) - ): - # return empty model - appendFilter = vtk.vtkAppendPolyData() - emptyPolyData = vtk.vtkPolyData() - appendFilter.AddInputData(emptyPolyData) - combine = appendFilter - preBooleanOperationHandlingDone = True - elif ( - (operation == 'difference') and - (onlyModelAIsValid) - ): - combine = transformerA - preBooleanOperationHandlingDone = True - elif ( - (operation == 'difference2') and - (onlyModelBIsValid) - ): - combine = transformerB - preBooleanOperationHandlingDone = True - # else: - # combine is a vtkPolyDataBooleanFilter - - - # These parameters might be useful to expose: - # combine.MergeRegsOn() # default off - # combine.DecPolysOff() # default on - combine.Update() - - collisionDetectionFilter = vtk.vtkCollisionDetectionFilter() - collisionDetectionFilter.SetInputData(0, transformerA.GetOutput()) - collisionDetectionFilter.SetInputData(1, transformerB.GetOutput()) - identityMatrix = vtk.vtkMatrix4x4() - collisionDetectionFilter.SetMatrix(0,identityMatrix) - collisionDetectionFilter.SetMatrix(1,identityMatrix) - collisionDetectionFilter.SetCollisionModeToFirstContact() - - combineFilterSuccessful = combine.GetOutput().GetNumberOfPoints() != 0 - if not combineFilterSuccessful and not preBooleanOperationHandlingDone: - for retry in range(numberOfRetries+1): - if ( - operation == 'union' - ): - if retry == 0: - # check if the models are already intersecting - collisionDetectionFilter.Update() - if collisionDetectionFilter.GetNumberOfContacts() == 0: - # models do not touch so we append them + inputpolyData_Output = [] # polydata inputs in "Output" coordinate system + for inputModel in [inputModelA, inputModelB]: + transformToOutput = vtk.vtkGeneralTransform() + slicer.vtkMRMLTransformNode.GetTransformBetweenNodes(inputModel.GetParentTransformNode(), outputModel.GetParentTransformNode(), transformToOutput) + transformerToOutput = vtk.vtkTransformPolyDataFilter() + transformerToOutput.SetTransform(transformToOutput) + if triangulateInputs: + triangulatedInputModel = vtk.vtkTriangleFilter() + triangulatedInputModel.SetInputData(inputModel.GetPolyData()) + triangulatedInputModel.Update() + transformerToOutput.SetInputData(triangulatedInputModel.GetPolyData()) + else: + transformerToOutput.SetInputData(inputModel.GetPolyData()) + transformerToOutput.Update() + inputpolyData_Output.append(transformerToOutput.GetOutput()) + + polydataA = inputpolyData_Output[0] + polydataB = inputpolyData_Output[1] + + # First handle cases where inputs are not valid otherwise the boolean filter will crash + modelAEmpty = polydataA.GetNumberOfPoints() == 0 + modelBEmpty = polydataB.GetNumberOfPoints() == 0 + if modelAEmpty and modelBEmpty: + # both inputs are empty, output is empty regardless of the operation + outputModel.SetAndObservePolyData(vtk.vtkPolyData()) + return + elif modelAEmpty or modelBEmpty: + # exactly one input is empty + if operation == "union": + outputModel.SetAndObservePolyData(polydataA if modelBEmpty else polydataB) + return + elif operation == "intersection": + # if one model is empty then intersection is empty + outputModel.SetAndObservePolyData(vtk.vtkPolyData()) + return + elif operation == "difference": + # A-B + outputModel.SetAndObservePolyData(polydataA if modelBEmpty else vtk.vtkPolyData()) + return + elif operation == "difference2": + # B-A + outputModel.SetAndObservePolyData(polydataB if modelAEmpty else vtk.vtkPolyData()) + return + + # We need to combine the models + polydataBOriginal = polydataB + for attemptIndex in range(numberOfRetries+1): + + if attemptIndex == 0: + polydataB = polydataBOriginal + else: + # Add random translation to model B + # https://github.com/zippy84/vtkbool/issues/81 + logging.info(f"Retrying boolean operation with random translation (attempt {attemptIndex+1})") + transform = vtk.vtkTransform() + unitVector = [vtk.vtkMath.Random()-0.5 for _ in range(3)] + vtk.vtkMath.Normalize(unitVector) + import numpy as np + translationVector = np.array(unitVector) * (10**-randomTranslationMagnitude) + perturbationTransform = vtk.vtkTransform() + perturbationTransform.Translate(translationVector) + perturbationTransformer = vtk.vtkTransformPolyDataFilter() + perturbationTransformer.SetTransform(perturbationTransform) + perturbationTransformer.SetInputData(polydataBOriginal) + perturbationTransformer.Update() + polydataB = perturbationTransformer.GetOutput() + + # Calculate the result using Boolean operation + combine.SetInputData(0, polydataA) + combine.SetInputData(1, polydataB) + # These parameters might be useful to expose: + # combine.MergeRegsOn() # default off + # combine.DecPolysOff() # default on + combine.Update() + + combineFilterSuccessful = combine.GetOutput().GetNumberOfPoints() != 0 + if combineFilterSuccessful: + polydataCombined = combine.GetOutput() + break + + # Boolean operation failed, but if the meshes are not intersecting + # then it may be a special case that we can handle with simpler methods + collisionDetectionFilter = vtk.vtkCollisionDetectionFilter() + collisionDetectionFilter.SetInputData(0, polydataA) + collisionDetectionFilter.SetInputData(1, polydataB) + identityMatrix = vtk.vtkMatrix4x4() + collisionDetectionFilter.SetMatrix(0,identityMatrix) + collisionDetectionFilter.SetMatrix(1,identityMatrix) + collisionDetectionFilter.SetCollisionModeToFirstContact() + collisionDetectionFilter.Update() + intersecting = collisionDetectionFilter.GetNumberOfContacts() > 0 + if intersecting: + # this is not a trivial case, try again + continue + + # No intersection, we can do without computing Boolean operation + if operation == 'union': + # models do not touch so we can simply append them appendFilter = vtk.vtkAppendPolyData() - appendFilter.AddInputData(transformerA.GetOutput()) - appendFilter.AddInputData(transformerB.GetOutput()) + appendFilter.AddInputData(polydataA) + appendFilter.AddInputData(polydataB) appendFilter.Update() - combine = appendFilter + polydataCombined = appendFilter.GetOutput() break - - if ( - operation == 'intersection' - ): - if retry == 0: - # check if the models are already intersecting - collisionDetectionFilter.Update() - if collisionDetectionFilter.GetNumberOfContacts() == 0: + elif operation == 'intersection': # models do not touch so we return an empty model + polydataCombined = vtk.vtkPolyData() break - - if ( - operation == 'difference' - ): - if retry == 0: - # check if the models are already intersecting - collisionDetectionFilter.Update() - if collisionDetectionFilter.GetNumberOfContacts() == 0: - # models do not touch so we return modelA - combine = transformerA + elif operation == 'difference': # A-B + polydataCombined = polydataA break - - if ( - operation == 'difference2' - ): - if retry == 0: - # check if the models are already intersecting - collisionDetectionFilter.Update() - if collisionDetectionFilter.GetNumberOfContacts() == 0: - # models do not touch so we return modelB - combine = transformerB + elif operation == 'difference2': # B-A + polydataCombined = polydataB break - if retry == 0 and triangulateInputs: - # in case inputs are not triangulated, triangulate them - triangulatedInputModelA = vtk.vtkTriangleFilter() - triangulatedInputModelA.SetInputData(inputModelA.GetPolyData()) - triangulatedInputModelA.Update() - transformerA.SetInputData(triangulatedInputModelA.GetOutput()) - transformerA.Update() - triangulatedInputModelB = vtk.vtkTriangleFilter() - triangulatedInputModelB.SetInputData(inputModelB.GetPolyData()) - triangulatedInputModelB.Update() - preTransformerB.SetInputData(triangulatedInputModelB.GetOutput()) - preTransformerB.Update() - transformerB.SetInputData(preTransformerB.GetOutput()) - - # retry with random translation if boolean operation fails - logging.info(f"Retrying boolean operation with random translation (retry {retry+1})") - transform = vtk.vtkTransform() - unitaryVector = [vtk.vtkMath.Random()-0.5 for _ in range(3)] - vtk.vtkMath.Normalize(unitaryVector) - import numpy as np - translationVector = np.array(unitaryVector) * (10**-translateRandomly) - transform.Translate(translationVector) - transformerB.SetTransform(transform) - transformerB.Update() - # recalculate the boolean operation - combine.SetInputData(1, transformerB.GetOutput()) - combine.Update() - - outputModel.SetAndObservePolyData(combine.GetOutput()) + outputModel.SetAndObservePolyData(polydataCombined) outputModel.CreateDefaultDisplayNodes() # The filter creates a few scalars, don't show them by default, as they would be somewhat distracting outputModel.GetDisplayNode().SetScalarVisibility(False) diff --git a/CombineModels/Resources/UI/CombineModels.ui b/CombineModels/Resources/UI/CombineModels.ui index 306931b..a710ec5 100644 --- a/CombineModels/Resources/UI/CombineModels.ui +++ b/CombineModels/Resources/UI/CombineModels.ui @@ -209,64 +209,77 @@ - - - Number of retries: - - - - - - - - - - 5 - - - 2 - - - - - - - Translate -randomly: - - - - - - - mm - - - 1e- - - - 5 - - - 4 - - - - - Triangulate -inputs: + Triangulate input models: - + + + + + Retry failed operations + + + + + + Number of retries: + + + + + + + 5 + + + 2 + + + + + + + Translation magnitude: + + + + + + + 1e + + + 0 + + + -5.000000000000000 + + + 0.000000000000000 + + + -4.000000000000000 + + + length + + + qMRMLSpinBox::MaximumValue|qMRMLSpinBox::MinimumValue|qMRMLSpinBox::Suffix + + + + + +
@@ -312,21 +325,31 @@ inputs: + + ctkCollapsibleButton + QWidget +
ctkCollapsibleButton.h
+ 1 +
+ + ctkDoubleSpinBox + QWidget +
ctkDoubleSpinBox.h
+
qMRMLNodeComboBox QWidget
qMRMLNodeComboBox.h
- qMRMLWidget - QWidget -
qMRMLWidget.h
- 1 + qMRMLSpinBox + ctkDoubleSpinBox +
qMRMLSpinBox.h
- ctkCollapsibleButton + qMRMLWidget QWidget -
ctkCollapsibleButton.h
+
qMRMLWidget.h
1
@@ -359,8 +382,8 @@ inputs: 135 - 260 - 278 + 331 + 237 @@ -380,5 +403,21 @@ inputs: + + CombineModels + mrmlSceneChanged(vtkMRMLScene*) + randomTranslationMagnitudeSpinBox + setMRMLScene(vtkMRMLScene*) + + + 283 + 549 + + + 228 + 372 + + +