diff --git a/OpenLIFUData/OpenLIFUData.py b/OpenLIFUData/OpenLIFUData.py index d1a03e3f..1dd53950 100644 --- a/OpenLIFUData/OpenLIFUData.py +++ b/OpenLIFUData/OpenLIFUData.py @@ -63,6 +63,8 @@ if TYPE_CHECKING: import openlifu import openlifu.nav.photoscan + import openlifu.plan + import openlifu.xdc from OpenLIFUHome.OpenLIFUHome import OpenLIFUHomeLogic from OpenLIFUPrePlanning.OpenLIFUPrePlanning import OpenLIFUPrePlanningWidget @@ -1286,7 +1288,7 @@ def updateLoadedObjectsView(self): self.loadedObjectsItemModel.appendRow(row) for transducer_slicer in parameter_node.loaded_transducers.values(): transducer_slicer : SlicerOpenLIFUTransducer - transducer_openlifu : "openlifu.Transducer" = transducer_slicer.transducer.transducer + transducer_openlifu : "openlifu.xdc.Transducer" = transducer_slicer.transducer.transducer row = list(map( create_noneditable_QStandardItem, [transducer_openlifu.name, "Transducer", transducer_openlifu.id] @@ -1415,7 +1417,7 @@ def updateSessionStatus(self): loaded_session = self._parameterNode.loaded_session session_openlifu : "openlifu.db.Session" = loaded_session.session.session subject_openlifu = self.logic.get_subject(session_openlifu.subject_id) - protocol_openlifu : "openlifu.Protocol" = loaded_session.get_protocol().protocol + protocol_openlifu : "openlifu.plan.Protocol" = loaded_session.get_protocol().protocol self.ui.sessionStatusSubjectNameIdValueLabel.setText( f"{subject_openlifu.name} (ID: {session_openlifu.subject_id})" @@ -1431,7 +1433,7 @@ def updateSessionStatus(self): # Add a validity check here since this function call is triggered after a transducer is removed but # before a session is invalidated. if loaded_session.transducer_is_valid(): - transducer_openlifu : "openlifu.Transducer" = loaded_session.get_transducer().transducer.transducer + transducer_openlifu : "openlifu.xdc.Transducer" = loaded_session.get_transducer().transducer.transducer self.ui.sessionStatusTransducerValueLabel.setText( f"{transducer_openlifu.name} (ID: {session_openlifu.transducer_id})" ) @@ -2121,10 +2123,10 @@ def _on_transducer_transform_modified(self, transducer: SlicerOpenLIFUTransducer def load_protocol_from_file(self, filepath:str) -> None: - protocol = openlifu_lz().Protocol.from_file(filepath) + protocol = openlifu_lz().plan.Protocol.from_file(filepath) self.load_protocol_from_openlifu(protocol) - def load_protocol_from_openlifu(self, protocol:"openlifu.Protocol", replace_confirmed: bool = False) -> None: + def load_protocol_from_openlifu(self, protocol:"openlifu.plan.Protocol", replace_confirmed: bool = False) -> None: """Load an openlifu protocol object into the scene as a SlicerOpenLIFUProtocol, adding it to the list of loaded openlifu objects. If there are changes in the protocol config, also confirms user wants to discard @@ -2188,7 +2190,7 @@ def load_transducer_from_file(self, filepath:str) -> None: def load_transducer_from_openlifu( self, - transducer: "openlifu.Transducer", + transducer: "openlifu.xdc.Transducer", transducer_abspaths_info: dict = {}, transducer_matrix: Optional[np.ndarray]=None, transducer_matrix_units: Optional[str]=None, diff --git a/OpenLIFUDatabase/OpenLIFUDatabase.py b/OpenLIFUDatabase/OpenLIFUDatabase.py index 81e47ab8..620b4739 100644 --- a/OpenLIFUDatabase/OpenLIFUDatabase.py +++ b/OpenLIFUDatabase/OpenLIFUDatabase.py @@ -353,7 +353,7 @@ def load_database(self, path: Path) -> None: Args: path: Path to the openlifu database folder on disk. """ - self.db = openlifu_lz().Database(path) + self.db = openlifu_lz().db.Database(path) add_slicer_log_handler_for_openlifu_object(self.db) @staticmethod diff --git a/OpenLIFULib/OpenLIFULib/Resources/python-requirements.txt b/OpenLIFULib/OpenLIFULib/Resources/python-requirements.txt index c782d44c..2f760d3d 100644 --- a/OpenLIFULib/OpenLIFULib/Resources/python-requirements.txt +++ b/OpenLIFULib/OpenLIFULib/Resources/python-requirements.txt @@ -1,4 +1,4 @@ -openlifu==v0.20.0 +openlifu[app] @ git+https://github.com/OpenwaterHealth/openlifu-python.git@e479cbd3464c54cba2ea071144701fe8aa4e6eaf bcrypt threadpoolctl requests diff --git a/OpenLIFULib/OpenLIFULib/__init__.py b/OpenLIFULib/OpenLIFULib/__init__.py index 99263ddc..447b7c51 100644 --- a/OpenLIFULib/OpenLIFULib/__init__.py +++ b/OpenLIFULib/OpenLIFULib/__init__.py @@ -1,5 +1,6 @@ from OpenLIFULib.lazyimport import ( openlifu_lz, + openlifu_sdk_lz, xarray_lz, bcrypt_lz, threadpoolctl_lz, @@ -43,6 +44,7 @@ __all__ = [ "openlifu_lz", + "openlifu_sdk_lz", "xarray_lz", "bcrypt_lz", "threadpoolctl_lz", diff --git a/OpenLIFULib/OpenLIFULib/lazyimport.py b/OpenLIFULib/OpenLIFULib/lazyimport.py index 2c31c044..4610d59e 100644 --- a/OpenLIFULib/OpenLIFULib/lazyimport.py +++ b/OpenLIFULib/OpenLIFULib/lazyimport.py @@ -106,8 +106,8 @@ def check_and_install_kwave_binaries() -> bool: Returns whether they were successfully installed (or just already present). This assumes that openlifu can be imported already, so do not call this function until after that is assured. """ - import openlifu - kwave_paths = openlifu.util.assets.get_kwave_paths() + from openlifu.util.assets import get_kwave_paths + kwave_paths = get_kwave_paths() if all(p.exists() for p,_ in kwave_paths): return True @@ -144,6 +144,18 @@ def openlifu_lz() -> "openlifu": with BusyCursor(): import openlifu + import openlifu_sdk + import openlifu.bf + import openlifu.db + import openlifu.geo + import openlifu.nav.photoscan + import openlifu.plan + import openlifu.seg.seg_methods + import openlifu.seg.skinseg + import openlifu.sim + import openlifu.util.assets + import openlifu.xdc + import openlifu.xdc.util if slicer.app.testingEnabled(): # Ensure kwave assets are present (no-op if already installed) @@ -181,4 +193,11 @@ def segno_lz() -> "segno": if "segno" not in sys.modules: check_and_install_python_requirements(prompt_if_found=False) import segno - return sys.modules["segno"] \ No newline at end of file + return sys.modules["segno"] + +def openlifu_sdk_lz() -> "openlifu_sdk": + """Import openlifu_sdk and return the module. openlifu_sdk is installed as a dependency + of openlifu, so openlifu_lz() must be called first to ensure it is available.""" + if "openlifu_sdk" not in sys.modules: + openlifu_lz() + return sys.modules["openlifu_sdk"] \ No newline at end of file diff --git a/OpenLIFULib/OpenLIFULib/parameter_node_utils.py b/OpenLIFULib/OpenLIFULib/parameter_node_utils.py index 79a1f364..2a641fa1 100644 --- a/OpenLIFULib/OpenLIFULib/parameter_node_utils.py +++ b/OpenLIFULib/OpenLIFULib/parameter_node_utils.py @@ -18,51 +18,53 @@ if TYPE_CHECKING: import openlifu # This import is deferred at runtime, but it is done here for IDE and static analysis purposes import openlifu.db + import openlifu.geo import openlifu.plan import openlifu.nav.photoscan + import openlifu.xdc import xarray -# This very thin wrapper around openlifu.Protocol is needed to do our lazy importing of openlifu +# This very thin wrapper around openlifu.plan.Protocol is needed to do our lazy importing of openlifu # while still providing type annotations that the parameter node wrapper can use. -# If we tried to make openlifu.Protocol directly supported as a type by parameter nodes, we would +# If we tried to make openlifu.plan.Protocol directly supported as a type by parameter nodes, we would # get errors from parameterNodeWrapper as it tries to use typing.get_type_hints. This fails because -# get_type_hints tries to *evaluate* the type annotations like "openlifu.Protocol" possibly before +# get_type_hints tries to *evaluate* the type annotations like "openlifu.plan.Protocol" possibly before # the user has installed openlifu, and possibly before the main window widgets exist that would allow # an install prompt to even show up. class SlicerOpenLIFUProtocol: - """Ultrathin wrapper of openlifu.Protocol. This exists so that protocols can have parameter node + """Ultrathin wrapper of openlifu.plan.Protocol. This exists so that protocols can have parameter node support while we still do lazy-loading of openlifu.""" - def __init__(self, protocol: "Optional[openlifu.Protocol]" = None): + def __init__(self, protocol: "Optional[openlifu.plan.Protocol]" = None): self.protocol = protocol -# For the same reason we have a thin wrapper around openlifu.Transducer. But the name SlicerOpenLIFUTransducer +# For the same reason we have a thin wrapper around openlifu.xdc.Transducer. But the name SlicerOpenLIFUTransducer # is reserved for the upcoming parameter pack. class SlicerOpenLIFUTransducerWrapper: - """Ultrathin wrapper of openlifu.Transducer. This exists so that transducers can have parameter node + """Ultrathin wrapper of openlifu.xdc.Transducer. This exists so that transducers can have parameter node support while we still do lazy-loading of openlifu.""" - def __init__(self, transducer: "Optional[openlifu.Transducer]" = None): + def __init__(self, transducer: "Optional[openlifu.xdc.Transducer]" = None): self.transducer = transducer -# For the same reason we have a thin wrapper around openlifu.Point +# For the same reason we have a thin wrapper around openlifu.geo.Point class SlicerOpenLIFUPoint: - """Ultrathin wrapper of openlifu.Point. This exists so that points can have parameter node + """Ultrathin wrapper of openlifu.geo.Point. This exists so that points can have parameter node support while we still do lazy-loading of openlifu.""" - def __init__(self, point: "Optional[openlifu.Point]" = None): + def __init__(self, point: "Optional[openlifu.geo.Point]" = None): self.point = point -# For the same reason we have a thin wrapper around openlifu.Session +# For the same reason we have a thin wrapper around openlifu.db.Session class SlicerOpenLIFUSessionWrapper: - """Ultrathin wrapper of openlifu.Session. This exists so that sessions can have parameter node + """Ultrathin wrapper of openlifu.db.Session. This exists so that sessions can have parameter node support while we still do lazy-loading of openlifu.""" def __init__(self, session: "Optional[openlifu.db.Session]" = None): self.session = session -# For the same reason we have a thin wrapper around openlifu.Solution +# For the same reason we have a thin wrapper around openlifu.plan.Solution class SlicerOpenLIFUSolutionWrapper: - """Ultrathin wrapper of openlifu.Solution. This exists so that solutions can have parameter node + """Ultrathin wrapper of openlifu.plan.Solution. This exists so that solutions can have parameter node support while we still do lazy-loading of openlifu.""" - def __init__(self, solution: "Optional[openlifu.Solution]" = None): + def __init__(self, solution: "Optional[openlifu.plan.Solution]" = None): self.solution = solution # For the same reason we have a thin wrapper around xarray.Dataset @@ -173,7 +175,7 @@ def read(self, parameterNode: slicer.vtkMRMLScriptedModuleNode, name: str) -> Sl Reads and returns the value with the given name from the parameterNode. """ json_string = parameterNode.GetParameter(name) - return SlicerOpenLIFUProtocol(openlifu_lz().Protocol.from_json(json_string)) + return SlicerOpenLIFUProtocol(openlifu_lz().plan.Protocol.from_json(json_string)) @parameterNodeSerializer class OpenLIFUTransducerSerializer(SlicerOpenLIFUSerializerBaseMaker(SlicerOpenLIFUTransducerWrapper)): @@ -191,7 +193,7 @@ def read(self, parameterNode: slicer.vtkMRMLScriptedModuleNode, name: str) -> Sl Reads and returns the value with the given name from the parameterNode. """ json_string = parameterNode.GetParameter(name) - return SlicerOpenLIFUTransducerWrapper(openlifu_lz().Transducer.from_json(json_string)) + return SlicerOpenLIFUTransducerWrapper(openlifu_lz().xdc.Transducer.from_json(json_string)) @parameterNodeSerializer class OpenLIFUSessionSerializer(SlicerOpenLIFUSerializerBaseMaker(SlicerOpenLIFUSessionWrapper)): @@ -215,7 +217,7 @@ def write(self, parameterNode: slicer.vtkMRMLScriptedModuleNode, name: str, valu def read(self, parameterNode: slicer.vtkMRMLScriptedModuleNode, name: str) -> SlicerOpenLIFUSolutionWrapper: json_string = parameterNode.GetParameter(name) - return SlicerOpenLIFUSolutionWrapper(openlifu_lz().Solution.from_json(json_string)) + return SlicerOpenLIFUSolutionWrapper(openlifu_lz().plan.Solution.from_json(json_string)) @parameterNodeSerializer class OpenLIFUPointSerializer(SlicerOpenLIFUSerializerBaseMaker(SlicerOpenLIFUPoint)): @@ -233,7 +235,7 @@ def read(self, parameterNode: slicer.vtkMRMLScriptedModuleNode, name: str) -> Sl Reads and returns the value with the given name from the parameterNode. """ json_string = parameterNode.GetParameter(name) - return SlicerOpenLIFUPoint(openlifu_lz().Point.from_json(json_string)) + return SlicerOpenLIFUPoint(openlifu_lz().geo.Point.from_json(json_string)) @parameterNodeSerializer class OpenLIFURunSerializer(SlicerOpenLIFUSerializerBaseMaker(SlicerOpenLIFURun)): diff --git a/OpenLIFULib/OpenLIFULib/simulation.py b/OpenLIFULib/OpenLIFULib/simulation.py index 384ac4a7..12ac1db1 100644 --- a/OpenLIFULib/OpenLIFULib/simulation.py +++ b/OpenLIFULib/OpenLIFULib/simulation.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: import openlifu import openlifu.db + import openlifu.plan import xarray from OpenLIFULib import SlicerOpenLIFUTransducer @@ -43,7 +44,7 @@ def make_volume_from_xarray_in_transducer_coords(data_array: "xarray.DataArray", return volumeNode -def make_xarray_in_transducer_coords_from_volume(volume_node:vtkMRMLScalarVolumeNode, transducer:"SlicerOpenLIFUTransducer", protocol:"openlifu.Protocol") -> "xarray.DataArray": +def make_xarray_in_transducer_coords_from_volume(volume_node:vtkMRMLScalarVolumeNode, transducer:"SlicerOpenLIFUTransducer", protocol:"openlifu.plan.Protocol") -> "xarray.DataArray": """Convert a volume node into a DataArray in the coordinates of a given transducer. See also `make_volume_from_xarray_in_transducer_coords`. """ diff --git a/OpenLIFULib/OpenLIFULib/solution.py b/OpenLIFULib/OpenLIFULib/solution.py index 37847771..d79b00e0 100644 --- a/OpenLIFULib/OpenLIFULib/solution.py +++ b/OpenLIFULib/OpenLIFULib/solution.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: import openlifu + import openlifu.plan import xarray @parameterPack @@ -32,7 +33,7 @@ class SlicerOpenLIFUSolution: @staticmethod def initialize_from_openlifu_data( - solution : "openlifu.Solution", + solution : "openlifu.plan.Solution", pnp_datarray : "xarray.DataArray", intensity_dataarray : "xarray.DataArray", transducer : SlicerOpenLIFUTransducer, diff --git a/OpenLIFULib/OpenLIFULib/targets.py b/OpenLIFULib/OpenLIFULib/targets.py index 1c483baa..70408565 100644 --- a/OpenLIFULib/OpenLIFULib/targets.py +++ b/OpenLIFULib/OpenLIFULib/targets.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: import openlifu + import openlifu.geo from OpenLIFULib.transducer import SlicerOpenLIFUTransducer def get_target_candidates() -> List[vtkMRMLMarkupsFiducialNode]: @@ -24,7 +25,7 @@ def get_target_candidates() -> List[vtkMRMLMarkupsFiducialNode]: if fiducial_node.GetNumberOfControlPoints() == 1 ] -def openlifu_point_to_fiducial(point : "openlifu.Point") -> vtkMRMLMarkupsFiducialNode: +def openlifu_point_to_fiducial(point : "openlifu.geo.Point") -> vtkMRMLMarkupsFiducialNode: """Create a fiducial node out of an openlifu Point, removing any existing nodes that would have the same name. The name of the node will be the openlifu point ID, so we do not allow this to be duplicated. """ @@ -60,14 +61,14 @@ def fiducial_to_openlifu_point_id(fiducial_node:vtkMRMLMarkupsFiducialNode) -> s """Get the openlifu point ID that we would use if we were to convert the given fiducial node to an openlifu Point""" return fiducial_node.GetName() -def fiducial_to_openlifu_point_in_transducer_coords(fiducial_node:vtkMRMLMarkupsFiducialNode, transducer:"SlicerOpenLIFUTransducer", name:Optional[str] = None) -> "openlifu.Point": +def fiducial_to_openlifu_point_in_transducer_coords(fiducial_node:vtkMRMLMarkupsFiducialNode, transducer:"SlicerOpenLIFUTransducer", name:Optional[str] = None) -> "openlifu.geo.Point": """Given a fiducial node with at least one point, return an openlifu Point in the local coordinates of the given transducer. If name is provided then it will be used as the name of the openlifu Point. Otherwise we use the label on the control point. """ if fiducial_node.GetNumberOfControlPoints() < 1: raise ValueError(f"Fiducial node {fiducial_node.GetID()} does not have any points.") position = (np.linalg.inv(slicer.util.arrayFromTransformMatrix(transducer.transform_node)) @ np.array([*fiducial_node.GetNthControlPointPosition(0),1]))[:3] # TODO handle 4th coord here actually, would need to unprojectivize - return openlifu_lz().Point( + return openlifu_lz().geo.Point( position=position, name = name if name is not None else fiducial_node.GetNthControlPointLabel(0), id = f"{fiducial_to_openlifu_point_id(fiducial_node)}-in-transducer-coords", @@ -75,14 +76,14 @@ def fiducial_to_openlifu_point_in_transducer_coords(fiducial_node:vtkMRMLMarkups units = transducer.transducer.transducer.units, ) -def fiducial_to_openlifu_point(fiducial_node:vtkMRMLMarkupsFiducialNode) -> "openlifu.Point": +def fiducial_to_openlifu_point(fiducial_node:vtkMRMLMarkupsFiducialNode) -> "openlifu.geo.Point": """Given a fiducial node with at least one point, return an openlifu Point in RAS coordinates. This tries to be roughly an inverse operation of `openlifu_point_to_fiducial`, but isn't an inverse when it comes to for example the coordinates, and units. The opnenlifu point ID is however preserved between this function and `openlifu_point_to_fiducial`, because it is used as the node name.""" if fiducial_node.GetNumberOfControlPoints() < 1: raise ValueError(f"Fiducial node {fiducial_node.GetID()} does not have any points.") - return openlifu_lz().Point( + return openlifu_lz().geo.Point( position = np.array(fiducial_node.GetNthControlPointPosition(0)), name = fiducial_node.GetNthControlPointLabel(0), id = fiducial_to_openlifu_point_id(fiducial_node), diff --git a/OpenLIFULib/OpenLIFULib/transducer.py b/OpenLIFULib/OpenLIFULib/transducer.py index 51279882..9102da22 100644 --- a/OpenLIFULib/OpenLIFULib/transducer.py +++ b/OpenLIFULib/OpenLIFULib/transducer.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: import openlifu # This import is deferred at runtime, but it is done here for IDE and static analysis purposes + import openlifu.xdc # Define transducer color dictionary @@ -40,7 +41,7 @@ class SlicerOpenLIFUTransducer: @staticmethod def initialize_from_openlifu_transducer( - transducer : "openlifu.Transducer", + transducer : "openlifu.xdc.Transducer", transducer_abspaths_info: dict = {}, transducer_matrix: Optional[np.ndarray]=None, transducer_matrix_units: Optional[str]=None, diff --git a/OpenLIFUPrePlanning/OpenLIFUPrePlanning.py b/OpenLIFUPrePlanning/OpenLIFUPrePlanning.py index c203f6e7..c8761515 100644 --- a/OpenLIFUPrePlanning/OpenLIFUPrePlanning.py +++ b/OpenLIFUPrePlanning/OpenLIFUPrePlanning.py @@ -61,7 +61,9 @@ if TYPE_CHECKING: import openlifu import openlifu.geo - import openlifu.virtual_fit + import openlifu.plan + import openlifu.seg.virtual_fit + import openlifu.xdc from OpenLIFUData.OpenLIFUData import OpenLIFUDataLogic PLACE_INTERACTION_MODE_ENUM_VALUE = slicer.vtkMRMLInteractionNode().Place @@ -1100,8 +1102,8 @@ def virtual_fit( add_slicer_log_handler("VirtualFit", "Virtual fitting") - transducer_openlifu : "openlifu.Transducer" = transducer.transducer.transducer - protocol_openlifu : "openlifu.Protocol" = protocol.protocol + transducer_openlifu : "openlifu.xdc.Transducer" = transducer.transducer.transducer + protocol_openlifu : "openlifu.plan.Protocol" = protocol.protocol units = "mm" # These are the units of the output space of the transform returned by get_IJK2RAS @@ -1116,7 +1118,7 @@ def virtual_fit( # tiny svd calls end up having more overhead than is worth it. # For some unknown reason, the improvement is only noticable when we do not use the embree # option in virtual fitting, which makes things very fast. - vf_transforms = openlifu_lz().run_virtual_fit( + vf_transforms = openlifu_lz().seg.run_virtual_fit( units = units, target_RAS = target.GetNthControlPointPosition(0), standoff_transform = transducer_openlifu.get_standoff_transform_in_units(units), @@ -1159,7 +1161,7 @@ def virtual_fit( return None return vf_result_nodes[0] - def load_vf_debugging_info(self, debug_info : "openlifu.virtual_fit.VirtualFitDebugInfo") -> None: + def load_vf_debugging_info(self, debug_info : "openlifu.seg.virtual_fit.VirtualFitDebugInfo") -> None: """Load virtual fit debugging info into the Slicer scene.""" skin_node = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLModelNode") skin_node.SetAndObservePolyData(debug_info.skin_mesh) diff --git a/OpenLIFUProtocolConfig/OpenLIFUProtocolConfig.py b/OpenLIFUProtocolConfig/OpenLIFUProtocolConfig.py index 1545f6f3..d5e9f81e 100644 --- a/OpenLIFUProtocolConfig/OpenLIFUProtocolConfig.py +++ b/OpenLIFUProtocolConfig/OpenLIFUProtocolConfig.py @@ -271,7 +271,7 @@ def setup(self) -> None: self.solution_analysis_options_definition_widget = OpenLIFUSolutionAnalysisOptionsDefinitionFormWidget() replace_widget(self.ui.solutionAnalysisOptionsDefinitionWidgetPlaceholder, self.solution_analysis_options_definition_widget, self.ui) - self.virtual_fit_options_definition_widget = OpenLIFUAbstractDataclassDefinitionFormWidget(cls=openlifu_lz().VirtualFitOptions, parent=self.ui.virtualFitOptionsDefinitionWidgetPlaceholder.parentWidget(), collapsible_title="Virtual Fit Options") + self.virtual_fit_options_definition_widget = OpenLIFUAbstractDataclassDefinitionFormWidget(cls=openlifu_lz().seg.virtual_fit.VirtualFitOptions, parent=self.ui.virtualFitOptionsDefinitionWidgetPlaceholder.parentWidget(), collapsible_title="Virtual Fit Options") replace_widget(self.ui.virtualFitOptionsDefinitionWidgetPlaceholder, self.virtual_fit_options_definition_widget, self.ui) self.virtual_fit_options_definition_widget.collapsible.collapsed = True # start collapsed @@ -618,7 +618,7 @@ def onLoadProtocolFromFileClicked(self, checked:bool) -> None: if not filepath: return - protocol = openlifu_lz().Protocol.from_file(filepath) + protocol = openlifu_lz().plan.Protocol.from_file(filepath) if not self.load_protocol_from_openlifu(protocol): return @@ -1017,7 +1017,7 @@ def get_default_solution_analysis_options(cls): @classmethod def get_default_virtual_fit_options(cls): - return openlifu_lz().VirtualFitOptions() + return openlifu_lz().seg.virtual_fit.VirtualFitOptions() @classmethod def get_default_protocol(cls): diff --git a/OpenLIFUSonicationControl/OpenLIFUSonicationControl.py b/OpenLIFUSonicationControl/OpenLIFUSonicationControl.py index 2c2c5ff9..0cea4e5d 100644 --- a/OpenLIFUSonicationControl/OpenLIFUSonicationControl.py +++ b/OpenLIFUSonicationControl/OpenLIFUSonicationControl.py @@ -24,6 +24,7 @@ SlicerOpenLIFURun, get_openlifu_data_parameter_node, openlifu_lz, + openlifu_sdk_lz, ) from OpenLIFULib.guided_mode_util import GuidedWorkflowMixin from OpenLIFULib.user_account_mode_util import UserAccountBanner @@ -34,6 +35,54 @@ # but is done here for IDE and static analysis purposes if TYPE_CHECKING: import openlifu + import openlifu_sdk + + +def _lifu_exceptions(): + """Lazy accessor for the ``openlifu_sdk.io.exceptions`` module. + + The exceptions module defines :class:`LIFUError` and its specialized + subclasses (e.g. :class:`LIFUHVSettleError`, :class:`LIFUSolutionError`, + :class:`LIFUNoTriggerStatusError`, ...). Each carries a stable numeric + ``code`` attribute and a human-readable message. + """ + # openlifu_sdk_lz() guarantees the package is imported. + openlifu_sdk_lz() + import importlib + return importlib.import_module("openlifu_sdk.io.exceptions") + + +def _format_lifu_error(exc: Exception) -> str: + """Format a :class:`LIFUError` (or any exception) into a user-friendly string. + + The resulting string includes the exception class name, the numeric LIFU + error code (when available), and the underlying message. + """ + code = getattr(exc, "code", None) + message = str(exc) + # LIFUError prepends a ``[LIFU-] `` tag to the message; strip it so + # we can present the code in a more explicit way in the dialog. + if code is not None: + prefix = f"[LIFU-{code}] " + if message.startswith(prefix): + message = message[len(prefix):] + return f"{type(exc).__name__} (LIFU error code {code}):\n{message}" + return f"{type(exc).__name__}:\n{message}" + + +def _display_lifu_error(exc: Exception, action_description: str) -> None: + """Show a Slicer error dialog describing a LIFU device exception. + + Args: + exc: The exception that was raised by the openlifu-sdk. + action_description: Short human-readable description of what was being + attempted when the error occurred (e.g. "starting sonication"). + """ + logging.error("LIFU error while %s: %s", action_description, exc, exc_info=True) + slicer.util.errorDisplay( + f"Error while {action_description}.\n\n{_format_lifu_error(exc)}", + windowTitle="LIFU Device Error", + ) # # OpenLIFUSonicationControl @@ -75,6 +124,8 @@ class SolutionOnHardwareState(Enum): SUCCESSFUL_SEND=0 FAILED_SEND=1 NOT_SENT=2 + SENDING=3 + RUN_FAILED=4 # # OpenLIFUSonicationControlParameterNode @@ -449,7 +500,11 @@ def onRunCompleted(self, new_sonication_run_complete_state: bool): returncode, run_parameters = runCompleteDialog.customexec_() if returncode: self.logic.create_openlifu_run(run_parameters) - self.logic.stop() + LIFUError = _lifu_exceptions().LIFUError + try: + self.logic.stop() + except LIFUError as e: + _display_lifu_error(e, "stopping sonication") self.updateAllButtonsEnabled() @display_errors @@ -496,22 +551,38 @@ def onReinitializeLIFUInterfacePushButtonClicked(self, checked=False): def onSendSonicationSolutionToDevicePushButtonClicked(self, checked=False): logging.debug("onSendSonicationSolutionToDevicePushButtonClicked() called") + LIFUError = _lifu_exceptions().LIFUError + # Reflect the in-progress state immediately and flush the event loop so + # the user gets visual feedback while the (synchronous) device call runs. + self.updateWidgetSolutionOnHardwareState(SolutionOnHardwareState.SENDING) + slicer.app.processEvents() + + success = False + lifu_error_detail: str | None = None try: - self.logic.cur_lifu_interface.set_solution(get_openlifu_data_parameter_node().loaded_solution.solution.solution) - if self.logic.cur_lifu_interface.get_status() != openlifu_lz().io.LIFUInterfaceStatus.STATUS_READY: + self.logic.cur_lifu_interface.set_solution(get_openlifu_data_parameter_node().loaded_solution.solution.solution.to_dict()) + if self.logic.cur_lifu_interface.get_status() != openlifu_sdk_lz().LIFUInterfaceStatus.STATUS_READY: raise RuntimeError("Interface not ready") self.logic.cur_solution_on_hardware = get_openlifu_data_parameter_node().loaded_solution.solution.solution logging.debug("Solution successfully sent to device") - self.updateWidgetSolutionOnHardwareState(SolutionOnHardwareState.SUCCESSFUL_SEND) - - except Exception as e: - logging.error("Exception thrown: %s", e) - import traceback - traceback.print_exc() - logging.debug(f" Failed to send solution to device: {e}") - self.updateWidgetSolutionOnHardwareState(SolutionOnHardwareState.FAILED_SEND, self.logic.cur_lifu_interface.get_status()) - - self.updateWorkflowControls() + success = True + except LIFUError as e: + # The openlifu-sdk raised a typed device-communication error. + # Show a descriptive popup including the exception type and LIFU error code. + lifu_error_detail = _format_lifu_error(e) + _display_lifu_error(e, "sending the sonication solution to the device") + finally: + # Any other (non-LIFUError) exception will propagate out via @display_errors, + # but we still want the UI state to reflect the failure on its way out. + if success: + self.updateWidgetSolutionOnHardwareState(SolutionOnHardwareState.SUCCESSFUL_SEND) + else: + self.updateWidgetSolutionOnHardwareState( + SolutionOnHardwareState.FAILED_SEND, + self.logic.cur_lifu_interface.get_status(), + detail=lifu_error_detail, + ) + self.updateWorkflowControls() def onManuallyGetDeviceStatusPushButtonClicked(self, checked=False): slicer.util.infoDisplay(text=f"{self.logic.cur_lifu_interface.get_status().name}", windowTitle="Device Status") @@ -530,12 +601,37 @@ def onRunClicked(self): raise RuntimeError("Invalid solution; not running sonication.") self.ui.runProgressBar.value = 0 - self.logic.run() + # Give the user immediate visual feedback while the (potentially long) + # synchronous start_sonication() call runs. This is especially important + # because the HV settle wait can take a few seconds, and a faulty + # console may take even longer before raising LIFUHVSettleError. + self.ui.runHardwareStatusLabel.setProperty("text", "⏳ Starting sonication...") + slicer.app.processEvents() + + LIFUError = _lifu_exceptions().LIFUError + try: + self.logic.run() + except LIFUError as e: + # The openlifu-sdk raised a typed device error (e.g. LIFUHVSettleError + # when the HV rail fails to settle on a faulty console). Surface it + # with the exception type and LIFU error code instead of a generic + # uncaught error popup, and reflect the failure in the solution + # state label so the green "Solution sent" message is replaced. + _display_lifu_error(e, "starting sonication") + self.ui.runHardwareStatusLabel.setProperty("text", "Run not in progress.") + self.updateWidgetSolutionOnHardwareState( + SolutionOnHardwareState.RUN_FAILED, + detail=_format_lifu_error(e), + ) self.updateWorkflowControls() def onAbortClicked(self): logging.debug("onAbortClicked() called") - self.logic.abort() + LIFUError = _lifu_exceptions().LIFUError + try: + self.logic.abort() + except LIFUError as e: + _display_lifu_error(e, "aborting sonication") runCompleteDialog = OnRunCompletedDialog(False) returncode, run_parameters = runCompleteDialog.customexec_() if returncode: @@ -568,29 +664,31 @@ def updateRunHardwareStatusLabel(self, new_run_hardware_status_value=None): def updateVersionLabels(self): """Populate SDK / console / TX firmware version labels when both devices are connected.""" if self._cur_device_connected_state == DeviceConnectedState.CONNECTED: + import importlib.metadata + LIFUError = _lifu_exceptions().LIFUError try: - sdk_ver = openlifu_lz().io.LIFUInterface.get_sdk_version() - except Exception as e: + sdk_ver = openlifu_sdk_lz().LIFUInterface.get_sdk_version() + except importlib.metadata.PackageNotFoundError as e: logging.warning("Could not read SDK version: %s", e) sdk_ver = "unknown" self.ui.sdkVersionLabel.setText(f"SDK: {sdk_ver or 'unknown'}") - + try: con_ver = self.logic.cur_lifu_interface.hvcontroller.get_version() - except Exception as e: + except LIFUError as e: logging.warning("Could not read console firmware version: %s", e) con_ver = "unknown" self.ui.consoleVersionLabel.setText(f"Console FW: {con_ver}") - + try: module_count = self.logic.cur_lifu_interface.txdevice.get_module_count() - except Exception as e: + except LIFUError as e: module_count = 0 logging.warning("Could not read TX module count: %s", e) - + modules_info = [] display_text = "" - + try: for module_idx in range(module_count): tx_ver = self.logic.cur_lifu_interface.txdevice.get_version(module=module_idx) @@ -603,7 +701,7 @@ def updateVersionLabels(self): f"TX {m['Module']} FW: v{m['FW']}" for m in modules_info ) if modules_info else "TX FW: unknown" - except Exception as e: + except LIFUError as e: logging.warning("Could not read TX firmware version: %s", e) display_text = "TX FW: unknown" self.ui.txVersionLabel.setText(display_text) @@ -626,26 +724,52 @@ def updateDeviceConnectedState(self, connected_state: DeviceConnectedState): self.ui.connectedStateLabel.setProperty("text", "🔴 LIFU Device (not connected)") self.updateAllButtonsEnabled() - def updateWidgetSolutionOnHardwareState(self, solution_state: SolutionOnHardwareState, hardware_state: "openlifu.io.LIFUInterfaceStatus | None" = None): + def updateWidgetSolutionOnHardwareState( + self, + solution_state: SolutionOnHardwareState, + hardware_state: "openlifu_sdk.LIFUInterfaceStatus | None" = None, + detail: str | None = None, + ): + """Update the solution-on-hardware status label. + + Args: + solution_state: One of the :class:`SolutionOnHardwareState` values. + hardware_state: Optional LIFUInterfaceStatus to include on FAILED_SEND. + detail: Optional extra text to append (e.g. the formatted message of + a :class:`LIFUError` for ``FAILED_SEND``/``RUN_FAILED`` states). + """ self._cur_solution_on_hardware_state = solution_state if solution_state == SolutionOnHardwareState.SUCCESSFUL_SEND: self.ui.solutionStateLabel.setProperty("text", "Solution sent to device.") self.ui.solutionStateLabel.setProperty("styleSheet", "color: green; border: 1px solid green; padding: 5px;") - self.updateRunEnabled() + elif solution_state == SolutionOnHardwareState.SENDING: + text = "⏳ Sending solution to device..." + self.ui.solutionStateLabel.setProperty("text", text) + # Amber/orange indicates an in-progress action. + self.ui.solutionStateLabel.setProperty( + "styleSheet", "color: #b36b00; border: 1px solid #b36b00; padding: 5px;" + ) elif solution_state == SolutionOnHardwareState.FAILED_SEND: # If we have information from the hardware, display that too. if hardware_state is not None: text = f"Send to device failed! (Hardware status: {hardware_state.name})" else: text = "Send to device failed!" + if detail: + text = f"{text}\n{detail}" self.ui.solutionStateLabel.setProperty("text", text) self.ui.solutionStateLabel.setProperty("styleSheet", "color: red; border: 1px solid red; padding: 5px;") - self.updateRunEnabled() + elif solution_state == SolutionOnHardwareState.RUN_FAILED: + text = "Run failed; re-send the solution to retry." + if detail: + text = f"{text}\n{detail}" + self.ui.solutionStateLabel.setProperty("text", text) + self.ui.solutionStateLabel.setProperty("styleSheet", "color: red; border: 1px solid red; padding: 5px;") elif solution_state == SolutionOnHardwareState.NOT_SENT: - self.ui.solutionStateLabel.setProperty("text", "") + self.ui.solutionStateLabel.setProperty("text", "") self.ui.solutionStateLabel.setProperty("styleSheet", "border: none;") - self.updateRunEnabled() + self.updateRunEnabled() def updateWorkflowControls(self): session = get_openlifu_data_parameter_node().loaded_session @@ -659,15 +783,17 @@ def updateWorkflowControls(self): # OpenLIFUSonicationControlLogic # -class LIFUQtSignals(qt.QObject): - runProgressUpdated = qt.Signal(float) # Expecting pulse_train_percent as float +class _LIFUBridge(qt.QObject): + """Thread-safe bridge from OWSignal to Qt, plus UI notification signals.""" + # Input bridge signals (OWSignal from hvcontroller/txdevice connects to these) + signal_connected = qt.Signal(str, str) # (descriptor, port) + signal_disconnected = qt.Signal(str, str) # (descriptor, port) + signal_data_received = qt.Signal(str, str) # (descriptor, data) + signal_error = qt.Signal(str, int, str) # (descriptor, code, message) + + # Output UI signals (Widget connects to these) + runProgressUpdated = qt.Signal(float) # Expecting pulse_train_percent as float finishScanning = qt.Signal(bool) # Signal to indicate that scanning is finished - deviceConnected = qt.Signal() # Emitted from monitor thread; Qt queues to main thread - deviceDisconnected = qt.Signal() # Emitted from monitor thread; Qt queues to main thread - dataReceived = qt.Signal(str, str) # (descriptor, message) - - def __init__(self, parent=None): - super().__init__(parent) class OpenLIFUSonicationControlLogic(ScriptedLoadableModuleLogic): @@ -680,12 +806,17 @@ def _pumpMonitoringLoop(self): def _run_monitor_loop(self): """Runs the asyncio event loop to monitor USB device status.""" asyncio.set_event_loop(self._monitor_loop) + # This runs on a background daemon thread, so a broad except is used here + # deliberately: an unhandled exception here would otherwise silently kill + # the monitor thread. LIFU-specific errors and asyncio/OS errors are the + # expected failure modes; anything else also gets logged. + LIFUError = _lifu_exceptions().LIFUError try: self._monitor_loop.run_until_complete( self.cur_lifu_interface.start_monitoring(interval=1) ) self._monitor_loop.run_forever() - except Exception as e: + except (LIFUError, OSError, RuntimeError) as e: logging.error(f"[LIFU] Monitor loop error: {e}") def __init__(self) -> None: @@ -728,21 +859,11 @@ def __init__(self) -> None: """List of functions to call when the LIFU interface receives data.""" # ---- LIFU Interface Connection ---- - - self.qt_signals = LIFUQtSignals() - - # These connections cross the monitor-thread → main-thread boundary. - # Qt auto-detects the thread mismatch and queues the calls safely. - self.qt_signals.deviceConnected.connect(self._dispatch_device_connected) - self.qt_signals.deviceDisconnected.connect(self._dispatch_device_disconnected) - self.qt_signals.dataReceived.connect(self._dispatch_data_received) - - self.cur_lifu_interface = openlifu_lz().io.LIFUInterface(run_async=True, TX_test_mode=False, HV_test_mode=False) + self._create_lifu_interface_bridge() + self.cur_lifu_interface = openlifu_sdk_lz().LIFUInterface(run_async=True, TX_test_mode=False, HV_test_mode=False) # Connect signals before starting the monitor thread to avoid missing early events - self.cur_lifu_interface.signal_connect.connect(self.on_lifu_device_connected) - self.cur_lifu_interface.signal_disconnect.connect(self.on_lifu_device_disconnected) - self.cur_lifu_interface.signal_data_received.connect(self.on_lifu_data_received) + self._connect_owsignals() # Set up asyncio event loop and monitoring thread self._monitor_loop = asyncio.new_event_loop() @@ -766,6 +887,21 @@ def __init__(self) -> None: logging.getLogger("LIFUHVController").setLevel(logging.ERROR) logging.getLogger("LIFUTXDevice").setLevel(logging.ERROR) + def _create_lifu_interface_bridge(self): + """Create the bridge QObject and wire its output signals to handlers. Call once from __init__.""" + self.qt_signals = _LIFUBridge() + self.qt_signals.signal_connected.connect(self.on_lifu_device_connected) + self.qt_signals.signal_disconnected.connect(self.on_lifu_device_disconnected) + self.qt_signals.signal_data_received.connect(self.on_lifu_data_received) + + def _connect_owsignals(self): + """Wire the current interface's OWSignals into the bridge. Call from __init__ and reinitialize_lifu_interface.""" + for device in (self.cur_lifu_interface.hvcontroller, self.cur_lifu_interface.txdevice): + device.signal_connected.connect(self.qt_signals.signal_connected.emit) + device.signal_disconnected.connect(self.qt_signals.signal_disconnected.emit) + device.signal_data_received.connect(self.qt_signals.signal_data_received.emit) + device.signal_error.connect(self.qt_signals.signal_error.emit) + def stop_monitoring(self): if self.cur_lifu_interface: self.cur_lifu_interface.stop_monitoring() @@ -781,13 +917,17 @@ def stop_monitoring(self): if hasattr(self, "_monitor_loop") and self._monitor_loop: try: self._monitor_loop.close() - except Exception as e: + except RuntimeError as e: + # asyncio raises RuntimeError if the loop is still running when + # close() is called; the call_soon_threadsafe(stop) above is + # best-effort and may race, so this is the realistic failure. logging.warning("Error closing monitor loop: %s", e) def reinitialize_lifu_interface(self, test_mode: bool = False): """Cleanly shut down and reinitialize the LIFUInterface.""" logging.debug("reinitialize_lifu_interface() called with test_mode=%s", test_mode) + LIFUError = _lifu_exceptions().LIFUError try: self.monitoring_timer.stop() self.stop_monitoring() @@ -795,20 +935,18 @@ def reinitialize_lifu_interface(self, test_mode: bool = False): if self.cur_lifu_interface: self.cur_lifu_interface.close() - except Exception as e: + except (LIFUError, RuntimeError, OSError) as e: logging.warning("[LIFU] Error during interface cleanup: %s", e) # Recreate interface - self.cur_lifu_interface = openlifu_lz().io.LIFUInterface( + self.cur_lifu_interface = openlifu_sdk_lz().LIFUInterface( run_async=True, TX_test_mode=test_mode, HV_test_mode=test_mode ) - # Reconnect signals - self.cur_lifu_interface.signal_connect.connect(self.on_lifu_device_connected) - self.cur_lifu_interface.signal_disconnect.connect(self.on_lifu_device_disconnected) - self.cur_lifu_interface.signal_data_received.connect(self.on_lifu_data_received) + # Connect the bridge to signals from the new interface + self._connect_owsignals() # Create fresh loop + thread self._monitor_loop = asyncio.new_event_loop() @@ -986,7 +1124,7 @@ def parse_status_string(self, status_str): return result - except Exception as e: + except (ValueError, AttributeError, TypeError, ZeroDivisionError) as e: logging.error(f"Failed to parse status string: {e}") return result @@ -1004,11 +1142,11 @@ def _dispatch_data_received(self, descriptor, message): def on_lifu_device_connected(self, descriptor, port): logging.info(f"🔌 CONNECTED: {descriptor} on port {port}") - self.qt_signals.deviceConnected.emit() + self._dispatch_device_connected() def on_lifu_device_disconnected(self, descriptor, port): logging.info(f"❌ DISCONNECTED: {descriptor} from port {port}") - self.qt_signals.deviceDisconnected.emit() + self._dispatch_device_disconnected() def on_lifu_data_received(self, descriptor, message): """Called when the LIFUInterface receives data from the hardware. @@ -1017,25 +1155,26 @@ def on_lifu_data_received(self, descriptor, message): logging.info(f"📦 DATA [{descriptor}]: {message}") if descriptor == "TX": + LIFUError = _lifu_exceptions().LIFUError try: parsed = self.parse_status_string(message) progress = parsed["pulse_train_percent"] - self.qt_signals.runProgressUpdated.emit(progress) + self.qt_signals.runProgressUpdated.emit(progress) if parsed["status"] in {"RUNNING", "STOPPED"}: # Update internal trigger state and notify QML if parsed["status"] == "STOPPED": logging.info("Trigger is stopped.") - self.cur_lifu_interface.set_status(openlifu_lz().io.LIFUInterfaceStatus.STATUS_FINISHED) - self.qt_signals.finishScanning.emit(True) # Signal that scanning is finished + self.cur_lifu_interface.set_status(openlifu_sdk_lz().LIFUInterfaceStatus.STATUS_FINISHED) + self.qt_signals.finishScanning.emit(True) # Signal that scanning is finished else: #update status - self.cur_lifu_interface.set_status(openlifu_lz().io.LIFUInterfaceStatus.STATUS_RUNNING) - - except Exception as e: + self.cur_lifu_interface.set_status(openlifu_sdk_lz().LIFUInterfaceStatus.STATUS_RUNNING) + + except (LIFUError, KeyError, TypeError) as e: logging.error(f"Failed to parse and update trigger state: {e}") - - self.qt_signals.dataReceived.emit(descriptor, message) + + self._dispatch_data_received(descriptor, message) def run(self): " Returns True when the sonication control algorithm is done" @@ -1050,8 +1189,18 @@ def run(self): # ---- Start the run ---- self.running = True - # TODO START SONICATION on HARDWARE - self.cur_lifu_interface.start_sonication() + started = False + try: + self.cur_lifu_interface.start_sonication() + started = True + finally: + # If the hardware refused to start (e.g. LIFUHVSettleError when the + # HV rail does not settle in time), roll the running state back so + # that the UI returns to a consistent "not running" state. The + # exception itself is allowed to propagate to the caller, which is + # responsible for surfacing it (typically via _display_lifu_error). + if not started: + self.running = False def stop(self): logging.debug("Logic.stop() called") diff --git a/OpenLIFUSonicationPlanner/OpenLIFUSonicationPlanner.py b/OpenLIFUSonicationPlanner/OpenLIFUSonicationPlanner.py index a8afbf19..7397aaa5 100644 --- a/OpenLIFUSonicationPlanner/OpenLIFUSonicationPlanner.py +++ b/OpenLIFUSonicationPlanner/OpenLIFUSonicationPlanner.py @@ -46,6 +46,7 @@ if TYPE_CHECKING: import openlifu import openlifu.plan + import openlifu.xdc import xarray from OpenLIFUData.OpenLIFUData import OpenLIFUDataLogic @@ -673,11 +674,11 @@ def format_value(val): # def compute_solution_openlifu( - protocol: "openlifu.Protocol", + protocol: "openlifu.plan.Protocol", transducer:SlicerOpenLIFUTransducer, target_node:vtkMRMLMarkupsFiducialNode, volume_node:vtkMRMLScalarVolumeNode - ) -> "Tuple[openlifu.Solution, xarray.DataArray, xarray.DataArray, openlifu.plan.SolutionAnalysis]": + ) -> "Tuple[openlifu.plan.Solution, xarray.DataArray, xarray.DataArray, openlifu.plan.SolutionAnalysis]": """Run openlifu beamforming and k-wave simulation Returns: