napari-nninteractive 2.2.0__tar.gz → 2.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {napari_nninteractive-2.2.0/src/napari_nninteractive.egg-info → napari_nninteractive-2.3.2}/PKG-INFO +3 -3
  2. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/pyproject.toml +3 -6
  3. napari_nninteractive-2.3.2/src/napari_nninteractive/__init__.py +12 -0
  4. napari_nninteractive-2.3.2/src/napari_nninteractive/_version_check.py +78 -0
  5. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/widget_controls.py +63 -14
  6. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/widget_gui.py +117 -4
  7. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/widget_main.py +167 -15
  8. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2/src/napari_nninteractive.egg-info}/PKG-INFO +3 -3
  9. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive.egg-info/SOURCES.txt +1 -0
  10. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive.egg-info/requires.txt +2 -2
  11. napari_nninteractive-2.2.0/src/napari_nninteractive/__init__.py +0 -4
  12. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/LICENSE +0 -0
  13. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/MANIFEST.in +0 -0
  14. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/README.md +0 -0
  15. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/setup.cfg +0 -0
  16. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/controls/__init__.py +0 -0
  17. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/controls/bbox_controls.py +0 -0
  18. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/controls/lasso_controls.py +0 -0
  19. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/controls/point_controls.py +0 -0
  20. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/controls/scribble_controls.py +0 -0
  21. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/layers/__init__.py +0 -0
  22. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/layers/abstract_layer.py +0 -0
  23. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/layers/bbox_layer.py +0 -0
  24. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/layers/lasso_layer.py +0 -0
  25. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/layers/point_layer.py +0 -0
  26. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/layers/scribble_layer.py +0 -0
  27. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/napari.yaml +0 -0
  28. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/utils/__init__.py +0 -0
  29. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/utils/affine.py +0 -0
  30. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive/utils/utils.py +0 -0
  31. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive.egg-info/dependency_links.txt +0 -0
  32. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive.egg-info/entry_points.txt +0 -0
  33. {napari_nninteractive-2.2.0 → napari_nninteractive-2.3.2}/src/napari_nninteractive.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: napari-nninteractive
3
- Version: 2.2.0
3
+ Version: 2.3.2
4
4
  Summary: nnInteractive plugin for Napari
5
5
  Author: Lars Krämer, Fabian Isensee, Maximilian Rokuss
6
6
  Author-email: lars.kraemer@dkfz-heidelberg.de, f.isensee@dkfz-heidelberg.de, maximilian.rokuss@dkfz-heidelberg.de
@@ -231,7 +231,7 @@ Requires-Dist: qtpy
231
231
  Requires-Dist: napari-nifti
232
232
  Requires-Dist: huggingface_hub
233
233
  Requires-Dist: hf_transfer
234
- Requires-Dist: nnInteractive>=2.3.0
234
+ Requires-Dist: nnInteractive>=2.4.0
235
235
  Requires-Dist: napari_toolkit
236
236
  Provides-Extra: testing
237
237
  Requires-Dist: tox; extra == "testing"
@@ -241,7 +241,7 @@ Requires-Dist: pytest-qt; extra == "testing"
241
241
  Requires-Dist: napari; extra == "testing"
242
242
  Requires-Dist: pyqt5; extra == "testing"
243
243
  Provides-Extra: remote
244
- Requires-Dist: nnInteractive[client]>=2.2.0; extra == "remote"
244
+ Requires-Dist: nnInteractive[client]>=2.4.0; extra == "remote"
245
245
  Dynamic: license-file
246
246
 
247
247
  <img src="https://github.com/MIC-DKFZ/napari-nninteractive/raw/main/imgs/nnInteractive_header.png" width="1200">
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "napari-nninteractive"
3
- dynamic = ["version"]
3
+ version = "2.3.2"
4
4
  description = "nnInteractive plugin for Napari"
5
5
  readme = "README.md"
6
6
  license = {file = "LICENSE"}
@@ -32,7 +32,7 @@ dependencies = [
32
32
  "napari-nifti",
33
33
  "huggingface_hub",
34
34
  "hf_transfer",
35
- "nnInteractive>=2.3.0",
35
+ "nnInteractive>=2.4.0",
36
36
  "napari_toolkit",
37
37
  ]
38
38
 
@@ -46,7 +46,7 @@ testing = [
46
46
  "pyqt5",
47
47
  ]
48
48
  remote = [
49
- "nnInteractive[client]>=2.2.0",
49
+ "nnInteractive[client]>=2.4.0",
50
50
  ]
51
51
 
52
52
  [project.entry-points."napari.manifest"]
@@ -69,9 +69,6 @@ where = ["src"]
69
69
  Homepage = "https://github.com/MIC-DKFZ/napari-nninteractive"
70
70
  Code = "https://github.com/MIC-DKFZ/napari-nninteractive"
71
71
 
72
- [tool.setuptools.dynamic]
73
- version = {attr = "napari_nninteractive.__init__.__version__"}
74
-
75
72
  [tool.black]
76
73
  line-length = 100
77
74
  target-version = ['py38', 'py39', 'py310']
