senoquant 1.0.0b1__py3-none-any.whl

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 (148) hide show
  1. senoquant/__init__.py +6 -0
  2. senoquant/_reader.py +7 -0
  3. senoquant/_widget.py +33 -0
  4. senoquant/napari.yaml +83 -0
  5. senoquant/reader/__init__.py +5 -0
  6. senoquant/reader/core.py +369 -0
  7. senoquant/tabs/__init__.py +15 -0
  8. senoquant/tabs/batch/__init__.py +10 -0
  9. senoquant/tabs/batch/backend.py +641 -0
  10. senoquant/tabs/batch/config.py +270 -0
  11. senoquant/tabs/batch/frontend.py +1283 -0
  12. senoquant/tabs/batch/io.py +326 -0
  13. senoquant/tabs/batch/layers.py +86 -0
  14. senoquant/tabs/quantification/__init__.py +1 -0
  15. senoquant/tabs/quantification/backend.py +228 -0
  16. senoquant/tabs/quantification/features/__init__.py +80 -0
  17. senoquant/tabs/quantification/features/base.py +142 -0
  18. senoquant/tabs/quantification/features/marker/__init__.py +5 -0
  19. senoquant/tabs/quantification/features/marker/config.py +69 -0
  20. senoquant/tabs/quantification/features/marker/dialog.py +437 -0
  21. senoquant/tabs/quantification/features/marker/export.py +879 -0
  22. senoquant/tabs/quantification/features/marker/feature.py +119 -0
  23. senoquant/tabs/quantification/features/marker/morphology.py +285 -0
  24. senoquant/tabs/quantification/features/marker/rows.py +654 -0
  25. senoquant/tabs/quantification/features/marker/thresholding.py +46 -0
  26. senoquant/tabs/quantification/features/roi.py +346 -0
  27. senoquant/tabs/quantification/features/spots/__init__.py +5 -0
  28. senoquant/tabs/quantification/features/spots/config.py +62 -0
  29. senoquant/tabs/quantification/features/spots/dialog.py +477 -0
  30. senoquant/tabs/quantification/features/spots/export.py +1292 -0
  31. senoquant/tabs/quantification/features/spots/feature.py +112 -0
  32. senoquant/tabs/quantification/features/spots/morphology.py +279 -0
  33. senoquant/tabs/quantification/features/spots/rows.py +241 -0
  34. senoquant/tabs/quantification/frontend.py +815 -0
  35. senoquant/tabs/segmentation/__init__.py +1 -0
  36. senoquant/tabs/segmentation/backend.py +131 -0
  37. senoquant/tabs/segmentation/frontend.py +1009 -0
  38. senoquant/tabs/segmentation/models/__init__.py +5 -0
  39. senoquant/tabs/segmentation/models/base.py +146 -0
  40. senoquant/tabs/segmentation/models/cpsam/details.json +65 -0
  41. senoquant/tabs/segmentation/models/cpsam/model.py +150 -0
  42. senoquant/tabs/segmentation/models/default_2d/details.json +69 -0
  43. senoquant/tabs/segmentation/models/default_2d/model.py +664 -0
  44. senoquant/tabs/segmentation/models/default_3d/details.json +69 -0
  45. senoquant/tabs/segmentation/models/default_3d/model.py +682 -0
  46. senoquant/tabs/segmentation/models/hf.py +71 -0
  47. senoquant/tabs/segmentation/models/nuclear_dilation/__init__.py +1 -0
  48. senoquant/tabs/segmentation/models/nuclear_dilation/details.json +26 -0
  49. senoquant/tabs/segmentation/models/nuclear_dilation/model.py +96 -0
  50. senoquant/tabs/segmentation/models/perinuclear_rings/__init__.py +1 -0
  51. senoquant/tabs/segmentation/models/perinuclear_rings/details.json +34 -0
  52. senoquant/tabs/segmentation/models/perinuclear_rings/model.py +132 -0
  53. senoquant/tabs/segmentation/stardist_onnx_utils/__init__.py +2 -0
  54. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/__init__.py +3 -0
  55. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/__init__.py +6 -0
  56. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/generate.py +470 -0
  57. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/prepare.py +273 -0
  58. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/rawdata.py +112 -0
  59. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/transform.py +384 -0
  60. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/__init__.py +0 -0
  61. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/blocks.py +184 -0
  62. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/losses.py +79 -0
  63. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/nets.py +165 -0
  64. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/predict.py +467 -0
  65. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/probability.py +67 -0
  66. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/train.py +148 -0
  67. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/io/__init__.py +163 -0
  68. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/__init__.py +52 -0
  69. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/base_model.py +329 -0
  70. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_isotropic.py +160 -0
  71. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_projection.py +178 -0
  72. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_standard.py +446 -0
  73. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_upsampling.py +54 -0
  74. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/config.py +254 -0
  75. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/pretrained.py +119 -0
  76. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/scripts/__init__.py +0 -0
  77. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/scripts/care_predict.py +180 -0
  78. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/__init__.py +5 -0
  79. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/plot_utils.py +159 -0
  80. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/six.py +18 -0
  81. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/tf.py +644 -0
  82. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/utils.py +272 -0
  83. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/version.py +1 -0
  84. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/docs/source/conf.py +368 -0
  85. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/setup.py +68 -0
  86. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_datagen.py +169 -0
  87. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_models.py +462 -0
  88. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_utils.py +166 -0
  89. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +34 -0
  90. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/__init__.py +30 -0
  91. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/big.py +624 -0
  92. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/bioimageio_utils.py +494 -0
  93. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/data/__init__.py +39 -0
  94. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/__init__.py +10 -0
  95. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/geom2d.py +215 -0
  96. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/geom3d.py +349 -0
  97. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/matching.py +483 -0
  98. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/__init__.py +28 -0
  99. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/base.py +1217 -0
  100. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/model2d.py +594 -0
  101. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/model3d.py +696 -0
  102. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/nms.py +384 -0
  103. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/__init__.py +2 -0
  104. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/plot.py +74 -0
  105. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/render.py +298 -0
  106. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/rays3d.py +373 -0
  107. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/sample_patches.py +65 -0
  108. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/__init__.py +0 -0
  109. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/predict2d.py +90 -0
  110. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/predict3d.py +93 -0
  111. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/utils.py +408 -0
  112. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/version.py +1 -0
  113. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/__init__.py +45 -0
  114. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/__init__.py +17 -0
  115. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/cli.py +55 -0
  116. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/core.py +285 -0
  117. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/__init__.py +15 -0
  118. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/cli.py +36 -0
  119. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/divisibility.py +193 -0
  120. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +100 -0
  121. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/receptive_field.py +182 -0
  122. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/rf_cli.py +48 -0
  123. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/valid_sizes.py +278 -0
  124. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/post/__init__.py +8 -0
  125. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/post/core.py +157 -0
  126. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/pre/__init__.py +17 -0
  127. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/pre/core.py +226 -0
  128. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/predict/__init__.py +5 -0
  129. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/predict/core.py +401 -0
  130. senoquant/tabs/settings/__init__.py +1 -0
  131. senoquant/tabs/settings/backend.py +29 -0
  132. senoquant/tabs/settings/frontend.py +19 -0
  133. senoquant/tabs/spots/__init__.py +1 -0
  134. senoquant/tabs/spots/backend.py +139 -0
  135. senoquant/tabs/spots/frontend.py +800 -0
  136. senoquant/tabs/spots/models/__init__.py +5 -0
  137. senoquant/tabs/spots/models/base.py +94 -0
  138. senoquant/tabs/spots/models/rmp/details.json +61 -0
  139. senoquant/tabs/spots/models/rmp/model.py +499 -0
  140. senoquant/tabs/spots/models/udwt/details.json +103 -0
  141. senoquant/tabs/spots/models/udwt/model.py +482 -0
  142. senoquant/utils.py +25 -0
  143. senoquant-1.0.0b1.dist-info/METADATA +193 -0
  144. senoquant-1.0.0b1.dist-info/RECORD +148 -0
  145. senoquant-1.0.0b1.dist-info/WHEEL +5 -0
  146. senoquant-1.0.0b1.dist-info/entry_points.txt +2 -0
  147. senoquant-1.0.0b1.dist-info/licenses/LICENSE +28 -0
  148. senoquant-1.0.0b1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1009 @@
