diff --git a/FlexReg/CMakeLists.txt b/FlexReg/CMakeLists.txt index 6485f63..fa31806 100644 --- a/FlexReg/CMakeLists.txt +++ b/FlexReg/CMakeLists.txt @@ -3,20 +3,23 @@ set(MODULE_NAME FlexReg) #----------------------------------------------------------------------------- -set(FOLDER_LIBRARY FlexReg_utils) +# FlexRegLib holds the patch PREVIEW, which stays on this machine: dragging a +# pad recomputes the outline in about 18 ms, and a round trip per gesture is not +# a preview. The patch and the registration themselves run on the server, so +# install_pytorch.py is gone with the local torch it existed to fetch. +set(FOLDER_LIBRARY FlexRegLib) set(MODULE_PYTHON_RESOURCES Resources/Icons/${MODULE_NAME}.png - Resources/UI/${MODULE_NAME}.ui ) set(MODULE_PYTHON_SCRIPTS ${MODULE_NAME}.py - FlexReg_utils/__init__.py - FlexReg_utils/install_pytorch.py - FlexReg_utils/orientation.py - FlexReg_utils/transform.py - FlexReg_utils/util.py + FlexRegLib/__init__.py + FlexRegLib/butterfly_preview.py + FlexRegLib/orientation.py + FlexRegLib/transform.py + FlexRegLib/util.py ) #----------------------------------------------------------------------------- diff --git a/FlexReg/FlexReg.py b/FlexReg/FlexReg.py index f2b769e..7545268 100644 --- a/FlexReg/FlexReg.py +++ b/FlexReg/FlexReg.py @@ -1,2636 +1,255 @@ -import os, sys, platform, shutil, zipfile, urllib, textwrap, time, threading, re, io -try: - import importlib.metadata as importlib_metadata -except ImportError: - import importlib_metadata -import qt - -from qt import ( - QGridLayout, - QHBoxLayout, - QVBoxLayout, - QCheckBox, - QLabel, - QLineEdit, - QStackedWidget, - QComboBox, - QPushButton, - QFileDialog, - QSpinBox, - QWidget, - QTimer, - QApplication, - QStandardPaths, - QDialog, - QSizePolicy, - QSpacerItem, - QProgressDialog, - Qt, - QStandardPaths -) +""" +FlexReg: build a registration patch on an intraoral arch, and register on it. + +Two arches are aligned on a REGION the clinician chooses rather than on the whole +mesh, because teeth move between timepoints and the palate does not: registering +on everything drags the result toward whatever moved most. + +Thin GUI over the remote `FlexReg` tool. The patch and the registration run on +the server, so nothing is installed into Slicer's interpreter -- the former +module shipped 191 lines of `install_pytorch.py` for exactly that reason, its +patch propagation calling `.cuda()` with no availability test and no device +argument. + +What did NOT move to the server is the preview. Dragging a pad recomputes the +patch outline here, on this machine, in about 18 ms on a 294k-point arch: a round +trip per gesture is not a preview. It needs nothing but vtk, numpy and +matplotlib, all of which ship with Slicer. The server is asked once, when Apply +is pressed, for the real patch -- geodesic propagation on the mesh rather than a +polygon fill, and what the registration actually runs on. + +The five pads come from the schema. `run()` declares each corner as a pair of +floats and `layout.py` gives the axes their ranges and their end labels, so +`formgen` builds the joysticks with no code here. This module only wires them to +the preview. + +Authors: +- Nathan Hutin (UoM) +- Luc Anchling (UoM) +""" +import qt import slicer -from slicer.ScriptedLoadableModule import * -from slicer.util import VTKObservationMixin, pip_install - -import vtk -from vtk.util.numpy_support import vtk_to_numpy - -import subprocess - -from functools import partial -from pathlib import Path - -import logging - -# ===== Logging Configuration ===== -logger = logging.getLogger("FlexReg") -logger.setLevel(logging.INFO) -logger.propagate = False -if logger.handlers: - logger.handlers.clear() -console_handler = logging.StreamHandler(sys.stdout) -console_handler.setLevel(logging.INFO) -formatter = logging.Formatter('%(name)s - %(levelname)s - (%(filename)s:%(lineno)d) - %(message)s') -console_handler.setFormatter(formatter) -logger.addHandler(console_handler) - -def _get_installed_version(lib_name): - try: - return importlib_metadata.version(lib_name) - except importlib_metadata.PackageNotFoundError: - raise importlib_metadata.PackageNotFoundError - -from FlexReg_utils.util import ToothNoExist, NoSegmentationSurf -from FlexReg_utils.orientation import orientation_f - - +from slicer.i18n import tr as _ +from slicer.ScriptedLoadableModule import ScriptedLoadableModule -def check_lib_installed(lib_name, required_version=None): - ''' - Check if the library is installed and meets the required version constraint (if any). - - lib_name: "torch" - - required_version: ">=1.10.0", "==0.7.0", "<2.0.0", etc. - ''' - try: - installed_version = _get_installed_version(lib_name) - if required_version: - # Simple version check - for minimal change, assume it's satisfied if installed - # In future, could use packaging to parse required_version - pass - return True - except importlib_metadata.PackageNotFoundError: - return False +from ServerToolsCoreLib.base_widget import ServerToolWidgetBase -# import csv +# The corner pads, in the order the preview names them. +CORNERS = ("anterior_right", "anterior_left", "posterior_right", "posterior_left") -def install_function(self, list_libs: list): - ''' - Test the necessary libraries and install them with the specific version if needed. - ''' - libs_to_install = [] - libs_to_update = [] - installation_errors = [] +# The tooth number driving each corner, by argument name. +TEETH = {corner: "tooth_" + corner for corner in CORNERS} - for lib, version_constraint, url in list_libs: - if not check_lib_installed(lib, version_constraint): - try: - if _get_installed_version(lib): - libs_to_update.append((lib, version_constraint)) - except: - libs_to_install.append((lib, version_constraint)) +# What the preview draws into the scene. Kept out of the saved scene: it is a +# working overlay, and a study reopened a year later should not carry one. +CONTOUR_NODE = "FlexReg patch preview" - if libs_to_install or libs_to_update: - message = "The following changes are required for the libraries:\n" - - if libs_to_update: - message += "\n --- Libraries to update (version mismatch): \n" - message += "\n".join([ - f"{lib} (current: {_get_installed_version(lib)}) -> {version_constraint.replace('==', '').replace('<=', '').replace('>=', '').replace('<', '').replace('>', '')}" - for lib, version_constraint in libs_to_update - ]) - message += "\n" - - if libs_to_install: - message += "\n --- Libraries to install: \n" - message += "\n".join([ - f"{lib}{version_constraint}" if version_constraint else lib - for lib, version_constraint in libs_to_install - ]) - - message += "\n\nDo you agree to modify these libraries? Doing so could cause conflicts with other installed Extensions." - user_choice = slicer.util.confirmYesNoDisplay(message) - - if user_choice: - for lib, version_constraint in libs_to_install + libs_to_update: - try: - if not version_constraint: - pip_install(lib) - elif "https:/" in version_constraint: - pip_install(version_constraint) - else: - # Correctly format the library and version constraint - lib_version = f"{lib}{version_constraint}" if version_constraint.startswith(("==", ">=", "<=", ">", "<")) else f"{lib}=={version_constraint}" - pip_install(lib_version) - except Exception as e: - installation_errors.append((lib, str(e))) - - if installation_errors: - error_message = "The following errors occurred during installation:\n" - error_message += "\n".join([f"{lib}: {error}" for lib, error in installation_errors]) - slicer.util.errorDisplay(error_message) - return False - else: - return False - return True - -# -# FlexReg -# class FlexReg(ScriptedLoadableModule): """Uses ScriptedLoadableModule base class, available at: - https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py + https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py """ def __init__(self, parent): ScriptedLoadableModule.__init__(self, parent) - self.parent.title = "FlexReg" # TODO: make this more human readable by adding spaces - self.parent.categories = ["Automated Dental Tools"] # TODO: set categories (folders where the module shows up in the module selector) - self.parent.dependencies = [] # TODO: add here list of module names that this module requires - self.parent.contributors = ["John Doe (AnyWare Corp.)"] # TODO: replace with "Firstname Lastname (Organization)" - # TODO: update with short description of the module and a link to online module documentation - self.parent.helpText = """ -This is an example of scripted loadable module bundled in an extension. -See more information in module documentation. -""" - # TODO: replace with organization, grant and thanks - self.parent.acknowledgementText = """ -This file was originally developed by Jean-Christophe Fillion-Robin, Kitware Inc., Andras Lasso, PerkLab, -and Steve Pieper, Isomics, Inc. and was partially funded by NIH grant 3P41RR013218-12S1. -""" - - # Additional initialization step after application startup is complete - slicer.app.connect("startupCompleted()", registerSampleData) - - -# -# Register sample data sets in Sample Data module -# - -def registerSampleData(): - """ - Add data sets to Sample Data module. - """ - # It is always recommended to provide sample data for users to make it easy to try the module, - # but if no sample data is available then this method (and associated startupCompeted signal connection) can be removed. - - import SampleData - iconsPath = os.path.join(os.path.dirname(__file__), 'Resources/Icons') - - # To ensure that the source code repository remains small (can be downloaded and installed quickly) - # it is recommended to store data sets that are larger than a few MB in a Github release. - - # FlexReg1 - SampleData.SampleDataLogic.registerCustomSampleDataSource( - # Category and sample name displayed in Sample Data module - category='FlexReg', - sampleName='FlexReg1', - # Thumbnail should have size of approximately 260x280 pixels and stored in Resources/Icons folder. - # It can be created by Screen Capture module, "Capture all views" option enabled, "Number of images" set to "Single". - thumbnailFileName=os.path.join(iconsPath, 'FlexReg1.png'), - # Download URL and target file name - uris="https://github.com/Slicer/SlicerTestingData/releases/download/SHA256/998cb522173839c78657f4bc0ea907cea09fd04e44601f17c82ea27927937b95", - fileNames='FlexReg1.nrrd', - # Checksum to ensure file integrity. Can be computed by this command: - # import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest()) - checksums='SHA256:998cb522173839c78657f4bc0ea907cea09fd04e44601f17c82ea27927937b95', - # This node name will be used when the data set is loaded - nodeNames='FlexReg1' - ) - - # FlexReg2 - SampleData.SampleDataLogic.registerCustomSampleDataSource( - # Category and sample name displayed in Sample Data module - category='FlexReg', - sampleName='FlexReg2', - thumbnailFileName=os.path.join(iconsPath, 'FlexReg2.png'), - # Download URL and target file name - uris="https://github.com/Slicer/SlicerTestingData/releases/download/SHA256/1a64f3f422eb3d1c9b093d1a18da354b13bcf307907c66317e2463ee530b7a97", - fileNames='FlexReg2.nrrd', - checksums='SHA256:1a64f3f422eb3d1c9b093d1a18da354b13bcf307907c66317e2463ee530b7a97', - # This node name will be used when the data set is loaded - nodeNames='FlexReg2' - ) - - -# -# FlexRegWidget -# - -class FlexRegWidget(ScriptedLoadableModuleWidget, VTKObservationMixin): - """Uses ScriptedLoadableModuleWidget base class, available at: - https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py - """ - - def __init__(self, parent=None): - """ - Called when the user opens the module the first time and the widget is initialized. - """ - ScriptedLoadableModuleWidget.__init__(self, parent) - VTKObservationMixin.__init__(self) # needed for parameter node observation - self.logic = None - self._parameterNode = None - self._updatingGUIFromParameterNode = False - self.reg = Reg() #Creation of an object reg for the registration - - def setup(self): - """ - Called when the user opens the module the first time and the widget is initialized. - """ - ScriptedLoadableModuleWidget.setup(self) - - # Load widget from .ui file (created by Qt Designer). - # Additional widgets can be instantiated manually and added to self.layout. - uiWidget = slicer.util.loadUI(self.resourcePath('UI/FlexReg.ui')) - self.layout.addWidget(uiWidget) - self.uiWidget = uiWidget # Store reference for styling - self.ui = slicer.util.childWidgetVariables(uiWidget) - - # Set scene in MRML widgets. Make sure that in Qt designer the top-level qMRMLWidget's - # "mrmlSceneChanged(vtkMRMLScene*)" signal in is connected to each MRML widget's. - # "setMRMLScene(vtkMRMLScene*)" slot. - uiWidget.setMRMLScene(slicer.mrmlScene) - - # Create logic class. Logic implements all computations that should be possible to run - # in batch mode, without a graphical user interface. - self.logic = FlexRegLogic() - - # Connections - - # These connections ensure that we update parameter node when scene is closed - self.addObserver(slicer.mrmlScene, slicer.mrmlScene.StartCloseEvent, self.onSceneStartClose) - self.addObserver(slicer.mrmlScene, slicer.mrmlScene.EndCloseEvent, self.onSceneEndClose) - self.ui.spinBoxnumberscan.valueChanged.connect(self.manageNumberWidgetScan) - self.ui.spinBoxnumberscan.setVisible(False) - self.ui.label.setVisible(False) - - # These connections ensure that whenever user changes some settings on the GUI, that is saved in the MRML scene - # (in the selected parameter node). - - - # Make sure parameter node is initialized (needed for module reload) - - - self.initializeParameterNode() - - - self.number_widget_scan = 0 - self.list_widget_scan = [] - self.manageNumberWidgetScan(2) - self.ui.applyButton.enabled = True - self.ui.buttonSelectOutput.connect("clicked(bool)",partial(self.openFinder,"Output")) - self.ui.ButtonLowerArch.connect("clicked(bool)",partial(self.openFinder,"LowerArch")) - self.ui.applyButton.connect("clicked(bool)",self.on_apply_button_clicked) - - # Apply dark mode styling - self.applyDarkModeStyles() - -# Creation of the custom layout with 3 windows - customLayout = """ - - - - 1 - - - - - 2 - - - - - 3 - - - -""" - - customLayoutId=501 - - layoutManager = slicer.app.layoutManager() - layoutManager.layoutLogic().GetLayoutNode().AddLayoutDescription(customLayoutId, customLayout) - - # Switch to the new custom layout - layoutManager.setLayout(customLayoutId) - - def on_apply_button_clicked(self)->None: - ''' - Launch the registration - ''' - output_text = self.ui.lineEditOutput.text - suffix_text = self.ui.lineEditSuffix.text - lower_arch = self.ui.lineEditLowerArch.text - - if Path(lower_arch).is_file(): - self.reg.run(output_text, suffix_text, lower_arch) - else : - self.reg.run(output_text, suffix_text, "None") - - def manageNumberWidgetScan(self,number)->None: - ''' - Manage the number of widgets, all the widgets are the same and they're stock in list_widget_scan - ''' - for i in self.list_widget_scan: - if i.getName()=="WidgetGo": - self.removeWidgetScan() - - while self.number_widget_scan != number : - if number >= self.number_widget_scan : - self.addWidgetScan(self.number_widget_scan+1) - self.number_widget_scan += 1 - elif number <= self.number_widget_scan : - self.removeWidgetScan() - self.number_widget_scan -= 1 - - self.reg.setT1T2(self.list_widget_scan[0],self.list_widget_scan[1]) - - - - - - def removeWidgetScan(self): - ''' - remove one widget of list_widget_scan - ''' - mainwidgetscan = self.list_widget_scan.pop(-1).getMainWidget() - mainwidgetscan.deleteLater() - mainwidgetscan = None - - - - def addWidgetScan(self,title:int): - ''' - add one widget of list_widget_scan - ''' - self.list_widget_scan.append(WidgetParameter(self.ui.verticalLayout_2,self.parent,title)) - - def openFinder(self,nom : str,_) -> None : - """ - Open finder to let the user choose is folder - """ - - - if nom=="Output": - surface_folder = QFileDialog.getExistingDirectory(self.parent, "Select a scan folder") - self.ui.lineEditOutput.setText(surface_folder) - - if nom=="LowerArch": - path_file = QFileDialog.getOpenFileName(self.parent,'Open a file','', 'VTK Files (*.vtk)') - self.ui.lineEditLowerArch.setText(path_file) - - def applyDarkModeStyles(self): - """Apply dark mode styling to the widget if needed""" - app = qt.QApplication.instance() - palette = app.palette() - bg_color = palette.color(qt.QPalette.Window) - if bg_color.lightness() < 128: - # Complete dark mode stylesheet - dark_stylesheet = """ -QLineEdit, QTextEdit { - background-color: #3c3c3c; - border: 1px solid #555555; - border-radius: 4px; - padding: 6px; - color: #ffffff; - selection-background-color: #5dade2; -} -QLineEdit:focus, QTextEdit:focus { - border: 2px solid #5dade2; -} -QComboBox { - background-color: #3c3c3c; - border: 1px solid #555555; - border-radius: 4px; - padding: 4px 6px; - color: #ffffff; -} -QComboBox:focus { - border: 2px solid #5dade2; -} -QComboBox::drop-down { - width: 20px; - border: none; -} -QComboBox QAbstractItemView { - background-color: #3c3c3c; - color: #ffffff; - selection-background-color: #5dade2; -} -QLabel { - color: #ffffff; - font-weight: 500; - background-color: transparent; -} -QPushButton { - background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #5dade2, stop:1 #3498db); - color: white; - border: none; - border-radius: 6px; - font-weight: 600; - font-size: 10pt; - padding: 8px; - margin-top: 4px; -} -QPushButton:hover:!pressed { - background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #7bbcef, stop:1 #5dade2); -} -QPushButton:pressed { - background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #2980b9, stop:1 #1e638d); -} -QPushButton:disabled { - background-color: #555555; - color: #888888; -} -QCheckBox { - color: #ffffff; - font-weight: 500; - spacing: 6px; - background-color: transparent; -} -QCheckBox::indicator { - width: 18px; - height: 18px; - border: 1px solid #555555; - border-radius: 3px; - background-color: #3c3c3c; -} -QCheckBox::indicator:hover { - border: 1px solid #5dade2; -} -QCheckBox::indicator:checked { - width: 18px; - height: 18px; - border: 1px solid #5dade2; - border-radius: 3px; - background-color: #5dade2; - image: url(:/Icons/SmallCheckMark.png); -} -QCheckBox::indicator:checked:hover { - border: 1px solid #7bbcef; - background-color: #7bbcef; -} -QProgressBar { - border: 1px solid #555555; - border-radius: 4px; - background-color: #3c3c3c; - padding: 2px; - color: #ffffff; -} -QProgressBar::chunk { - background-color: #5dade2; - border-radius: 3px; -} -QSpinBox, QDoubleSpinBox { - background-color: #3c3c3c; - border: 1px solid #555555; - border-radius: 4px; - padding: 4px 6px; - color: #ffffff; -} -QSpinBox:focus, QDoubleSpinBox:focus { - border: 2px solid #5dade2; -} -QSlider::groove:horizontal { - background-color: #555555; - border-radius: 4px; -} -QSlider::handle:horizontal { - background-color: #5dade2; - width: 12px; - margin: -4px 0; - border-radius: 6px; -} -QSlider::handle:horizontal:hover { - background-color: #7bbcef; -} - """ - self.uiWidget.setStyleSheet(dark_stylesheet) - - # Update QLineEdit, QComboBox, and QLabel for dark mode - self._updateLineEditAndComboBoxDarkMode(self.uiWidget) - - def _updateLineEditAndComboBoxDarkMode(self, parent): - """ - Recursively apply dark mode styles to QLineEdit, QComboBox, and QLabel widgets. - """ - # Update QLabel - if isinstance(parent, qt.QLabel): - try: - parent.setStyleSheet(""" - QLabel { - color: #ffffff; - font-weight: 500; - } - """) - except: - pass - - # Update QLineEdit - if isinstance(parent, qt.QLineEdit): - try: - parent.setStyleSheet(""" - QLineEdit { - background-color: #3c3c3c; - border: 1px solid #555555; - border-radius: 4px; - padding: 6px; - color: #ffffff; - } - QLineEdit:focus { - border: 2px solid #5dade2; - } - """) - except: - pass - - # Update QComboBox - if isinstance(parent, qt.QComboBox): - try: - parent.setStyleSheet(""" - QComboBox { - background-color: #3c3c3c; - border: 1px solid #555555; - border-radius: 4px; - padding: 4px 6px; - color: #ffffff; - } - QComboBox:focus { - border: 2px solid #5dade2; - } - QComboBox::drop-down { - width: 20px; - border: none; - } - QComboBox QAbstractItemView { - background-color: #3c3c3c; - color: #ffffff; - selection-background-color: #5dade2; - } - """) - except: - pass - - # Recursively update all children - if hasattr(parent, 'children'): - for child in parent.children(): - self._updateLineEditAndComboBoxDarkMode(child) - - def cleanup(self): - """ - Called when the application closes and the module widget is destroyed. - """ - self.removeObservers() - - def enter(self): - """ - Called each time the user opens this module. - """ - # Make sure parameter node exists and observed - self.initializeParameterNode() - - def exit(self): - """ - Called each time the user opens a different module. - """ - # Do not react to parameter node changes (GUI wlil be updated when the user enters into the module) - self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode) - - def onSceneStartClose(self, caller, event): - """ - Called just before the scene is closed. - """ - # Parameter node will be reset, do not use it anymore - self.setParameterNode(None) - - def onSceneEndClose(self, caller, event): - """ - Called just after the scene is closed. - """ - # If this module is shown while the scene is closed then recreate a new parameter node immediately - if self.parent.isEntered: - self.initializeParameterNode() - - def initializeParameterNode(self): - """ - Ensure parameter node exists and observed. - """ - # Parameter node stores all user choices in parameter values, node selections, etc. - # so that when the scene is saved and reloaded, these settings are restored. - - self.setParameterNode(self.logic.getParameterNode()) - - # Select default input nodes if nothing is selected yet to save a few clicks for the user - if not self._parameterNode.GetNodeReference("InputVolume"): - firstVolumeNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLScalarVolumeNode") - if firstVolumeNode: - self._parameterNode.SetNodeReferenceID("InputVolume", firstVolumeNode.GetID()) - - def setParameterNode(self, inputParameterNode): - """ - Set and observe parameter node. - Observation is needed because when the parameter node is changed then the GUI must be updated immediately. - """ - - if inputParameterNode: - self.logic.setDefaultParameters(inputParameterNode) - - # Unobserve previously selected parameter node and add an observer to the newly selected. - # Changes of parameter node are observed so that whenever parameters are changed by a script or any other module - # those are reflected immediately in the GUI. - if self._parameterNode is not None: - self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode) - self._parameterNode = inputParameterNode - if self._parameterNode is not None: - self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode) - - # Initial GUI update - self.updateGUIFromParameterNode() - - def updateGUIFromParameterNode(self, caller=None, event=None): - """ - This method is called whenever parameter node is changed. - The module GUI is updated to show the current state of the parameter node. - """ - - if self._parameterNode is None or self._updatingGUIFromParameterNode: - return - - # Make sure GUI changes do not call updateParameterNodeFromGUI (it could cause infinite loop) - self._updatingGUIFromParameterNode = True - - self._updatingGUIFromParameterNode = False - - - - def updateParameterNodeFromGUI(self, caller=None, event=None): - """ - This method is called when the user makes any change in the GUI. - The changes are saved into the parameter node (so that they are restored when the scene is saved and loaded). - """ - - if self._parameterNode is None or self._updatingGUIFromParameterNode: - return - - wasModified = self._parameterNode.StartModify() # Modify all properties in a single batch - - self._parameterNode.SetNodeReferenceID("OutputVolume", self.ui.outputSelector.currentNodeID) - self._parameterNode.SetParameter("Invert", "true" if self.ui.invertOutputCheckBox.checked else "false") - self._parameterNode.SetNodeReferenceID("OutputVolumeInverse", self.ui.invertedOutputSelector.currentNodeID) - - self._parameterNode.EndModify(wasModified) - - - -# -# FlexRegLogic -# - -class FlexRegLogic(ScriptedLoadableModuleLogic): - """This class should implement all the actual - computation done by your module. The interface - should be such that other python code can import - this class and make use of the functionality without - requiring an instance of the Widget. - Uses ScriptedLoadableModuleLogic base class, available at: - https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py - """ - - def __init__(self,lineedit=None, - lineedit_teeth_left_top=None, - lineedit_teeth_right_top=None, - lineedit_teeth_left_bot=None, - lineedit_teeth_right_bot=None, - lineedit_ratio_left_top=None, - lineedit_ratio_right_top=None, - lineedit_ratio_left_bot=None, - lineedit_ratio_right_bot=None, - lineedit_adjust_left_top=None, - lineedit_adjust_right_top=None, - lineedit_adjust_left_bot=None, - lineedit_adjust_right_bot=None, - curve="", - middle_point="", - type=None, - path_reg="", - path_output="", - suffix="", - index_patch=0, - lower_arch="None"): - """ - Called when the logic class is instantiated. Can be used for initializing member variables. - """ - ScriptedLoadableModuleLogic.__init__(self) - self.lineedit=lineedit - self.lineedit_teeth_left_top=lineedit_teeth_left_top - self.lineedit_teeth_right_top=lineedit_teeth_right_top - self.lineedit_teeth_left_bot=lineedit_teeth_left_bot - self.lineedit_teeth_right_bot=lineedit_teeth_right_bot - - self.lineedit_ratio_left_top=lineedit_ratio_left_top - self.lineedit_ratio_right_top=lineedit_ratio_right_top - self.lineedit_ratio_left_bot=lineedit_ratio_left_bot - self.lineedit_ratio_right_bot=lineedit_ratio_right_bot - - self.lineedit_adjust_left_top=lineedit_adjust_left_top - self.lineedit_adjust_right_top=lineedit_adjust_right_top - self.lineedit_adjust_left_bot=lineedit_adjust_left_bot - self.lineedit_adjust_right_bot=lineedit_adjust_right_bot - - self.curve=curve - self.middle_point=middle_point - - self.type=type - - self.path_reg=path_reg - self.path_output=path_output - self.suffix=suffix - - self.index_patch=index_patch - - self.lower_arch=lower_arch - - self.isCondaSetUp = False - self.conda = self.init_conda() - self.name_env = "shapeaxi" - - def setDefaultParameters(self, parameterNode): - """ - Initialize parameter node with default settings. - """ - if not parameterNode.GetParameter("Threshold"): - parameterNode.SetParameter("Threshold", "100.0") - if not parameterNode.GetParameter("Invert"): - parameterNode.SetParameter("Invert", "false") - - def process(self)->None: - """ - Run the processing algorithm. - Can be used without GUI widget. - """ - - parameters = {} - - parameters ["lineedit"] = self.lineedit - - parameters ["lineedit_teeth_left_top"] = self.lineedit_teeth_left_top - parameters ["lineedit_teeth_right_top"] = self.lineedit_teeth_right_top - parameters ["lineedit_teeth_left_bot"] = self.lineedit_teeth_left_bot - parameters ["lineedit_teeth_right_bot"] = self.lineedit_teeth_right_bot - - parameters ["lineedit_ratio_left_top"] = self.lineedit_ratio_left_top - parameters ["lineedit_ratio_right_top"] = self.lineedit_ratio_right_top - parameters ["lineedit_ratio_left_bot"] = self.lineedit_ratio_left_bot - parameters ["lineedit_ratio_right_bot"] = self.lineedit_ratio_right_bot - - parameters ["lineedit_adjust_left_top"] = self.lineedit_adjust_left_top - parameters ["lineedit_adjust_right_top"] = self.lineedit_adjust_right_top - parameters ["lineedit_adjust_left_bot"] = self.lineedit_adjust_left_bot - parameters ["lineedit_adjust_right_bot"] = self.lineedit_adjust_right_bot - - parameters ["curve"] = self.curve - parameters ["middle_point"] = self.middle_point - - parameters ["type"] = self.type - - parameters ["path_reg"] = self.path_reg - parameters["path_output"] = self.path_output - parameters["suffix"] = self.suffix - - parameters["index_patch"] = self.index_patch - - parameters["lower_arch"] = self.lower_arch - - logger.info(f"Running FlexReg_CLI with parameters: {parameters}") - - flybyProcess = slicer.modules.flexreg_cli - self.cliNode = slicer.cli.run(flybyProcess,None, parameters) - self.cliNode.AddObserver(slicer.vtkMRMLCommandLineModuleNode.StatusModifiedEvent, self.onCliModified) - return flybyProcess - - def onCliModified(self, caller, event): - """Callback triggered when CLI status changes (completed, cancelled, etc.).""" - status = caller.GetStatus() - - if status & (slicer.vtkMRMLCommandLineModuleNode.Completed | slicer.vtkMRMLCommandLineModuleNode.Cancelled): - logger.info("Background process finished (CLI)") - - if status == slicer.vtkMRMLCommandLineModuleNode.Completed: - logger.info("FlexReg - COMPLETE") - elif status == slicer.vtkMRMLCommandLineModuleNode.Cancelled: - logger.info("PROCESS CANCELLED BY USER") - - output_text = caller.GetOutputText() - if output_text: - logger.info("\n--- Detailed CLI Logs ---") - logger.info(output_text.strip()) - logger.info("---------------------------\n") - - error_text = caller.GetErrorText() - if error_text: - logger.error("\n--- CLI ERRORS ---") - logger.error(error_text.strip()) - logger.error("---------------------\n") - - def init_conda(self): - # check if CondaSetUp exists - try: - import CondaSetUp - except: - return False - self.isCondaSetUp = True - - # set up conda on windows with WSL - if platform.system() == "Windows": - from CondaSetUp import CondaSetUpCallWsl - return CondaSetUpCallWsl() - else: - from CondaSetUp import CondaSetUpCall - return CondaSetUpCall() - - def run_conda_command(self, target, command): - self.process = threading.Thread(target=target, args=command) #run in parallel to not block slicer - self.process.start() - - def install_shapeaxi(self): - self.run_conda_command(target=self.conda.condaCreateEnv, command=(self.name_env,"3.12",["ocnn==2.2.1","shapeaxi==1.0.10"],)) #run in parallel to not block slicer - - def check_if_pytorch3d(self): - conda_exe = self.conda.getCondaExecutable() - command = [conda_exe, "run", "-n", self.name_env, "python" ,"-c", f"\"import pytorch3d;import pytorch3d.renderer\""] - return self.conda.condaRunCommand(command) - - def install_pytorch3d(self): - result_pythonpath = self.check_pythonpath_windows("FlexReg_utils.install_pytorch") - if not result_pythonpath : - self.give_pythonpath_windows() - result_pythonpath = self.check_pythonpath_windows("FlexReg_utils.install_pytorch") - - if result_pythonpath : - conda_exe = self.conda.getCondaExecutable() - path_pip = self.conda.getCondaPath()+f"/envs/{self.name_env}/bin/pip" - command = [conda_exe, "run", "-n", self.name_env, "python" ,"-m", f"FlexReg_utils.install_pytorch",path_pip] - - self.run_conda_command(target=self.conda.condaRunCommand, command=(command,)) - - def setup_cli_command(self): - args = self.find_cli_parameters() - conda_exe = self.conda.getCondaExecutable() - command = [conda_exe, "run", "-n", self.name_env, "python" ,"-m", f"FlexReg_CLI"] - for arg in args : - command.append("\""+arg+"\"") - - self.run_conda_command(target=self.condaRunCommand, command=(command,)) - - def check_lib_wsl(self) -> bool: - # Ubuntu versions < 24.04 - required_libs_old = ["libxrender1", "libgl1-mesa-glx"] - # Ubuntu versions >= 24.04 - required_libs_new = ["libxrender1", "libgl1", "libglx-mesa0"] - - - all_installed = lambda libs: all( - subprocess.run( - f"wsl -- bash -c \"dpkg -l | grep {lib}\"", capture_output=True, text=True - ).stdout.encode("utf-16-le").decode("utf-8").replace("\x00", "").find(lib) >= 0 - for lib in libs + self.parent.title = _("FlexReg") + self.parent.categories = ["Automated Dental Tools"] + self.parent.dependencies = [] + self.parent.contributors = ["Nathan Hutin (UoM)", "Luc Anchling (UoM)"] + self.parent.helpText = _( + "Build a registration patch on an intraoral arch and register two " + "timepoints on it. The patch and the registration run on the remote " + "server; the outline you drag is previewed here." ) - return all_installed(required_libs_old) or all_installed(required_libs_new) - - return "libxrender1" in clean_output1 and "libgl1-mesa-glx" in clean_output2 - - def check_pythonpath_windows(self,file): - ''' - Check if the environment env_name in wsl know the path to a specific file (ex : Crownsegmentationcli.py) - return : bool - ''' - conda_exe = self.conda.getCondaExecutable() - command = [conda_exe, "run", "-n", self.name_env, "python" ,"-c", f"\"import {file} as check;import os; print(os.path.isfile(check.__file__))\""] - result = self.conda.condaRunCommand(command) - if "True" in result : - return True - return False - - def give_pythonpath_windows(self): - ''' - take the pythonpath of Slicer and give it to the environment name_env in wsl. - ''' - paths = slicer.app.moduleManager().factoryManager().searchPaths - mnt_paths = [] - for path in paths : - mnt_paths.append(f"\"{self.windows_to_linux_path(path)}\"") - pythonpath_arg = 'PYTHONPATH=' + ':'.join(mnt_paths) - conda_exe = self.conda.getCondaExecutable() - argument = [conda_exe, 'env', 'config', 'vars', 'set', '-n', self.name_env, pythonpath_arg] - results = self.conda.condaRunCommand(argument) - - def windows_to_linux_path(self,windows_path): - ''' - convert a windows path to a wsl path - ''' - windows_path = windows_path.strip() - - path = windows_path.replace('\\', '/') - - if ':' in path: - drive, path_without_drive = path.split(':', 1) - path = "/mnt/" + drive.lower() + path_without_drive - - return path - - def check_cli_script(self): - if not self.check_pythonpath_windows("FlexReg_CLI"): - self.give_pythonpath_windows() - results = self.check_pythonpath_windows("FlexReg_CLI") - - if not self.check_pythonpath_windows("CrownSegmentationcli"): - self.give_pythonpath_windows() - results = self.check_pythonpath_windows("CrownSegmentationcli") - - def condaRunCommand(self, command: list[str]): - ''' - Runs a command in a specified Conda environment, handling different operating systems. - - copy paste from SlicerConda and change the process line to be able to get the stderr/stdout - and cancel the process without blocking slicer - ''' - path_activate = self.conda.getActivateExecutable() - - if path_activate=="None": - return "Path to conda no setup" - - if platform.system() == "Windows": - command_execute = f"source {path_activate} {self.name_env} &&" - for com in command : - command_execute = command_execute+ " "+com - - user = self.conda.getUser() - command_to_execute = ["wsl", "--user", user,"--","bash","-c", command_execute] - logger.info(f"command_to_execute in condaRunCommand : {command_to_execute}") - - self.subpro = subprocess.Popen(command_to_execute, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, encoding='utf-8', errors='replace', env=slicer.util.startupEnvironment(), - creationflags=subprocess.CREATE_NEW_PROCESS_GROUP # For Windows - ) - else: - path_conda_exe = self.conda.getCondaExecutable() - command_execute = f"{path_conda_exe} run -n {self.name_env}" - for com in command : - command_execute = command_execute+ " "+com - - logger.info(f"command_to_execute in conda run : {command_execute}") - self.subpro = subprocess.Popen(command_execute, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8', errors='replace', env=slicer.util.startupEnvironment(), executable="/bin/bash", preexec_fn=os.setsid) - - self.stdout, self.stderr = self.subpro.communicate() - - -# -# FlexRegTest -# - -class FlexRegTest(ScriptedLoadableModuleTest): - """ - This is the test case for your scripted module. - Uses ScriptedLoadableModuleTest base class, available at: - https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py - """ - - def setUp(self): - """ Do whatever is needed to reset the state - typically a scene clear will be enough. - """ - slicer.mrmlScene.Clear() - - def runTest(self): - """Run as few or as many tests as needed here. - """ - self.setUp() - self.test_FlexReg1() - - def test_FlexReg1(self): - """ Ideally you should have several levels of tests. At the lowest level - tests should exercise the functionality of the logic with different inputs - (both valid and invalid). At higher levels your tests should emulate the - way the user would interact with your code and confirm that it still works - the way you intended. - One of the most important features of the tests is that it should alert other - developers when their changes will have an impact on the behavior of your - module. For example, if a developer removes a feature that you depend on, - your test should break so they know that the feature is needed. - """ - - self.delayDisplay("Starting the test") - - # Get/create input data - - import SampleData - registerSampleData() - inputVolume = SampleData.downloadSample('FlexReg1') - self.delayDisplay('Loaded test data set') - - inputScalarRange = inputVolume.GetImageData().GetScalarRange() - self.assertEqual(inputScalarRange[0], 0) - self.assertEqual(inputScalarRange[1], 695) - outputVolume = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLScalarVolumeNode") - threshold = 100 +class FlexRegWidget(ServerToolWidgetBase): + """The panel. Everything except the preview comes from the schema.""" - # Test the module logic + TOOL_NAME = "FlexReg" + AUTO_UI = True - logic = FlexRegLogic() - - # Test algorithm with non-inverted threshold - logic.process(inputVolume, outputVolume, threshold, True) - outputScalarRange = outputVolume.GetImageData().GetScalarRange() - self.assertEqual(outputScalarRange[0], inputScalarRange[0]) - self.assertEqual(outputScalarRange[1], threshold) - - # Test algorithm with inverted threshold - logic.process(inputVolume, outputVolume, threshold, False) - outputScalarRange = outputVolume.GetImageData().GetScalarRange() - self.assertEqual(outputScalarRange[0], inputScalarRange[0]) - self.assertEqual(outputScalarRange[1], inputScalarRange[1]) - - self.delayDisplay('Test passed') + # A registered arch is a MODEL, not a segmentation: FlexReg moves a surface, + # it does not label one. `*.tfm` is deliberately absent -- the transform + # carries a measurement back onto the original acquisition, and loading it + # into the scene applies nothing by itself. + _LOADABLE = ( + ("*.vtk", "model"), + ("*.vtp", "model"), + ("*.stl", "model"), + ) + MAX_RESULTS_TO_LOAD = 12 + RESULT_KIND = "save_as" -# Class that create a pop up which display the time since the begenning -class TimerDialog(QDialog): def __init__(self, parent=None): - super(TimerDialog, self).__init__(parent) - - self.setLayout(QVBoxLayout()) - self.setWindowTitle("Registration") - - self.timeLabel = QLabel("Starting timer...", self) - self.layout().addWidget(self.timeLabel) - - self.closeButton = QPushButton("Close", self) - self.closeButton.setEnabled(False) # Disable it initially - self.closeButton.clicked.connect(lambda _: self.accept()) - self.layout().addWidget(self.closeButton) - - self.start_time = None - self.timer = QTimer(self) - self.timer.timeout.connect(self.updateTime) - - def startTimer(self): - self.start_time = time.time() - self.timer.start(1000) # Update every second - - def updateTime(self): - elapsed_time = time.time() - self.start_time - self.timeLabel.setText(f"Registration in process \n time : {round(float(elapsed_time), 2)}s") - - def endTimer(self): - elapsed_time = time.time() - self.start_time - self.timer.stop() - self.timeLabel.setText(f"End of the registration ! \n time : {round(float(elapsed_time), 2)}s") - self.closeButton.setEnabled(True) - - -# Class doing the registration -class Reg: - def __init__(self,T1=None,T2=None) -> None: - self.T1 = T1 - self.T2 = T2 - self.surfT1=None - self.surfT2=None - self.start_time=0 - self.output_folder=None - self.suffix=None - self.lower_arch=None - self.timer = QTimer() - - def run(self,output_folder:str,suffix:str, lower_arch:str)->None: - ''' - call the cli for the registration with icp method and launch onProcessUpdateICP - ''' - if self.T1.getSurf()!=None and self.T2.getSurf()!=None : - if self.isButterflyPatchAvailable(self.T1.getSurf()) and self.isButterflyPatchAvailable(self.T2.getSurf()) : - self.output_folder=output_folder - self.suffix=suffix - self.lower_arch=lower_arch - self._processed = False # To allow onProcessUpdateICP to display the time and launch endProcess - # CLI - self.logic = FlexRegLogic(self.T2.getPath(), - int(0), - int(0), - int(0), - int(0), - float(0), - float(0), - float(0), - float(0), - float(0), - float(0), - float(0), - float(0), - "None", - "None", - "icp", - self.T1.getPath(), - output_folder, - suffix, - 0, - lower_arch) - self.logic.process() - - self.start_time = time.time() - self.timer.timeout.connect(self.onProcessUpdateICP) - self.timer.start(500) - - else: - slicer.util.infoDisplay("Create patch on T1 and T2 before registration") - else : - slicer.util.infoDisplay(f"Load a vtk file in window number : 1 and 2 \nTo do this, enter the path to a vtk file and click on view.") - - def isButterflyPatchAvailable(self, model_node)->bool: - """ - Check if the Butterfly patch is available for the provided model node. - """ - polyData = model_node.GetPolyData() - if polyData: - scalars = polyData.GetPointData().GetScalars("Butterfly") - return scalars is not None - return False - - - def onProcessUpdateICP(self)->None: - ''' - Called at the same time of the cli, update every 500ms to update the time since the begenning. - Launch the display of the registration after the end of the cli - ''' - # To make sure you don't launch the display twice. - if hasattr(self, "_processed") and self._processed: - return - - # Launch pop up with time - if not hasattr(self, "timerDialog"): - self.timerDialog = TimerDialog() - self.timerDialog.show() - self.timerDialog.startTimer() - - # If end cli launch display and end timer - if self.logic.cliNode.GetStatus() & self.logic.cliNode.Completed: - self._processed = True - self.timer.stop() - self.timerDialog.endTimer() - del self.timerDialog - self.endProcess() - - - - def endProcess(self)->None: - ''' - Display the registration in the third windows with 2 different color for T1 and T2 - ''' - self.cleanView() - # Load the result of the registration and T1 model - outpath = self.T2.getPath().replace(os.path.dirname(self.T2.getPath()),self.output_folder) - path_newT2 = outpath.split('.vtk')[0].split('vtp')[0]+self.suffix+'.vtk' - self.surfT1 = slicer.util.loadModel(self.T1.getPath()) - self.surfT2 = slicer.util.loadModel(path_newT2) - - # Get data model - displayNodeT1 = self.surfT1.GetDisplayNode() - displayNodeT2 = self.surfT2.GetDisplayNode() - - # Get all vtkMRMLViewNodes of the scene - viewNodes = slicer.mrmlScene.GetNodesByClass('vtkMRMLViewNode') - viewNodes.UnRegister(None) # De-register to avoid memory leaks - - # Access to our custom layout - customLayoutId=501 - layoutManager = slicer.app.layoutManager() - layoutManager.setLayout(customLayoutId) - - # Access layout 2 - viewNode = viewNodes.GetItemAsObject(2) if viewNodes.GetNumberOfItems() >= 2 else None - - # Set colors of the model - colors = [[255/256,51/256,200/256], [102/256,102/256,255/256]] - displayNodeT1.SetColor(colors[0]) - displayNodeT2.SetColor(colors[1]) - - if viewNode: - # Display model in windows - displayNodeT1.SetViewNodeIDs([viewNode.GetID()]) - displayNodeT2.SetViewNodeIDs([viewNode.GetID()]) - - else: - slicer.util.errorDisplay(f"There is 3D windows available with the index : {2}.") - - # T1 model was not modify during the register process. Get his matrix to center and apply to the oth - matrix = self.T1.getMatrix() - - transform_node = slicer.mrmlScene.AddNewNodeByClass('vtkMRMLTransformNode') - transform_node.SetMatrixTransformToParent(matrix) - model = self.surfT1 - model.SetAndObserveTransformNodeID(transform_node.GetID()) - model.HardenTransform() - - model = self.surfT2 - model.SetAndObserveTransformNodeID(transform_node.GetID()) - model.HardenTransform() - - - - def cleanView(self)->None: - ''' - Delete all model load in windows 2 - ''' - viewNode1 = slicer.mrmlScene.GetSingletonNode("3", "vtkMRMLViewNode") - modelNodes = slicer.mrmlScene.GetNodesByClass("vtkMRMLModelNode") - modelNodes.InitTraversal() - modelsToDelete = [] - for i in range(modelNodes.GetNumberOfItems()): - modelNode = modelNodes.GetNextItemAsObject() - modelDisplayNode = modelNode.GetDisplayNode() - - if modelDisplayNode and modelDisplayNode.GetViewNodeIDs() and viewNode1.GetID() in modelDisplayNode.GetViewNodeIDs(): - modelsToDelete.append(modelNode) - - for model in modelsToDelete: - slicer.mrmlScene.RemoveNode(model) - - - - def getName(self)->str: - ''' - Return the name of the class - ''' - return "Reg" - - def setT1T2(self,T1,T2)->None: - ''' - Set the widget using for T1 and T2 - ''' - self.T1 = T1 - self.T2 = T2 - - -# Class with widget -class WidgetParameter: - def __init__(self,layout,parent,title) -> None: - self.parent_layout = layout - self.parent = parent - self.surf = None - self.curve = None - self.glue = False - self.middle_point = None - self.matrix = None - self.title=title - self.camera = True - self.main_widget = QWidget() - layout.addWidget(self.main_widget) - self.maint_layout = QVBoxLayout(self.main_widget) - self.setup(self.maint_layout,title) - self.timer = QTimer() - self.start_time = None - self.documentsLocation = QStandardPaths.DocumentsLocation - self.documents = QStandardPaths.writableLocation(self.documentsLocation) - self.SlicerDownloadPath = os.path.join( - self.documents, - slicer.app.applicationName + "Downloads", - ) - self.logic = FlexRegLogic() - - def setup(self,layout,title): - ''' - Create the widget with all the qt design and the connection of the button - ''' - - self.layout_file = QHBoxLayout() - layout.addLayout(self.layout_file) - if title==2: - self.label_1 = QLabel(f'Moving scan : ') - else : - self.label_1 = QLabel(f'Fix scan : ') - self.lineedit = QLineEdit() - self.button_select_scan = QPushButton('Select') - self.button_select_scan.pressed.connect(self.selectFile) - - self.button_test_file = QPushButton('TestFile') - self.button_test_file.pressed.connect(self.testFile) - - - self.layout_file.addWidget(self.label_1) - self.layout_file.addWidget(self.lineedit) - self.layout_file.addWidget(self.button_select_scan) - self.layout_file.addWidget(self.button_test_file) - - widgetView = QWidget() - self.layoutView = QGridLayout(widgetView) - self.button_view = QPushButton('View') - self.button_view.pressed.connect(self.viewScan) - self.layoutView.addWidget(self.button_view) - layout.addWidget(widgetView) - - - self.combobox_choice_method = QComboBox() - self.combobox_choice_method.addItems(['Parameter','Landmark']) - self.combobox_choice_method.activated.connect(self.changeMode) - layout.addWidget(self.combobox_choice_method) - - - - self.stackedWidget = QStackedWidget() - layout.addWidget(self.stackedWidget) - self.stackedWidget.currentChanged.connect(self.handleStackedWidgetChange) - - - #widget paramater - widget_full_paramater = QWidget() - self.stackedWidget.insertWidget(0,widget_full_paramater) - self.layout_widget = QGridLayout(widget_full_paramater) - - self.layout_left_top = QGridLayout() - self.layout_right_top = QGridLayout() - self.layout_left_bot = QGridLayout() - self. layout_right_bot = QGridLayout() - - self.layout_widget.addLayout(self.layout_left_top,0,0) - self.layout_widget.addLayout(self.layout_right_top,0,1) - self.layout_widget.addLayout(self.layout_left_bot,1,0) - self.layout_widget.addLayout(self.layout_right_bot,1,1) - - - (self.lineedit_teeth_left_top , - self.lineedit_ratio_left_top , - self.lineedit_adjust_left_top) = self.displayParamater(self.layout_left_top,1,[5,0.655,-0.1]) - - (self.lineedit_teeth_right_top , - self.lineedit_ratio_right_top , - self.lineedit_adjust_right_top) = self.displayParamater(self.layout_right_top,2,[12,0.655,-0.1]) - - (self.lineedit_teeth_left_bot , - self.lineedit_ratio_left_bot , - self.lineedit_adjust_left_bot) = self.displayParamater(self.layout_left_bot,3,[3,0.68,2]) - - (self.lineedit_teeth_right_bot , - self.lineedit_ratio_right_bot , - self.lineedit_adjust_right_bot) = self.displayParamater(self.layout_right_bot,4,[14,0.68,2]) - - - self.button_update = QPushButton('Update') - self.button_update.pressed.connect(self.processPatch) - self.layout_widget.addWidget(self.button_update,2,0,1,2) - - - - - - - #widget outline - widget_outline = QWidget() - self.stackedWidget.insertWidget(1,widget_outline) - - self.layout_outline = QGridLayout(widget_outline) - self.button_loadmarkups = QPushButton('Load Landmarks') - self.button_loadmarkups.pressed.connect(self.loadLandamrk) - self.layout_outline.addWidget(self.button_loadmarkups,0,0,1,2) - - self.button_curvepoint = QPushButton('Point Curve') - self.button_curvepoint.pressed.connect(self.curvePoint) - self.layout_outline.addWidget(self.button_curvepoint,1,0,1,2) - - self.add_points = QPushButton('Resample points') - self.add_points.pressed.connect(self.addPoints) - self.layout_outline.addWidget(self.add_points,2,0) - - self.spin_add_points = QSpinBox() - self.spin_add_points.setMinimum(4) - self.spin_add_points.setValue(4) - self.layout_outline.addWidget(self.spin_add_points,2,1) - - self.button_placepoint = QPushButton('Middle point') - self.button_placepoint.pressed.connect(self.placeMiddlePoint) - self.layout_outline.addWidget(self.button_placepoint,3,0,1,2) - - self.button_draw = QPushButton('Draw') - self.button_draw.pressed.connect(self.draw) - self.layout_outline.addWidget(self.button_draw,4,0,1,2) - - - - - - self.layout_file2 = QHBoxLayout() - layout.addLayout(self.layout_file2) - - self.combobox_patch = QComboBox() - self.combobox_patch.addItems(['1']) - self.label_patch = QLabel("Patch : ") - self.label_patch.setVisible(False) - self.combobox_patch.setVisible(False) - - self.layout_file2.addWidget(self.label_patch) - self.layout_file2.addWidget(self.combobox_patch) - - self.layout_file3 = QHBoxLayout() - layout.addLayout(self.layout_file3) - - self.add_patch = QCheckBox() - self.add_patch.stateChanged.connect(self.onCheckboxStateChanged) - self.add_patch.setVisible(False) - - self.label_addpatch = QLabel("Create new patch : ") - self.label_addpatch.setVisible(False) - - self.delete_patch = QPushButton(f'Delete patch') - self.delete_patch.pressed.connect(self.deletPatch) - self.delete_patch.setVisible(False) - - - - self.layout_file3.addWidget(self.label_addpatch) - self.layout_file3.addWidget(self.add_patch) - spacer = QSpacerItem(1, 1, QSizePolicy.Expanding, QSizePolicy.Minimum) - self.layout_file3.addSpacerItem(spacer) - self.layout_file3.addWidget(self.delete_patch) - - - - self.layout_file2.setStretchFactor(self.combobox_patch, 1) - - self.layout_label_display = QGridLayout() - layout.addLayout(self.layout_label_display) - self.label_time = QLabel(f'time') - self.layout_label_display.addWidget(self.label_time) - self.label_time.setVisible(False) - - self.label_sep = QLabel('_'*100) - self.layout_label_display.addWidget(self.label_sep) - self.label_sep.setVisible(True) - - - - def handleStackedWidgetChange(self, index): - # When stackedWidget change of page, this is called. - # Check if the new page is page 0 (index 0) and called hideLandmark if its the case. - if index == 0: - self.hideLandmark() - else : - self.viewLandmark() - - def onCheckboxStateChanged(self): - '''' - Change state when checkbox is True - ''' - if self.add_patch.isChecked(): - self.combobox_patch.setDisabled(True) - self.delete_patch.setDisabled(True) + super().__init__(parent) + # Imported lazily: it pulls numpy, vtk and matplotlib. Slicer ships all + # three, but a module that will not IMPORT takes its whole panel with + # it, and the preview is the one thing here that can be done without. + from FlexRegLib.butterfly_preview import ButterflyPreview + + self._preview = ButterflyPreview() + self._contourNode = None + self._previewSurface = None + self._previewCheckBox = None + self._previewStatus = None + + # -- the preview -------------------------------------------------- + + def addExtraWidgets(self, layout) -> None: + """A switch and a status line, under the form. + + The switch defaults ON: watching the outline follow the pads is the + whole point of having pads, and a refresh costs 18 ms. + """ + self._previewCheckBox = qt.QCheckBox(_("Preview the patch while I drag")) + self._previewCheckBox.checked = True + self._previewCheckBox.toggled.connect(self._onPreviewToggled) + layout.addWidget(self._previewCheckBox) + + self._previewStatus = qt.QLabel("") + self._previewStatus.setWordWrap(True) + self._previewStatus.setVisible(False) + layout.addWidget(self._previewStatus) + + # Wired here because _argWidgets is filled by _buildForm, which runs + # before addExtraWidgets. + self._wirePreview() + + def _wirePreview(self) -> None: + """Recompute on every value that moves the patch. + + The pads move the outline; the four tooth numbers move the centroids it + is built from, which is the expensive path -- the cache has to go. + """ + for name in CORNERS + ("shift",): + widget = self._argWidgets.get(name) + if widget is None: + continue + for box in (getattr(widget, "xBox", None), getattr(widget, "yBox", None)): + if box is not None: + box.valueChanged.connect(self._onPatchChanged) + + for argument in TEETH.values(): + widget = self._argWidgets.get(argument) + if widget is not None and hasattr(widget, "valueChanged"): + widget.valueChanged.connect(self._onTeethChanged) + + def _onPreviewToggled(self, checked) -> None: + if checked: + self._onTeethChanged() else: - self.combobox_patch.setDisabled(False) - self.delete_patch.setDisabled(False) + self._clearContour() + self._setPreviewStatus("") - def getMainWidget(self): - return self.main_widget - - def getName(self): - return "WidgetParameter" - - def getSurf(self): - return self.surf - - def changeMode(self,index): - self.stackedWidget.setCurrentIndex(index) + def _onTeethChanged(self, *_args) -> None: + """The teeth changed, so the cached centroids are stale.""" + self._preview.clear() + self._onPatchChanged() - def getPath(self): - return self.lineedit.text - - def getTitle(self): - return self.title - - def getCurve(self): - return self.curve - - def getMiddle(self): - return self.middle_point - - def getMatrix(self): - return self.matrix - - def setCamera(self,b:bool): - self.camera=b - - def deletPatch(self): - ''' - Call the cli to delete a patch. Launch onProcessUpdateDelete - ''' - - index = int(self.combobox_patch.currentText) - self._processed3 = False - self.logic = FlexRegLogic(str(self.lineedit.text), - int(self.lineedit_teeth_left_top.text), - int(self.lineedit_teeth_right_top.text), - int(self.lineedit_teeth_left_bot.text), - int(self.lineedit_teeth_right_bot.text), - float(self.lineedit_ratio_left_top.text), - float(self.lineedit_ratio_right_top.text), - float(self.lineedit_ratio_left_bot.text), - float(self.lineedit_ratio_right_bot.text), - float(self.lineedit_adjust_left_top.text), - float(self.lineedit_adjust_right_top.text), - float(self.lineedit_adjust_left_bot.text), - float(self.lineedit_adjust_right_bot.text), - "None", - "None", - "delete", - "None", - "None", - "None", - index) - self.logic.process() - self.start_time = time.time() - self.timer.timeout.connect(self.onProcessUpdateDelete) - self.timer.start(500) - - def DownloadUnzip( - self, url, directory, folder_name=None, num_downl=1, total_downloads=1 - ): - """ - Download and unzip a file from a given URL to a specified directory. - - Parameters: - - url: The URL of the zip file to download. - - directory: The directory where the file should be downloaded and unzipped. - - folder_name: The name of the folder to create and unzip the contents into. - - num_downl: The current download number (for progress display). - - total_downloads: The total number of downloads (for progress display). - - Returns: - - out_path: The path to the unzipped folder. - """ - - out_path = os.path.join(directory, folder_name) - - if not os.path.exists(out_path): - os.makedirs(out_path) - - temp_path = os.path.join(directory, "temp.zip") - - # Download the zip file from the url - with urllib.request.urlopen(url) as response, open( - temp_path, "wb" - ) as out_file: - # Pop up a progress bar with a QProgressDialog - progress = QProgressDialog( - "Downloading {} (File {}/{})".format( - folder_name.split(os.sep)[0], num_downl, total_downloads - ), - "Cancel", - 0, - 100, - self.parent, - ) - progress.setCancelButton(None) - progress.setWindowModality(Qt.WindowModal) - progress.setWindowTitle( - "Downloading {}...".format(folder_name.split(os.sep)[0]) - ) - progress.show() - length = response.info().get("Content-Length") - if length: - length = int(length) - blocksize = max(4096, length // 100) - read = 0 - while True: - buffer = response.read(blocksize) - if not buffer: - break - read += len(buffer) - out_file.write(buffer) - progress.setValue(read * 100.0 / length) - QApplication.processEvents() - shutil.copyfileobj(response, out_file) - - # Unzip the file - with zipfile.ZipFile(temp_path, "r") as zip: - zip.extractall(out_path) - - # Delete the zip file - os.remove(temp_path) - - return out_path - - def testFile(self): - url = "https://github.com/GaelleLeroux/SlicerAutomatedDentalTools/releases/download/testfileFlexReg/TestFiles.zip" - - - _ = self.DownloadUnzip( - url=url, - directory=os.path.join(self.SlicerDownloadPath), - folder_name=os.path.join("FlexReg"), - num_downl=1, - total_downloads=1, - ) - model_folder = os.path.join(self.SlicerDownloadPath,"FlexReg", "TestFiles") - path_file = os.path.join(model_folder,f"T{self.title}_test_file.vtk") - self.lineedit.setText(path_file) - self.viewScan() - - def onProcessUpdateDelete(self): - ''' - Update time since the beginning of the cli. When it's the end of the cli, display the patch and update combo box - ''' - if hasattr(self, "_processed3") and self._processed3: + def _onPatchChanged(self, *_args) -> None: + if self._previewCheckBox is None or not self._previewCheckBox.checked: return - - elapsed_time = time.time() - self.start_time - self.label_time.setVisible(True) - self.label_time.setText(f"Patch deletion, time : {round(float(elapsed_time),2)}s") - - if self.logic.cliNode.GetStatus() & self.logic.cliNode.Completed: - self.label_time.setText(f"Patch deleted, time : {round(float(elapsed_time),2)}s") - self._processed3 = True - self.timer.stop() - self.viewScan() - indexC = self.combobox_patch.findText(str(int(self.addItemsCombobox())-1)) - if indexC!=0: - self.combobox_patch.removeItem(indexC) - self.displaySegmentation(self.surf) - - - - - def displayParamater(self,layout,number,parameter): - label_teeth= QLabel(f'Teeth {number}') - lineedit_teeth= QLineEdit(str(parameter[0])) - label_ratio= QLabel('Ratio (R-L)') - lineedit_ratio= QLineEdit(str(parameter[1])) - label_adjust = QLabel('Adjust (A-P)') - lineedit_adjust = QLineEdit(str(parameter[2])) - - layout.addWidget(label_teeth,0,0) - layout.addWidget(lineedit_teeth,0,1) - layout.addWidget(label_ratio,1,0) - layout.addWidget(lineedit_ratio,1,1) - layout.addWidget(label_adjust,2,0) - layout.addWidget(lineedit_adjust,2,1) - - return lineedit_teeth, lineedit_ratio, lineedit_adjust - - - def selectFile(self): - path_file = QFileDialog.getOpenFileName(self.parent,'Open a file','', 'VTK Files (*.vtk)') - - self.lineedit.setText(path_file) - - def checkLineEdit(self)->bool: - ''' - check if input path is a vtk file - ''' - fname, extension = os.path.splitext(os.path.basename(self.lineedit.text)) - return extension=='.vtk' - - - def viewScan(self): - ''' - Display the scan in the correct window. If scan already loaded, delete it and display the new one - ''' - - # Install the libraries only if it's the first time - if not FlexRegBootManager.booted: - check_env = self.onCheckRequirements() - is_installed = False - if check_env: - if platform.system() == "Windows": - list_libs_windows = [('numpy',"<2.0.0",None),('itk',None,None),('torch','==2.2.0',None),('monai','==1.3.2',None)] #(lib_name, version, url) - is_installed = install_function(self,list_libs_windows) - - else: - list_libs_linux = [('numpy',"<2.0.0",None),('itk',None,None),('torch','==2.2.0',None),('monai','==1.3.2',None)] #(lib_name, version, url) - is_installed = install_function(self,list_libs_linux) - - if not is_installed: - qt.QMessageBox.warning(self.parent, 'Warning', 'The module will not work properly without the required libraries.\nPlease install them and try again.') - return - - import numpy as np - from packaging.version import Version - - numpy_version = Version(np.__version__) - if numpy_version > Version("2.0"): - pip_install("numpy<2.0.0") - - FlexRegBootManager.booted = True - self.label_time.setHidden(True) - - - if self.surf == None : - if self.checkLineEdit(): - # Load model - self.surf = slicer.util.loadModel(self.lineedit.text) - - # Get data model - displayNode = self.surf.GetDisplayNode() - - # Retrieve all availables vtkMRMLViewNodes in the scene - viewNodes = slicer.mrmlScene.GetNodesByClass('vtkMRMLViewNode') - viewNodes.UnRegister(None) # Unregister to avoid memory leakage - - customLayoutId=501 - layoutManager = slicer.app.layoutManager() - layoutManager.setLayout(customLayoutId) - - viewNode = viewNodes.GetItemAsObject(self.title - 1) if viewNodes.GetNumberOfItems() >= self.title else None - - if viewNode: - # Display model in windows - displayNode.SetViewNodeIDs([viewNode.GetID()]) - - else: - slicer.util.errorDisplay(f"There is 3D windows available with the index : {self.title - 1}.") - - # Get data of model - points = self.surf.GetPolyData().GetPoints() - - # Get center of model - center = [0.0, 0.0, 0.0] - for i in range(points.GetNumberOfPoints()): - x, y, z = points.GetPoint(i) - center[0] += x - center[1] += y - center[2] += z - - center[0] /= points.GetNumberOfPoints() - center[1] /= points.GetNumberOfPoints() - center[2] /= points.GetNumberOfPoints() - - - # Get the focal point of the camera - render_view = slicer.app.layoutManager().threeDWidget(0).threeDView() - camera = render_view.renderWindow().GetRenderers().GetFirstRenderer().GetActiveCamera() - focal_point = camera.GetFocalPoint() - center[0]-=focal_point[0] - center[1]-=focal_point[1] - center[2]-=focal_point[2] - - - # Create matrix to center the vtk - matrix = vtk.vtkMatrix4x4() - matrix.Identity() - matrix.SetElement(0, 3, -center[0]) - matrix.SetElement(1, 3, -center[1]) - matrix.SetElement(2, 3, -center[2]) - - self.matrix = matrix - - transform_node = slicer.mrmlScene.AddNewNodeByClass('vtkMRMLTransformNode') - transform_node.SetMatrixTransformToParent(matrix) - model = self.surf - - if self.camera : - model.SetAndObserveTransformNodeID(transform_node.GetID()) - model.HardenTransform() - - self.displaySegmentation(self.surf) - if not self.combobox_patch.isVisible(): - self.displayComboBox(self.surf) - - else: - slicer.util.infoDisplay("Enter a path to a vtk file") - - - else : - viewNode1 = slicer.mrmlScene.GetSingletonNode(str(self.title), "vtkMRMLViewNode") - modelNodes = slicer.mrmlScene.GetNodesByClass("vtkMRMLModelNode") - modelNodes.InitTraversal() - modelsToDelete = [] - for i in range(modelNodes.GetNumberOfItems()): - modelNode = modelNodes.GetNextItemAsObject() - modelDisplayNode = modelNode.GetDisplayNode() - - if modelDisplayNode and modelDisplayNode.GetViewNodeIDs() and viewNode1.GetID() in modelDisplayNode.GetViewNodeIDs(): - modelsToDelete.append(modelNode) - - for model in modelsToDelete: - slicer.mrmlScene.RemoveNode(model) - - self.surf = None - self.viewScan() - - - def displayComboBox(self,model_node): - ''' - Display combobox - Add number of element to match number of patch in the model - ''' - index = 1 - polydata = model_node.GetPolyData() - self.combobox_patch.clear() - self.combobox_patch.addItem("1") - while True: - array_name = f"Butterfly{index}" - - if self.isButterflyPatchAvailable(polydata,array_name): - if index==1: - self.label_patch.setVisible(True) - self.combobox_patch.setVisible(True) - self.delete_patch.setVisible(True) - self.label_addpatch.setVisible(True) - self.add_patch.setVisible(True) - - else : - self.combobox_patch.addItem(str(index)) - - - index += 1 - else: - break - - - - - def checkSurfExist(self)->bool: - return not (self.surf==None) - - def update_message_box(self,msg_box, start_time): - elapsed_time = time.time() - start_time - msg_box.setText(f"Your file wasn't segmented.\nSegmentation in process. This task may take a few minutes.\ntime: {elapsed_time:.1f}s") - - def downloadModel(self): - ''' - Download the latest model to do the segmentation of the teeth - ''' - url = "https://github.com/DCBIA-OrthoLab/Fly-by-CNN/releases/download/3.0/07-21-22_val-loss0.169.pth" - name = "Model_segmentation_teeh.pth" - - documentsLocation = QStandardPaths.DocumentsLocation - documentsPath = QStandardPaths.writableLocation(documentsLocation) - - # Path for Slicer downloads - slicerDownloadPath = os.path.join(documentsPath, slicer.app.applicationName + "Downloads") - # Create the directory if it does not exist - if not os.path.exists(slicerDownloadPath): - os.makedirs(slicerDownloadPath) - - # Full path where the file will be saved - modelFilePath = os.path.join(slicerDownloadPath, name) - - # Download the file - if not os.path.isfile(modelFilePath): - slicer.util.downloadFile(url, modelFilePath) - - # Now you can use the downloaded model file path as needed - logger.info(f"Model file downloaded to: {modelFilePath}") - return modelFilePath - - def checkSegmentation(self)->bool: - ''' - This function is doing the first step of makebutterfly to be sure the segmentation and the tooth are existing. - If the segmentation is not existing, calling the module crownsegmentation to do it - ''' - reader = vtk.vtkPolyDataReader() - reader.SetFileName(str(self.lineedit.text)) - reader.Update() - modelNode = reader.GetOutput() - - # Transform the data to read it in coordinate RAS (like slicer) - transform = vtk.vtkTransform() - transform.Scale(-1, -1, 1) - - transformFilter = vtk.vtkTransformPolyDataFilter() - transformFilter.SetInputData(modelNode) - transformFilter.SetTransform(transform) - transformFilter.Update() - - modelNode = transformFilter.GetOutput() - surf_tmp = vtk.vtkPolyData() - surf_tmp.DeepCopy(modelNode) - - try : - surf_tmp = orientation_f(surf_tmp,[[-0.5,-0.5,0],[0,0,0],[0.5,-0.5,0]], - ['3','5','12','14']) - return True - - except ToothNoExist as error : - slicer.util.infoDisplay(f' Error : {error}') - return False - - except NoSegmentationSurf as error : - sucess_segmentation = self.shapeaxi_conda() - if sucess_segmentation: - self.viewScan() - # msg_box.hide() - return True - return False - - def check_lib_wsl(self) -> bool: - # Ubuntu versions under 24.04 - required_libs_old = ["libxrender1", "libgl1-mesa-glx"] - # Ubuntu versions after 24.04 - required_libs_new = ["libxrender1", "libgl1", "libglx-mesa0"] - - - all_installed = lambda libs: all( - subprocess.run( - f"wsl -- bash -c \"dpkg -l | grep {lib}\"", capture_output=True, text=True - ).stdout.encode("utf-16-le").decode("utf-8").replace("\x00", "").find(lib) >= 0 - for lib in libs - ) - - return all_installed(required_libs_old) or all_installed(required_libs_new) - - def shapeaxi_conda(self): - slicer.app.processEvents() - - output_command = self.logic.conda.condaRunCommand(["which","dentalmodelseg"],self.logic.name_env).strip() - clean_output = re.search(r"Result: (.+)", output_command) - dentalmodelseg_path = clean_output.group(1).strip() - dentalmodelseg_path_clean = dentalmodelseg_path.replace("\\n","") - - args = [self.lineedit.text, #surf - "None", #input_csv - os.path.dirname(self.lineedit.text),#out - "1", #overwrite - "latest", #model - "0", #crownsegmentation - "Universal_ID", #array_name - "0", #fdi - "None", #suffix - os.path.dirname(self.lineedit.text),#vtk_folder - dentalmodelseg_path_clean] #dentalmodelseg_path - - - conda_exe = self.logic.conda.getCondaExecutable() - command = [conda_exe, "run", "-n", self.logic.name_env, "python" ,"-m", f"CrownSegmentationcli"] - for arg in args : - command.append("\""+arg+"\"") - - # running in // to not block Slicer - process = threading.Thread(target=self.logic.conda.condaRunCommand, args=(command,)) - process.start() - self.label_time.setVisible(True) - self.label_time.setText(f"Your file wasn't segmented.\nSegmentation in process. This task may take a few minutes.\ntime: 0.0s") - start_time = time.time() - previous_time = start_time - while process.is_alive(): - slicer.app.processEvents() - current_time = time.time() - gap=current_time-previous_time - if gap>0.3: - previous_time = current_time - elapsed_time = current_time - start_time - self.label_time.setText(f"Your file wasn't segmented.\nSegmentation in process. This task may take a few minutes.\ntime: {elapsed_time:.1f}s") - - self.viewScan() - - return True - - def parall_process(self,function,arguments=[],message=""): - ''' - to be able to run function in parralle with a message - ''' - process = threading.Thread(target=function, args=tuple(arguments)) #run in paralle to not block slicer - process.start() - start_time = time.time() - previous_time = time.time() - self.label_time.setVisible(True) - self.label_time.setText(f"{message}\ntime: 0s") - while process.is_alive(): - slicer.app.processEvents() - current_time = time.time() - gap=current_time-previous_time - if gap>0.3: - previous_time = current_time - elapsed_time = current_time - start_time - self.label_time.setText(f"{message}\ntime: {elapsed_time:.1f}s") - - def onCheckRequirements(self): - self.label_time.setHidden(False) - - if not self.logic.isCondaSetUp: - messageBox = qt.QMessageBox() - text = textwrap.dedent(""" - SlicerConda is not set up, please click - here for installation. - """).strip() - messageBox.information(None, "Information", text) - return False - - if platform.system() == "Windows": - self.label_time.setText(f"Checking if wsl is installed, this task may take a moments") - - if self.logic.conda.testWslAvailable(): - self.label_time.setText(f"WSL installed") - if not self.logic.check_lib_wsl(): - self.label_time.setText(f"Checking if the required librairies are installed, this task may take a moments") - messageBox = qt.QMessageBox() - text = textwrap.dedent(""" - WSL doesn't have all the necessary libraries, please download the installer - and follow the instructions - here - for installation. The link may be blocked by Chrome, just authorize it.""").strip() - - messageBox.information(None, "Information", text) - return False - - else : # if wsl not install, ask user to install it ans stop process - messageBox = qt.QMessageBox() - text = textwrap.dedent(""" - WSL is not installed, please download the installer and follow the instructions - here - for installation. The link may be blocked by Chrome, just authorize it.""").strip() - - messageBox.information(None, "Information", text) - return False - - - ## MiniConda - - - self.label_time.setText(f"Checking if miniconda is installed") - if "no setup" in self.logic.conda.condaRunCommand([self.logic.conda.getCondaExecutable(),"--version"]): - messageBox = qt.QMessageBox() - text = textwrap.dedent(""" - Code can't be launch. \nConda is not setup. - Please go the extension CondaSetUp in SlicerConda to do it.""").strip() - messageBox.information(None, "Information", text) - return False - - - ## shapeAXI - - - self.label_time.setText(f"Checking if environnement exists") - if not self.logic.conda.condaTestEnv(self.logic.name_env) : # check is environnement exist, if not ask user the permission to do it - userResponse = slicer.util.confirmYesNoDisplay("The environnement to run the classification doesn't exist, do you want to create it ? ", windowTitle="Env doesn't exist") - if userResponse : - start_time = time.time() - previous_time = start_time - formatted_time = self.format_time(0) - self.label_time.setText(f"Creation of the new environment. This task may take a few minutes.\ntime: {formatted_time}") - process = self.logic.install_shapeaxi() - - while self.logic.process.is_alive(): - slicer.app.processEvents() - formatted_time = self.update_ui_time(start_time, previous_time) - self.label_time.setText(f"Creation of the new environment. This task may take a few minutes.\ntime: {formatted_time}") - - start_time = time.time() - previous_time = start_time - formatted_time = self.format_time(0) - text = textwrap.dedent(f""" - Installation of librairies into the new environnement. - This task may take a few minutes.\ntime: {formatted_time}""").strip() - self.label_time.setText(text) - else: - return False - else: - self.label_time.setText(f"Ennvironnement already exists") - - - ## pytorch3d - - - self.label_time.setText(f"Checking if pytorch3d is installed") - process = self.logic.install_pytorch3d() - start_time = time.time() - previous_time = start_time - - while self.logic.process.is_alive(): - slicer.app.processEvents() - formatted_time = self.update_ui_time(start_time, previous_time) - text = textwrap.dedent(f""" - Installation of pytorch into the new environnement. - This task may take a few minutes.\ntime: {formatted_time} - """).strip() - self.label_time.setText(text) - - self.all_installed = True - return True - - def format_time(self,seconds): - """ Convert seconds to H:M:S format. """ - hours = int(seconds // 3600) - minutes = int((seconds % 3600) // 60) - secs = int(seconds % 60) - return f"{hours:02}:{minutes:02}:{secs:02}" - - def update_ui_time(self, start_time, previous_time): - current_time = time.time() - gap=current_time-previous_time - if gap>0.3: - previous_time = current_time - self.elapsed_time = current_time - start_time - formatted_time = self.format_time(self.elapsed_time) - return formatted_time - - def shapeaxi(self): - ''' - run shapeaxi (segmentation of the crown, dentalmodelseg) in slicer (for Linux system) - ''' - slicer_path = slicer.app.applicationDirPath() - dentalmodelseg_path = os.path.join(slicer_path,"..","lib","Python","bin","dentalmodelseg") - - moduleName = "CrownSegmentation" - moduleAvailable = moduleName in slicer.app.moduleManager().modulesNames() - self._processed2 = False - if moduleAvailable : - parameters = { - "surf" :self.lineedit.text, - "input_csv":"None", - "out" : "None", - "overwrite":"1", - "model": "latest", - "crown_segmentation" : "0", - "array_name":"Universal_ID", - "fdi":"0", - "suffix":"None", - "vtk_folder":os.path.dirname(self.lineedit.text), - "dentalmodelseg_path":dentalmodelseg_path - } - self.start_time = time.time() - flybyProcess = slicer.modules.crownsegmentationcli - self.start_time = time.time() - try: - self.timer.timeout.disconnect() - except TypeError: - pass - self.timer.timeout.connect(self.onProcessUpdateSeg) - self.timer.start(500) - self.seg_clinode = slicer.cli.run(flybyProcess,None, parameters) - - self._segmentationCompleted = False - while not self._segmentationCompleted: - slicer.app.processEvents() # Process GUI events - return True - - return True - - - def onProcessUpdateSeg(self): - ''' - Update time since the beginning of the segmentation. When it's the end of it, load the new scan segmented - ''' - if hasattr(self, "_processed2") and self._processed2: + surface = self._selectedSurface() + if surface is None: + self._clearContour() + self._setPreviewStatus(_("Pick a labelled arch to preview the patch.")) return - - elapsed_time = time.time() - self.start_time - self.label_time.setVisible(True) - self.label_time.setText(f"Your file wasn't segmented.\nSegmentation in process. This task may take a few minutes.\ntime: {elapsed_time:.1f}s") - - if self.seg_clinode.GetStatus() & self.seg_clinode.Completed: - self._processed2 = True - self.timer.stop() - self.viewScan() - self._segmentationCompleted = True - - - def processPatch(self)->None: - ''' - Call the cli for the butterfly patch. Launch onProcessUpdateButterfly - ''' - if self.checkSurfExist() : - seg = self.checkSegmentation() - if seg: - self._processed2 = False - if self.add_patch.isChecked(): - index=int(self.addItemsCombobox()) - else: - index=int(self.combobox_patch.currentText) - - self.logic = FlexRegLogic(str(self.lineedit.text), - - int(self.lineedit_teeth_left_top.text), - int(self.lineedit_teeth_right_top.text), - int(self.lineedit_teeth_left_bot.text), - int(self.lineedit_teeth_right_bot.text), - - float(self.lineedit_ratio_left_top.text), - float(self.lineedit_ratio_right_top.text), - float(self.lineedit_ratio_left_bot.text), - float(self.lineedit_ratio_right_bot.text), - - float(self.lineedit_adjust_left_top.text), - float(self.lineedit_adjust_right_top.text), - float(self.lineedit_adjust_left_bot.text), - float(self.lineedit_adjust_right_bot.text), - "None", - "None", - "butterfly", - "None", - "None", - "None", - index, - "None") - self.logic.process() - self.start_time = time.time() - try: - self.timer.timeout.disconnect() - except TypeError: - pass - self.timer.timeout.connect(self.onProcessUpdateButterfly) - self.timer.start(500) - else : - slicer.util.infoDisplay(f"Load a vtk file in window number : {self.title} \nTo do this, enter the path to a vtk file and click on view.") - - - def onProcessUpdateButterfly(self): - ''' - Update time since the beginning of the cli. When it's the end of the cli, display the patch - ''' - if hasattr(self, "_processed2") and self._processed2: + teeth = self._selectedTeeth() + if (not self._preview.ready + or self._previewSurface is not surface + or not self._preview.matches(teeth)): + self._previewSurface = surface + # Rebuilt only here: it walks every vertex and projects it, which is + # the expensive half. Dragging a pad reuses it. + self._preview.prepare(surface, teeth) + + if not self._preview.ready: + self._clearContour() + self._setPreviewStatus(self._preview.error or _("Cannot preview this arch.")) return - - elapsed_time = time.time() - self.start_time - self.label_time.setVisible(True) - self.label_time.setText(f"Creation of the patch, time : {round(float(elapsed_time),2)}s") - - if self.logic.cliNode.GetStatus() & self.logic.cliNode.Completed: - self.label_time.setText(f"Patch created, time : {round(float(elapsed_time),2)}s") - self._processed2 = True - self.timer.stop() - self.viewScan() - self.displaySegmentation(self.surf) - if self.add_patch.isChecked(): - number_to_add = self.addItemsCombobox() - self.combobox_patch.addItem(number_to_add) - self.add_patch.setChecked(False) - index = self.combobox_patch.findText(number_to_add) - if index >= 0: # -1 signify that the value hasn't been found - self.combobox_patch.setCurrentIndex(index) - if not self.combobox_patch.isVisible(): - self.displayComboBox(self.surf) - - - def loadLandamrk(self)->None: - ''' - Load the landmars creating the curve. Center it in the middle of the load model - ''' - - bounding_box = [0, 0, 0, 0, 0, 0] - self.surf.GetRASBounds(bounding_box) - center = [(bounding_box[1] + bounding_box[0]) / 2, (bounding_box[3] + bounding_box[2]) / 2, (bounding_box[5] + bounding_box[4]) / 2] - - self.curve = slicer.app.mrmlScene().AddNewNodeByClass("vtkMRMLMarkupsClosedCurveNode", f'T{self.title} curve') - - self.curve.AddControlPoint([center[0]+10,center[1]-10,center[2]-5],f'F1') - self.curve.AddControlPoint([center[0]+10,center[1]+10,center[2]-5],f'F2') - self.curve.AddControlPoint([center[0]-10,center[1]+10,center[2]-5],f'F3') - self.curve.AddControlPoint([center[0]-10,center[1]-10,center[2]-5],f'F4') - - self.viewLandmark() - - - - - def viewLandmark(self)->None: - ''' - Display the landmarks - ''' - viewNodes = slicer.mrmlScene.GetNodesByClass('vtkMRMLViewNode') - viewNodes.UnRegister(None) # Unregister to avoid memory leakage - - if self.curve!=None: - displayNode = self.curve.GetDisplayNode() - if displayNode is not None: - displayNode.SetVisibility2D(False) - displayNode.SetVisibility3D(True) - - view_ids_to_display = [viewNodes.GetItemAsObject(self.title-1).GetID()] - displayNode.SetViewNodeIDs(view_ids_to_display) - - if self.middle_point!=None: - displayNode = self.middle_point.GetDisplayNode() - if displayNode is not None: - displayNode.SetVisibility2D(False) - displayNode.SetVisibility3D(True) - view_ids_to_display = [viewNodes.GetItemAsObject(self.title-1).GetID()] - displayNode.SetViewNodeIDs(view_ids_to_display) - - def hideLandmark(self) -> None: - ''' - Hide the landmarks - ''' - viewNodes = slicer.mrmlScene.GetNodesByClass('vtkMRMLViewNode') - viewNodes.UnRegister(None) # Unregister to avoid memory leakage - - if self.curve!=None : - displayNode = self.curve.GetDisplayNode() - if displayNode is not None: - displayNode.SetVisibility2D(True) #Restore 2D view - displayNode.SetVisibility3D(False) # Hide 3D view - - view_ids_to_display = [viewNodes.GetItemAsObject(self.title-1).GetID()] - displayNode.SetViewNodeIDs(view_ids_to_display) - - if self.middle_point!=None : - displayNode = self.middle_point.GetDisplayNode() - if displayNode is not None: - displayNode.SetVisibility2D(True) #Restore 2D view - displayNode.SetVisibility3D(False) # Hide 3D view - - view_ids_to_display = [viewNodes.GetItemAsObject(self.title-1).GetID()] - displayNode.SetViewNodeIDs(view_ids_to_display) - - - - def curvePoint(self)->None: - ''' - Match the points with the load model - ''' - - self.curve.SetAndObserveSurfaceConstraintNode(self.surf) - self.glue=True - - - - def addPoints(self)->None: - ''' - Resample the curve with more control points. - ''' - # Get your curve node - curveNode = self.curve - curvePolyData = curveNode.GetCurveWorld() - points = curvePolyData.GetPoints() - - # Create splines to interpolate curve points - splineX = vtk.vtkCardinalSpline() - splineY = vtk.vtkCardinalSpline() - splineZ = vtk.vtkCardinalSpline() - - # Add curve points to splines - for i in range(points.GetNumberOfPoints()): - p = points.GetPoint(i) - splineX.AddPoint(i, p[0]) - splineY.AddPoint(i, p[1]) - splineZ.AddPoint(i, p[2]) - - # Determine the desired number of points - numberOfPoints = self.spin_add_points.value - newCurveNode = slicer.mrmlScene.AddNewNodeByClass('vtkMRMLMarkupsClosedCurveNode',f'T{self.title} curve') - - # Evaluate the splines at regular intervals to obtain the new set of points - for i in range(numberOfPoints): - u = i / (numberOfPoints - 1.0) * (points.GetNumberOfPoints() - 1) - if i == numberOfPoints-1: - u = u -(points.GetNumberOfPoints() - 1)/(numberOfPoints*2) - x = splineX.Evaluate(u) - y = splineY.Evaluate(u) - z = splineZ.Evaluate(u) - newCurveNode.AddControlPoint(vtk.vtkVector3d(x, y, z)) - - # If you wish, you can now delete the old curve node - self.curve = newCurveNode - slicer.mrmlScene.RemoveNode(curveNode) - self.viewLandmark() - if self.glue: - self.curve.SetAndObserveSurfaceConstraintNode(self.surf) - - - def placeMiddlePoint(self)->None: - ''' - Place the middle point for the curve patch - ''' - - bounding_box = [0, 0, 0, 0, 0, 0] - self.surf.GetRASBounds(bounding_box) - center = [(bounding_box[1] + bounding_box[0]) / 2, (bounding_box[3] + bounding_box[2]) / 2, (bounding_box[5] + bounding_box[4]) / 2] - - self.middle_point = slicer.app.mrmlScene().AddNewNodeByClass("vtkMRMLMarkupsFiducialNode") - - self.middle_point.AddControlPoint(center,'F1') - - viewNodes = slicer.mrmlScene.GetNodesByClass('vtkMRMLViewNode') - viewNodes.UnRegister(None) # Unregister to avoid memory leakage - - displayNode = self.middle_point.GetDisplayNode() - if displayNode is not None: - displayNode.SetVisibility2D(False) - displayNode.SetVisibility3D(True) - view_ids_to_display = [viewNodes.GetItemAsObject(self.title-1).GetID()] - displayNode.SetViewNodeIDs(view_ids_to_display) - - - def moveCurve(self,matrix)->None: - ''' - apply the matrix to the landmarks - ''' - transform_node = slicer.mrmlScene.AddNewNodeByClass('vtkMRMLTransformNode') - transform_node.SetMatrixTransformToParent(matrix) - - self.curve.SetAndObserveTransformNodeID(transform_node.GetID()) - self.curve.HardenTransform() - self.middle_point.SetAndObserveTransformNodeID(transform_node.GetID()) - self.middle_point.HardenTransform() - - - def draw(self)->None: - ''' - launch the cli for the curve patch and lauch onProcessUpdateCurve - ''' - if self.checkSurfExist(): - self._processed = False - - # Move the curve and the middle point where the original model is located - inverse_matrix = vtk.vtkMatrix4x4() - - # Calculate invert matrix to reg curve and middle point with model not center in front of the camera - inverse_matrix.DeepCopy(self.getMatrix()) - inverse_matrix.Invert() - - self.moveCurve(inverse_matrix) - self.camera=False - self.viewScan() - self.curve.SetAndObserveSurfaceConstraintNode(self.surf) - - middle_point_vector3D = self.middle_point.GetNthControlPointPositionWorld(0) - - # put the data in str type - vector_middle = ','.join([str(middle_point_vector3D.GetX()), str(middle_point_vector3D.GetY()), str(middle_point_vector3D.GetZ())]) - list_curve = list(vtk_to_numpy(self.curve.GetCurvePointsWorld().GetData())) - list_curve_str = ','.join(map(str, list_curve)) - vector_middle="["+vector_middle+"]" - - if self.add_patch.isChecked(): - index=int(self.addItemsCombobox()) - else: - index=int(self.combobox_patch.currentText) - - # CLI - self.logic = FlexRegLogic(str(self.lineedit.text), - int(self.lineedit_teeth_left_top.text), - int(self.lineedit_teeth_right_top.text), - int(self.lineedit_teeth_left_bot.text), - int(self.lineedit_teeth_right_bot.text), - float(self.lineedit_ratio_left_top.text), - float(self.lineedit_ratio_right_top.text), - float(self.lineedit_ratio_left_bot.text), - float(self.lineedit_ratio_right_bot.text), - float(self.lineedit_adjust_left_top.text), - float(self.lineedit_adjust_right_top.text), - float(self.lineedit_adjust_left_bot.text), - float(self.lineedit_adjust_right_bot.text), - list_curve_str, - vector_middle, - "curve", - "None", - "None", - "None", - index, - "None") - self.logic.process() - - self.start_time = time.time() - try: - self.timer.timeout.disconnect() - except TypeError: - pass - self.timer.timeout.connect(self.onProcessUpdateCurve) - self.timer.start(500) - - else : - slicer.util.infoDisplay(f"Load a vtk file in window number : {self.title} \nTo do this, enter the path to a vtk file and click on view.") - - - - - - def onProcessUpdateCurve(self)->None: - '''' - Update time since the beginning of the cli. When it's the end of the cli, display the patch and move the curve at their original place - ''' - # If already processed, do nothing. - if hasattr(self, "_processed") and self._processed: + values = self._padValues() + try: + # with_fill=False: filling labels every vertex inside the contour, + # which is what the SERVER computes properly. The outline is what a + # hand needs to follow, and it is the cheap half. + contour, _labels, _corners = self._preview.compute( + values["ratios"], values["adjusts"], values["shift"], with_fill=False + ) + except Exception as error: # a preview must never take the panel down + self._clearContour() + self._setPreviewStatus(str(error)) return - elapsed_time = time.time() - self.start_time - self.label_time.setVisible(True) - self.label_time.setText(f"Creation of the patch, time : {round(float(elapsed_time),2)}s") - - if self.logic.cliNode.GetStatus() & self.logic.cliNode.Completed: - #PLACE BACK THE CURVE AND THE MIDDLE POINT ON THE CENTER MODEL - self.label_time.setText(f"Patch created, time : {round(float(elapsed_time),2)}s") - self.camera=True - self.viewScan() - self.moveCurve(self.matrix) - # Load the new model and display the patch - self.curve.SetAndObserveSurfaceConstraintNode(self.surf) - self.displaySegmentation(self.surf) - self._processed = True # set the flag to prevent reprocessing - self.timer.stop() - if self.add_patch.isChecked(): - number_to_add = self.addItemsCombobox() - self.combobox_patch.addItem(number_to_add) - self.add_patch.setChecked(False) - index = self.combobox_patch.findText(number_to_add) - if index >= 0: - self.combobox_patch.setCurrentIndex(index) - if not self.combobox_patch.isVisible(): - self.displayComboBox(self.surf) - - - - def addItemsCombobox(self): - ''' - Return the number of the last element of the combo box + 1 - ''' - max_num = -float('inf') - - for index in range(self.combobox_patch.count): - try: - num = int(self.combobox_patch.itemText(index)) - - if num > max_num: - max_num = num - except ValueError: - pass - - return str(max_num + 1) - - - - def displaySurf(self,surf)->None: - ''' - Display the model - ''' - mesh = slicer.app.mrmlScene().AddNewNodeByClass("vtkMRMLModelNode", 'First data') - mesh.SetAndObservePolyData(surf) - mesh.CreateDefaultDisplayNodes() - - - - - def displaySegmentation(self,model_node)->None: - ''' - Display the patch - ''' - - self.createButterfly(model_node.GetPolyData()) - - displayNode = model_node.GetModelDisplayNode() - displayNode.SetScalarVisibility(False) - disabledModify = displayNode.StartModify() - displayNode.SetActiveScalarName("Butterfly") - displayNode.SetScalarVisibility(True) - displayNode.EndModify(disabledModify) - - - def isButterflyPatchAvailable(self, model_node,name)->bool: - """ - Check if the Butterfly patch is available for the provided model node. - """ - polyData = model_node - if polyData: - scalars = polyData.GetPointData().GetScalars(name) - return scalars is not None - return False - - def createButterfly(self,polydata): - ''' - Check if a Butterfly1 exist, if no disable the display of the combobox - ''' - import torch - index = 1 - final_array = None - - while True: - array_name = f"Butterfly{index}" - - if self.isButterflyPatchAvailable(polydata,array_name): - current_array = polydata.GetPointData().GetArray(array_name) - current_tensor = torch.tensor(vtk_to_numpy(current_array)).to(torch.float32) - - if final_array is None: - final_array = current_tensor - else: - # Use ane operation OR to merge the patches - final_array = torch.logical_or(final_array, current_tensor).to(torch.float32) - - index += 1 - else: - break - - - if final_array is None and self.combobox_patch.isVisible(): - self.label_patch.setVisible(False) - self.combobox_patch.setVisible(False) - self.delete_patch.setVisible(False) - self.label_addpatch.setVisible(False) - self.add_patch.setVisible(False) - - self.combobox_patch.addItem(str(1)) - -class DummyFile(io.IOBase): - def close(self): - pass - -class FlexRegBootManager: - booted = False \ No newline at end of file + self._showContour(contour) + self._setPreviewStatus("") + + def _padValues(self) -> dict: + """The five pads, named as the preview names them.""" + ratios, adjusts = {}, {} + for corner in CORNERS: + widget = self._argWidgets.get(corner) + pair = widget.value() if widget is not None else [0.5, 0.0] + ratios[corner], adjusts[corner] = float(pair[0]), float(pair[1]) + + widget = self._argWidgets.get("shift") + shift = widget.value() if widget is not None else [0.0, 0.0] + return { + "ratios": ratios, + "adjusts": adjusts, + "shift": (float(shift[0]), float(shift[1])), + } + + def _selectedTeeth(self) -> dict: + teeth = {} + for corner, argument in TEETH.items(): + widget = self._argWidgets.get(argument) + value = getattr(widget, "value", 0) + teeth[corner] = int(value() if callable(value) else value) + return teeth + + def _selectedSurface(self): + """The polydata behind the `surfaces` row, when it points at a node. + + None when the row holds a path instead, which is the batch case: a + folder of forty arches has no single outline to draw. + """ + widget = self._argWidgets.get("surfaces") + node = getattr(widget, "currentNode", None) + node = node() if callable(node) else node + if node is None or not hasattr(node, "GetPolyData"): + return None + return node.GetPolyData() + + def _showContour(self, contour) -> None: + if self._contourNode is None: + self._contourNode = slicer.mrmlScene.AddNewNodeByClass( + "vtkMRMLModelNode", CONTOUR_NODE + ) + self._contourNode.CreateDefaultDisplayNodes() + display = self._contourNode.GetDisplayNode() + display.SetColor(1.0, 0.85, 0.1) + display.SetLineWidth(3) + # A working overlay, not a result: a study reopened a year later + # should not carry one. + self._contourNode.SetSaveWithScene(False) + self._contourNode.SetAndObservePolyData(contour) + self._contourNode.Modified() + + def _clearContour(self) -> None: + if self._contourNode is not None: + slicer.mrmlScene.RemoveNode(self._contourNode) + self._contourNode = None + + def _setPreviewStatus(self, message) -> None: + if self._previewStatus is not None: + self._previewStatus.setText(message) + self._previewStatus.setVisible(bool(message)) + + def cleanup(self) -> None: + self._clearContour() + super().cleanup() diff --git a/FlexReg/FlexRegLib/__init__.py b/FlexReg/FlexRegLib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/FlexReg/FlexRegLib/butterfly_preview.py b/FlexReg/FlexRegLib/butterfly_preview.py new file mode 100644 index 0000000..206ae67 --- /dev/null +++ b/FlexReg/FlexRegLib/butterfly_preview.py @@ -0,0 +1,366 @@ +''' +Live preview of the butterfly patch. + +What makes the real patch expensive is the geodesic flood fill of +FlexReg_Method.propagation.Dilation : it needs a GPU and runs in the CLI. +Everything that decides *where* the patch sits -- the arch orientation and the +four tooth centroids -- is pure numpy and does not depend on the ratio/adjust +values, so it is computed once per scan (prepare) and reused for every joystick +move (compute). What is left to redo on each move is the contour and an +approximate fill, which is fast enough to follow the mouse. + +The contour is the exact same curve the CLI uses to carve the patch, so what the +preview draws is what Update produces; only the fill is approximated, by a +point-in-polygon test instead of a flood fill on the mesh. +''' + +import numpy as np +import vtk + +# The array a labelled arch is expected to carry; anything single-component +# stands in when it is absent. +LABEL_PREFERENCE = 'Universal_ID' +from vtk.util.numpy_support import vtk_to_numpy + +from .orientation import orientation_matrix + +# Canonical frame and band width used by FlexReg_Method.make_butterfly +ORIENT_TARGET = [[-0.5, -0.5, 0], [0, 0, 0], [0.5, -0.5, 0]] +ORIENT_TEETH = ['3', '5', '12', '14'] +RADIUS = 0.7 + +# make_butterfly negates the adjust of the two posterior corners, so that a +# positive value pushes both ends of the patch outwards and grows it. Keep the +# same convention here : these signs must track the CLI. +ADJUST_SIGN = { + 'anterior_left': 1.0, + 'anterior_right': 1.0, + 'posterior_left': -1.0, + 'posterior_right': -1.0, +} + +# In the canonical frame : +x is the side of teeth 12/14, +y is anterior, +# z is the occlusal axis (sign resolved per scan in prepare). +CELL = 1.0 # height map resolution, in mm +LIFT = 0.4 # how far the contour floats above the teeth, in mm +NB_POINTS = 120 # samples per contour edge + + +def _segment(p1, p2, n=NB_POINTS): + t = np.linspace(0.0, 1.0, n, endpoint=False)[:, None] + return p1[None, :2] + t * (p2[:2] - p1[:2])[None, :] + + +def _mirrored_bezier(start, middle, end, n=NB_POINTS): + ''' + Quadratic Bezier reflected across the start-end chord, so that it bulges + away from the centre of the arch. Mirrors compute_bezier_patch in + FlexReg_Method/make_butterfly.py. + ''' + t = np.linspace(0.0, 1.0, n, endpoint=False)[:, None] + bez = ((1 - t) ** 2) * start[None, :2] + 2 * (1 - t) * t * middle[None, :2] + (t ** 2) * end[None, :2] + + direction = end[:2] - start[:2] + direction = direction / (np.linalg.norm(direction) + 1e-6) + proj = (bez - start[None, :2]) @ direction + return 2 * (np.outer(proj, direction) + start[None, :2]) - bez + + +def _points_in_polygon(points, polygon): + ''' + Crossing-number test, vectorised over points. matplotlib ships with Slicer + and does this in C, so use it when available and keep the numpy version as + a fallback rather than a hard dependency. + ''' + try: + from matplotlib.path import Path + return Path(polygon).contains_points(points) + except Exception: + pass + + x = points[:, 0][:, None] + y = points[:, 1][:, None] + x1 = polygon[:, 0][None, :] + y1 = polygon[:, 1][None, :] + x2 = np.roll(polygon[:, 0], -1)[None, :] + y2 = np.roll(polygon[:, 1], -1)[None, :] + + straddles = (y1 > y) != (y2 > y) + with np.errstate(divide='ignore', invalid='ignore'): + x_cross = x1 + (y - y1) * (x2 - x1) / (y2 - y1) + crossings = np.count_nonzero(straddles & (x < x_cross), axis=1) + return (crossings % 2) == 1 + + +class ButterflyPreview: + ''' + Holds the per-scan cache and turns (teeth, ratios, adjusts) into a contour + and a patch label array, both expressed in the coordinates of the model + node displayed in the scene. + ''' + + def __init__(self): + self.clear() + + def clear(self): + self.ready = False + self.error = None + self._inverse = None + self._centroids = {} + self._teeth = {} + self._xy = None + self._nb_points = 0 + self._grid = None + self._grid_origin = None + self._grid_shape = None + self._occlusal = 1.0 + self._default_z = 0.0 + + def prepare(self, polydata, teeth_by_key): + ''' + Cache what only depends on the scan and the selected teeth : the + orientation matrix, the four centroids, the projected vertices and a + coarse height map used to lay the contour on the teeth. + teeth_by_key maps 'anterior_left', 'anterior_right', 'posterior_left' + and 'posterior_right' to a tooth number. + Returns True when the preview can be computed. + ''' + self.clear() + teeth_by_key = {key: int(tooth) for key, tooth in teeth_by_key.items()} + teeth = teeth_by_key.values() + + if polydata is None or polydata.GetNumberOfPoints() == 0: + self.error = 'no surface loaded' + return False + + # Universal_ID by preference, then whatever labels the mesh does carry. + # The server's engine already picks the array this way, and hardcoding + # one name here made the preview stricter than the run it previews: + # Crown_Seg writes `PredictedID`, so a mesh it had just labelled was + # refused by the panel and accepted by the server. + ids = None + point_data = polydata.GetPointData() + for index in range(point_data.GetNumberOfArrays()): + name = point_data.GetArrayName(index) + if name == LABEL_PREFERENCE: + ids = point_data.GetArray(name) + break + if ids is None and point_data.GetArray(name).GetNumberOfComponents() == 1: + ids = point_data.GetArray(name) + if ids is None: + self.error = 'the scan carries no per-point tooth labels' + return False + self.label_array = ids.GetName() + + try: + matrix = orientation_matrix(polydata, ORIENT_TARGET, ORIENT_TEETH, + label_array=self.label_array) + except Exception as error: + self.error = str(error) + return False + + if not np.all(np.isfinite(matrix)): + self.error = 'the arch orientation could not be computed' + return False + + labels = vtk_to_numpy(ids).ravel().astype(np.int32) + points = vtk_to_numpy(polydata.GetPoints().GetData()).astype(np.float64) + oriented = points @ matrix[:3, :3].T + matrix[:3, 3] + + for tooth in teeth: + selection = labels == int(tooth) + if not selection.any(): + self.error = f'tooth {int(tooth)} is not segmented' + return False + self._centroids[int(tooth)] = oriented[selection].mean(axis=0) + + self._inverse = np.linalg.inv(matrix) + self._teeth = teeth_by_key + self._xy = np.ascontiguousarray(oriented[:, :2]) + self._nb_points = oriented.shape[0] + + # Which way the crowns point : the four centroids sit on the occlusal + # side, so compare them to the bulk of the mesh. + centroid_z = float(np.mean([c[2] for c in self._centroids.values()])) + self._occlusal = 1.0 if centroid_z > float(np.median(oriented[:, 2])) else -1.0 + self._default_z = centroid_z + + self._buildHeightMap(oriented) + self.ready = True + return True + + def _buildHeightMap(self, oriented): + ''' + Coarse grid holding, per cell, the height of the outermost surface on + the occlusal side. Sorting once beats np.maximum.at on 100k points. + ''' + origin = oriented[:, :2].min(axis=0) - CELL + extent = oriented[:, :2].max(axis=0) + CELL - origin + shape = (np.ceil(extent / CELL).astype(np.int64) + 1) + + cells = np.floor((oriented[:, :2] - origin) / CELL).astype(np.int64) + flat = cells[:, 0] * shape[1] + cells[:, 1] + height = self._occlusal * oriented[:, 2] + + order = np.lexsort((height, flat)) + flat_sorted = flat[order] + keep = np.ones(flat_sorted.shape[0], dtype=bool) + keep[:-1] = flat_sorted[1:] != flat_sorted[:-1] + + grid = np.full(int(shape[0] * shape[1]), np.nan) + grid[flat_sorted[keep]] = height[order][keep] + + self._grid = grid + self._grid_origin = origin + self._grid_shape = shape + + def _heightAt(self, xy): + cells = np.floor((xy - self._grid_origin) / CELL).astype(np.int64) + np.clip(cells[:, 0], 0, self._grid_shape[0] - 1, out=cells[:, 0]) + np.clip(cells[:, 1], 0, self._grid_shape[1] - 1, out=cells[:, 1]) + height = self._grid[cells[:, 0] * self._grid_shape[1] + cells[:, 1]] + return np.where(np.isnan(height), self._occlusal * self._default_z, height) + + def matches(self, teeth_by_key): + if not self.ready: + return False + return self._teeth == {key: int(tooth) for key, tooth in teeth_by_key.items()} + + def centroids(self): + '''Tooth centroids in the canonical frame, keyed by corner.''' + return {key: self._centroids[tooth] for key, tooth in self._teeth.items()} + + def landmarks(self, ratios, adjusts, shift=(0.0, 0.0)): + ''' + The four corners of the patch, in the canonical frame. Reproduces + butterflyPatch : the ratio is mapped through (1 - r) / 2, then used to + interpolate between the tooth centroid and the centroid of the tooth + facing it, and the adjust shifts the centroid along the antero-posterior + axis with the sign of its corner. A ratio of 1 therefore lands on the + tooth itself, at the outer edge of the arch, and 0 at mid-arch. + Keys are 'anterior_left', 'anterior_right', 'posterior_left', + 'posterior_right'. + + shift is (medio-lateral, antero-posterior) in mm, added to every + centroid at once. Each landmark is an affine combination of two + centroids whose weights sum to 1, so the same vector comes back out of + the interpolation : the patch moves, its shape does not change. + ''' + translation = np.array([float(shift[0]), float(shift[1]), 0.0]) + + centroids = {} + for key, tooth in self._teeth.items(): + centroid = np.array(self._centroids[tooth], dtype=np.float64) + centroids[key] = (centroid + translation + + np.array([0.0, ADJUST_SIGN[key] * float(adjusts[key]), 0.0])) + + facing = { + 'anterior_left': 'anterior_right', + 'anterior_right': 'anterior_left', + 'posterior_left': 'posterior_right', + 'posterior_right': 'posterior_left', + } + + result = {} + for key, other in facing.items(): + ratio = (1.0 - float(ratios[key])) / 2.0 + result[key] = (1 - ratio) * centroids[key] + ratio * centroids[other] + return result + + def contour(self, landmarks): + '''Closed contour of the patch, in the canonical frame (2D).''' + anterior_left = landmarks['anterior_left'] + anterior_right = landmarks['anterior_right'] + posterior_left = landmarks['posterior_left'] + posterior_right = landmarks['posterior_right'] + middle_posterior = (posterior_left + posterior_right) / 2 + + top = _segment(anterior_left, anterior_right) + right = _mirrored_bezier(posterior_right, middle_posterior, anterior_right)[::-1] + bottom = _segment(posterior_right, posterior_left) + left = _mirrored_bezier(posterior_left, middle_posterior, anterior_left) + + return np.concatenate([top, right, bottom, left], axis=0) + + def fill(self, contour): + ''' + Approximate patch labels : the mesh vertices whose projection falls + inside the contour. The CLI instead floods the mesh from the middle + point, bounded by the same contour, so the two agree except where the + surface folds back on itself. + ''' + labels = np.zeros(self._nb_points, dtype=np.float32) + + low = contour.min(axis=0) - RADIUS + high = contour.max(axis=0) + RADIUS + inside_box = np.flatnonzero( + (self._xy[:, 0] >= low[0]) & (self._xy[:, 0] <= high[0]) + & (self._xy[:, 1] >= low[1]) & (self._xy[:, 1] <= high[1]) + ) + if inside_box.size == 0: + return labels + + coarse = contour[::3] + inside = _points_in_polygon(self._xy[inside_box], coarse) + labels[inside_box[inside]] = 1.0 + return labels + + def toScene(self, points): + '''Canonical frame -> coordinates of the model node in the scene.''' + points = np.atleast_2d(points) + return points @ self._inverse[:3, :3].T + self._inverse[:3, 3] + + def compute(self, ratios, adjusts, shift=(0.0, 0.0), with_fill=True): + ''' + Full preview for one set of values. + Returns (contour_polydata, labels, landmarks_scene) with the contour + and the landmarks already expressed in scene coordinates. + ''' + landmarks = self.landmarks(ratios, adjusts, shift) + contour = self.contour(landmarks) + + labels = self.fill(contour) if with_fill else None + + height = (self._heightAt(contour) + LIFT) * self._occlusal + contour_3d = np.column_stack([contour, height]) + + order = ('anterior_left', 'anterior_right', 'posterior_right', 'posterior_left') + corners = np.array([landmarks[key] for key in order]) + corners[:, 2] = (self._heightAt(corners[:, :2]) + LIFT) * self._occlusal + + return ( + self._contourPolyData(self.toScene(contour_3d), self.toScene(corners)), + labels, + self.toScene(corners), + ) + + def _contourPolyData(self, contour, corners): + '''Closed polyline plus a marker on each corner, as a single polydata.''' + append = vtk.vtkAppendPolyData() + + points = vtk.vtkPoints() + for point in contour: + points.InsertNextPoint(float(point[0]), float(point[1]), float(point[2])) + + lines = vtk.vtkCellArray() + lines.InsertNextCell(len(contour) + 1) + for index in range(len(contour)): + lines.InsertCellPoint(index) + lines.InsertCellPoint(0) + + polyline = vtk.vtkPolyData() + polyline.SetPoints(points) + polyline.SetLines(lines) + append.AddInputData(polyline) + + for corner in corners: + sphere = vtk.vtkSphereSource() + sphere.SetCenter(float(corner[0]), float(corner[1]), float(corner[2])) + sphere.SetRadius(0.6) + sphere.SetThetaResolution(12) + sphere.SetPhiResolution(12) + sphere.Update() + append.AddInputData(sphere.GetOutput()) + + append.Update() + return append.GetOutput() diff --git a/FlexReg/FlexReg_utils/orientation.py b/FlexReg/FlexRegLib/orientation.py similarity index 57% rename from FlexReg/FlexReg_utils/orientation.py rename to FlexReg/FlexRegLib/orientation.py index 5c07c5f..6b3d42c 100644 --- a/FlexReg/FlexReg_utils/orientation.py +++ b/FlexReg/FlexRegLib/orientation.py @@ -1,7 +1,7 @@ import numpy as np import vtk -from FlexReg_utils.util import vtkMeanTeeth -from FlexReg_utils.transform import RotationMatrix, TransformSurf +from .util import vtkMeanTeeth +from .transform import RotationMatrix, TransformSurf import sys import logging @@ -39,8 +39,36 @@ def make_vector(points2,point1): -def orientation_f(source,target,landmarks): +def rotation_between(source_vector,target_vector): + ''' + Rotation bringing source_vector onto target_vector. + + The dot product is clamped on both ends -- rounding can push it just past + -1, where arccos returns NaN -- and the two degenerate cases are handled + explicitly : when the vectors are already aligned or exactly opposed their + cross product vanishes, and normalising that null axis would turn the whole + matrix into NaN without raising anything. + ''' + dt = float(np.clip(np.dot(source_vector,target_vector),-1.0,1.0)) + axis = cross(source_vector,target_vector) + + if np.linalg.norm(axis) < 1e-8: + if dt > 0: + return np.identity(3) + # opposed : half a turn around any axis perpendicular to the source + fallback = np.array([0.0,1.0,0.0]) if abs(source_vector[0]) > 0.9 else np.array([1.0,0.0,0.0]) + axis = cross(source_vector,fallback) + + return RotationMatrix(axis,np.arccos(dt)) + +def orientation_matrix(source, target, landmarks, label_array='Universal_ID'): + ''' + Compute the 4x4 rigid matrix bringing source into the canonical frame + described by target. Same computation as orientation_f, which now builds + on it -- the matrix itself is needed to place the patch preview back in + the scene without transforming the whole surface. + ''' left =landmarks[0] middle1 = landmarks[1] @@ -48,7 +76,10 @@ def orientation_f(source,target,landmarks): right = landmarks[3] - meanTeeth = vtkMeanTeeth([int(left),int(middle1),int(middle2),int(right)],property='Universal_ID') + # The label array is passed in rather than fixed: a mesh straight out of + # Crown_Seg carries `PredictedID`, and hardcoding one name refused it. + meanTeeth = vtkMeanTeeth([int(left), int(middle1), int(middle2), int(right)], + property=label_array) mean_source = meanTeeth(source) left_source, middle1_source, middle2_source , right_source = mean_source[left], mean_source[middle1], mean_source[middle2],mean_source[right] @@ -62,33 +93,15 @@ def orientation_f(source,target,landmarks): - dt = np.dot(normal_source,normal_target) - if dt > 1.0 : - dt = 1.0 + matrix_normal = rotation_between(normal_source,normal_target) - angle_normal = np.arccos(dt) - normal_normal = cross(normal_source,normal_target) - - - - matrix_normal = RotationMatrix(normal_normal,angle_normal) - - - direction_source = np.matmul(matrix_normal,direction_source.T).T direction_source = direction_source / np.linalg.norm(direction_source) - - direction_normal = cross(direction_source,direction_target) - - dt = np.dot(direction_source,direction_target) - if dt > 1.0: - dt = 1.0 - angle_direction = np.arccos(dt) - matrix_direction = RotationMatrix(direction_normal ,angle_direction) + matrix_direction = rotation_between(direction_source,direction_target) @@ -110,11 +123,17 @@ def orientation_f(source,target,landmarks): matrix = np.concatenate((matrix,np.array([mean]).T),axis=1) matrix = np.concatenate((matrix,np.array([[0,0,0,1]])),axis=0) + return matrix + + +def orientation_f(source,target,landmarks): + + matrix = orientation_matrix(source,target,landmarks) output = vtk.vtkPolyData() output.DeepCopy(source) - + output = TransformSurf(output,matrix) diff --git a/FlexReg/FlexReg_utils/transform.py b/FlexReg/FlexRegLib/transform.py similarity index 100% rename from FlexReg/FlexReg_utils/transform.py rename to FlexReg/FlexRegLib/transform.py diff --git a/FlexReg/FlexReg_utils/util.py b/FlexReg/FlexRegLib/util.py similarity index 100% rename from FlexReg/FlexReg_utils/util.py rename to FlexReg/FlexRegLib/util.py diff --git a/FlexReg/FlexReg_utils/__init__.py b/FlexReg/FlexReg_utils/__init__.py deleted file mode 100644 index 2b342e3..0000000 --- a/FlexReg/FlexReg_utils/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .util import ToothNoExist, NoSegmentationSurf -from .orientation import orientation_f \ No newline at end of file diff --git a/FlexReg/FlexReg_utils/install_pytorch.py b/FlexReg/FlexReg_utils/install_pytorch.py deleted file mode 100644 index b43e5be..0000000 --- a/FlexReg/FlexReg_utils/install_pytorch.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import logging -import sys -# ===== Logging Configuration ===== -logger = logging.getLogger("FlexReg_install_pytorch") -logger.setLevel(logging.INFO) -logger.propagate = False -if logger.handlers: - logger.handlers.clear() -console_handler = logging.StreamHandler(sys.stdout) -console_handler.setLevel(logging.INFO) -formatter = logging.Formatter('%(name)s - %(levelname)s - (%(filename)s:%(lineno)d) - %(message)s') -console_handler.setFormatter(formatter) -logger.addHandler(console_handler) - -def install_pytorch3d(pip_path): - import torch - - pyt_version_str=torch.__version__.split("+")[0].replace(".", "") - version_str="".join([ - f"py3{sys.version_info.minor}_cu", - torch.version.cuda.replace(".",""), - f"_pyt{pyt_version_str}" - ]) - cmd = [pip_path, 'install','--no-index', '--no-cache-dir' ,'pytorch3d', '-f', f'https://dl.fbaipublicfiles.com/pytorch3d/packaging/wheels/{version_str}/download.html'] - - result = subprocess.run(cmd,stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) - logger.info(f"Result : {result.stdout}") - logger.info(f"Error : {result.stderr}") - logger.info("\nPyTorch3D installed in the environnement") - - -def main(pip_path): - install_pytorch3d(pip_path) - -if __name__ == "__main__": - main(sys.argv[1]) \ No newline at end of file diff --git a/FlexReg/Resources/UI/FlexReg.ui b/FlexReg/Resources/UI/FlexReg.ui deleted file mode 100644 index 1c0851d..0000000 --- a/FlexReg/Resources/UI/FlexReg.ui +++ /dev/null @@ -1,351 +0,0 @@ - - - ButterfkyPatch - - - - 0 - 0 - 961 - 789 - - - - /* Light Mode (Default) */ -qMRMLWidget { - background-color: #f8f9fa; -} -ctkCollapsibleButton { - background-color: #ffffff !important; - border: 1px solid #e0e6ed !important; - border-radius: 6px; - margin-bottom: 8px; - font-weight: 600; - padding: 6px 10px; - color: #2c3e50 !important; -} -ctkCollapsibleButton:hover { - border: 1px solid #3498db !important; - background-color: #fbfcfd !important; -} -QLineEdit, QTextEdit { - background-color: #ffffff !important; - border: 1px solid #e0e6ed !important; - border-radius: 4px; - padding: 6px; - color: #2c3e50 !important; - selection-background-color: #3498db; -} -QLineEdit:focus, QTextEdit:focus { - border: 2px solid #3498db !important; -} -QComboBox { - background-color: #ffffff !important; - border: 1px solid #e0e6ed !important; - border-radius: 4px; - padding: 4px 6px; - color: #2c3e50 !important; -} -QComboBox:focus { - border: 2px solid #3498db !important; -} -QComboBox::drop-down { - width: 20px; - border: none; -} -QComboBox QAbstractItemView { - background-color: #ffffff !important; - color: #2c3e50 !important; - selection-background-color: #3498db; -} -QLabel { - color: #2c3e50 !important; - font-weight: 500; -} -QPushButton { - background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4ba3ff, stop:1 #3498db); - color: white; - border: none; - border-radius: 6px; - font-weight: 600; - font-size: 10pt; - padding: 8px; - margin-top: 4px; -} -QPushButton:hover:!pressed { - background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #5cb3ff, stop:1 #2980b9); -} -QPushButton:pressed { - background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #2980b9, stop:1 #1f618d); -} -QPushButton:disabled { - background-color: #bdc3c7; - color: #95a5a6; -} -QCheckBox { - color: #2c3e50 !important; - font-weight: 500; - spacing: 6px; -} -QCheckBox::indicator { - width: 18px; - height: 18px; - border: 1px solid #e0e6ed; - border-radius: 3px; - background-color: #ffffff; -} -QCheckBox::indicator:hover { - border: 1px solid #3498db; -} -QCheckBox::indicator:checked { - width: 18px; - height: 18px; - border: 1px solid #3498db; - border-radius: 3px; - background-color: #3498db; - image: url(:/Icons/SmallCheckMark.png); -} -QCheckBox::indicator:checked:hover { - border: 1px solid #2980b9; - background-color: #2980b9; -} -QProgressBar { - border: 1px solid #e0e6ed; - border-radius: 4px; - background-color: #ffffff; - padding: 2px; - color: #2c3e50 !important; -} -QProgressBar::chunk { - background-color: #3498db; - border-radius: 3px; -} -qMRMLNodeComboBox { - background-color: #ffffff !important; - border: 1px solid #e0e6ed !important; - border-radius: 4px; - padding: 4px 6px; - color: #2c3e50 !important; -} -qMRMLNodeComboBox:focus { - border: 2px solid #3498db !important; -} - - - - - 8 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - 2 - - - 10 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Inputs - - - - - 6 - - - label - - - - - - - - - - - - Outputs - - - - 6 - - - - - Output folder : - - - - - - 6 - - 6 - - - - - - Select - - - - - 6 - - - - - - - - - Suffix : - - - - - - - _REG - - - - - - - Apply to lower arch : - - - - - - 6 - - - None - - - - - - - Select - - - - - - - - - - - - Advanced - - - true - - - - - - If checked, values above threshold are set to 0. If unchecked, values below are set to 0. - - - - - - - - - - - - - false - - - Run the algorithm. - - - Registration - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - qMRMLWidget - QWidget -
qMRMLWidget.h
- 1 -
- - ctkCollapsibleButton - QWidget -
ctkCollapsibleButton.h
- 1 -
-
- - -
\ No newline at end of file diff --git a/FlexReg/Testing/Python/CMakeLists.txt b/FlexReg/Testing/Python/CMakeLists.txt index 5658d8b..54fef45 100644 --- a/FlexReg/Testing/Python/CMakeLists.txt +++ b/FlexReg/Testing/Python/CMakeLists.txt @@ -1,2 +1,15 @@ #slicer_add_python_unittest(SCRIPT ${MODULE_NAME}ModuleTest.py) + +#----------------------------------------------------------------------------- +# test_flexreg_client.py builds the panel from FlexReg's own GET /tools schema +# against the qt/ctk/slicer stand-ins in ServerToolsCore/Testing/Python. Pure +# Python once the widgets are stubbed, so it is a plain Python3 ctest rather +# than a Slicer interpreter launch. Same rationale as AREG's. +find_package(Python3 COMPONENTS Interpreter REQUIRED) + +add_test( + NAME FlexReg_test_flexreg_client + COMMAND ${Python3_EXECUTABLE} -m unittest test_flexreg_client + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + ) diff --git a/FlexReg/Testing/Python/test_flexreg_client.py b/FlexReg/Testing/Python/test_flexreg_client.py new file mode 100644 index 0000000..6c6940b --- /dev/null +++ b/FlexReg/Testing/Python/test_flexreg_client.py @@ -0,0 +1,162 @@ +"""FlexReg's panel: the pads come from the schema, the preview is wired to them. + +Run outside Slicer against the qt/ctk/slicer stubs, so what is asserted is which +widgets a schema produces and what the module does with them, not Qt itself. + + python3 -m unittest test_flexreg_client +""" + +import os +import sys +import types +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +sys.path.insert(0, os.path.join(ROOT, "ServerToolsCore")) +sys.path.insert(0, os.path.join(ROOT, "ServerToolsCore", "Testing", "Python")) + +import qt_stubs # noqa: E402 + +qt_stubs.install() + +import qt # noqa: E402 +from ServerToolsCoreLib import formgen # noqa: E402 + + +def _stub_slicer_module_framework(): + """The three `slicer` submodules AREG.py touches at import time.""" + slicer = sys.modules["slicer"] + + i18n = types.ModuleType("slicer.i18n") + i18n.tr = lambda text: text + sys.modules["slicer.i18n"] = i18n + slicer.i18n = i18n + + framework = types.ModuleType("slicer.ScriptedLoadableModule") + + class ScriptedLoadableModule: + def __init__(self, parent): + self.parent = parent + + class ScriptedLoadableModuleWidget: + def __init__(self, parent=None): + pass + + framework.ScriptedLoadableModule = ScriptedLoadableModule + framework.ScriptedLoadableModuleWidget = ScriptedLoadableModuleWidget + sys.modules["slicer.ScriptedLoadableModule"] = framework + slicer.ScriptedLoadableModule = framework + + util = types.ModuleType("slicer.util") + + class VTKObservationMixin: + def __init__(self, *args, **kwargs): + pass + + util.VTKObservationMixin = VTKObservationMixin + sys.modules["slicer.util"] = util + slicer.util = util + + +_stub_slicer_module_framework() + + +def _schema(): + """FlexReg's arguments, as the server publishes them. + + Written out rather than fetched: the point is that this panel is built from + a schema, and a test that needs a running server proves nothing about the + panel. + """ + corner = { + "type": "vec2", "types": ["vec2"], "required": False, "choices": None, + "server_selectable": None, "description": "", "initial": None, + "ui": "joystick", "x_range": [0.0, 1.0], "y_range": [-5.0, 5.0], + "x_labels": ["mid", "out"], "y_labels": ["POST", "ANT"], + } + arguments = {name: dict(corner) for name in ( + "anterior_right", "anterior_left", "posterior_right", "posterior_left")} + arguments["shift"] = dict(corner, x_range=[-15.0, 15.0], y_range=[-15.0, 15.0], + x_labels=["L", "R"]) + return arguments + + +class PadsComeFromTheSchemaTest(unittest.TestCase): + def setUp(self): + self.widgets = formgen.build(_schema(), qt.QFormLayout()) + + def test_every_corner_and_the_translation_get_a_pad(self): + """Five pads, and no code in FlexReg.py builds any of them.""" + pads = [name for name, widget in self.widgets.items() + if type(widget).__name__ == "JoystickInput"] + + self.assertEqual(sorted(pads), sorted( + ["anterior_right", "anterior_left", "posterior_right", + "posterior_left", "shift"])) + + def test_a_corner_pad_carries_the_arch_s_own_axes(self): + """0 is mid-arch and 1 lands on the tooth, which is why both ends are + named: "0.8" says nothing about where that is in a mouth.""" + pad = self.widgets["anterior_right"].pad + + self.assertEqual((pad.x_start, pad.x_end), (0.0, 1.0)) + self.assertEqual((pad.y_start, pad.y_end), (-5.0, 5.0)) + + def test_the_translation_pad_is_millimetres_on_both_axes(self): + pad = self.widgets["shift"].pad + + self.assertEqual((pad.x_start, pad.x_end), (-15.0, 15.0)) + self.assertEqual((pad.y_start, pad.y_end), (-15.0, 15.0)) + + def test_a_pair_reads_back_as_two_numbers(self): + """What travels to the server is the pair, in (ratio, adjust) order.""" + widget = self.widgets["anterior_right"] + widget.xBox.setValue(0.8) + widget.yBox.setValue(-2.0) + + self.assertEqual([round(v, 3) for v in widget.value()], [0.8, -2.0]) + + +class PreviewWiringTest(unittest.TestCase): + """The module reads the pads the way the preview expects them.""" + + def setUp(self): + sys.path.insert(0, os.path.join(ROOT, "FlexReg")) + from FlexReg import CORNERS, TEETH + + self.CORNERS = CORNERS + self.TEETH = TEETH + + @staticmethod + def _preview_signs(): + """`ADJUST_SIGN`, or None where numpy is absent. + + The preview needs numpy, vtk and matplotlib, which Slicer ships and a + bare CI runner may not. Skipping is honest here: this asserts that two + name lists agree, and it runs wherever the library can be imported. + """ + try: + from FlexRegLib.butterfly_preview import ADJUST_SIGN + except ImportError: + return None + return ADJUST_SIGN + + def test_every_corner_has_a_tooth_argument(self): + """A corner is placed along a tooth, so the two lists cannot drift.""" + self.assertEqual(sorted(self.TEETH), sorted(self.CORNERS)) + for corner, argument in self.TEETH.items(): + self.assertEqual(argument, "tooth_" + corner) + + def test_the_corner_names_are_the_ones_the_preview_uses(self): + """The preview keys its centroids by these exact strings; a rename on + either side would place a patch from the wrong four teeth.""" + signs = self._preview_signs() + if signs is None: + self.skipTest("the preview's libraries are not installed here") + + self.assertEqual(sorted(self.CORNERS), sorted(signs)) + + +if __name__ == "__main__": + unittest.main() diff --git a/ServerToolsCore/ServerToolsCoreLib/base_widget.py b/ServerToolsCore/ServerToolsCoreLib/base_widget.py index 5435e0a..bb400aa 100644 --- a/ServerToolsCore/ServerToolsCoreLib/base_widget.py +++ b/ServerToolsCore/ServerToolsCoreLib/base_widget.py @@ -131,7 +131,13 @@ def setup(self) -> None: # The schema-driven part lives in its own container so it can be thrown # away and rebuilt in place — see _buildForm. self._rootLayout = rootLayout - self._buildForm() + # force_refresh: the client caches GET /tools on a singleton that + # OUTLIVES this widget, so "Reload" rebuilt the panel from the response + # fetched when Slicer started. A tool whose schema changed since -- a + # new field, a hidden one, a different layout -- kept rendering the old + # one, and only restarting Slicer showed the change. Setup runs once per + # module load, so this costs one request per reload. + self._buildForm(force_refresh=True) extraLayout = qt.QVBoxLayout() rootLayout.addLayout(extraLayout) @@ -322,7 +328,15 @@ def _buildAutoUI(self, rootLayout, force_refresh: bool = False) -> None: for sectionName in formgen.sections_of(arguments, extraSections): box = ctk.ctkCollapsibleButton() box.text = _(sectionName) - self._sectionLayouts[sectionName] = qt.QFormLayout(box) + # A section the schema lays out in columns gets a grid; everything + # else keeps the one-argument-per-row form. FlexReg's four patch + # corners are a 2x2 that mirrors the arch, so where a pad sits on + # screen is where that corner sits in the mouth. + columns = formgen.section_columns(arguments, sectionName) + if columns > 1: + self._sectionLayouts[sectionName] = qt.QGridLayout(box) + else: + self._sectionLayouts[sectionName] = qt.QFormLayout(box) self._sectionBoxes[sectionName] = box rootLayout.addWidget(box) diff --git a/ServerToolsCore/ServerToolsCoreLib/design.py b/ServerToolsCore/ServerToolsCoreLib/design.py index 04f4359..9517efd 100644 --- a/ServerToolsCore/ServerToolsCoreLib/design.py +++ b/ServerToolsCore/ServerToolsCoreLib/design.py @@ -373,7 +373,7 @@ def link_button(text: str) -> qt.QPushButton: # paint colors are FlexReg's pad palette, which was designed against this same # blue theme. Hex strings rather than QColors so this module stays importable # under the test stubs; the pad wraps them at paint time. -PAD_SIZE = 128 +PAD_SIZE = 160 _PAD_LIGHT = { "background": "#f4f7fa", "border": "#d3dce5", "grid": "#e3eaf1", "text": "#93a2b1", "label": "#6b7c8d", "knob": "#3498db", "trail": "#bcd7ef", diff --git a/ServerToolsCore/ServerToolsCoreLib/formgen.py b/ServerToolsCore/ServerToolsCoreLib/formgen.py index 979f7c8..594bb25 100644 --- a/ServerToolsCore/ServerToolsCoreLib/formgen.py +++ b/ServerToolsCore/ServerToolsCoreLib/formgen.py @@ -424,11 +424,11 @@ def _onPadMoved(self, pad) -> None: if pad.spring_back: # The knob's offset from its rest position is a displacement # dealt onto the committed base, not a value of its own. - self.xBox.setValue(self._base[0] + (pad.x - pad.default_x)) - self.yBox.setValue(self._base[1] + (pad.y - pad.default_y)) + self.xBox.setValue(self._base[0] + (pad.value_x - pad.default_x)) + self.yBox.setValue(self._base[1] + (pad.value_y - pad.default_y)) else: - self.xBox.setValue(pad.x) - self.yBox.setValue(pad.y) + self.xBox.setValue(pad.value_x) + self.yBox.setValue(pad.value_y) finally: self._syncing = False @@ -458,7 +458,17 @@ def setToolTip(self, text) -> None: def _axis_spinbox(bounds, step) -> qt.QDoubleSpinBox: + """The number a pad is showing, read-only. + + The pad IS the input: it sets both axes with one gesture, and the knob sits + where the point sits on the arch. A box that also accepts typing gives the + same value two owners and reads as a form to fill in, which is not what the + original was -- there the numbers report what the pad is doing. + """ box = qt.QDoubleSpinBox() + box.setReadOnly(True) + box.setButtonSymbols(qt.QAbstractSpinBox.NoButtons) + box.setFocusPolicy(qt.Qt.NoFocus) low, high = sorted((float(bounds[0]), float(bounds[1]))) box.setRange(low, high) box.setDecimals(_decimals_for_step(step)) @@ -806,6 +816,40 @@ def section_of(spec: dict) -> str: return spec.get("section") or DEFAULT_SECTION +# A section whose arguments are laid out in a grid rather than one per row. +# Declared per ARGUMENT (`section_columns`) because that is the only place the +# schema has to hang a hint, and read back per section: every argument in one +# section must agree, and the first that speaks wins. +# +# FlexReg is why. Its four patch corners are a 2x2 that MIRRORS THE ARCH -- left +# column one side, right column the other, top row anterior -- so a pad's +# position on screen is where that corner is in the mouth. Stacked one per row +# that meaning is gone, and the panel is four identical pads in a column. +def cell_of(name: str, spec: dict) -> str: + """Which grid cell an argument shares. Its own name when it names none. + + Several arguments describing ONE thing belong together: FlexReg's anterior + right corner is a tooth number and a position along it, and upstream drew + them in one box with the pad. One argument per cell puts the four teeth in a + column and the four pads in another, which is a table of arguments rather + than a picture of an arch. + """ + return spec.get("cell") or name + + +def section_columns(arguments_schema: dict, section: str) -> int: + """How many columns `section` is laid out in. 1 is one argument per row.""" + for spec in arguments_schema.values(): + if section_of(spec) == section: + declared = spec.get("section_columns") + if declared: + try: + return max(1, int(declared)) + except (TypeError, ValueError): + return 1 + return 1 + + def sections_of(arguments_schema: dict, extra=()) -> list: """Every distinct section a tool's arguments name, in the order they are first mentioned — the schema's declaration order, which is the tool @@ -908,6 +952,9 @@ def build(arguments_schema: dict, layout, sections=None, rows=None) -> dict: field reliably across PythonQt versions. """ widgets = {} + # {(layout, cell name): the QWidget holding that cell}, so several arguments + # naming one cell stack inside it instead of taking a cell each. + grid_cells = {} for name, spec in arguments_schema.items(): if is_file_type(spec.get("type", "")): continue @@ -922,7 +969,32 @@ def build(arguments_schema: dict, layout, sections=None, rows=None) -> dict: label = design.required_label(text) if spec.get("required") else design.section_title(text) target = (sections or {}).get(section_of(spec), layout) field = row_widget(widget) - target.addRow(label, field) + if hasattr(target, "addRow"): + target.addRow(label, field) + else: + # A grid section: the caller handed a QGridLayout instead, and the + # label goes above its field rather than beside it, so a 2x2 of pads + # reads as a 2x2 rather than as four labelled rows. + cell = qt.QWidget() + stack = qt.QVBoxLayout(cell) + stack.setContentsMargins(0, 0, 0, 0) + stack.addWidget(label) + stack.addWidget(field) + # Read back from the schema, never stored on the layout: PythonQt + # forbids creating an attribute on a C++ object, so `grid.columns = + # 2` fails with "creating new attributes on C++ objects is not + # allowed" and takes the whole panel down. + columns = section_columns(arguments_schema, section_of(spec)) + key = (id(target), cell_of(name, spec)) + holder = grid_cells.get(key) + if holder is None: + holder = qt.QWidget() + qt.QVBoxLayout(holder).setContentsMargins(0, 0, 0, 0) + placed = len( + [k for k in grid_cells if k[0] == id(target)]) + target.addWidget(holder, placed // columns, placed % columns) + grid_cells[key] = holder + holder.layout().addWidget(cell) widgets[name] = widget if rows is not None: rows[name] = (label, field) diff --git a/ServerToolsCore/ServerToolsCoreLib/joystick.py b/ServerToolsCore/ServerToolsCoreLib/joystick.py index d164f22..308262a 100644 --- a/ServerToolsCore/ServerToolsCoreLib/joystick.py +++ b/ServerToolsCore/ServerToolsCoreLib/joystick.py @@ -94,8 +94,12 @@ def __init__(self, x_range=(0.0, 1.0), y_range=(0.0, 1.0), x_step=None, y_step=N centre_x = (self.x_start + self.x_end) / 2.0 centre_y = (self.y_start + self.y_end) / 2.0 - self.x = centre_x - self.y = centre_y + # NOT self.x / self.y: QWidget already owns those as its position, and + # PythonQt refuses the assignment outright -- "Property 'x' of + # JoystickPad object is not writable", which takes the whole panel down + # with it. Invisible against a stub that is not a real QWidget. + self.value_x = centre_x + self.value_y = centre_y self.default_x = centre_x self.default_y = centre_y @@ -119,10 +123,10 @@ def __init__(self, x_range=(0.0, 1.0), y_range=(0.0, 1.0), x_step=None, y_step=N def setValues(self, x, y, notify=False): x = min(max(float(x), self._x_lo), self._x_hi) y = min(max(float(y), self._y_lo), self._y_hi) - if x == self.x and y == self.y: + if x == self.value_x and y == self.value_y: return - self.x = x - self.y = y + self.value_x = x + self.value_y = y self.update() if notify and self.onChanged: self.onChanged(self) @@ -149,8 +153,8 @@ def _area(self): def _knobPosition(self): left, top, width, height = self._area() - fraction_x = (self.x - self.x_start) / (self.x_end - self.x_start) - fraction_y = (self.y - self.y_start) / (self.y_end - self.y_start) + fraction_x = (self.value_x - self.x_start) / (self.x_end - self.x_start) + fraction_y = (self.value_y - self.y_start) / (self.y_end - self.y_start) # Screen y grows downwards; the axis end (index 1) is the top. return left + fraction_x * width, top + (1.0 - fraction_y) * height @@ -216,9 +220,9 @@ def wheelEvent(self, event): self.onWheel(self, steps) return if _modifiers() & qt.Qt.ShiftModifier: - self.setValues(self.x + self.x_step * steps * self._x_dir, self.y, notify=True) + self.setValues(self.value_x + self.x_step * steps * self._x_dir, self.value_y, notify=True) else: - self.setValues(self.x, self.y + self.y_step * steps * self._y_dir, notify=True) + self.setValues(self.value_x, self.value_y + self.y_step * steps * self._y_dir, notify=True) self._springNudgeBack() def keyPressEvent(self, event): @@ -228,13 +232,13 @@ def keyPressEvent(self, event): # Arrows are screen-directional: Right always walks the knob right, # whichever way the axis is declared. if key == qt.Qt.Key_Left: - self.setValues(self.x - self.x_step * self._x_dir, self.y, notify=True) + self.setValues(self.value_x - self.x_step * self._x_dir, self.value_y, notify=True) elif key == qt.Qt.Key_Right: - self.setValues(self.x + self.x_step * self._x_dir, self.y, notify=True) + self.setValues(self.value_x + self.x_step * self._x_dir, self.value_y, notify=True) elif key == qt.Qt.Key_Up: - self.setValues(self.x, self.y + self.y_step * self._y_dir, notify=True) + self.setValues(self.value_x, self.value_y + self.y_step * self._y_dir, notify=True) elif key == qt.Qt.Key_Down: - self.setValues(self.x, self.y - self.y_step * self._y_dir, notify=True) + self.setValues(self.value_x, self.value_y - self.y_step * self._y_dir, notify=True) else: return self._springNudgeBack() diff --git a/ServerToolsCore/Testing/Python/qt_stubs.py b/ServerToolsCore/Testing/Python/qt_stubs.py index 8cf081f..8510a73 100644 --- a/ServerToolsCore/Testing/Python/qt_stubs.py +++ b/ServerToolsCore/Testing/Python/qt_stubs.py @@ -156,6 +156,8 @@ def __init__(self, shape=0): class Qt: + # A read-only box takes no focus: it reports, it does not accept. + NoFocus = 0 """The Qt namespace enum values design/formgen/joystick reach for.""" ScrollBarAlwaysOff = 1 @@ -367,6 +369,18 @@ def __init__(self): def setDecimals(self, decimals): self.decimals = decimals + def setReadOnly(self, readOnly): + self.readOnly = readOnly + + def setButtonSymbols(self, symbols): + self.buttonSymbols = symbols + + +class QAbstractSpinBox: + """Only the enum formgen names: a read-only box hides its arrows.""" + + NoButtons = 2 + class QPalette: Window = 0 diff --git a/ServerToolsCore/Testing/Python/test_formgen.py b/ServerToolsCore/Testing/Python/test_formgen.py index 24ef91d..2a3f516 100644 --- a/ServerToolsCore/Testing/Python/test_formgen.py +++ b/ServerToolsCore/Testing/Python/test_formgen.py @@ -1129,7 +1129,7 @@ def test_the_declared_initial_reaches_boxes_and_pad(self): widget = self._one(_vec2(ui="joystick", x_range=[-15, 15], y_range=[-5, 5], initial=[3, -2])) self.assertEqual(widget.value(), [3.0, -2.0]) - self.assertEqual((widget.pad.x, widget.pad.y), (3.0, -2.0)) + self.assertEqual((widget.pad.value_x, widget.pad.value_y), (3.0, -2.0)) def test_no_initial_opens_at_the_centre_of_both_axes(self): widget = self._one(_vec2(ui="joystick", x_range=[0, 10], y_range=[-5, 5])) @@ -1147,7 +1147,7 @@ def test_editing_a_box_moves_the_pad(self): widget.xBox.setValue(7.5) - self.assertEqual(widget.pad.x, 7.5) + self.assertEqual(widget.pad.value_x, 7.5) def test_moving_the_pad_updates_the_boxes(self): widget = self._one(_vec2(ui="joystick", x_range=[-15, 15], y_range=[-5, 5], initial=[0, 0])) @@ -1175,7 +1175,7 @@ def test_a_spring_back_pad_accumulates_displacements(self): pad.setValues(2.0, 1.0, notify=True) self.assertEqual(widget.value(), [2.0, 1.0]) pad.mouseReleaseEvent(None) - self.assertEqual((pad.x, pad.y), (0.0, 0.0)) # sprang home + self.assertEqual((pad.value_x, pad.value_y), (0.0, 0.0)) # sprang home # A second push adds to the committed base instead of replacing it. pad.setValues(1.0, 1.0, notify=True)