@@ -0,0 +1,12 @@
1
+ from importlib.metadata import PackageNotFoundError
2
+ from importlib.metadata import version as _version
3
+
4
+ from .widget_main import nnInteractiveWidget
5
+
6
+ try:
7
+ __version__ = _version("napari-nninteractive")
8
+ except PackageNotFoundError:
9
+ __version__ = "unknown"
10
+
11
+
12
+ __all__ = ("nnInteractiveWidget",)
@@ -0,0 +1,78 @@
1
+ """Non-blocking check for newer releases of the plugin and its backend on PyPI.
2
+
3
+ The network request runs in a background daemon thread so it never blocks GUI
4
+ startup, and the result is delivered back on the GUI thread via a Qt signal.
5
+ The check is best effort: when PyPI is unreachable (offline, behind a firewall,
6
+ etc.) it simply stays silent instead of nagging the user.
7
+ """
8
+
9
+ import json
10
+ import threading
11
+ from importlib.metadata import PackageNotFoundError, version
12
+ from urllib.request import urlopen
13
+
14
+ from qtpy.QtCore import QObject, Signal
15
+
16
+ # PyPI project names to check, in display order.
17
+ PACKAGES = ("napari-nninteractive", "nnInteractive")
18
+
19
+ try:
20
+ from packaging.version import InvalidVersion
21
+ from packaging.version import parse as _parse_version
22
+ except ImportError: # packaging is virtually always present, but degrade gracefully
23
+ _parse_version = None
24
+ InvalidVersion = Exception
25
+
26
+
27
+ def _installed_version(package: str):
28
+ """Return the installed version string, or None if the package is missing."""
29
+ try:
30
+ return version(package)
31
+ except PackageNotFoundError:
32
+ return None
33
+
34
+
35
+ def _latest_version(package: str, timeout: float = 5.0) -> str:
36
+ """Return the latest release version for `package` from the PyPI JSON API."""
37
+ url = f"https://pypi.org/pypi/{package}/json"
38
+ with urlopen(url, timeout=timeout) as response: # noqa: S310 - fixed https URL
39
+ data = json.load(response)
40
+ return data["info"]["version"]
41
+
42
+
43
+ def _is_outdated(installed: str, latest: str) -> bool:
44
+ """True if `installed` is an older release than `latest`."""
45
+ if _parse_version is not None:
46
+ try:
47
+ return _parse_version(installed) < _parse_version(latest)
48
+ except InvalidVersion:
49
+ return False
50
+ # Fallback when `packaging` is unavailable: only flag an exact mismatch.
51
+ return installed != latest
52
+
53
+
54
+ class VersionChecker(QObject):
55
+ """Checks PyPI for newer releases in a background daemon thread.
56
+
57
+ Connect to `finished`, then call `start()`. The signal carries a dict mapping
58
+ each package name to an `(installed, latest)` tuple; either entry may be None
59
+ (package not installed, or PyPI could not be reached). It is emitted from the
60
+ worker thread, so Qt delivers it to GUI-thread slots via a queued connection.
61
+ """
62
+
63
+ # Emits {package_name: (installed_or_None, latest_or_None)}
64
+ finished = Signal(object)
65
+
66
+ def start(self) -> None:
67
+ threading.Thread(target=self._run, name="nni-version-check", daemon=True).start()
68
+
69
+ def _run(self) -> None:
70
+ results = {}
71
+ for package in PACKAGES:
72
+ installed = _installed_version(package)
73
+ try:
74
+ latest = _latest_version(package)
75
+ except Exception: # noqa: BLE001 - offline / PyPI down / bad payload: skip silently
76
+ latest = None
77
+ results[package] = (installed, latest)
78
+ self.finished.emit(results)
@@ -59,6 +59,12 @@ class LayerControls(BaseGUI):
59
59
  self.colormap = ColorMapper(49, seed=0.5, background_value=0)
60
60
  self._scribble_brush_size = 5
61
61
  self.object_index = 0
62
+ # Names of the interaction layers that committed an interaction, newest last. Used by
63
+ # on_undo to remove the visual marker of the most recently undone interaction. The
64
+ # backend supports single-level undo, so only the top entry is ever undoable; older
65
+ # entries stay because their interactions are still applied. None means an interaction
66
+ # without a layer marker (e.g. Initialize with Mask).
67
+ self._interaction_history = []
62
68
 
63
69
  self._viewer.layers.selection.events.active.connect(self.on_layer_selected)
64
70
 
@@ -69,6 +75,8 @@ class LayerControls(BaseGUI):
69
75
  for layer_name in layer_names:
70
76
  if layer_name in self._viewer.layers:
71
77
  self._viewer.layers.remove(layer_name)
78
+ # The interaction markers are gone, so nothing is left to undo for them.
79
+ self._interaction_history = []
72
80
 
73
81
  def add_point_layer(self) -> None:
74
82
  """Adds a single point layer to the viewer."""
@@ -335,11 +343,12 @@ class LayerControls(BaseGUI):
335
343
  self.session_cfg["affine"].scale
336
344
  )
337
345
 