1
+ """Frontend widget for the Segmentation tab."""
2
+
3
+ from qtpy.QtCore import QObject, QThread, Signal
4
+ from qtpy.QtWidgets import (
5
+ QCheckBox,
6
+ QComboBox,
7
+ QDoubleSpinBox,
8
+ QFormLayout,
9
+ QGroupBox,
10
+ QLabel,
11
+ QFrame,
12
+ QPushButton,
13
+ QSizePolicy,
14
+ QSpinBox,
15
+ QVBoxLayout,
16
+ QWidget,
17
+ )
18
+
19
+ try:
20
+ from napari.layers import Image, Labels
21
+ from napari.utils.notifications import (
22
+ Notification,
23
+ NotificationSeverity,
24
+ show_console_notification,
25
+ )
26
+ except Exception: # pragma: no cover - optional import for runtime
27
+ Image = None
28
+ Labels = None
29
+ show_console_notification = None
30
+ Notification = None
31
+ NotificationSeverity = None
32
+
33
+
34
+ class RefreshingComboBox(QComboBox):
35
+ """Combo box that refreshes its items when opened."""
36
+
37
+ def __init__(self, refresh_callback=None, parent=None) -> None:
38
+ """Create a combo box that refreshes on popup.
39
+
40
+ Parameters
41
+ ----------
42
+ refresh_callback : callable or None
43
+ Function invoked before showing the popup.
44
+ parent : QWidget or None
45
+ Optional parent widget.
46
+ """
47
+ super().__init__(parent)
48
+ self._refresh_callback = refresh_callback
49
+
50
+ def showPopup(self) -> None:
51
+ """Refresh items before showing the popup."""
52
+ if self._refresh_callback is not None:
53
+ self._refresh_callback()
54
+ super().showPopup()
55
+
56
+
57
+ # Layer dropdowns refresh at click-time so the UI stays in sync with napari.
58
+ # This keeps options limited to Image layers and preserves existing selections.
59
+
60
+ from .backend import SegmentationBackend
61
+ from ..settings.backend import SettingsBackend
62
+
63
+
64
+ class SegmentationTab(QWidget):
65
+ """Segmentation tab UI with nuclear and cytoplasmic sections.
66
+
67
+ Parameters
68
+ ----------
69
+ backend : SegmentationBackend or None
70
+ Backend instance used to discover and load models.
71
+ napari_viewer : object or None
72
+ Napari viewer used to populate layer choices.
73
+ settings_backend : SettingsBackend or None
74
+ Settings store used for preload configuration.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ backend: SegmentationBackend | None = None,
80
+ napari_viewer=None,
81
+ settings_backend: SettingsBackend | None = None,
82
+ ) -> None:
83
+ """Create the segmentation tab UI.
84
+
85
+ Parameters
86
+ ----------
87
+ backend : SegmentationBackend or None
88
+ Backend instance used to discover and load models.
89
+ napari_viewer : object or None
90
+ Napari viewer used to populate layer choices.
91
+ settings_backend : SettingsBackend or None
92
+ Settings store used for preload configuration.
93
+ """
94
+ super().__init__()
95
+ self._backend = backend or SegmentationBackend()
96
+ self._viewer = napari_viewer
97
+ self._nuclear_settings_widgets = {}
98
+ self._cyto_settings_widgets = {}
99
+ self._nuclear_settings_meta = {}
100
+ self._cyto_settings_meta = {}
101
+ self._settings = settings_backend or SettingsBackend()
102
+ self._settings.preload_models_changed.connect(
103
+ self._on_preload_models_changed
104
+ )
105
+ self._active_workers: list[tuple[QThread, QObject]] = []
106
+
107
+ layout = QVBoxLayout()
108
+ layout.addWidget(self._make_nuclear_section())
109
+ layout.addWidget(self._make_cytoplasmic_section())
110
+ layout.addStretch(1)
111
+ self.setLayout(layout)
112
+
113
+ self._refresh_layer_choices()
114
+ self._refresh_model_choices()
115
+ self._update_nuclear_model_settings(self._nuclear_model_combo.currentText())
116
+ self._update_cytoplasmic_model_settings(self._cyto_model_combo.currentText())
117
+
118
+ if self._settings.preload_models_enabled():
119
+ if (
120
+ show_console_notification is not None
121
+ and Notification is not None
122
+ and NotificationSeverity is not None
123
+ ):
124
+ show_console_notification(
125
+ Notification(
126
+ "Preloading segmentation models...",
127
+ severity=NotificationSeverity.INFO,
128
+ )
129
+ )
130
+ self._backend.preload_models()
131
+
132
+ def _make_nuclear_section(self) -> QGroupBox:
133
+ """Build the nuclear segmentation UI section.
134
+
135
+ Returns
136
+ -------
137
+ QGroupBox
138
+ Group box containing nuclear segmentation controls.
139
+ """
140
+ section = QGroupBox("Nuclear segmentation")
141
+ section_layout = QVBoxLayout()
142
+
143
+ form_layout = QFormLayout()
144
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
145
+ self._nuclear_layer_combo = RefreshingComboBox(
146
+ refresh_callback=self._refresh_layer_choices
147
+ )
148
+ self._configure_combo(self._nuclear_layer_combo)
149
+ self._nuclear_model_combo = QComboBox()
150
+ self._configure_combo(self._nuclear_model_combo)
151
+ self._nuclear_model_combo.currentTextChanged.connect(
152
+ self._update_nuclear_model_settings
153
+ )
154
+
155
+ form_layout.addRow("Nuclear layer", self._nuclear_layer_combo)
156
+ form_layout.addRow("Model", self._nuclear_model_combo)
157
+
158
+ section_layout.addLayout(form_layout)
159
+ section_layout.addWidget(
160
+ self._make_model_settings_section("Model settings", "nuclear")
161
+ )
162
+
163
+ self._nuclear_run_button = QPushButton("Run")
164
+ self._nuclear_run_button.clicked.connect(self._run_nuclear)
165
+ section_layout.addWidget(self._nuclear_run_button)
166
+ section.setLayout(section_layout)
167
+
168
+ return section
169
+
170
+ def _make_cytoplasmic_section(self) -> QGroupBox:
171
+ """Build the cytoplasmic segmentation UI section.
172
+
173
+ Returns
174
+ -------
175
+ QGroupBox
176
+ Group box containing cytoplasmic segmentation controls.
177
+ """
178
+ section = QGroupBox("Cytoplasmic segmentation")
179
+ section_layout = QVBoxLayout()
180
+
181
+ form_layout = QFormLayout()
182
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
183
+ self._cyto_layer_combo = RefreshingComboBox(
184
+ refresh_callback=self._refresh_layer_choices
185
+ )
186
+ self._configure_combo(self._cyto_layer_combo)
187
+ self._cyto_nuclear_layer_combo = RefreshingComboBox(
188
+ refresh_callback=self._refresh_layer_choices
189
+ )
190
+ self._configure_combo(self._cyto_nuclear_layer_combo)
191
+ self._cyto_nuclear_layer_combo.currentTextChanged.connect(
192
+ self._on_cyto_nuclear_layer_changed
193
+ )
194
+ self._cyto_model_combo = QComboBox()
195
+ self._configure_combo(self._cyto_model_combo)
196
+ self._cyto_model_combo.currentTextChanged.connect(
197
+ self._update_cytoplasmic_model_settings
198
+ )
199
+
200
+ self._cyto_layer_label = QLabel("Cytoplasmic layer")
201
+ form_layout.addRow(self._cyto_layer_label, self._cyto_layer_combo)
202
+ self._cyto_nuclear_label = QLabel("Nuclear layer")
203
+ form_layout.addRow(self._cyto_nuclear_label, self._cyto_nuclear_layer_combo)
204
+ form_layout.addRow("Model", self._cyto_model_combo)
205
+
206
+ section_layout.addLayout(form_layout)
207
+ section_layout.addWidget(
208
+ self._make_model_settings_section("Model settings", "cytoplasmic")
209
+ )
210
+
211
+ self._cyto_run_button = QPushButton("Run")
212
+ self._cyto_run_button.clicked.connect(self._run_cytoplasmic)
213
+ section_layout.addWidget(self._cyto_run_button)
214
+ section.setLayout(section_layout)
215
+ return section
216
+
217
+ def _make_model_settings_section(self, title: str, section_key: str) -> QGroupBox:
218
+ """Build the model settings section container.
219
+
220
+ Parameters
221
+ ----------
222
+ title : str
223
+ Section title displayed on the ring.
224
+ section_key : str
225
+ Section identifier used to store the settings layout.
226
+
227
+ Returns
228
+ -------
229
+ QGroupBox
230
+ Group box containing model-specific settings.
231
+ """
232
+ return self._make_titled_section(title, section_key)
233
+
234
+ def _make_titled_section(self, title: str, section_key: str) -> QGroupBox:
235
+ """Create a titled box that mimics a group box ring.
236
+
237
+ Parameters
238
+ ----------
239
+ title : str
240
+ Title displayed on the ring.
241
+ section_key : str
242
+ Section identifier used to store the settings layout.
243
+
244
+ Returns
245
+ -------
246
+ QGroupBox
247
+ Group box containing a framed content area.
248
+ """
249
+ section = QGroupBox(title)
250
+ section.setFlat(True)
251
+ section.setStyleSheet(
252
+ "QGroupBox {"
253
+ " margin-top: 8px;"
254
+ "}"
255
+ "QGroupBox::title {"
256
+ " subcontrol-origin: margin;"
257
+ " subcontrol-position: top left;"
258
+ " padding: 0 6px;"
259
+ "}"
260
+ )
261
+
262
+ frame = QFrame()
263
+ frame.setFrameShape(QFrame.StyledPanel)
264
+ frame.setFrameShadow(QFrame.Plain)
265
+ frame.setObjectName("titled-section-frame")
266
+ frame.setStyleSheet(
267
+ "QFrame#titled-section-frame {"
268
+ " border: 1px solid palette(mid);"
269
+ " border-radius: 4px;"
270
+ "}"
271
+ )
272
+
273
+ settings_layout = QVBoxLayout()
274
+ settings_layout.setContentsMargins(10, 12, 10, 10)
275
+ frame.setLayout(settings_layout)
276
+
277
+ section_layout = QVBoxLayout()
278
+ section_layout.setContentsMargins(8, 12, 8, 4)
279
+ section_layout.addWidget(frame)
280
+ section.setLayout(section_layout)
281
+
282
+ if section_key == "nuclear":
283
+ self._nuclear_model_settings_layout = settings_layout
284
+ else:
285
+ self._cyto_model_settings_layout = settings_layout
286
+
287
+ return section
288
+
289
+ def _refresh_layer_choices(self) -> None:
290
+ """Populate layer dropdowns from the napari viewer."""
291
+ nuclear_current = self._nuclear_layer_combo.currentText()
292
+ cyto_current = self._cyto_layer_combo.currentText()
293
+ cyto_nuclear_current = self._cyto_nuclear_layer_combo.currentText()
294
+
295
+ self._nuclear_layer_combo.clear()
296
+ self._cyto_layer_combo.clear()
297
+ self._cyto_nuclear_layer_combo.clear()
298
+ if self._viewer is None:
299
+ self._nuclear_layer_combo.addItem("Select a layer")
300
+ self._cyto_layer_combo.addItem("Select a layer")
301
+ self._cyto_nuclear_layer_combo.addItem("Select a layer")
302
+ return
303
+
304
+ # For nuclear and cytoplasmic layers, use Image layers
305
+ names = [layer.name for layer in self._iter_image_layers()]
306
+ for name in names:
307
+ self._nuclear_layer_combo.addItem(name)
308
+ self._cyto_layer_combo.addItem(name)
309
+
310
+ # For cytoplasmic nuclear layer, check if model uses nuclear-only mode
311
+ cyto_model_name = self._cyto_model_combo.currentText()
312
+ if cyto_model_name and cyto_model_name != "No models found":
313
+ try:
314
+ model = self._backend.get_model(cyto_model_name)
315
+ modes = model.cytoplasmic_input_modes()
316
+ if modes == ["nuclear"]:
317
+ # Nuclear-only mode: populate with Labels layers
318
+ label_names = [layer.name for layer in self._iter_label_layers()]
319
+ for name in label_names:
320
+ self._cyto_nuclear_layer_combo.addItem(name)
321
+ else:
322
+ # Standard mode: populate with Image layers
323
+ for name in names:
324
+ self._cyto_nuclear_layer_combo.addItem(name)
325
+ except Exception:
326
+ # Fallback to Image layers if model can't be loaded
327
+ for name in names:
328
+ self._cyto_nuclear_layer_combo.addItem(name)
329
+ else:
330
+ # No model selected: populate with Image layers
331
+ for name in names:
332
+ self._cyto_nuclear_layer_combo.addItem(name)
333
+
334
+ self._cyto_nuclear_layer_combo.insertItem(0, "Select a layer")
335
+
336
+ self._restore_combo_selection(self._nuclear_layer_combo, nuclear_current)
337
+ self._restore_combo_selection(self._cyto_layer_combo, cyto_current)
338
+ self._restore_combo_selection(
339
+ self._cyto_nuclear_layer_combo, cyto_nuclear_current
340
+ )
341
+
342
+ def _refresh_model_choices(self) -> None:
343
+ """Populate the model dropdowns from available model folders."""
344
+ self._nuclear_model_combo.clear()
345
+ self._cyto_model_combo.clear()
346
+
347
+ nuclear_names = self._backend.list_model_names(task="nuclear")
348
+ if not nuclear_names:
349
+ self._nuclear_model_combo.addItem("No models found")
350
+ else:
351
+ self._nuclear_model_combo.addItems(nuclear_names)
352
+
353
+ cyto_names = self._backend.list_model_names(task="cytoplasmic")
354
+ if not cyto_names:
355
+ self._cyto_model_combo.addItem("No models found")
356
+ else:
357
+ self._cyto_model_combo.addItems(cyto_names)
358
+
359
+ # Trigger initial model settings update to configure layer filters
360
+ if cyto_names:
361
+ self._update_cytoplasmic_model_settings(self._cyto_model_combo.currentText())
362
+
363
+ def _update_nuclear_model_settings(self, model_name: str) -> None:
364
+ """Rebuild the nuclear model settings area for the selected model.
365
+
366
+ Parameters
367
+ ----------
368
+ model_name : str
369
+ Selected model name from the dropdown.
370
+ """
371
+ self._refresh_model_settings_layout(
372
+ self._nuclear_model_settings_layout, model_name
373
+ )
374
+
375
+ def _update_cytoplasmic_model_settings(self, model_name: str) -> None:
376
+ """Rebuild the cytoplasmic model settings area for the selected model.
377
+
378
+ Parameters
379
+ ----------
380
+ model_name : str
381
+ Selected model name from the dropdown.
382
+ """
383
+ self._refresh_model_settings_layout(
384
+ self._cyto_model_settings_layout, model_name
385
+ )
386
+
387
+ if not model_name or model_name == "No models found":
388
+ self._cyto_layer_combo.setVisible(True)
389
+ self._cyto_layer_combo.setEnabled(False)
390
+ self._cyto_nuclear_layer_combo.setEnabled(False)
391
+ self._cyto_nuclear_label.setText("Nuclear layer")
392
+ return
393
+
394
+ model = self._backend.get_model(model_name)
395
+ modes = model.cytoplasmic_input_modes()
396
+
397
+ # Check if model only uses nuclear input (nuclear-only mode)
398
+ if modes == ["nuclear"]:
399
+ # Hide cytoplasmic layer and label, show only nuclear
400
+ self._cyto_layer_combo.setVisible(False)
401
+ self._cyto_layer_label.setVisible(False)
402
+ self._cyto_nuclear_layer_combo.setEnabled(True)
403
+ self._cyto_nuclear_label.setText("Nuclear layer")
404
+ # For nuclear-only models, populate with Labels layers
405
+ self._refresh_nuclear_labels_for_cyto()
406
+ elif "nuclear+cytoplasmic" in modes:
407
+ self._cyto_layer_combo.setVisible(True)
408
+ self._cyto_layer_label.setVisible(True)
409
+ self._cyto_layer_combo.setEnabled(True)
410
+ optional = model.cytoplasmic_nuclear_optional()
411
+ suffix = "optional" if optional else "mandatory"
412
+ self._cyto_nuclear_label.setText(f"Nuclear layer ({suffix})")
413
+ self._cyto_nuclear_layer_combo.setEnabled(True)
414
+ # For standard models, populate with Image layers
415
+ self._refresh_nuclear_images_for_cyto()
416
+ else:
417
+ # Only cytoplasmic
418
+ self._cyto_layer_combo.setVisible(True)
419
+ self._cyto_layer_label.setVisible(True)
420
+ self._cyto_layer_combo.setEnabled(True)
421
+ self._cyto_nuclear_label.setText("Nuclear layer")
422
+ self._cyto_nuclear_layer_combo.setEnabled(False)
423
+ # For standard models, populate with Image layers
424
+ self._refresh_nuclear_images_for_cyto()
425
+
426
+ self._update_cytoplasmic_run_state(model)
427
+
428
+ def _refresh_nuclear_labels_for_cyto(self) -> None:
429
+ """Refresh cytoplasmic nuclear layer combo with Labels layers."""
430
+ current = self._cyto_nuclear_layer_combo.currentText()
431
+ self._cyto_nuclear_layer_combo.clear()
432
+
433
+ if self._viewer is None:
434
+ self._cyto_nuclear_layer_combo.addItem("Select a layer")
435
+ return
436
+
437
+ label_names = [layer.name for layer in self._iter_label_layers()]
438
+ for name in label_names:
439
+ self._cyto_nuclear_layer_combo.addItem(name)
440
+ self._cyto_nuclear_layer_combo.insertItem(0, "Select a layer")
441
+ self._restore_combo_selection(self._cyto_nuclear_layer_combo, current)
442
+
443
+ def _refresh_nuclear_images_for_cyto(self) -> None:
444
+ """Refresh cytoplasmic nuclear layer combo with Image layers."""
445
+ current = self._cyto_nuclear_layer_combo.currentText()
446
+ self._cyto_nuclear_layer_combo.clear()
447
+
448
+ if self._viewer is None:
449
+ self._cyto_nuclear_layer_combo.addItem("Select a layer")
450
+ return
451
+
452
+ image_names = [layer.name for layer in self._iter_image_layers()]
453
+ for name in image_names:
454
+ self._cyto_nuclear_layer_combo.addItem(name)
455
+ self._cyto_nuclear_layer_combo.insertItem(0, "Select a layer")
456
+ self._restore_combo_selection(self._cyto_nuclear_layer_combo, current)
457
+
458
+ def _iter_label_layers(self) -> list:
459
+ """Iterate over Labels layers in the viewer."""
460
+ if self._viewer is None:
461
+ return []
462
+
463
+ label_layers = []
464
+ for layer in self._viewer.layers:
465
+ if Labels is not None:
466
+ if isinstance(layer, Labels):
467
+ label_layers.append(layer)
468
+ else:
469
+ if layer.__class__.__name__ == "Labels":
470
+ label_layers.append(layer)
471
+ return label_layers
472
+
473
+ def _iter_image_layers(self) -> list:
474
+ if self._viewer is None:
475
+ return []
476
+
477
+ image_layers = []
478
+ for layer in self._viewer.layers:
479
+ if Image is not None:
480
+ if isinstance(layer, Image):
481
+ image_layers.append(layer)
482
+ else:
483
+ if layer.__class__.__name__ == "Image":
484
+ image_layers.append(layer)
485
+ return image_layers
486
+
487
+ def _restore_combo_selection(self, combo: QComboBox, name: str) -> None:
488
+ if not name:
489
+ return
490
+ index = combo.findText(name)
491
+ if index != -1:
492
+ combo.setCurrentIndex(index)
493
+
494
+ def _refresh_model_settings_layout(
495
+ self,
496
+ settings_layout: QVBoxLayout,
497
+ model_name: str,
498
+ ) -> None:
499
+ """Rebuild the provided model settings area for the selected model.
500
+
501
+ Parameters
502
+ ----------
503
+ settings_layout : QVBoxLayout
504
+ Layout to update with model settings controls.
505
+ model_name : str
506
+ Selected model name from the dropdown.
507
+ """
508
+ self._clear_layout(settings_layout)
509
+
510
+ if not model_name or model_name == "No models found":
511
+ settings_layout.addWidget(
512
+ QLabel("Select a model to configure its settings.")
513
+ )
514
+ return
515
+
516
+ model = self._backend.get_model(model_name)
517
+ settings_map = (
518
+ self._nuclear_settings_widgets
519
+ if settings_layout is self._nuclear_model_settings_layout
520
+ else self._cyto_settings_widgets
521
+ )
522
+ settings_meta = (
523
+ self._nuclear_settings_meta
524
+ if settings_layout is self._nuclear_model_settings_layout
525
+ else self._cyto_settings_meta
526
+ )
527
+ settings_map.clear()
528
+ settings_meta.clear()
529
+ form_layout = self._build_model_settings(
530
+ model, settings_map, settings_meta
531
+ )
532
+ if form_layout is None:
533
+ settings_layout.addWidget(
534
+ QLabel(f"No settings defined for '{model_name}'.")
535
+ )
536
+ else:
537
+ settings_layout.addLayout(form_layout)
538
+
539
+ def _update_cytoplasmic_run_state(self, model) -> None:
540
+ """Enable/disable cytoplasmic run button based on required inputs."""
541
+ modes = model.cytoplasmic_input_modes()
542
+
543
+ # Nuclear-only model: only needs nuclear layer
544
+ if modes == ["nuclear"]:
545
+ nuclear_layer = self._get_layer_by_name(
546
+ self._cyto_nuclear_layer_combo.currentText()
547
+ )
548
+ self._cyto_run_button.setEnabled(nuclear_layer is not None)
549
+ return
550
+
551
+ # Check if nuclear is required
552
+ if self._cyto_requires_nuclear(model):
553
+ nuclear_layer = self._get_layer_by_name(
554
+ self._cyto_nuclear_layer_combo.currentText()
555
+ )
556
+ self._cyto_run_button.setEnabled(nuclear_layer is not None)
557
+ else:
558
+ self._cyto_run_button.setEnabled(True)
559
+
560
+ def _clear_layout(self, layout: QVBoxLayout) -> None:
561
+ """Remove widgets and nested layouts from the provided layout.
562
+
563
+ Parameters
564
+ ----------
565
+ layout : QVBoxLayout
566
+ Layout to clear.
567
+ """
568
+ while layout.count():
569
+ item = layout.takeAt(0)
570
+ child_layout = item.layout()
571
+ if child_layout is not None:
572
+ self._clear_layout(child_layout)
573
+ continue
574
+ widget = item.widget()
575
+ if widget is not None:
576
+ widget.deleteLater()
577
+
578
+ def _build_model_settings(
579
+ self, model, settings_map: dict, settings_meta: dict
580
+ ) -> QFormLayout | None:
581
+ """Build model settings controls from model metadata.
582
+
583
+ Parameters
584
+ ----------
585
+ model : SenoQuantSegmentationModel
586
+ Model wrapper providing settings metadata.
587
+ settings_map : dict
588
+ Mapping of setting keys to their widgets.
589
+ settings_meta : dict
590
+ Mapping of setting keys to their metadata dictionaries.
591
+
592
+ Returns
593
+ -------
594
+ QFormLayout or None
595
+ Form layout containing controls or None if no settings exist.
596
+ """
597
+ settings = model.list_settings()
598
+ if not settings:
599
+ return None
600
+
601
+ form_layout = QFormLayout()
602
+ for setting in settings:
603
+ setting_type = setting.get("type")
604
+ label = setting.get("label", setting.get("key", "Setting"))
605
+ key = setting.get("key", label)
606
+ settings_meta[key] = setting
607
+
608
+ if setting_type == "float":
609
+ widget = QDoubleSpinBox()
610
+ decimals = int(setting.get("decimals", 1))
611
+ widget.setDecimals(decimals)
612
+ widget.setRange(
613
+ float(setting.get("min", 0.0)),
614
+ float(setting.get("max", 1.0)),
615
+ )
616
+ widget.setSingleStep(0.1)
617
+ widget.setValue(float(setting.get("default", 0.0)))
618
+ settings_map[key] = widget
619
+ form_layout.addRow(label, widget)
620
+ elif setting_type == "int":
621
+ widget = QSpinBox()
622
+ widget.setRange(
623
+ int(setting.get("min", 0)),
624
+ int(setting.get("max", 100)),
625
+ )
626
+ widget.setSingleStep(1)
627
+ widget.setValue(int(setting.get("default", 0)))
628
+ settings_map[key] = widget
629
+ form_layout.addRow(label, widget)
630
+ elif setting_type == "bool":
631
+ widget = QCheckBox()
632
+ widget.setChecked(bool(setting.get("default", False)))
633
+ widget.toggled.connect(
634
+ lambda _checked, m=settings_map, meta=settings_meta: self._apply_setting_dependencies(m, meta)
635
+ )
636
+ settings_map[key] = widget
637
+ form_layout.addRow(label, widget)
638
+ else:
639
+ form_layout.addRow(label, QLabel("Unsupported setting type"))
640
+
641
+ self._apply_setting_dependencies(settings_map, settings_meta)
642
+
643
+ return form_layout
644
+
645
+ def _apply_setting_dependencies(
646
+ self, settings_map: dict, settings_meta: dict
647
+ ) -> None:
648
+ """Apply enabled/disabled relationships between settings."""
649
+ for key, setting in settings_meta.items():
650
+ widget = settings_map.get(key)
651
+ if widget is None:
652
+ continue
653
+
654
+ enabled_by = setting.get("enabled_by")
655
+ disabled_by = setting.get("disabled_by")
656
+
657
+ if enabled_by:
658
+ controller = settings_map.get(enabled_by)
659
+ if isinstance(controller, QCheckBox):
660
+ widget.setEnabled(controller.isChecked())
661
+ if disabled_by:
662
+ controller = settings_map.get(disabled_by)
663
+ if isinstance(controller, QCheckBox):
664
+ widget.setEnabled(not controller.isChecked())
665
+
666
+ def _collect_settings(self, settings_map: dict) -> dict:
667
+ """Collect current values from the settings widgets.
668
+
669
+ Parameters
670
+ ----------
671
+ settings_map : dict
672
+ Mapping of setting keys to their widgets.
673
+
674
+ Returns
675
+ -------
676
+ dict
677
+ Setting values keyed by setting name.
678
+ """
679
+ values = {}
680
+ for key, widget in settings_map.items():
681
+ if hasattr(widget, "value"):
682
+ values[key] = widget.value()
683
+ elif isinstance(widget, QCheckBox):
684
+ values[key] = widget.isChecked()
685
+ return values
686
+
687
+ def _configure_combo(self, combo: QComboBox) -> None:
688
+ """Apply sizing defaults to combo boxes."""
689
+ combo.setSizeAdjustPolicy(
690
+ QComboBox.AdjustToMinimumContentsLengthWithIcon
691
+ )
692
+ combo.setMinimumContentsLength(20)
693
+ combo.setMinimumWidth(180)
694
+ combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
695
+
696
+ def _run_nuclear(self) -> None:
697
+ """Run nuclear segmentation for the selected model."""
698
+ model_name = self._nuclear_model_combo.currentText()
699
+ if not model_name or model_name == "No models found":
700
+ return
701
+ model = self._backend.get_preloaded_model(model_name)
702
+ settings = self._collect_settings(self._nuclear_settings_widgets)
703
+ layer_name = self._nuclear_layer_combo.currentText()
704
+ layer = self._get_layer_by_name(layer_name)
705
+ if not self._validate_single_channel_layer(layer, "Nuclear layer"):
706
+ return
707
+ self._start_background_run(
708
+ run_button=self._nuclear_run_button,
709
+ run_text="Run",
710
+ task="nuclear",
711
+ run_callable=lambda: model.run(
712
+ task="nuclear",
713
+ layer=layer,
714
+ settings=settings,
715
+ ),
716
+ on_success=lambda result: self._add_labels_layer(
717
+ layer,
718
+ result.get("masks"),
719
+ model_name=model_name,
720
+ label_type="nuc",
721
+ ),
722
+ )
723
+
724
+ def _run_cytoplasmic(self) -> None:
725
+ """Run cytoplasmic segmentation for the selected model."""
726
+ model_name = self._cyto_model_combo.currentText()
727
+ if not model_name or model_name == "No models found":
728
+ return
729
+ model = self._backend.get_preloaded_model(model_name)
730
+ settings = self._collect_settings(self._cyto_settings_widgets)
731
+ modes = model.cytoplasmic_input_modes()
732
+
733
+ # Handle nuclear-only models
734
+ if modes == ["nuclear"]:
735
+ nuclear_layer = self._get_layer_by_name(
736
+ self._cyto_nuclear_layer_combo.currentText()
737
+ )
738
+ if not self._validate_single_channel_layer(nuclear_layer, "Nuclear layer"):
739
+ return
740
+ self._start_background_run(
741
+ run_button=self._cyto_run_button,
742
+ run_text="Run",
743
+ task="cytoplasmic",
744
+ run_callable=lambda: model.run(
745
+ task="cytoplasmic",
746
+ nuclear_layer=nuclear_layer,
747
+ settings=settings,
748
+ ),
749
+ on_success=lambda result: self._add_labels_layer(
750
+ nuclear_layer,
751
+ result.get("masks"),
752
+ model_name=model_name,
753
+ label_type="cyto",
754
+ ),
755
+ )
756
+ return
757
+
758
+ # Standard models: require cytoplasmic layer
759
+ cyto_layer = self._get_layer_by_name(self._cyto_layer_combo.currentText())
760
+ nuclear_layer = self._get_layer_by_name(
761
+ self._cyto_nuclear_layer_combo.currentText()
762
+ )
763
+ if not self._validate_single_channel_layer(cyto_layer, "Cytoplasmic layer"):
764
+ return
765
+ if nuclear_layer is not None and not self._validate_single_channel_layer(
766
+ nuclear_layer, "Nuclear layer"
767
+ ):
768
+ return
769
+ if self._cyto_requires_nuclear(model) and nuclear_layer is None:
770
+ return
771
+ self._start_background_run(
772
+ run_button=self._cyto_run_button,
773
+ run_text="Run",
774
+ task="cytoplasmic",
775
+ run_callable=lambda: model.run(
776
+ task="cytoplasmic",
777
+ cytoplasmic_layer=cyto_layer,
778
+ nuclear_layer=nuclear_layer,
779
+ settings=settings,
780
+ ),
781
+ on_success=lambda result: self._add_labels_layer(
782
+ cyto_layer,
783
+ result.get("masks"),
784
+ model_name=model_name,
785
+ label_type="cyto",
786
+ ),
787
+ )
788
+
789
+ def _start_background_run(
790
+ self,
791
+ run_button: QPushButton,
792
+ run_text: str,
793
+ task: str,
794
+ run_callable,
795
+ on_success,
796
+ ) -> None:
797
+ """Run a model in a background thread and manage UI state.
798
+
799
+ Parameters
800
+ ----------
801
+ run_button : QPushButton
802
+ Button to disable while the background task runs.
803
+ run_text : str
804
+ Label text to restore after completion.
805
+ task : str
806
+ Task name used for error messaging.
807
+ run_callable : callable
808
+ Callable that executes the model run.
809
+ on_success : callable
810
+ Callback invoked with the run result dictionary.
811
+ """
812
+ run_button.setEnabled(False)
813
+ run_button.setText("Running...")
814
+
815
+ thread = QThread(self)
816
+ worker = _RunWorker(run_callable)
817
+ worker.moveToThread(thread)
818
+
819
+ def handle_success(result: dict) -> None:
820
+ on_success(result)
821
+ self._finish_background_run(run_button, run_text, thread, worker)
822
+
823
+ def handle_error(message: str) -> None:
824
+ self._notify(f"{task.capitalize()} run failed: {message}")
825
+ self._finish_background_run(run_button, run_text, thread, worker)
826
+
827
+ thread.started.connect(worker.run)
828
+ worker.finished.connect(handle_success)
829
+ worker.error.connect(handle_error)
830
+ worker.finished.connect(thread.quit)
831
+ worker.error.connect(thread.quit)
832
+ thread.finished.connect(thread.deleteLater)
833
+ thread.finished.connect(worker.deleteLater)
834
+
835
+ self._active_workers.append((thread, worker))
836
+ thread.start()
837
+
838
+ def _finish_background_run(
839
+ self,
840
+ run_button: QPushButton,
841
+ run_text: str,
842
+ thread: QThread,
843
+ worker: QObject,
844
+ ) -> None:
845
+ """Restore UI state after a background run completes.
846
+
847
+ Parameters
848
+ ----------
849
+ run_button : QPushButton
850
+ Button to restore after completion.
851
+ run_text : str
852
+ Label text to restore on the button.
853
+ thread : QThread
854
+ Background thread being torn down.
855
+ worker : QObject
856
+ Worker object associated with the thread.
857
+ """
858
+ run_button.setEnabled(True)
859
+ run_button.setText(run_text)
860
+ try:
861
+ self._active_workers.remove((thread, worker))
862
+ except ValueError:
863
+ pass
864
+
865
+
866
+ def _get_layer_by_name(self, name: str):
867
+ """Return a viewer layer with the given name, if it exists.
868
+
869
+ Parameters
870
+ ----------
871
+ name : str
872
+ Layer name to locate.
873
+
874
+ Returns
875
+ -------
876
+ object or None
877
+ Matching layer object or None if not found.
878
+ """
879
+ if self._viewer is None:
880
+ return None
881
+ for layer in self._viewer.layers:
882
+ if layer.name == name:
883
+ return layer
884
+ return None
885
+
886
+ def _validate_single_channel_layer(self, layer, label: str) -> bool:
887
+ """Validate that a layer is single-channel 2D/3D image data.
888
+
889
+ Parameters
890
+ ----------
891
+ layer : object or None
892
+ Napari layer to validate.
893
+ label : str
894
+ User-facing label for notifications.
895
+
896
+ Returns
897
+ -------
898
+ bool
899
+ True if the layer is valid for single-channel processing.
900
+ """
901
+ if layer is None:
902
+ return False
903
+ if getattr(layer, "rgb", False):
904
+ self._notify(f"{label} must be single-channel (not RGB).")
905
+ return False
906
+ shape = getattr(getattr(layer, "data", None), "shape", None)
907
+ if shape is None:
908
+ return False
909
+ squeezed_ndim = sum(dim != 1 for dim in shape)
910
+ if squeezed_ndim not in (2, 3):
911
+ self._notify(f"{label} must be 2D or 3D single-channel.")
912
+ return False
913
+ return True
914
+
915
+ def _notify(self, message: str) -> None:
916
+ """Send a warning notification to the napari console.
917
+
918
+ Parameters
919
+ ----------
920
+ message : str
921
+ Notification message to display.
922
+ """
923
+ if (
924
+ show_console_notification is not None
925
+ and Notification is not None
926
+ and NotificationSeverity is not None
927
+ ):
928
+ show_console_notification(
929
+ Notification(message, severity=NotificationSeverity.WARNING)
930
+ )
931
+
932
+ def _on_preload_models_changed(self, enabled: bool) -> None:
933
+ """Handle preload setting changes.
934
+
935
+ Parameters
936
+ ----------
937
+ enabled : bool
938
+ Whether preloading is enabled.
939
+ """
940
+ if enabled:
941
+ if (
942
+ show_console_notification is not None
943
+ and Notification is not None
944
+ and NotificationSeverity is not None
945
+ ):
946
+ show_console_notification(
947
+ Notification(
948
+ "Preloading segmentation models...",
949
+ severity=NotificationSeverity.INFO,
950
+ )
951
+ )
952
+ self._backend.preload_models()
953
+
954
+ def _cyto_requires_nuclear(self, model) -> bool:
955
+ """Return True when cytoplasmic mode requires a nuclear channel."""
956
+ modes = model.cytoplasmic_input_modes()
957
+ if modes == ["nuclear"]:
958
+ return True
959
+ if "nuclear+cytoplasmic" not in modes:
960
+ return False
961
+ return not model.cytoplasmic_nuclear_optional()
962
+
963
+ def _on_cyto_nuclear_layer_changed(self) -> None:
964
+ model_name = self._cyto_model_combo.currentText()
965
+ if not model_name or model_name == "No models found":
966
+ self._cyto_run_button.setEnabled(False)
967
+ return
968
+ model = self._backend.get_model(model_name)
969
+ self._update_cytoplasmic_run_state(model)
970
+
971
+ def _add_labels_layer(self, source_layer, masks, model_name: str, label_type: str) -> None:
972
+ if self._viewer is None or source_layer is None or masks is None:
973
+ return
974
+ label_name = f"{source_layer.name}_{model_name}_{label_type}_labels"
975
+ self._viewer.add_labels(
976
+ masks,
977
+ name=label_name,
978
+ )
979
+
980
+ # Get the labels layer and set contour = 2
981
+ labels_layer = self._viewer.layers[label_name]
982
+ labels_layer.contour = 2
983
+
984
+
985
+ class _RunWorker(QObject):
986
+ """Worker that executes a callable in a background thread."""
987
+
988
+ finished = Signal(dict)
989
+ error = Signal(str)
990
+
991
+ def __init__(self, run_callable) -> None:
992
+ """Initialize the worker with a callable.
993
+
994
+ Parameters
995
+ ----------
996
+ run_callable : callable
997
+ Callable to execute on the worker thread.
998
+ """
999
+ super().__init__()
1000
+ self._run_callable = run_callable
1001
+
1002
+ def run(self) -> None:
1003
+ """Execute the callable and emit results."""
1004
+ try:
1005
+ result = self._run_callable()
1006
+ except Exception as exc: # pragma: no cover - runtime error path
1007
+ self.error.emit(str(exc))
1008
+ return
1009
+ self.finished.emit(result)