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
-
- 1
-
-
- ctkCollapsibleButton
- QWidget
-
- 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)