338
- # Decide whether to resume the previous segmentation. We only resume
339
- # after a connection loss (flag set by _handle_session_expired) and only
340
- # when the surviving label layer belongs to the *same* image layer object
341
- # (identity, not merely the same shape, which would be brittle). The
342
- # shape check is a cheap guard against a layer that no longer matches.
346
+ # Decide whether to resume the previous segmentation. We only resume when
347
+ # the _resume_after_reconnect flag is armed -- set after a connection loss
348
+ # (_handle_session_expired) -- and only when the surviving label layer
349
+ # belongs to the *same* image layer object (identity, not merely the same
350
+ # shape, which would be brittle). The shape check is a cheap guard against
351
+ # a layer that no longer matches.
343
352
  resume = (
344
353
  getattr(self, "_resume_after_reconnect", False)
345
354
  and self.label_layer_name in self._viewer.layers
@@ -356,10 +365,15 @@ class LayerControls(BaseGUI):
356
365
  else:
357
366
  # Create the target label array and layer
358
367
  self._data_result = np.zeros(self.session_cfg["shape"], dtype=np.uint8)
359
- self.object_index = 0
368
+ # Continue numbering/colors from any objects already segmented for this
369
+ # image in a previous session instead of restarting at 1.
370
+ self.object_index = self._next_object_index()
360
371
  if self.label_layer_name in self._viewer.layers:
361
372
  self._viewer.layers.remove(self.label_layer_name)
362
373
  self.add_label_layer(self._data_result, self.label_layer_name)
374
+ # Colour the working layer to match where the counter resumes, so the
375
+ # in-progress object already shows its eventual colour.
376
+ self._viewer.layers[self.label_layer_name].colormap = self.colormap[self.object_index]
363
377
 
364
378
  # Pin the resume to this image object so a later reconnect can verify it
365
379
  # is the same image before resuming. _resuming is read by the subclass'
@@ -367,6 +381,9 @@ class LayerControls(BaseGUI):
367
381
  self._resume_image_layer = image_layer
368
382
  self._resuming = resume
369
383
 
384
+ # Fresh image/model pair: nothing from a previous object is undoable.
385
+ self._interaction_history = []
386
+
370
387
  # Lock the Session
371
388
  self._lock_session()
372
389
 
@@ -375,15 +392,36 @@ class LayerControls(BaseGUI):
375
392
  super().on_reset_interactions()
376
393
  self.on_layer_selected()
377
394
 
378
- def on_next(self) -> None:
395
+ def _next_object_index(self) -> int:
396
+ """Continue object numbering from work already present in the viewer for
397
+ this image, so a new session does not restart at 1 and collide with the
398
+ names/colors of objects from a previous session. Considers both the
399
+ per-object ``object N - <name>`` layers and the values of the aggregated
400
+ ``semantic map - <name>`` layer. Returns 0 when nothing is present.
379
401
  """
380
- Prepares the next label layer for interactions in the viewer.
381
-
382
- Retrieves the index of the last labeled object, renames the current label layer with
383
- this index, unbinds the original data by creating a deep copy, and clears all interaction
384
- layers. A new label layer with an updated colormap is then added to the viewer.
402
+ name = self.session_cfg["name"]
403
+ # determine_layer_index returns max(N) + 1 over the "object N - <name>"
404
+ # layers (or 0 when there are none); object_index is that max, one less.
405
+ highest = (
406
+ determine_layer_index(
407
+ [layer.name for layer in self._viewer.layers], "object ", f" - {name}"
408
+ )
409
+ - 1
410
+ )
411
+ sem_name = f"semantic map - {name}"
412
+ if sem_name in self._viewer.layers:
413
+ highest = max(highest, int(self._viewer.layers[sem_name].data.max()))
414
+ return max(highest, 0)
415
+
416
+ def _store_current_object(self) -> None:
417
+ """Promote the in-progress label layer to a finished object.
418
+
419
+ Either as its own ``object N - <name>`` layer or, in instance-aggregation
420
+ mode, merged into the shared ``semantic map - <name>`` layer. Advances
421
+ object_index so subsequent objects keep consistent numbering and colours.
422
+ Shared by ``on_next`` and by the reset paths that offer to keep the
423
+ in-progress segmentation before tearing down the session.
385
424
  """
386
- # Rename the current layer and add a new one
387
425
  label_layer = self._viewer.layers[self.label_layer_name]
388
426
  if not self.instance_aggregation_ckbx.isChecked():
389
427
 
@@ -402,7 +440,18 @@ class LayerControls(BaseGUI):
402
440
  sem_layer.refresh()
403
441
 
404
442
  self.object_index += 1
405
- label_layer.colormap = self.colormap[self.object_index]
443
+
444
+ def on_next(self) -> None:
445
+ """
446
+ Prepares the next label layer for interactions in the viewer.
447
+
448
+ Retrieves the index of the last labeled object, renames the current label layer with
449
+ this index, unbinds the original data by creating a deep copy, and clears all interaction
450
+ layers. A new label layer with an updated colormap is then added to the viewer.
451
+ """
452
+ # Store the current object and recolour the working layer for the next one.
453
+ self._store_current_object()
454
+ self._viewer.layers[self.label_layer_name].colormap = self.colormap[self.object_index]
406
455
 
407
456
  self._clear_layers()
408
457
  self.prompt_button._uncheck()
@@ -30,6 +30,8 @@ from qtpy.QtWidgets import (
30
30
  QWidget,
31
31
  )
32
32
 
33
+ from napari_nninteractive._version_check import VersionChecker, _is_outdated
34
+
33
35
 
34
36
  class BaseGUI(QWidget):
35
37
  """
@@ -66,15 +68,61 @@ class BaseGUI(QWidget):
66
68
 
67
69
  _ = setup_acknowledgements(_scroll_layout, width=self._width) # Acknowledgements
68
70
 
71
+ # Update notice, below the logo (filled in asynchronously once PyPI has been queried).
72
+ self.version_status_label = QLabel("")
73
+ self.version_status_label.setWordWrap(True)
74
+ self.version_status_label.setAlignment(Qt.AlignLeft)
75
+ # Let the user select/copy the update command with the mouse or keyboard.
76
+ self.version_status_label.setTextInteractionFlags(
77
+ Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard
78
+ )
79
+ self.version_status_label.setVisible(False)
80
+ _scroll_layout.addWidget(self.version_status_label)
81
+
69
82
  self._unlock_session()
70
83
  self._viewer.bind_key("Ctrl+Q", self._close, overwrite=True)
71
84
 
85
+ # Non-blocking check for newer releases on PyPI. Kept as an attribute so
86
+ # it outlives __init__; the daemon thread it spawns never blocks startup.
87
+ self._version_checker = VersionChecker()
88
+ self._version_checker.finished.connect(self._on_version_check_finished)
89
+ self._version_checker.start()
90
+
72
91
  # Base Behaviour
73
92
  def _close(self):
74
93
  """Closes the viewer and quits the application."""
75
94
  self._viewer.close()
76
95
  quit()
77
96
 
97
+ def _on_version_check_finished(self, results: dict) -> None:
98
+ """Show an up-to-date / update-available notice from the PyPI check.
99
+
100
+ `results` maps each package name to an `(installed, latest)` tuple; either
101
+ entry may be None (package not installed or PyPI unreachable). When nothing
102
+ could be compared the label stays hidden rather than showing a false notice.
103
+ """
104
+ outdated = [
105
+ pkg
106
+ for pkg, (installed, latest) in results.items()
107
+ if installed and latest and _is_outdated(installed, latest)
108
+ ]
109
+ checkable = any(installed and latest for installed, latest in results.values())
110
+
111
+ if not checkable:
112
+ self.version_status_label.setVisible(False)
113
+ return
114
+
115
+ self.version_status_label.setVisible(True)
116
+ if outdated:
117
+ self.version_status_label.setText(
118
+ "Update available. Please run:\n"
119
+ "pip install -U nnInteractive napari-nninteractive"
120
+ )
121
+ self.version_status_label.setStyleSheet("color: #e8830c; font-weight: bold;") # orange
122
+ else:
123
+ self.version_status_label.setText("nnInteractive is up to date")
124
+ self.version_status_label.setStyleSheet("color: #2e9e2e;") # green
125
+
78
126
  def _unlock_session(self):
79
127
  """Unlocks the session, enabling model and image selection, and initializing controls."""
80
128
  self.init_button.setEnabled(True)
@@ -90,6 +138,7 @@ class BaseGUI(QWidget):
90
138
  self.run_ckbx.setEnabled(False)
91
139
  self.export_button.setEnabled(False)
92
140
  self.reset_interaction_button.setEnabled(False)
141
+ self.undo_button.setEnabled(False)
93
142
  self.propagate_ckbx.setEnabled(False)
94
143
  self.label_for_init.setEnabled(False)
95
144
  self.class_for_init.setEnabled(False)
@@ -127,6 +176,7 @@ class BaseGUI(QWidget):
127
176
  self.run_ckbx.setEnabled(True)
128
177
  self.export_button.setEnabled(True)
129
178
  self.reset_interaction_button.setEnabled(True)
179
+ self.undo_button.setEnabled(True)
130
180
  self.propagate_ckbx.setEnabled(True)
131
181
  self.label_for_init.setEnabled(True)
132
182
  self.class_for_init.setEnabled(True)
@@ -169,26 +219,51 @@ class BaseGUI(QWidget):
169
219
  _boxlayout = QHBoxLayout()
170
220
  _local_layout.addLayout(_boxlayout)
171
221
  self.model_selection_local = setup_lineedit(
172
- _boxlayout, placeholder="Use Local Checkpoint...", function=self.on_model_selected
222
+ _boxlayout, placeholder="Use Local Checkpoint...", function=self.on_checkpoint_changed
173
223
  )
174
224
 
175
225
  def _reset_local_ckpt_lineedit():
176
226
  self.model_selection_local.setText("")
177
- self.on_model_selected()
227
+ self.on_checkpoint_changed()
178
228
 
179
229
  btn = setup_iconbutton(
180
230
  _boxlayout, "", "delete_shape", self._viewer.theme, function=_reset_local_ckpt_lineedit
181
231
  )
182
232
  btn.setFixedWidth(30)
183
233
 
234
+ # --- Advanced (local) options --- #
235
+ # These are niche settings, so they live in a collapsible section that is folded
236
+ # by default. The fold state and the chosen values are persisted via QSettings.
237
+ advanced_collapsed = self._settings.value("advanced_collapsed", True, type=bool)
238
+ self.advanced_box, _advanced_layout = setup_vcollapsiblegroupbox(
239
+ _local_layout, text="Advanced", collapsed=advanced_collapsed
240
+ )
241
+
184
242
  self.use_torch_compile_ckbx = setup_checkbox(
185
- _local_layout,
243
+ _advanced_layout,
186
244
  "use torch.compile",
187
- False,
245
+ self._settings.value("use_torch_compile", False, type=bool),
188
246
  tooltips="If checked: enable torch.compile for local inference. The model is compiled "
189
247
  "during Initialize, so initialization takes longer, but every prediction afterwards is faster.",
190
248
  )
191
249
 
250
+ _storage_layout = QHBoxLayout()
251
+ _advanced_layout.addLayout(_storage_layout)
252
+ setup_label(_storage_layout, "interaction storage")
253
+ self.interactions_storage_combo = setup_combobox(
254
+ _storage_layout,
255
+ options=["auto", "blosc2", "tensor"],
256
+ tooltips="Storage backend for the interaction tensor (local inference only):\n"
257
+ "• auto: dense tensor for smaller images, blosc2 above ~512x512x512 (default)\n"
258
+ "• blosc2: much less RAM, slightly slower\n"
259
+ "• tensor: much more RAM, slightly faster\n"
260
+ "Pick blosc2 manually if you are short on RAM.",
261
+ )
262
+ saved_storage = self._settings.value("interactions_storage", "auto", type=str)
263
+ _storage_idx = self.interactions_storage_combo.findText(saved_storage)
264
+ if _storage_idx >= 0:
265
+ self.interactions_storage_combo.setCurrentIndex(_storage_idx)
266
+
192
267
  # --- Remote container --- #
193
268
  self.remote_container = QWidget()
194
269
  _remote_layout = QVBoxLayout()
@@ -251,6 +326,26 @@ class BaseGUI(QWidget):
251
326
  lambda t: self._settings.setValue("server_url", t)
252
327
  )
253
328
 
329
+ # Persist the advanced options (fold state + chosen values) between sessions.
330
+ self.advanced_box.toggled.connect(
331
+ lambda expanded: self._settings.setValue("advanced_collapsed", not expanded)
332
+ )
333
+ self.use_torch_compile_ckbx.toggled.connect(
334
+ lambda checked: self._settings.setValue("use_torch_compile", checked)
335
+ )
336
+ self.interactions_storage_combo.currentTextChanged.connect(
337
+ lambda t: self._settings.setValue("interactions_storage", t)
338
+ )
339
+
340
+ # torch.compile and interaction storage are baked into the session at Initialize.
341
+ # Changing one afterwards would leave the GUI out of sync with the live session, so
342
+ # uninitialize and force a re-Initialize -- but keep the in-progress segmentation.
343
+ # Wired after the construction-time restore above, so it never fires during build.
344
+ self.use_torch_compile_ckbx.toggled.connect(lambda *_: self.on_local_settings_changed())
345
+ self.interactions_storage_combo.currentTextChanged.connect(
346
+ lambda *_: self.on_local_settings_changed()
347
+ )
348
+
254
349
  _group_box.setLayout(_layout)
255
350
  return _group_box
256
351
 
@@ -284,6 +379,15 @@ class BaseGUI(QWidget):
284
379
  self.model_license_label.setWordWrap(True)
285
380
  _layout.addWidget(self.model_license_label)
286
381
 
382
+ self.undo_button = setup_iconbutton(
383
+ _layout,
384
+ "Undo",
385
+ "step_left",
386
+ self._viewer.theme,
387
+ self.on_undo,
388
+ tooltips="Undo the last interaction for the current object - press Ctrl+Z",
389
+ shortcut="Ctrl+Z",
390
+ )
287
391
  self.reset_interaction_button = setup_iconbutton(
288
392
  _layout,
289
393
  "Reset Object",
@@ -495,10 +599,19 @@ class BaseGUI(QWidget):
495
599
  def on_remote_settings_changed(self, *args, **kwargs) -> None:
496
600
  """Placeholder for handling changes to remote URL/API key fields."""
497
601
 
602
+ def on_local_settings_changed(self, *args, **kwargs) -> None:
603
+ """Placeholder for changes to baked-in local options (torch.compile / storage)."""
604
+
605
+ def on_checkpoint_changed(self, *args, **kwargs) -> None:
606
+ """Placeholder for edits to / clearing of the local checkpoint path."""
607
+
498
608
  def on_reset_interactions(self):
499
609
  """Reset only the current interaction"""
500
610
  self._clear_layers()
501
611
 
612
+ def on_undo(self, *args, **kwargs) -> None:
613
+ """Placeholder method for undoing the last interaction."""
614
+
502
615
  def on_next(self) -> None:
503
616
  """Resets the interactions."""
504
617
  print("_reset_interactions")
@@ -45,6 +45,32 @@ class nnInteractiveWidget(LayerControls):
45
45
  """
46
46
  A widget for the nnInteractive plugin in Napari that manages model inference sessions
47
47
  and allows interactive layer-based actions.
48
+
49
+ Handling the in-progress object when a session ends
50
+ ---------------------------------------------------
51
+ Whenever a live session is torn down we have to decide what happens to the
52
+ object the user is currently working on (the un-committed "nnInteractive -
53
+ Label Layer"). The behaviour deliberately splits along *why* the session
54
+ ended:
55
+
56
+ * **User-triggered reinitialization** -- changing the model, the Local/Remote
57
+ mode, the server URL/key (``on_model_selected``), the local checkpoint
58
+ (``on_checkpoint_changed``) or a baked-in option such as torch.compile or
59
+ the interaction storage backend (``on_local_settings_changed``). The new
60
+ session cannot meaningfully continue the old object, so we *wrap it up*:
61
+ ``_store_in_progress_segmentation`` commits it as a finished object (exactly
62
+ like "Next Object") and the user starts fresh on the next Initialize. If
63
+ they don't want the stored object they can simply delete the layer.
64
+
65
+ * **Unintentional loss** -- the remote lease expired or the connection dropped
66
+ (``_handle_session_expired``). The user did not ask to stop, so we instead
67
+ *resume*: the label layer is kept and the resume machinery
68
+ (``_resume_after_reconnect`` / ``_resume_image_layer`` / ``_resuming``,
69
+ consumed in ``LayerControls.on_init``) seeds the reconnected session with it
70
+ so refinement continues on the same object.
71
+
72
+ In short: deliberate resets bank the work and start over; accidental drops
73
+ preserve and resume it.
48
74
  """
49
75
 
50
76
  def __init__(self, viewer: Viewer, parent: Optional[QWidget] = None):
@@ -64,6 +90,9 @@ class nnInteractiveWidget(LayerControls):
64
90
  self._resume_after_reconnect = False
65
91
  self._resume_image_layer = None
66
92
  self._resuming = False
93
+ # Checkpoint-path text the current session was built from. Lets a
94
+ # re-submitted, unchanged path be a no-op instead of an uninitialize.
95
+ self._active_checkpoint_text = None
67
96
  self._viewer.dims.events.order.connect(self.on_axis_change)
68
97
 
69
98
  # Belt-and-suspenders lease release on shutdown. closeEvent on this
@@ -163,6 +192,9 @@ class nnInteractiveWidget(LayerControls):
163
192
  # Init succeeded; clear the resume state so a normal re-init starts fresh.
164
193
  self._resume_after_reconnect = False
165
194
  self._resuming = False
195
+ # Remember the checkpoint text this session was built from, so re-pressing
196
+ # Enter on an unchanged path keeps the session instead of resetting it.
197
+ self._active_checkpoint_text = self.model_selection_local.text()
166
198
 
167
199
  if self._viewer.dims.not_displayed != ():
168
200
  self._scribble_brush_size = self.session.preferred_scribble_thickness[
@@ -210,6 +242,7 @@ class nnInteractiveWidget(LayerControls):
210
242
  torch_n_threads=os.cpu_count(),
211
243
  verbose=False,
212
244
  do_autozoom=self.propagate_ckbx.isChecked(),
245
+ interactions_storage=self.interactions_storage_combo.currentText(),
213
246
  )
214
247
 
215
248
  self.session.initialize_from_trained_model_folder(
@@ -238,32 +271,40 @@ class nnInteractiveWidget(LayerControls):
238
271
  server_url=server_url, api_key=api_key
239
272
  )
240
273
  except ServerAtCapacityError:
241
- self.remote_status_label.setText("server is full, try again later")
242
- return None
243
- except _SESSION_LOST_ERRORS:
244
- # Extremely unlikely at /claim time, but handle for completeness.
245
- self.remote_status_label.setText("server rejected the claim; try again")
274
+ self.remote_status_label.setText("Server full; try again later.")
246
275
  return None
276
+ # Connectivity problems must be handled BEFORE the session-lost case below:
277
+ # httpx.ConnectError/ConnectTimeout are subclasses of httpx.TransportError, so a
278
+ # broad TransportError catch would otherwise swallow them and report the wrong cause.
247
279
  except httpx.ConnectError:
248
- self.remote_status_label.setText(f"cannot reach {server_url}")
280
+ # DNS failure, connection refused, no route — nothing is listening/reachable.
281
+ self.remote_status_label.setText("Cannot reach server; check URL/port.")
249
282
  return None
250
283
  except httpx.ConnectTimeout:
251
- self.remote_status_label.setText(f"timed out reaching {server_url}")
284
+ self.remote_status_label.setText("Connection timed out; check URL/network.")
285
+ return None
286
+ except httpx.TimeoutException:
287
+ # Connected, but the server did not answer the claim in time.
288
+ self.remote_status_label.setText("Server not responding; try again.")
289
+ return None
290
+ except SessionExpiredError:
291
+ # The connection worked but the server refused/expired the claim itself.
292
+ self.remote_status_label.setText("Claim rejected; try again.")
252
293
  return None
253
294
  except httpx.HTTPStatusError as e:
254
295
  if e.response.status_code == 401:
255
- self.remote_status_label.setText("server rejected the API key")
296
+ self.remote_status_label.setText("Invalid API key.")
256
297
  elif "text/html" in e.response.headers.get("content-type", ""):
257
- self.remote_status_label.setText(
258
- "server returned HTML (HTTP proxy?); set NO_PROXY"
259
- )
298
+ self.remote_status_label.setText("Not an nnInteractive server (proxy?).")
260
299
  else:
261
- self.remote_status_label.setText(
262
- f"server error {e.response.status_code}"
263
- )
300
+ self.remote_status_label.setText(f"Server error {e.response.status_code}.")
301
+ return None
302
+ except httpx.TransportError:
303
+ # Any other network-level failure (proxy, protocol, broken connection).
304
+ self.remote_status_label.setText("Network error; check connection.")
264
305
  return None
265
306
  except Exception as e: # noqa: BLE001
266
- self.remote_status_label.setText(f"error: {e}")
307
+ self.remote_status_label.setText(f"Error: {e}")
267
308
  return None
268
309
 
269
310
  # Honor the current auto-zoom checkbox on the remote session too.
@@ -354,8 +395,35 @@ class nnInteractiveWidget(LayerControls):
354
395
  self.remote_container.setVisible(self._remote_mode)
355
396
  self.on_model_selected()
356
397
 
398
+ def _store_in_progress_segmentation(self) -> None:
399
+ """Before a genuine reset (model / mode / server change) drops the session,
400
+ store the object currently being worked on.
401
+
402
+ A genuine reset starts a fresh session, so the in-progress segmentation
403
+ cannot be resumed (unlike a reconnect or a baked-in option change). Rather
404
+ than silently discarding it, store it as a finished object - exactly like
405
+ 'Next Object' does. The user can delete the stored object manually if they
406
+ do not want it. The working layer is then removed: its data has already
407
+ been copied into the stored object, and removing it prevents the same
408
+ object being stored twice if the user resets again before re-initializing.
409
+
410
+ Does nothing when there is no non-empty in-progress segmentation.
411
+ """
412
+ if (
413
+ self.session_cfg is None
414
+ or self.label_layer_name not in self._viewer.layers
415
+ or not np.any(self._viewer.layers[self.label_layer_name].data)
416
+ ):
417
+ return
418
+
419
+ self._store_current_object()
420
+ self._viewer.layers.remove(self.label_layer_name)
421
+
357
422
  def on_model_selected(self):
358
423
  """Reset the current session completely"""
424
+ # A genuine reset cannot resume the in-progress object, so store it as a
425
+ # finished object before the session is gone instead of losing it.
426
+ self._store_in_progress_segmentation()
359
427
  super().on_model_selected()
360
428
  self.session = None
361
429
  # Genuine reset: the previous model's license no longer applies.
@@ -366,6 +434,50 @@ class nnInteractiveWidget(LayerControls):
366
434
  self._resume_after_reconnect = False
367
435
  self._resume_image_layer = None
368
436
 
437
+ def _uninitialize_storing_segmentation(self) -> bool:
438
+ """Drop the live session, first storing the in-progress object as a
439
+ finished object (like a model/mode change) instead of resuming it on the
440
+ next Initialize. Returns True if a session was actually torn down, False
441
+ when nothing was initialized.
442
+ """
443
+ if self.session is None:
444
+ # Nothing initialized yet, so there is nothing to store or tear down;
445
+ # the new value is simply picked up at the next Initialize.
446
+ return False
447
+ self._store_in_progress_segmentation()
448
+ self.session = None
449
+ self._clear_layers()
450
+ self._unlock_session()
451
+ return True
452
+
453
+ def on_local_settings_changed(self, *args, **kwargs):
454
+ """A baked-in local option (torch.compile / interaction storage) changed.
455
+
456
+ The live session was built with the old value, so drop it and force a
457
+ re-Initialize. The new session cannot resume the in-progress object, so
458
+ store it as a finished object first instead of discarding it. The model is
459
+ unchanged, so the displayed license still applies.
460
+ """
461
+ self._uninitialize_storing_segmentation()
462
+
463
+ def on_checkpoint_changed(self, *args, **kwargs):
464
+ """The local checkpoint path was edited or cleared (the 'x' button).
465
+
466
+ Like a settings change, drop the session and store the in-progress object
467
+ as a finished object before it is lost. The checkpoint may point at a
468
+ different model though, so drop the displayed license; on_init repopulates
469
+ it once the new session is up.
470
+ """
471
+ # Re-submitting the same path the live session was built from changes
472
+ # nothing, so leave the session initialized.
473
+ if (
474
+ self.session is not None
475
+ and self.model_selection_local.text() == self._active_checkpoint_text
476
+ ):
477
+ return
478
+ if self._uninitialize_storing_segmentation():
479
+ self._update_license_display(None)
480
+
369
481
  def on_image_selected(self):
370
482
  """Reset the current sessions interaction but keep the session itself"""
371
483
  super().on_image_selected()
@@ -499,8 +611,46 @@ class nnInteractiveWidget(LayerControls):
499
611
  self._handle_session_expired()
500
612
  return
501
613
 
614
+ # Record which layer holds this interaction's marker so on_undo can remove it.
615
+ self._interaction_history.append(_layer_name)
502
616
  self._viewer.layers[self.label_layer_name].refresh()
503
617
 
618
+ def on_undo(self):
619
+ """Undo the most recent interaction for the current object.
620
+
621
+ Reverts the segmentation via the backend's single-level undo and removes the visual
622
+ marker of the undone interaction. Only the most recent interaction can be undone; the
623
+ backend re-arms so the next new interaction becomes undoable again.
624
+ """
625
+ if self.session is None:
626
+ return
627
+ if not getattr(self.session, "supports_undo", False):
628
+ show_warning("Undo is not supported by this server. Please update nninteractive-server.")
629
+ return
630
+ try:
631
+ undone = self.session.undo()
632
+ except _SESSION_LOST_ERRORS:
633
+ self._handle_session_expired()
634
+ return
635
+
636
+ if not undone:
637
+ show_warning("Nothing to undo.")
638
+ return
639
+
640
+ # Remove the visual marker of the undone interaction, if we tracked one.
641
+ if self._interaction_history:
642
+ layer_name = self._interaction_history.pop()
643
+ if layer_name is not None and layer_name in self._viewer.layers:
644
+ layer = self._viewer.layers[layer_name]
645
+ try:
646
+ layer.remove_last()
647
+ layer.refresh()
648
+ except Exception as e: # noqa: BLE001
649
+ print(f"[napari-nninteractive] could not remove last interaction marker: {e!r}")
650
+
651
+ if self.label_layer_name in self._viewer.layers:
652
+ self._viewer.layers[self.label_layer_name].refresh()
653
+
504
654
  def on_load_mask(self):
505
655
 
506
656
  _layer_data = self._viewer.layers[self.label_for_init.currentText()].data
@@ -520,6 +670,8 @@ class nnInteractiveWidget(LayerControls):
520
670
  except _SESSION_LOST_ERRORS:
521
671
  self._handle_session_expired()
522
672
  return
673
+ # Undoable via the backend; there is no interaction-layer marker to remove.
674
+ self._interaction_history.append(None)
523
675
  self._viewer.layers[self.label_layer_name].refresh()
524
676
  else:
525
677
  warnings.warn("Mask is not valid - probably its empty", UserWarning, stacklevel=1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: napari-nninteractive
3
- Version: 2.2.0
3
+ Version: 2.3.2
4
4
  Summary: nnInteractive plugin for Napari
5
5
  Author: Lars Krämer, Fabian Isensee, Maximilian Rokuss
6
6
  Author-email: lars.kraemer@dkfz-heidelberg.de, f.isensee@dkfz-heidelberg.de, maximilian.rokuss@dkfz-heidelberg.de
@@ -231,7 +231,7 @@ Requires-Dist: qtpy
231
231
  Requires-Dist: napari-nifti
232
232
  Requires-Dist: huggingface_hub
233
233
  Requires-Dist: hf_transfer
234
- Requires-Dist: nnInteractive>=2.3.0
234
+ Requires-Dist: nnInteractive>=2.4.0
235
235
  Requires-Dist: napari_toolkit
236
236
  Provides-Extra: testing
237
237
  Requires-Dist: tox; extra == "testing"
@@ -241,7 +241,7 @@ Requires-Dist: pytest-qt; extra == "testing"
241
241
  Requires-Dist: napari; extra == "testing"
242
242
  Requires-Dist: pyqt5; extra == "testing"
243
243
  Provides-Extra: remote
244
- Requires-Dist: nnInteractive[client]>=2.2.0; extra == "remote"
244
+ Requires-Dist: nnInteractive[client]>=2.4.0; extra == "remote"
245
245
  Dynamic: license-file
246
246
 
247
247
  <img src="https://github.com/MIC-DKFZ/napari-nninteractive/raw/main/imgs/nnInteractive_header.png" width="1200">
@@ -3,6 +3,7 @@ MANIFEST.in
3
3
  README.md
4
4
  pyproject.toml
5
5
  src/napari_nninteractive/__init__.py
6
+ src/napari_nninteractive/_version_check.py
6
7
  src/napari_nninteractive/napari.yaml
7
8
  src/napari_nninteractive/widget_controls.py
8
9
  src/napari_nninteractive/widget_gui.py
@@ -4,11 +4,11 @@ qtpy
4
4
  napari-nifti
5
5
  huggingface_hub
6
6
  hf_transfer
7
- nnInteractive>=2.3.0
7
+ nnInteractive>=2.4.0
8
8
  napari_toolkit
9
9
 
10
10
  [remote]
11
- nnInteractive[client]>=2.2.0
11
+ nnInteractive[client]>=2.4.0
12
12
 
13
13
  [testing]
14
14
  tox
@@ -1,4 +0,0 @@
1
- __version__ = "2.2.0"
2
- from .widget_main import nnInteractiveWidget
3
-
4
- __all__ = ("nnInteractiveWidget",)