From 55a8e4d4e5d04701fcaa2196f260fbceddefc727 Mon Sep 17 00:00:00 2001 From: chezhia Date: Tue, 14 Apr 2026 14:56:25 -0400 Subject: [PATCH 1/3] v1 --- .../SlicerNNInteractive.py | 373 +++++++++++++++--- .../SlicerNNInteractiveSegmentationTest.py | 48 +++ 2 files changed, 370 insertions(+), 51 deletions(-) diff --git a/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py b/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py index ed57771..f6480b8 100644 --- a/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py +++ b/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py @@ -9,6 +9,7 @@ import numpy as np from pathlib import Path +from collections import deque import slicer import qt @@ -182,6 +183,11 @@ def setup(self): _ = self.get_current_segment_id() self.previous_states = {} + self.max_history_depth = 2 + self._is_replaying_history = False + self._active_request_count = 0 + self._history_state = None + self.reset_prompt_history() def init_ui_functionality(self): """ @@ -235,6 +241,8 @@ def setup_shortcuts(self): "r": self.clear_current_segment, "Shift+L": self.submit_lasso_if_present, "t": self.toggle_prompt_type, # Add 'T' shortcut to toggle between positive/negative + "Ctrl+Z": self.undo_prompt, + "Ctrl+Y": self.redo_prompt, } self.shortcut_items = {} @@ -568,6 +576,163 @@ def remove_all_but_last_prompt(self): for i in range(n): node.RemoveNthControlPoint(0) + def make_history_entry(self, prompt_type, positive_click, payload, segmentation_mask): + return { + "type": prompt_type, + "positive": bool(positive_click), + "payload": payload, + "mask": np.array(segmentation_mask, dtype=np.uint8, copy=True), + } + + def reset_prompt_history(self, baseline_mask=None): + if baseline_mask is None: + image_data = self.get_image_data() + if image_data is not None: + baseline_mask = np.zeros(image_data.shape, dtype=np.uint8) + else: + baseline_mask = np.zeros((0,), dtype=np.uint8) + else: + baseline_mask = np.array(baseline_mask, dtype=np.uint8, copy=True) + + self._history_state = { + "base_mask": baseline_mask, + "past": deque(), + "future": deque(), + } + + def history_can_undo(self): + return bool(self._history_state and self._history_state["past"]) + + def history_can_redo(self): + return bool(self._history_state and self._history_state["future"]) + + def record_history_entry(self, prompt_type, positive_click, payload, segmentation_mask): + if self._is_replaying_history: + return + + if self._history_state is None: + self.reset_prompt_history() + + self._history_state["future"].clear() + if len(self._history_state["past"]) >= self.max_history_depth: + dropped_entry = self._history_state["past"].popleft() + self._history_state["base_mask"] = np.array( + dropped_entry["mask"], dtype=np.uint8, copy=True + ) + + entry = self.make_history_entry( + prompt_type=prompt_type, + positive_click=positive_click, + payload=payload, + segmentation_mask=segmentation_mask, + ) + self._history_state["past"].append(entry) + + def set_history_base_mask(self, baseline_mask): + baseline_mask = np.array(baseline_mask, dtype=np.uint8, copy=True) + if self._history_state is None: + self.reset_prompt_history(baseline_mask=baseline_mask) + return + + self._history_state["base_mask"] = baseline_mask + self._history_state["past"].clear() + self._history_state["future"].clear() + + def build_history_payload(self, **kwargs): + payload = {} + for key, value in kwargs.items(): + if isinstance(value, np.ndarray): + payload[key] = np.array(value, copy=True) + elif isinstance(value, list): + payload[key] = list(value) + else: + payload[key] = value + return payload + + def cancel_pending_lasso(self): + lasso_node = self.prompt_types["lasso"]["node"] + if lasso_node is None: + return False + + if lasso_node.GetNumberOfControlPoints() < 1: + return False + + self.on_lasso_cancel_clicked() + self.ui.pbInteractionLasso.setChecked(False) + return True + + def apply_history_state(self, target_entries): + if self._history_state is None: + return + + base_mask = np.array(self._history_state["base_mask"], dtype=np.uint8, copy=True) + self.show_segmentation(base_mask) + upload_result = self.upload_segment_to_server() + if upload_result is None: + raise RuntimeError("Failed to synchronize base segment during history replay.") + + if not target_entries: + return + + self._is_replaying_history = True + try: + for entry in target_entries: + self.replay_history_entry(entry) + finally: + self._is_replaying_history = False + + self.show_segmentation(target_entries[-1]["mask"]) + + def replay_history_entry(self, entry): + prompt_type = entry["type"] + payload = entry["payload"] + positive_click = entry["positive"] + + if prompt_type == "point": + self.point_prompt( + xyz=payload["xyz"], + positive_click=positive_click, + sync=False, + record_history=False, + ) + elif prompt_type == "bbox": + self.bbox_prompt( + outer_point_one=payload["outer_point_one"], + outer_point_two=payload["outer_point_two"], + positive_click=positive_click, + sync=False, + record_history=False, + ) + elif prompt_type in ["lasso", "scribble"]: + self.lasso_or_scribble_prompt( + mask=payload["mask"], + positive_click=positive_click, + tp=prompt_type, + sync=False, + record_history=False, + ) + else: + raise ValueError(f"Unknown prompt type '{prompt_type}' in history replay.") + + def undo_prompt(self): + if self.cancel_pending_lasso(): + return + + if self._active_request_count > 0 or not self.history_can_undo(): + return + + entry = self._history_state["past"].pop() + self._history_state["future"].appendleft(entry) + self.apply_history_state(list(self._history_state["past"])) + + def redo_prompt(self): + if self._active_request_count > 0 or not self.history_can_redo(): + return + + entry = self._history_state["future"].popleft() + self._history_state["past"].append(entry) + self.apply_history_state(list(self._history_state["past"])) + def on_place_button_clicked(self, checked, prompt_name): self.setup_prompts(skip_if_exists=True) @@ -643,11 +808,32 @@ def on_point_placed(self, caller, event): if volume_node: self.point_prompt(xyz=xyz, positive_click=self.is_positive) - @ensure_synched - def point_prompt(self, xyz=None, positive_click=False): + def point_prompt(self, xyz=None, positive_click=False, sync=True, record_history=True): """ Uploads point prompt to the server. """ + if sync: + return self._point_prompt_synched( + xyz=xyz, + positive_click=positive_click, + record_history=record_history, + ) + + return self._point_prompt_impl( + xyz=xyz, + positive_click=positive_click, + record_history=record_history, + ) + + @ensure_synched + def _point_prompt_synched(self, xyz=None, positive_click=False, record_history=True): + return self._point_prompt_impl( + xyz=xyz, + positive_click=positive_click, + record_history=record_history, + ) + + def _point_prompt_impl(self, xyz=None, positive_click=False, record_history=True): url = f"{self.server}/add_point_interaction" seg_response = self.request_to_server( @@ -662,6 +848,15 @@ def point_prompt(self, xyz=None, positive_click=False): debug_print(f"{positive_click} point prompt triggered! {xyz}") self.show_segmentation(unpacked_segmentation) + if record_history: + self.record_history_entry( + prompt_type="point", + positive_click=positive_click, + payload=self.build_history_payload(xyz=xyz), + segmentation_mask=unpacked_segmentation, + ) + + return unpacked_segmentation # # -- Bounding Box @@ -709,11 +904,35 @@ def _next(): self.prev_caller = caller - @ensure_synched - def bbox_prompt(self, outer_point_one, outer_point_two, positive_click=False): + def bbox_prompt(self, outer_point_one, outer_point_two, positive_click=False, sync=True, record_history=True): """ Uploads BBox prompt to the server. """ + if sync: + return self._bbox_prompt_synched( + outer_point_one=outer_point_one, + outer_point_two=outer_point_two, + positive_click=positive_click, + record_history=record_history, + ) + + return self._bbox_prompt_impl( + outer_point_one=outer_point_one, + outer_point_two=outer_point_two, + positive_click=positive_click, + record_history=record_history, + ) + + @ensure_synched + def _bbox_prompt_synched(self, outer_point_one, outer_point_two, positive_click=False, record_history=True): + return self._bbox_prompt_impl( + outer_point_one=outer_point_one, + outer_point_two=outer_point_two, + positive_click=positive_click, + record_history=record_history, + ) + + def _bbox_prompt_impl(self, outer_point_one, outer_point_two, positive_click=False, record_history=True): url = f"{self.server}/add_bbox_interaction" seg_response = self.request_to_server( @@ -729,6 +948,18 @@ def bbox_prompt(self, outer_point_one, outer_point_two, positive_click=False): seg_response.content, decompress=False ) self.show_segmentation(unpacked_segmentation) + if record_history: + self.record_history_entry( + prompt_type="bbox", + positive_click=positive_click, + payload=self.build_history_payload( + outer_point_one=outer_point_one, + outer_point_two=outer_point_two, + ), + segmentation_mask=unpacked_segmentation, + ) + + return unpacked_segmentation # # -- Lasso @@ -832,11 +1063,35 @@ def on_scribble_clicked(self, checked=False): # # -- Lasso/scribble # - @ensure_synched - def lasso_or_scribble_prompt(self, mask, positive_click=False, tp="lasso"): + def lasso_or_scribble_prompt(self, mask, positive_click=False, tp="lasso", sync=True, record_history=True): """ Uploads lasso or scribble prompt to the server. """ + if sync: + return self._lasso_or_scribble_prompt_synched( + mask=mask, + positive_click=positive_click, + tp=tp, + record_history=record_history, + ) + + return self._lasso_or_scribble_prompt_impl( + mask=mask, + positive_click=positive_click, + tp=tp, + record_history=record_history, + ) + + @ensure_synched + def _lasso_or_scribble_prompt_synched(self, mask, positive_click=False, tp="lasso", record_history=True): + return self._lasso_or_scribble_prompt_impl( + mask=mask, + positive_click=positive_click, + tp=tp, + record_history=record_history, + ) + + def _lasso_or_scribble_prompt_impl(self, mask, positive_click=False, tp="lasso", record_history=True): if np.sum(mask) == 0: return @@ -869,6 +1124,14 @@ def lasso_or_scribble_prompt(self, mask, positive_click=False, tp="lasso"): seg_response.content, decompress=False ) self.show_segmentation(unpacked_segmentation) + if record_history: + self.record_history_entry( + prompt_type=tp, + positive_click=positive_click, + payload=self.build_history_payload(mask=np.array(mask, dtype=np.uint8, copy=True)), + segmentation_mask=unpacked_segmentation, + ) + return unpacked_segmentation else: debug_print( f"lasso_or_scribble_prompt upload failed with status code: {seg_response.status_code}" @@ -952,6 +1215,7 @@ def make_new_segment(self): # Make sure the right node is selected self.ui.editor_widget.setSegmentationNode(segmentation_node) self.segment_editor_node.SetSelectedSegmentID(new_segment_id) + self.reset_prompt_history() return segmentation_node, new_segment_id @@ -968,6 +1232,7 @@ def clear_current_segment(self): if selected_segment_id: debug_print(f"Clearing segment: {selected_segment_id}") + self.reset_prompt_history() self.show_segmentation( np.zeros(self.get_image_data().shape, dtype=np.uint8) ) @@ -981,6 +1246,7 @@ def show_segmentation(self, segmentation_mask): Updates the currently selected segment with the given binary mask array. """ t0 = time.time() + segmentation_mask = np.array(segmentation_mask, dtype=np.uint8, copy=True) self.previous_states["segment_data"] = segmentation_mask segmentationNode, selectedSegmentID = ( @@ -1013,8 +1279,6 @@ def show_segmentation(self, segmentation_mask): # (see https://github.com/coendevente/SlicerNNInteractive/issues/38) segmentationNode.GetSegmentation().CollapseBinaryLabelmaps() - del segmentation_mask - debug_print(f"show_segmentation took {time.time() - t0}") def get_segmentation_node(self): @@ -1099,6 +1363,9 @@ def selected_segment_changed(self): debug_print(f"selected_segment_changed: {selected_segment_changed}") + if selected_segment_changed and not self._is_replaying_history: + self.set_history_base_mask(segment_data.astype(np.uint8)) + return selected_segment_changed ############################################################################### @@ -1174,50 +1441,54 @@ def request_to_server(self, *args, **kwargs): Wraps requests.post in a try/except and shows error in pop up windows if necessary. """ - with slicer.util.tryWithErrorDisplay(_("Segmentation failed."), waitCursor=True): - - error_message = None - try: - response = requests.post(*args, **kwargs) - debug_print('response:', response) - except requests.exceptions.MissingSchema as e: - response = None - if self.server == "": - raise RuntimeError("It seems you have not set the server URL yet. You can configure it in the 'Configuration' tab.") - else: - raise RuntimeError(f"Server URL '{self.server}' is unreachable. You can edit the URL in the 'Configuration' tab.") - except requests.exceptions.ConnectionError as e: - response = None - raise RuntimeError(f"Failed to connect to server '{self.server}'. Please make sure the server is running and check the server URL in the 'Configuration' tab.") - except requests.exceptions.InvalidSchema as e: - append_text_to_error_message = "" - if not args[0].startswith("http://"): - append_text_to_error_message = "\n\nHint: Perhaps your Server URL in the 'Configuration' tab should start with 'http://'. For example, if your server runs on localhost and port 1527, 'localhost:1527' would not work as a Server URL, while 'http://localhost:1527' would." - raise RuntimeError(f'{e}{append_text_to_error_message}') - - if response.status_code != 200: - status_code = response.status_code - response = None - raise RuntimeError(f"Something has gone wrong with your request (Status code {status_code}).") - - t0 = time.time() - # Try to parse JSON and check for a specific error. - content_type = response.headers.get("Content-Type", "") - if "application/json" in content_type: - resp_json = response.json() - if resp_json.get("status") == "error": - if "No image uploaded" in resp_json.get("message", ""): - debug_print("No image has been uploaded to the server. Please upload an image first.") - self.upload_image_to_server() - self.upload_segment_to_server() - return self.request_to_server(*args, **kwargs) + self._active_request_count += 1 + try: + with slicer.util.tryWithErrorDisplay(_("Segmentation failed."), waitCursor=True): + + error_message = None + try: + response = requests.post(*args, **kwargs) + debug_print('response:', response) + except requests.exceptions.MissingSchema as e: + response = None + if self.server == "": + raise RuntimeError("It seems you have not set the server URL yet. You can configure it in the 'Configuration' tab.") else: - response = None - raise RuntimeError(f"Server error: {resp_json.get('message', 'Unknown error')}") - - debug_print('1157 took', time.time() - t0) - - return response + raise RuntimeError(f"Server URL '{self.server}' is unreachable. You can edit the URL in the 'Configuration' tab.") + except requests.exceptions.ConnectionError as e: + response = None + raise RuntimeError(f"Failed to connect to server '{self.server}'. Please make sure the server is running and check the server URL in the 'Configuration' tab.") + except requests.exceptions.InvalidSchema as e: + append_text_to_error_message = "" + if not args[0].startswith("http://"): + append_text_to_error_message = "\n\nHint: Perhaps your Server URL in the 'Configuration' tab should start with 'http://'. For example, if your server runs on localhost and port 1527, 'localhost:1527' would not work as a Server URL, while 'http://localhost:1527' would." + raise RuntimeError(f'{e}{append_text_to_error_message}') + + if response.status_code != 200: + status_code = response.status_code + response = None + raise RuntimeError(f"Something has gone wrong with your request (Status code {status_code}).") + + t0 = time.time() + # Try to parse JSON and check for a specific error. + content_type = response.headers.get("Content-Type", "") + if "application/json" in content_type: + resp_json = response.json() + if resp_json.get("status") == "error": + if "No image uploaded" in resp_json.get("message", ""): + debug_print("No image has been uploaded to the server. Please upload an image first.") + self.upload_image_to_server() + self.upload_segment_to_server() + return self.request_to_server(*args, **kwargs) + else: + response = None + raise RuntimeError(f"Server error: {resp_json.get('message', 'Unknown error')}") + + debug_print('1157 took', time.time() - t0) + + return response + finally: + self._active_request_count -= 1 def upload_image_to_server(self): """ diff --git a/slicer_plugin/SlicerNNInteractive/Testing/Python/SlicerNNInteractiveSegmentationTest.py b/slicer_plugin/SlicerNNInteractive/Testing/Python/SlicerNNInteractiveSegmentationTest.py index fe50756..2b38723 100644 --- a/slicer_plugin/SlicerNNInteractive/Testing/Python/SlicerNNInteractiveSegmentationTest.py +++ b/slicer_plugin/SlicerNNInteractive/Testing/Python/SlicerNNInteractiveSegmentationTest.py @@ -184,6 +184,8 @@ def runTest(self): mask, prompt_name ) + + self._verify_bounded_undo_redo(widget) finally: self.tearDown() @@ -271,6 +273,52 @@ def clamp(pt): ) return labelmap.astype(np.uint8) + def _current_segment_mask(self, widget): + segmentation_node, segment_id = widget.get_selected_segmentation_node_and_segment_id() + labelmap = slicer.util.arrayFromSegmentBinaryLabelmap( + segmentation_node, segment_id, widget.get_volume_node() + ) + return labelmap.astype(np.uint8) + + def _verify_bounded_undo_redo(self, widget): + widget.clear_current_segment() + + prompt_sequence = [ + positive([141, 114, 85]), + positive([109, 114, 58]), + positive([177, 114, 38]), + ] + + masks = [] + for interaction in prompt_sequence: + masks.append( + self._trigger_point_prompt(widget, interaction["coords"], interaction["positive"]) + ) + + widget.undo_prompt() + slicer.app.processEvents() + self.assertTrue(np.array_equal(self._current_segment_mask(widget), masks[1])) + + widget.undo_prompt() + slicer.app.processEvents() + self.assertTrue(np.array_equal(self._current_segment_mask(widget), masks[0])) + + widget.undo_prompt() + slicer.app.processEvents() + self.assertTrue(np.array_equal(self._current_segment_mask(widget), masks[0])) + + widget.redo_prompt() + slicer.app.processEvents() + self.assertTrue(np.array_equal(self._current_segment_mask(widget), masks[1])) + + widget.redo_prompt() + slicer.app.processEvents() + self.assertTrue(np.array_equal(self._current_segment_mask(widget), masks[2])) + + widget.redo_prompt() + slicer.app.processEvents() + self.assertTrue(np.array_equal(self._current_segment_mask(widget), masks[2])) + def _trigger_scribble_prompt(self, widget, interaction): mask = self._build_scribble_mask(widget, interaction) self._save_scribble_mask(interaction["mask_name"], mask) From 2b3aab65cba2264cad2401a7c9048e3f73fa6642 Mon Sep 17 00:00:00 2001 From: Elan Somasundaram Date: Fri, 24 Apr 2026 10:53:30 -0400 Subject: [PATCH 2/3] ctrl+z with setting for history length in gui --- .../Resources/UI/SlicerNNInteractive.ui | 27 +++++++++ .../SlicerNNInteractive.py | 57 ++++++++++++++++--- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/slicer_plugin/SlicerNNInteractive/Resources/UI/SlicerNNInteractive.ui b/slicer_plugin/SlicerNNInteractive/Resources/UI/SlicerNNInteractive.ui index 3fc919e..eee6743 100644 --- a/slicer_plugin/SlicerNNInteractive/Resources/UI/SlicerNNInteractive.ui +++ b/slicer_plugin/SlicerNNInteractive/Resources/UI/SlicerNNInteractive.ui @@ -326,6 +326,33 @@ + + + + + + Undo history steps: + + + + + + + Maximum number of plugin prompt states kept for Ctrl+Z / Ctrl+Y. + + + 1 + + + 100 + + + 2 + + + + + diff --git a/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py b/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py index f6480b8..e6be87b 100644 --- a/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py +++ b/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py @@ -179,14 +179,15 @@ def setup(self): self.all_prompt_buttons = {} self.setup_prompts() - self.init_ui_functionality() - - _ = self.get_current_segment_id() - self.previous_states = {} self.max_history_depth = 2 self._is_replaying_history = False self._active_request_count = 0 self._history_state = None + + self.init_ui_functionality() + + _ = self.get_current_segment_id() + self.previous_states = {} self.reset_prompt_history() def init_ui_functionality(self): @@ -200,7 +201,15 @@ def init_ui_functionality(self): self.ui.Server.text = savedServer self.server = savedServer.rstrip("/") + settings = qt.QSettings() + saved_history_depth = settings.value( + "SlicerNNInteractive/maxHistoryDepth", self.max_history_depth + ) + self.set_max_history_depth(saved_history_depth, update_widget=False) + self.ui.sbHistoryDepth.value = self.max_history_depth + self.ui.Server.editingFinished.connect(self.update_server) + self.ui.sbHistoryDepth.valueChanged.connect(self.on_history_depth_changed) self.ui.pbTestServer.clicked.connect(self.test_server_connection) # Set initial prompt type @@ -638,6 +647,32 @@ def set_history_base_mask(self, baseline_mask): self._history_state["past"].clear() self._history_state["future"].clear() + def set_max_history_depth(self, max_history_depth, update_widget=True): + max_history_depth = max(1, int(max_history_depth)) + self.max_history_depth = max_history_depth + + if update_widget and hasattr(self, "ui") and hasattr(self.ui, "sbHistoryDepth"): + blocker = qt.QSignalBlocker(self.ui.sbHistoryDepth) + self.ui.sbHistoryDepth.value = self.max_history_depth + del blocker + + if self._history_state is None: + return + + while len(self._history_state["past"]) > self.max_history_depth: + dropped_entry = self._history_state["past"].popleft() + self._history_state["base_mask"] = np.array( + dropped_entry["mask"], dtype=np.uint8, copy=True + ) + + while len(self._history_state["future"]) > self.max_history_depth: + self._history_state["future"].pop() + + def on_history_depth_changed(self, value): + self.set_max_history_depth(value, update_widget=False) + settings = qt.QSettings() + settings.setValue("SlicerNNInteractive/maxHistoryDepth", self.max_history_depth) + def build_history_payload(self, **kwargs): payload = {} for key, value in kwargs.items(): @@ -711,6 +746,9 @@ def replay_history_entry(self, entry): sync=False, record_history=False, ) + elif prompt_type == "clear": + # "Clear" is a no-op during replay because show_segmentation is called on masks + pass else: raise ValueError(f"Unknown prompt type '{prompt_type}' in history replay.") @@ -1232,10 +1270,15 @@ def clear_current_segment(self): if selected_segment_id: debug_print(f"Clearing segment: {selected_segment_id}") - self.reset_prompt_history() - self.show_segmentation( - np.zeros(self.get_image_data().shape, dtype=np.uint8) + # Record the clear action in history before resetting prompt history or clearing + empty_mask = np.zeros(self.get_image_data().shape, dtype=np.uint8) + self.record_history_entry( + prompt_type="clear", + positive_click=True, + payload={}, + segmentation_mask=empty_mask ) + self.show_segmentation(empty_mask) self.setup_prompts() self.upload_segment_to_server() else: From 21969cd60618f1a27a300887fb378b73053f7e97 Mon Sep 17 00:00:00 2001 From: Elan Somasundaram Date: Fri, 24 Apr 2026 12:29:43 -0400 Subject: [PATCH 3/3] ctrl+z with setting for history length in gui --- CMakeLists.txt | 10 ++ slicer_plugin/CMakeLists.txt | 1 + .../SlicerNNInteractive.py | 39 ++++++- slicer_plugin/nninteractive/CMakeLists.txt | 63 +++++++++++ .../nninteractive/Logic/CMakeLists.txt | 26 +++++ .../Logic/vtkSlicernninteractiveLogic.cxx | 73 ++++++++++++ .../Logic/vtkSlicernninteractiveLogic.h | 59 ++++++++++ .../Resources/Icons/nninteractive.png | Bin 0 -> 20628 bytes .../UI/qSlicernninteractiveFooBarWidget.ui | 31 ++++++ .../UI/qSlicernninteractiveModuleWidget.ui | 66 +++++++++++ .../Resources/qSlicernninteractiveModule.qrc | 5 + .../nninteractive/Testing/CMakeLists.txt | 1 + .../nninteractive/Testing/Cxx/CMakeLists.txt | 17 +++ .../nninteractive/Widgets/CMakeLists.txt | 42 +++++++ .../qSlicernninteractiveFooBarWidget.cxx | 63 +++++++++++ .../qSlicernninteractiveFooBarWidget.h | 50 +++++++++ .../qSlicernninteractiveModule.cxx | 105 ++++++++++++++++++ .../qSlicernninteractiveModule.h | 68 ++++++++++++ .../qSlicernninteractiveModuleWidget.cxx | 57 ++++++++++ .../qSlicernninteractiveModuleWidget.h | 50 +++++++++ 20 files changed, 823 insertions(+), 3 deletions(-) create mode 100644 slicer_plugin/nninteractive/CMakeLists.txt create mode 100644 slicer_plugin/nninteractive/Logic/CMakeLists.txt create mode 100644 slicer_plugin/nninteractive/Logic/vtkSlicernninteractiveLogic.cxx create mode 100644 slicer_plugin/nninteractive/Logic/vtkSlicernninteractiveLogic.h create mode 100644 slicer_plugin/nninteractive/Resources/Icons/nninteractive.png create mode 100644 slicer_plugin/nninteractive/Resources/UI/qSlicernninteractiveFooBarWidget.ui create mode 100644 slicer_plugin/nninteractive/Resources/UI/qSlicernninteractiveModuleWidget.ui create mode 100644 slicer_plugin/nninteractive/Resources/qSlicernninteractiveModule.qrc create mode 100644 slicer_plugin/nninteractive/Testing/CMakeLists.txt create mode 100644 slicer_plugin/nninteractive/Testing/Cxx/CMakeLists.txt create mode 100644 slicer_plugin/nninteractive/Widgets/CMakeLists.txt create mode 100644 slicer_plugin/nninteractive/Widgets/qSlicernninteractiveFooBarWidget.cxx create mode 100644 slicer_plugin/nninteractive/Widgets/qSlicernninteractiveFooBarWidget.h create mode 100644 slicer_plugin/nninteractive/qSlicernninteractiveModule.cxx create mode 100644 slicer_plugin/nninteractive/qSlicernninteractiveModule.h create mode 100644 slicer_plugin/nninteractive/qSlicernninteractiveModuleWidget.cxx create mode 100644 slicer_plugin/nninteractive/qSlicernninteractiveModuleWidget.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 07b007a..db637b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,14 @@ cmake_minimum_required(VERSION 3.12.1) project(NNInteractive) + +# Mirror extension metadata here so Slicer's Extension Wizard can open either +# the repository root or the slicer_plugin directory as the extension source. +set(EXTENSION_HOMEPAGE "https://github.com/coendevente/SlicerNNInteractive") +set(EXTENSION_CONTRIBUTORS "Coen de Vente (University of Amsterdam)") +set(EXTENSION_DESCRIPTION "Deep learning-based framework for interactive segmentation of 3D images. The extension is available under an Apache-2.0 license, but the weights that are being downloaded when running the SlicerNNInteractive server are available under a Creative Commons Attribution Non Commercial Share Alike 4.0 license, as described in the original nnInteractive respository.") +set(EXTENSION_ICONURL "https://raw.githubusercontent.com/coendevente/SlicerNNInteractive/main/slicer_plugin/SlicerNNInteractive/Resources/Icons/SlicerNNInteractive.png") +set(EXTENSION_SCREENSHOTURLS "https://raw.githubusercontent.com/coendevente/SlicerNNInteractive/main/img/segmentation_result.jpg https://raw.githubusercontent.com/coendevente/SlicerNNInteractive/main/img/plugin_first_sight.png") +set(EXTENSION_DEPENDS "NA") + add_subdirectory(slicer_plugin) diff --git a/slicer_plugin/CMakeLists.txt b/slicer_plugin/CMakeLists.txt index ac5e943..e080fc8 100644 --- a/slicer_plugin/CMakeLists.txt +++ b/slicer_plugin/CMakeLists.txt @@ -19,6 +19,7 @@ include(${Slicer_USE_FILE}) #----------------------------------------------------------------------------- # Extension modules add_subdirectory(SlicerNNInteractive) +add_subdirectory(nninteractive) ## NEXT_MODULE if(BUILD_TESTING) diff --git a/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py b/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py index e6be87b..077104c 100644 --- a/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py +++ b/slicer_plugin/SlicerNNInteractive/SlicerNNInteractive.py @@ -1192,7 +1192,7 @@ def on_scribble_finished(self, caller, event): else: return - mask = slicer.util.arrayFromSegmentBinaryLabelmap( + mask = self.get_segment_mask_or_empty( self.scribble_segment_node, label_name, self.get_volume_node() ) @@ -1271,7 +1271,7 @@ def clear_current_segment(self): if selected_segment_id: debug_print(f"Clearing segment: {selected_segment_id}") # Record the clear action in history before resetting prompt history or clearing - empty_mask = np.zeros(self.get_image_data().shape, dtype=np.uint8) + empty_mask = self.get_empty_mask() self.record_history_entry( prompt_type="clear", positive_click=True, @@ -1372,6 +1372,39 @@ def get_current_segment_id(self): """ return self.ui.editor_widget.mrmlSegmentEditorNode().GetSelectedSegmentID() + def get_empty_mask(self): + image_data = self.get_image_data() + if image_data is None: + return np.zeros((0,), dtype=np.uint8) + + return np.zeros(image_data.shape, dtype=np.uint8) + + def get_segment_mask_or_empty(self, segmentation_node, segment_id, volume_node=None): + if segmentation_node is None or not segment_id: + return self.get_empty_mask() + + segment = segmentation_node.GetSegmentation().GetSegment(segment_id) + if segment is None: + return self.get_empty_mask() + + if volume_node is None: + volume_node = self.get_volume_node() + + try: + mask = slicer.util.arrayFromSegmentBinaryLabelmap( + segmentation_node, segment_id, volume_node + ) + except Exception as exc: + debug_print( + f"Falling back to empty mask for segment '{segment_id}': {exc}" + ) + return self.get_empty_mask() + + if mask is None: + return self.get_empty_mask() + + return np.array(mask, dtype=np.uint8, copy=True) + def get_segment_data(self): """ Gets the labelmap array (binary) of the currently selected segment. @@ -1380,7 +1413,7 @@ def get_segment_data(self): self.get_selected_segmentation_node_and_segment_id() ) - mask = slicer.util.arrayFromSegmentBinaryLabelmap( + mask = self.get_segment_mask_or_empty( segmentation_node, selected_segment_id, self.get_volume_node() ) seg_data_bool = mask.astype(bool) diff --git a/slicer_plugin/nninteractive/CMakeLists.txt b/slicer_plugin/nninteractive/CMakeLists.txt new file mode 100644 index 0000000..1ded2b2 --- /dev/null +++ b/slicer_plugin/nninteractive/CMakeLists.txt @@ -0,0 +1,63 @@ + +#----------------------------------------------------------------------------- +set(MODULE_NAME nninteractive) + +string(TOUPPER ${MODULE_NAME} MODULE_NAME_UPPER) + +#----------------------------------------------------------------------------- +add_subdirectory(Logic) +add_subdirectory(Widgets) + +#----------------------------------------------------------------------------- +set(MODULE_EXPORT_DIRECTIVE "Q_SLICER_QTMODULES_${MODULE_NAME_UPPER}_EXPORT") + +# Current_{source,binary} and Slicer_{Libs,Base} already included +set(MODULE_INCLUDE_DIRECTORIES + ${CMAKE_CURRENT_SOURCE_DIR}/Logic + ${CMAKE_CURRENT_BINARY_DIR}/Logic + ${CMAKE_CURRENT_SOURCE_DIR}/Widgets + ${CMAKE_CURRENT_BINARY_DIR}/Widgets + ) + +set(MODULE_SRCS + qSlicer${MODULE_NAME}Module.cxx + qSlicer${MODULE_NAME}Module.h + qSlicer${MODULE_NAME}ModuleWidget.cxx + qSlicer${MODULE_NAME}ModuleWidget.h + ) + +set(MODULE_MOC_SRCS + qSlicer${MODULE_NAME}Module.h + qSlicer${MODULE_NAME}ModuleWidget.h + ) + +set(MODULE_UI_SRCS + Resources/UI/qSlicer${MODULE_NAME}ModuleWidget.ui + ) + +set(MODULE_TARGET_LIBRARIES + vtkSlicer${MODULE_NAME}ModuleLogic + qSlicer${MODULE_NAME}ModuleWidgets + ) + +set(MODULE_RESOURCES + Resources/qSlicer${MODULE_NAME}Module.qrc + ) + +#----------------------------------------------------------------------------- +slicerMacroBuildLoadableModule( + NAME ${MODULE_NAME} + EXPORT_DIRECTIVE ${MODULE_EXPORT_DIRECTIVE} + INCLUDE_DIRECTORIES ${MODULE_INCLUDE_DIRECTORIES} + SRCS ${MODULE_SRCS} + MOC_SRCS ${MODULE_MOC_SRCS} + UI_SRCS ${MODULE_UI_SRCS} + TARGET_LIBRARIES ${MODULE_TARGET_LIBRARIES} + RESOURCES ${MODULE_RESOURCES} + WITH_GENERIC_TESTS + ) + +#----------------------------------------------------------------------------- +if(BUILD_TESTING) + add_subdirectory(Testing) +endif() diff --git a/slicer_plugin/nninteractive/Logic/CMakeLists.txt b/slicer_plugin/nninteractive/Logic/CMakeLists.txt new file mode 100644 index 0000000..d6f0cee --- /dev/null +++ b/slicer_plugin/nninteractive/Logic/CMakeLists.txt @@ -0,0 +1,26 @@ +project(vtkSlicer${MODULE_NAME}ModuleLogic) + +set(KIT ${PROJECT_NAME}) + +set(${KIT}_EXPORT_DIRECTIVE "VTK_SLICER_${MODULE_NAME_UPPER}_MODULE_LOGIC_EXPORT") + +set(${KIT}_INCLUDE_DIRECTORIES + ) + +set(${KIT}_SRCS + vtkSlicer${MODULE_NAME}Logic.cxx + vtkSlicer${MODULE_NAME}Logic.h + ) + +set(${KIT}_TARGET_LIBRARIES + ${ITK_LIBRARIES} + ) + +#----------------------------------------------------------------------------- +SlicerMacroBuildModuleLogic( + NAME ${KIT} + EXPORT_DIRECTIVE ${${KIT}_EXPORT_DIRECTIVE} + INCLUDE_DIRECTORIES ${${KIT}_INCLUDE_DIRECTORIES} + SRCS ${${KIT}_SRCS} + TARGET_LIBRARIES ${${KIT}_TARGET_LIBRARIES} + ) diff --git a/slicer_plugin/nninteractive/Logic/vtkSlicernninteractiveLogic.cxx b/slicer_plugin/nninteractive/Logic/vtkSlicernninteractiveLogic.cxx new file mode 100644 index 0000000..0dff685 --- /dev/null +++ b/slicer_plugin/nninteractive/Logic/vtkSlicernninteractiveLogic.cxx @@ -0,0 +1,73 @@ +/*============================================================================== + + Program: 3D Slicer + + Portions (c) Copyright Brigham and Women's Hospital (BWH) All Rights Reserved. + + See COPYRIGHT.txt + or http://www.slicer.org/copyright/copyright.txt for details. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==============================================================================*/ + +// nninteractive Logic includes +#include "vtkSlicernninteractiveLogic.h" + +// MRML includes +#include + +// VTK includes +#include +#include +#include + +// STD includes +#include + +//---------------------------------------------------------------------------- +vtkStandardNewMacro(vtkSlicernninteractiveLogic); + +//---------------------------------------------------------------------------- +vtkSlicernninteractiveLogic::vtkSlicernninteractiveLogic() {} + +//---------------------------------------------------------------------------- +vtkSlicernninteractiveLogic::~vtkSlicernninteractiveLogic() {} + +//---------------------------------------------------------------------------- +void vtkSlicernninteractiveLogic::PrintSelf(ostream& os, vtkIndent indent) +{ + this->Superclass::PrintSelf(os, indent); +} + +//--------------------------------------------------------------------------- +void vtkSlicernninteractiveLogic::SetMRMLSceneInternal(vtkMRMLScene* newScene) +{ + vtkNew events; + events->InsertNextValue(vtkMRMLScene::NodeAddedEvent); + events->InsertNextValue(vtkMRMLScene::NodeRemovedEvent); + events->InsertNextValue(vtkMRMLScene::EndBatchProcessEvent); + this->SetAndObserveMRMLSceneEventsInternal(newScene, events.GetPointer()); +} + +//----------------------------------------------------------------------------- +void vtkSlicernninteractiveLogic::RegisterNodes() +{ + assert(this->GetMRMLScene() != 0); +} + +//--------------------------------------------------------------------------- +void vtkSlicernninteractiveLogic::UpdateFromMRMLScene() +{ + assert(this->GetMRMLScene() != 0); +} + +//--------------------------------------------------------------------------- +void vtkSlicernninteractiveLogic::OnMRMLSceneNodeAdded(vtkMRMLNode* vtkNotUsed(node)) {} + +//--------------------------------------------------------------------------- +void vtkSlicernninteractiveLogic::OnMRMLSceneNodeRemoved(vtkMRMLNode* vtkNotUsed(node)) {} diff --git a/slicer_plugin/nninteractive/Logic/vtkSlicernninteractiveLogic.h b/slicer_plugin/nninteractive/Logic/vtkSlicernninteractiveLogic.h new file mode 100644 index 0000000..876a0e8 --- /dev/null +++ b/slicer_plugin/nninteractive/Logic/vtkSlicernninteractiveLogic.h @@ -0,0 +1,59 @@ +/*============================================================================== + + Program: 3D Slicer + + Portions (c) Copyright Brigham and Women's Hospital (BWH) All Rights Reserved. + + See COPYRIGHT.txt + or http://www.slicer.org/copyright/copyright.txt for details. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==============================================================================*/ + +// .NAME vtkSlicernninteractiveLogic - slicer logic class for volumes manipulation +// .SECTION Description +// This class manages the logic associated with reading, saving, +// and changing propertied of the volumes + +#ifndef __vtkSlicernninteractiveLogic_h +#define __vtkSlicernninteractiveLogic_h + +// Slicer includes +#include "vtkSlicerModuleLogic.h" + +// MRML includes + +// STD includes +#include + +#include "vtkSlicernninteractiveModuleLogicExport.h" + +class VTK_SLICER_NNINTERACTIVE_MODULE_LOGIC_EXPORT vtkSlicernninteractiveLogic : public vtkSlicerModuleLogic +{ +public: + static vtkSlicernninteractiveLogic* New(); + vtkTypeMacro(vtkSlicernninteractiveLogic, vtkSlicerModuleLogic); + void PrintSelf(ostream& os, vtkIndent indent) override; + +protected: + vtkSlicernninteractiveLogic(); + ~vtkSlicernninteractiveLogic() override; + + void SetMRMLSceneInternal(vtkMRMLScene* newScene) override; + /// Register MRML Node classes to Scene. Gets called automatically when the MRMLScene is attached to this logic class. + void RegisterNodes() override; + void UpdateFromMRMLScene() override; + void OnMRMLSceneNodeAdded(vtkMRMLNode* node) override; + void OnMRMLSceneNodeRemoved(vtkMRMLNode* node) override; + +private: + vtkSlicernninteractiveLogic(const vtkSlicernninteractiveLogic&); // Not implemented + void operator=(const vtkSlicernninteractiveLogic&); // Not implemented +}; + +#endif diff --git a/slicer_plugin/nninteractive/Resources/Icons/nninteractive.png b/slicer_plugin/nninteractive/Resources/Icons/nninteractive.png new file mode 100644 index 0000000000000000000000000000000000000000..9c5938ea6d9b8b44ce19e091d7f36d34f3733eb9 GIT binary patch literal 20628 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 + + qSlicernninteractiveFooBarWidget + + + + 0 + 0 + 103 + 27 + + + + Foo bar + + + + 0 + + + + + Foo Bar + + + + + + + + diff --git a/slicer_plugin/nninteractive/Resources/UI/qSlicernninteractiveModuleWidget.ui b/slicer_plugin/nninteractive/Resources/UI/qSlicernninteractiveModuleWidget.ui new file mode 100644 index 0000000..029b1fb --- /dev/null +++ b/slicer_plugin/nninteractive/Resources/UI/qSlicernninteractiveModuleWidget.ui @@ -0,0 +1,66 @@ + + + qSlicernninteractiveModuleWidget + + + + 0 + 0 + 525 + 319 + + + + nninteractive + + + + + + Display + + + + + + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + qSlicerWidget + QWidget +
qSlicerWidget.h
+ 1 +
+ + ctkCollapsibleButton + QWidget +
ctkCollapsibleButton.h
+ 1 +
+ + qSlicernninteractiveFooBarWidget + QWidget +
qSlicernninteractiveFooBarWidget.h
+ 1 +
+
+ + +
diff --git a/slicer_plugin/nninteractive/Resources/qSlicernninteractiveModule.qrc b/slicer_plugin/nninteractive/Resources/qSlicernninteractiveModule.qrc new file mode 100644 index 0000000..2244c04 --- /dev/null +++ b/slicer_plugin/nninteractive/Resources/qSlicernninteractiveModule.qrc @@ -0,0 +1,5 @@ + + + Icons/nninteractive.png + + diff --git a/slicer_plugin/nninteractive/Testing/CMakeLists.txt b/slicer_plugin/nninteractive/Testing/CMakeLists.txt new file mode 100644 index 0000000..35f9732 --- /dev/null +++ b/slicer_plugin/nninteractive/Testing/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(Cxx) diff --git a/slicer_plugin/nninteractive/Testing/Cxx/CMakeLists.txt b/slicer_plugin/nninteractive/Testing/Cxx/CMakeLists.txt new file mode 100644 index 0000000..b7341cd --- /dev/null +++ b/slicer_plugin/nninteractive/Testing/Cxx/CMakeLists.txt @@ -0,0 +1,17 @@ +set(KIT qSlicer${MODULE_NAME}Module) + +#----------------------------------------------------------------------------- +set(KIT_TEST_SRCS + #qSlicer${MODULE_NAME}ModuleTest.cxx + ) + +#----------------------------------------------------------------------------- +slicerMacroConfigureModuleCxxTestDriver( + NAME ${KIT} + SOURCES ${KIT_TEST_SRCS} + WITH_VTK_DEBUG_LEAKS_CHECK + WITH_VTK_ERROR_OUTPUT_CHECK + ) + +#----------------------------------------------------------------------------- +#simple_test(qSlicer${MODULE_NAME}ModuleTest) diff --git a/slicer_plugin/nninteractive/Widgets/CMakeLists.txt b/slicer_plugin/nninteractive/Widgets/CMakeLists.txt new file mode 100644 index 0000000..7701b68 --- /dev/null +++ b/slicer_plugin/nninteractive/Widgets/CMakeLists.txt @@ -0,0 +1,42 @@ +project(qSlicer${MODULE_NAME}ModuleWidgets) + +set(KIT ${PROJECT_NAME}) + +set(${KIT}_EXPORT_DIRECTIVE "Q_SLICER_MODULE_${MODULE_NAME_UPPER}_WIDGETS_EXPORT") + +set(${KIT}_INCLUDE_DIRECTORIES + ) + +set(${KIT}_SRCS + qSlicer${MODULE_NAME}FooBarWidget.cxx + qSlicer${MODULE_NAME}FooBarWidget.h + ) + +set(${KIT}_MOC_SRCS + qSlicer${MODULE_NAME}FooBarWidget.h + ) + +set(${KIT}_UI_SRCS + ../Resources/UI/qSlicer${MODULE_NAME}FooBarWidget.ui + ) + +set(${KIT}_RESOURCES + ../Resources/qSlicer${MODULE_NAME}Module.qrc + ) + +set(${KIT}_TARGET_LIBRARIES + vtkSlicer${MODULE_NAME}ModuleLogic + ) + +#----------------------------------------------------------------------------- +SlicerMacroBuildModuleWidgets( + NAME ${KIT} + EXPORT_DIRECTIVE ${${KIT}_EXPORT_DIRECTIVE} + INCLUDE_DIRECTORIES ${${KIT}_INCLUDE_DIRECTORIES} + SRCS ${${KIT}_SRCS} + MOC_SRCS ${${KIT}_MOC_SRCS} + UI_SRCS ${${KIT}_UI_SRCS} + TARGET_LIBRARIES ${${KIT}_TARGET_LIBRARIES} + RESOURCES ${${KIT}_RESOURCES} + WRAP_PYTHONQT + ) diff --git a/slicer_plugin/nninteractive/Widgets/qSlicernninteractiveFooBarWidget.cxx b/slicer_plugin/nninteractive/Widgets/qSlicernninteractiveFooBarWidget.cxx new file mode 100644 index 0000000..4bda2ec --- /dev/null +++ b/slicer_plugin/nninteractive/Widgets/qSlicernninteractiveFooBarWidget.cxx @@ -0,0 +1,63 @@ +/*============================================================================== + + Program: 3D Slicer + + Copyright (c) Kitware Inc. + + See COPYRIGHT.txt + or http://www.slicer.org/copyright/copyright.txt for details. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + This file was originally developed by Jean-Christophe Fillion-Robin, Kitware Inc. + and was partially funded by NIH grant 3P41RR013218-12S1 + +==============================================================================*/ + +// FooBar Widgets includes +#include "qSlicernninteractiveFooBarWidget.h" +#include "ui_qSlicernninteractiveFooBarWidget.h" + +//----------------------------------------------------------------------------- +class qSlicernninteractiveFooBarWidgetPrivate : public Ui_qSlicernninteractiveFooBarWidget +{ + Q_DECLARE_PUBLIC(qSlicernninteractiveFooBarWidget); + +protected: + qSlicernninteractiveFooBarWidget* const q_ptr; + +public: + qSlicernninteractiveFooBarWidgetPrivate(qSlicernninteractiveFooBarWidget& object); + virtual void setupUi(qSlicernninteractiveFooBarWidget*); +}; + +// -------------------------------------------------------------------------- +qSlicernninteractiveFooBarWidgetPrivate::qSlicernninteractiveFooBarWidgetPrivate(qSlicernninteractiveFooBarWidget& object) + : q_ptr(&object) +{ +} + +// -------------------------------------------------------------------------- +void qSlicernninteractiveFooBarWidgetPrivate::setupUi(qSlicernninteractiveFooBarWidget* widget) +{ + this->Ui_qSlicernninteractiveFooBarWidget::setupUi(widget); +} + +//----------------------------------------------------------------------------- +// qSlicernninteractiveFooBarWidget methods + +//----------------------------------------------------------------------------- +qSlicernninteractiveFooBarWidget::qSlicernninteractiveFooBarWidget(QWidget* parentWidget) + : Superclass(parentWidget) + , d_ptr(new qSlicernninteractiveFooBarWidgetPrivate(*this)) +{ + Q_D(qSlicernninteractiveFooBarWidget); + d->setupUi(this); +} + +//----------------------------------------------------------------------------- +qSlicernninteractiveFooBarWidget ::~qSlicernninteractiveFooBarWidget() {} diff --git a/slicer_plugin/nninteractive/Widgets/qSlicernninteractiveFooBarWidget.h b/slicer_plugin/nninteractive/Widgets/qSlicernninteractiveFooBarWidget.h new file mode 100644 index 0000000..ce4c1b4 --- /dev/null +++ b/slicer_plugin/nninteractive/Widgets/qSlicernninteractiveFooBarWidget.h @@ -0,0 +1,50 @@ +/*============================================================================== + + Program: 3D Slicer + + Copyright (c) Kitware Inc. + + See COPYRIGHT.txt + or http://www.slicer.org/copyright/copyright.txt for details. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + This file was originally developed by Jean-Christophe Fillion-Robin, Kitware Inc. + and was partially funded by NIH grant 3P41RR013218-12S1 + +==============================================================================*/ + +#ifndef __qSlicernninteractiveFooBarWidget_h +#define __qSlicernninteractiveFooBarWidget_h + +// Qt includes +#include + +// FooBar Widgets includes +#include "qSlicernninteractiveModuleWidgetsExport.h" + +class qSlicernninteractiveFooBarWidgetPrivate; + +class Q_SLICER_MODULE_NNINTERACTIVE_WIDGETS_EXPORT qSlicernninteractiveFooBarWidget : public QWidget +{ + Q_OBJECT +public: + typedef QWidget Superclass; + qSlicernninteractiveFooBarWidget(QWidget* parent = 0); + ~qSlicernninteractiveFooBarWidget() override; + +protected slots: + +protected: + QScopedPointer d_ptr; + +private: + Q_DECLARE_PRIVATE(qSlicernninteractiveFooBarWidget); + Q_DISABLE_COPY(qSlicernninteractiveFooBarWidget); +}; + +#endif diff --git a/slicer_plugin/nninteractive/qSlicernninteractiveModule.cxx b/slicer_plugin/nninteractive/qSlicernninteractiveModule.cxx new file mode 100644 index 0000000..86f74af --- /dev/null +++ b/slicer_plugin/nninteractive/qSlicernninteractiveModule.cxx @@ -0,0 +1,105 @@ +/*============================================================================== + + Program: 3D Slicer + + Portions (c) Copyright Brigham and Women's Hospital (BWH) All Rights Reserved. + + See COPYRIGHT.txt + or http://www.slicer.org/copyright/copyright.txt for details. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==============================================================================*/ + +// nninteractive Logic includes +#include + +// nninteractive includes +#include "qSlicernninteractiveModule.h" +#include "qSlicernninteractiveModuleWidget.h" + +//----------------------------------------------------------------------------- +class qSlicernninteractiveModulePrivate +{ +public: + qSlicernninteractiveModulePrivate(); +}; + +//----------------------------------------------------------------------------- +// qSlicernninteractiveModulePrivate methods + +//----------------------------------------------------------------------------- +qSlicernninteractiveModulePrivate::qSlicernninteractiveModulePrivate() {} + +//----------------------------------------------------------------------------- +// qSlicernninteractiveModule methods + +//----------------------------------------------------------------------------- +qSlicernninteractiveModule::qSlicernninteractiveModule(QObject* _parent) + : Superclass(_parent) + , d_ptr(new qSlicernninteractiveModulePrivate) +{ +} + +//----------------------------------------------------------------------------- +qSlicernninteractiveModule::~qSlicernninteractiveModule() {} + +//----------------------------------------------------------------------------- +QString qSlicernninteractiveModule::helpText() const +{ + return "This is a loadable module that can be bundled in an extension"; +} + +//----------------------------------------------------------------------------- +QString qSlicernninteractiveModule::acknowledgementText() const +{ + return "This work was partially funded by NIH grant NXNNXXNNNNNN-NNXN"; +} + +//----------------------------------------------------------------------------- +QStringList qSlicernninteractiveModule::contributors() const +{ + QStringList moduleContributors; + moduleContributors << QString("John Doe (AnyWare Corp.)"); + return moduleContributors; +} + +//----------------------------------------------------------------------------- +QIcon qSlicernninteractiveModule::icon() const +{ + return QIcon(":/Icons/nninteractive.png"); +} + +//----------------------------------------------------------------------------- +QStringList qSlicernninteractiveModule::categories() const +{ + return QStringList() << "Examples"; +} + +//----------------------------------------------------------------------------- +QStringList qSlicernninteractiveModule::dependencies() const +{ + return QStringList(); +} + +//----------------------------------------------------------------------------- +void qSlicernninteractiveModule::setup() +{ + this->Superclass::setup(); +} + +//----------------------------------------------------------------------------- +qSlicerAbstractModuleRepresentation* qSlicernninteractiveModule::createWidgetRepresentation() +{ + return new qSlicernninteractiveModuleWidget; +} + +//----------------------------------------------------------------------------- +vtkMRMLAbstractLogic* qSlicernninteractiveModule::createLogic() +{ + return vtkSlicernninteractiveLogic::New(); +} diff --git a/slicer_plugin/nninteractive/qSlicernninteractiveModule.h b/slicer_plugin/nninteractive/qSlicernninteractiveModule.h new file mode 100644 index 0000000..0a172cc --- /dev/null +++ b/slicer_plugin/nninteractive/qSlicernninteractiveModule.h @@ -0,0 +1,68 @@ +/*============================================================================== + + Program: 3D Slicer + + Portions (c) Copyright Brigham and Women's Hospital (BWH) All Rights Reserved. + + See COPYRIGHT.txt + or http://www.slicer.org/copyright/copyright.txt for details. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==============================================================================*/ + +#ifndef __qSlicernninteractiveModule_h +#define __qSlicernninteractiveModule_h + +// Slicer includes +#include "qSlicerLoadableModule.h" + +#include "qSlicernninteractiveModuleExport.h" + +class qSlicernninteractiveModulePrivate; + +class Q_SLICER_QTMODULES_NNINTERACTIVE_EXPORT qSlicernninteractiveModule : public qSlicerLoadableModule +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.slicer.modules.loadable.qSlicerLoadableModule/1.0"); + Q_INTERFACES(qSlicerLoadableModule); + +public: + typedef qSlicerLoadableModule Superclass; + explicit qSlicernninteractiveModule(QObject* parent = nullptr); + ~qSlicernninteractiveModule() override; + + qSlicerGetTitleMacro(tr("nninteractive")); + + QString helpText() const override; + QString acknowledgementText() const override; + QStringList contributors() const override; + + QIcon icon() const override; + + QStringList categories() const override; + QStringList dependencies() const override; + +protected: + /// Initialize the module. Register the volumes reader/writer + void setup() override; + + /// Create and return the widget representation associated to this module + qSlicerAbstractModuleRepresentation* createWidgetRepresentation() override; + + /// Create and return the logic associated to this module + vtkMRMLAbstractLogic* createLogic() override; + +protected: + QScopedPointer d_ptr; + +private: + Q_DECLARE_PRIVATE(qSlicernninteractiveModule); + Q_DISABLE_COPY(qSlicernninteractiveModule); +}; + +#endif diff --git a/slicer_plugin/nninteractive/qSlicernninteractiveModuleWidget.cxx b/slicer_plugin/nninteractive/qSlicernninteractiveModuleWidget.cxx new file mode 100644 index 0000000..2a93d76 --- /dev/null +++ b/slicer_plugin/nninteractive/qSlicernninteractiveModuleWidget.cxx @@ -0,0 +1,57 @@ +/*============================================================================== + + Program: 3D Slicer + + Portions (c) Copyright Brigham and Women's Hospital (BWH) All Rights Reserved. + + See COPYRIGHT.txt + or http://www.slicer.org/copyright/copyright.txt for details. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==============================================================================*/ + +// Qt includes +#include + +// Slicer includes +#include "qSlicernninteractiveModuleWidget.h" +#include "ui_qSlicernninteractiveModuleWidget.h" + +//----------------------------------------------------------------------------- +class qSlicernninteractiveModuleWidgetPrivate : public Ui_qSlicernninteractiveModuleWidget +{ +public: + qSlicernninteractiveModuleWidgetPrivate(); +}; + +//----------------------------------------------------------------------------- +// qSlicernninteractiveModuleWidgetPrivate methods + +//----------------------------------------------------------------------------- +qSlicernninteractiveModuleWidgetPrivate::qSlicernninteractiveModuleWidgetPrivate() {} + +//----------------------------------------------------------------------------- +// qSlicernninteractiveModuleWidget methods + +//----------------------------------------------------------------------------- +qSlicernninteractiveModuleWidget::qSlicernninteractiveModuleWidget(QWidget* _parent) + : Superclass(_parent) + , d_ptr(new qSlicernninteractiveModuleWidgetPrivate) +{ +} + +//----------------------------------------------------------------------------- +qSlicernninteractiveModuleWidget::~qSlicernninteractiveModuleWidget() {} + +//----------------------------------------------------------------------------- +void qSlicernninteractiveModuleWidget::setup() +{ + Q_D(qSlicernninteractiveModuleWidget); + d->setupUi(this); + this->Superclass::setup(); +} diff --git a/slicer_plugin/nninteractive/qSlicernninteractiveModuleWidget.h b/slicer_plugin/nninteractive/qSlicernninteractiveModuleWidget.h new file mode 100644 index 0000000..1d0aa50 --- /dev/null +++ b/slicer_plugin/nninteractive/qSlicernninteractiveModuleWidget.h @@ -0,0 +1,50 @@ +/*============================================================================== + + Program: 3D Slicer + + Portions (c) Copyright Brigham and Women's Hospital (BWH) All Rights Reserved. + + See COPYRIGHT.txt + or http://www.slicer.org/copyright/copyright.txt for details. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==============================================================================*/ + +#ifndef __qSlicernninteractiveModuleWidget_h +#define __qSlicernninteractiveModuleWidget_h + +// Slicer includes +#include "qSlicerAbstractModuleWidget.h" + +#include "qSlicernninteractiveModuleExport.h" + +class qSlicernninteractiveModuleWidgetPrivate; +class vtkMRMLNode; + +class Q_SLICER_QTMODULES_NNINTERACTIVE_EXPORT qSlicernninteractiveModuleWidget : public qSlicerAbstractModuleWidget +{ + Q_OBJECT + +public: + typedef qSlicerAbstractModuleWidget Superclass; + qSlicernninteractiveModuleWidget(QWidget* parent = 0); + virtual ~qSlicernninteractiveModuleWidget(); + +public slots: + +protected: + QScopedPointer d_ptr; + + void setup() override; + +private: + Q_DECLARE_PRIVATE(qSlicernninteractiveModuleWidget); + Q_DISABLE_COPY(qSlicernninteractiveModuleWidget); +}; + +#endif