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,800 @@
1
+ """Frontend widget for the Spots tab."""
2
+ import numpy as np
3
+ from qtpy.QtCore import QObject, QThread, Signal
4
+ from qtpy.QtGui import QPalette
5
+ from qtpy.QtWidgets import (
6
+ QCheckBox,
7
+ QComboBox,
8
+ QDoubleSpinBox,
9
+ QFormLayout,
10
+ QFrame,
11
+ QGroupBox,
12
+ QLabel,
13
+ QPushButton,
14
+ QSizePolicy,
15
+ QSpinBox,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ )
19
+
20
+ try:
21
+ from napari.layers import Image, Labels
22
+ from napari.utils.notifications import (
23
+ Notification,
24
+ NotificationSeverity,
25
+ show_console_notification,
26
+ )
27
+ except Exception: # pragma: no cover - optional import for runtime
28
+ Image = None
29
+ Labels = None
30
+ show_console_notification = None
31
+ Notification = None
32
+ NotificationSeverity = None
33
+
34
+ from senoquant.utils import layer_data_asarray
35
+ from .backend import SpotsBackend
36
+
37
+
38
+ def _filter_labels_by_size(
39
+ mask: np.ndarray,
40
+ min_size: int = 0,
41
+ max_size: int = 0,
42
+ ) -> np.ndarray:
43
+ """Filter a labeled mask by region size.
44
+
45
+ Parameters
46
+ ----------
47
+ mask : numpy.ndarray
48
+ Labeled mask array.
49
+ min_size : int, optional
50
+ Minimum region size in pixels (0 = no minimum).
51
+ max_size : int, optional
52
+ Maximum region size in pixels (0 = no maximum).
53
+
54
+ Returns
55
+ -------
56
+ numpy.ndarray
57
+ Filtered labeled mask with regions outside size range removed.
58
+ """
59
+ from skimage.measure import regionprops
60
+
61
+ if mask is None or mask.size == 0:
62
+ return mask
63
+
64
+ # If both are 0, no filtering needed
65
+ if min_size == 0 and max_size == 0:
66
+ return mask
67
+
68
+ # Get region properties
69
+ regions = regionprops(mask)
70
+ if not regions:
71
+ return mask
72
+
73
+ # Build a mask of labels to keep
74
+ filtered_mask = np.zeros_like(mask)
75
+ for region in regions:
76
+ area = region.area
77
+ keep = True
78
+
79
+ if min_size > 0 and area < min_size:
80
+ keep = False
81
+ if max_size > 0 and area > max_size:
82
+ keep = False
83
+
84
+ if keep:
85
+ filtered_mask[mask == region.label] = region.label
86
+
87
+ return filtered_mask
88
+
89
+
90
+ class RefreshingComboBox(QComboBox):
91
+ """Combo box that refreshes its items when opened."""
92
+
93
+ def __init__(self, refresh_callback=None, parent=None) -> None:
94
+ """Create a combo box that refreshes on popup.
95
+
96
+ Parameters
97
+ ----------
98
+ refresh_callback : callable or None
99
+ Function invoked before showing the popup.
100
+ parent : QWidget or None
101
+ Optional parent widget.
102
+ """
103
+ super().__init__(parent)
104
+ self._refresh_callback = refresh_callback
105
+
106
+ def showPopup(self) -> None:
107
+ """Refresh items before showing the popup."""
108
+ if self._refresh_callback is not None:
109
+ self._refresh_callback()
110
+ super().showPopup()
111
+
112
+
113
+ class SpotsTab(QWidget):
114
+ """Spots tab UI for spot detectors.
115
+
116
+ Parameters
117
+ ----------
118
+ backend : SpotsBackend or None
119
+ Backend instance used to discover and load detectors.
120
+ napari_viewer : object or None
121
+ Napari viewer used to populate layer choices.
122
+ """
123
+
124
+ def __init__(
125
+ self,
126
+ backend: SpotsBackend | None = None,
127
+ napari_viewer=None,
128
+ ) -> None:
129
+ super().__init__()
130
+ self._backend = backend or SpotsBackend()
131
+ self._viewer = napari_viewer
132
+ self._settings_widgets = {}
133
+ self._settings_meta = {}
134
+ self._active_workers: list[tuple[QThread, QObject]] = []
135
+ self._min_size_spin = None
136
+ self._max_size_spin = None
137
+
138
+ layout = QVBoxLayout()
139
+ layout.addWidget(self._make_detector_section())
140
+ layout.addWidget(self._make_colocalization_section())
141
+ layout.addStretch(1)
142
+ self.setLayout(layout)
143
+
144
+ self._refresh_layer_choices()
145
+ self._refresh_label_choices()
146
+ self._refresh_detector_choices()
147
+ self._update_detector_settings(self._detector_combo.currentText())
148
+
149
+
150
+ def _make_detector_section(self) -> QGroupBox:
151
+ """Build the detector UI section.
152
+
153
+ Returns
154
+ -------
155
+ QGroupBox
156
+ Group box containing spot detector controls.
157
+ """
158
+ section = QGroupBox("Spot detection")
159
+ section_layout = QVBoxLayout()
160
+
161
+ form_layout = QFormLayout()
162
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
163
+ self._layer_combo = RefreshingComboBox(
164
+ refresh_callback=self._refresh_layer_choices
165
+ )
166
+ self._configure_combo(self._layer_combo)
167
+ self._detector_combo = QComboBox()
168
+ self._configure_combo(self._detector_combo)
169
+ self._detector_combo.currentTextChanged.connect(
170
+ self._update_detector_settings
171
+ )
172
+
173
+ form_layout.addRow("Image layer", self._layer_combo)
174
+ form_layout.addRow("Detector", self._detector_combo)
175
+
176
+ section_layout.addLayout(form_layout)
177
+ section_layout.addWidget(self._make_settings_section())
178
+ section_layout.addWidget(self._make_size_filter_section())
179
+
180
+ self._run_button = QPushButton("Run")
181
+ self._run_button.clicked.connect(self._run_detector)
182
+ section_layout.addWidget(self._run_button)
183
+
184
+ section.setLayout(section_layout)
185
+ return section
186
+
187
+ def _make_colocalization_section(self) -> QGroupBox:
188
+ """Build the colocalization visualization section.
189
+
190
+ Returns
191
+ -------
192
+ QGroupBox
193
+ Group box containing colocalization controls.
194
+ """
195
+ section = QGroupBox("Visualize colocalization")
196
+ section_layout = QVBoxLayout()
197
+
198
+ form_layout = QFormLayout()
199
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
200
+ self._coloc_a_combo = RefreshingComboBox(
201
+ refresh_callback=self._refresh_label_choices
202
+ )
203
+ self._configure_combo(self._coloc_a_combo)
204
+ self._coloc_b_combo = RefreshingComboBox(
205
+ refresh_callback=self._refresh_label_choices
206
+ )
207
+ self._configure_combo(self._coloc_b_combo)
208
+ form_layout.addRow("Labels A", self._coloc_a_combo)
209
+ form_layout.addRow("Labels B", self._coloc_b_combo)
210
+
211
+ section_layout.addLayout(form_layout)
212
+
213
+ self._coloc_run_button = QPushButton("Visualize")
214
+ self._coloc_run_button.clicked.connect(self._run_colocalization)
215
+ section_layout.addWidget(self._coloc_run_button)
216
+
217
+ section.setLayout(section_layout)
218
+ return section
219
+
220
+ def _make_settings_section(self) -> QGroupBox:
221
+ """Build the detector settings section container.
222
+
223
+ Returns
224
+ -------
225
+ QGroupBox
226
+ Group box containing detector-specific settings.
227
+ """
228
+ return self._make_titled_section("Detector settings")
229
+
230
+ def _make_size_filter_section(self) -> QGroupBox:
231
+ """Build the spot size filter section.
232
+
233
+ Returns
234
+ -------
235
+ QGroupBox
236
+ Group box containing size filter controls.
237
+ """
238
+ section = QGroupBox("Filter spots by size (pixels)")
239
+ section.setFlat(False)
240
+
241
+ layout = QFormLayout()
242
+ layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
243
+
244
+ self._min_size_spin = QSpinBox()
245
+ self._min_size_spin.setRange(0, 100000)
246
+ self._min_size_spin.setValue(0)
247
+
248
+ self._max_size_spin = QSpinBox()
249
+ self._max_size_spin.setRange(0, 100000)
250
+ self._max_size_spin.setValue(0)
251
+
252
+ layout.addRow("Minimum size", self._min_size_spin)
253
+ layout.addRow("Maximum size", self._max_size_spin)
254
+
255
+ section.setLayout(layout)
256
+ return section
257
+
258
+ def _make_titled_section(self, title: str) -> QGroupBox:
259
+ """Create a titled box that mimics a group box ring.
260
+
261
+ Parameters
262
+ ----------
263
+ title : str
264
+ Title displayed on the ring.
265
+
266
+ Returns
267
+ -------
268
+ QGroupBox
269
+ Group box containing a framed content area.
270
+ """
271
+ section = QGroupBox(title)
272
+ section.setFlat(True)
273
+ section.setStyleSheet(
274
+ "QGroupBox {"
275
+ " margin-top: 8px;"
276
+ "}"
277
+ "QGroupBox::title {"
278
+ " subcontrol-origin: margin;"
279
+ " subcontrol-position: top left;"
280
+ " padding: 0 6px;"
281
+ "}"
282
+ )
283
+
284
+ frame = QFrame()
285
+ frame.setFrameShape(QFrame.StyledPanel)
286
+ frame.setFrameShadow(QFrame.Plain)
287
+ frame.setObjectName("titled-section-frame")
288
+ frame.setStyleSheet(
289
+ "QFrame#titled-section-frame {"
290
+ " border: 1px solid palette(mid);"
291
+ " border-radius: 4px;"
292
+ "}"
293
+ )
294
+
295
+ self._settings_layout = QVBoxLayout()
296
+ self._settings_layout.setContentsMargins(10, 12, 10, 10)
297
+ frame.setLayout(self._settings_layout)
298
+
299
+ section_layout = QVBoxLayout()
300
+ section_layout.setContentsMargins(8, 12, 8, 4)
301
+ section_layout.addWidget(frame)
302
+ section.setLayout(section_layout)
303
+
304
+ return section
305
+
306
+ def _refresh_layer_choices(self) -> None:
307
+ """Populate the image layer dropdown from the napari viewer."""
308
+ current = self._layer_combo.currentText()
309
+ self._layer_combo.clear()
310
+ if self._viewer is None:
311
+ self._layer_combo.addItem("Select a layer")
312
+ return
313
+
314
+ for layer in self._iter_image_layers():
315
+ self._layer_combo.addItem(layer.name)
316
+
317
+ if current:
318
+ index = self._layer_combo.findText(current)
319
+ if index != -1:
320
+ self._layer_combo.setCurrentIndex(index)
321
+
322
+ def _refresh_label_choices(self) -> None:
323
+ """Populate label layer dropdowns from the napari viewer."""
324
+ current_a = self._coloc_a_combo.currentText()
325
+ current_b = self._coloc_b_combo.currentText()
326
+ self._coloc_a_combo.clear()
327
+ self._coloc_b_combo.clear()
328
+ if self._viewer is None:
329
+ self._coloc_a_combo.addItem("Select labels")
330
+ self._coloc_b_combo.addItem("Select labels")
331
+ return
332
+
333
+ for layer in self._iter_label_layers():
334
+ self._coloc_a_combo.addItem(layer.name)
335
+ self._coloc_b_combo.addItem(layer.name)
336
+
337
+ if current_a:
338
+ index = self._coloc_a_combo.findText(current_a)
339
+ if index != -1:
340
+ self._coloc_a_combo.setCurrentIndex(index)
341
+ if current_b:
342
+ index = self._coloc_b_combo.findText(current_b)
343
+ if index != -1:
344
+ self._coloc_b_combo.setCurrentIndex(index)
345
+
346
+ def _refresh_detector_choices(self) -> None:
347
+ """Populate the detector dropdown from available detector folders."""
348
+ self._detector_combo.clear()
349
+ names = self._backend.list_detector_names()
350
+ if not names:
351
+ self._detector_combo.addItem("No detectors found")
352
+ return
353
+ self._detector_combo.addItems(names)
354
+
355
+ def _update_detector_settings(self, detector_name: str) -> None:
356
+ """Rebuild the detector settings area for the selected detector.
357
+
358
+ Parameters
359
+ ----------
360
+ detector_name : str
361
+ Selected detector name from the dropdown.
362
+ """
363
+ while self._settings_layout.count():
364
+ item = self._settings_layout.takeAt(0)
365
+ widget = item.widget()
366
+ if widget is not None:
367
+ widget.deleteLater()
368
+
369
+ if not detector_name or detector_name == "No detectors found":
370
+ self._settings_layout.addWidget(
371
+ QLabel("Select a detector to configure its settings.")
372
+ )
373
+ return
374
+
375
+ detector = self._backend.get_detector(detector_name)
376
+ self._settings_widgets.clear()
377
+ self._settings_meta.clear()
378
+ form_layout = self._build_detector_settings(detector)
379
+ if form_layout is None:
380
+ self._settings_layout.addWidget(
381
+ QLabel(f"No settings defined for '{detector_name}'.")
382
+ )
383
+ else:
384
+ form_container = QWidget()
385
+ form_container.setAutoFillBackground(True)
386
+ form_container.setBackgroundRole(QPalette.Window)
387
+ form_container.setLayout(form_layout)
388
+ self._settings_layout.addWidget(form_container)
389
+ self._apply_setting_dependencies()
390
+
391
+ def _build_detector_settings(self, detector) -> QFormLayout | None:
392
+ """Build detector settings controls from metadata.
393
+
394
+ Parameters
395
+ ----------
396
+ detector : SenoQuantSpotDetector
397
+ Detector wrapper providing settings metadata.
398
+
399
+ Returns
400
+ -------
401
+ QFormLayout or None
402
+ Form layout containing controls or None if no settings exist.
403
+ """
404
+ settings = detector.list_settings()
405
+ if not settings:
406
+ return None
407
+
408
+ form_layout = QFormLayout()
409
+ for setting in settings:
410
+ setting_type = setting.get("type")
411
+ label = setting.get("label", setting.get("key", "Setting"))
412
+ key = setting.get("key", label)
413
+ self._settings_meta[key] = setting
414
+
415
+ if setting_type == "float":
416
+ widget = QDoubleSpinBox()
417
+ decimals = int(setting.get("decimals", 1))
418
+ widget.setDecimals(decimals)
419
+ widget.setRange(
420
+ float(setting.get("min", 0.0)),
421
+ float(setting.get("max", 1.0)),
422
+ )
423
+ widget.setSingleStep(0.1)
424
+ widget.setValue(float(setting.get("default", 0.0)))
425
+ self._settings_widgets[key] = widget
426
+ form_layout.addRow(label, widget)
427
+ elif setting_type == "int":
428
+ widget = QSpinBox()
429
+ widget.setRange(
430
+ int(setting.get("min", 0)),
431
+ int(setting.get("max", 100)),
432
+ )
433
+ widget.setSingleStep(1)
434
+ widget.setValue(int(setting.get("default", 0)))
435
+ self._settings_widgets[key] = widget
436
+ form_layout.addRow(label, widget)
437
+ elif setting_type == "bool":
438
+ widget = QCheckBox()
439
+ widget.setChecked(bool(setting.get("default", False)))
440
+ widget.toggled.connect(self._apply_setting_dependencies)
441
+ self._settings_widgets[key] = widget
442
+ form_layout.addRow(label, widget)
443
+ else:
444
+ form_layout.addRow(label, QLabel("Unsupported setting type"))
445
+
446
+ return form_layout
447
+
448
+ def _configure_combo(self, combo: QComboBox) -> None:
449
+ """Apply sizing defaults to combo boxes."""
450
+ combo.setSizeAdjustPolicy(
451
+ QComboBox.AdjustToMinimumContentsLengthWithIcon
452
+ )
453
+ combo.setMinimumContentsLength(20)
454
+ combo.setMinimumWidth(180)
455
+ combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
456
+
457
+ def _collect_settings(self) -> dict:
458
+ """Collect current values from the settings widgets."""
459
+ values = {}
460
+ for key, widget in self._settings_widgets.items():
461
+ if hasattr(widget, "value"):
462
+ values[key] = widget.value()
463
+ elif isinstance(widget, QCheckBox):
464
+ values[key] = widget.isChecked()
465
+ return values
466
+
467
+ def _apply_setting_dependencies(self) -> None:
468
+ """Apply enabled/disabled relationships between settings."""
469
+ for key, setting in self._settings_meta.items():
470
+ widget = self._settings_widgets.get(key)
471
+ if widget is None:
472
+ continue
473
+
474
+ enabled_by = setting.get("enabled_by")
475
+ disabled_by = setting.get("disabled_by")
476
+
477
+ if enabled_by:
478
+ controller = self._settings_widgets.get(enabled_by)
479
+ if isinstance(controller, QCheckBox):
480
+ widget.setEnabled(controller.isChecked())
481
+ if disabled_by:
482
+ controller = self._settings_widgets.get(disabled_by)
483
+ if isinstance(controller, QCheckBox):
484
+ widget.setEnabled(not controller.isChecked())
485
+
486
+ def _run_detector(self) -> None:
487
+ """Run the selected detector with the current settings."""
488
+ detector_name = self._detector_combo.currentText()
489
+ if not detector_name or detector_name == "No detectors found":
490
+ return
491
+ detector = self._backend.get_detector(detector_name)
492
+ layer = self._get_layer_by_name(self._layer_combo.currentText())
493
+ settings = self._collect_settings()
494
+ self._start_background_run(
495
+ run_button=self._run_button,
496
+ run_text="Run",
497
+ detector_name=detector_name,
498
+ run_callable=lambda: detector.run(layer=layer, settings=settings),
499
+ on_success=lambda result: self._handle_run_result(
500
+ layer, detector_name, result
501
+ ),
502
+ )
503
+
504
+ def _run_colocalization(self) -> None:
505
+ """Visualize intersections between two label layers."""
506
+ layer_a = self._get_layer_by_name(self._coloc_a_combo.currentText())
507
+ layer_b = self._get_layer_by_name(self._coloc_b_combo.currentText())
508
+ if not self._validate_label_layer(layer_a, "Labels A"):
509
+ return
510
+ if not self._validate_label_layer(layer_b, "Labels B"):
511
+ return
512
+
513
+ data_a = layer_data_asarray(layer_a)
514
+ data_b = layer_data_asarray(layer_b)
515
+ if data_a.shape != data_b.shape:
516
+ self._notify("Label layers must have matching shapes.")
517
+ return
518
+
519
+ self._start_background_run(
520
+ run_button=self._coloc_run_button,
521
+ run_text="Visualize",
522
+ detector_name="colocalization",
523
+ run_callable=lambda: self._backend.compute_colocalization(
524
+ data_a, data_b
525
+ ),
526
+ on_success=lambda result: self._apply_colocalization_result(
527
+ layer_a, layer_b, result
528
+ ),
529
+ )
530
+
531
+ def _apply_colocalization_result(
532
+ self,
533
+ layer_a,
534
+ layer_b,
535
+ result: dict,
536
+ ) -> None:
537
+ """Apply colocalization results to the viewer."""
538
+ if not isinstance(result, dict):
539
+ return
540
+ points = result.get("points")
541
+ if points is None or len(points) == 0:
542
+ self._notify("No overlapping labels found.")
543
+ return
544
+ self._add_colocalization_points(layer_a, layer_b, points)
545
+
546
+ def _add_colocalization_points(
547
+ self,
548
+ layer_a,
549
+ layer_b,
550
+ points: np.ndarray,
551
+ ) -> None:
552
+ """Add colocalization points as yellow circles."""
553
+ if self._viewer is None:
554
+ return
555
+ name = f"{layer_a.name}_{layer_b.name}_colocalization"
556
+ if name in self._viewer.layers:
557
+ self._viewer.layers.remove(name)
558
+ self._viewer.add_points(
559
+ points,
560
+ name=name,
561
+ face_color="yellow",
562
+ symbol="ring",
563
+ size=6,
564
+ )
565
+
566
+ def _start_background_run(
567
+ self,
568
+ run_button: QPushButton,
569
+ run_text: str,
570
+ detector_name: str,
571
+ run_callable,
572
+ on_success,
573
+ ) -> None:
574
+ """Run a detector in a background thread and manage UI state.
575
+
576
+ Parameters
577
+ ----------
578
+ run_button : QPushButton
579
+ Button to disable while the background task runs.
580
+ run_text : str
581
+ Label text to restore after completion.
582
+ detector_name : str
583
+ Detector name used for error messaging.
584
+ run_callable : callable
585
+ Callable that executes the detector run.
586
+ on_success : callable
587
+ Callback invoked with the run result dictionary.
588
+ """
589
+ run_button.setEnabled(False)
590
+ run_button.setText("Running...")
591
+
592
+ thread = QThread(self)
593
+ worker = _RunWorker(run_callable)
594
+ worker.moveToThread(thread)
595
+
596
+ def handle_success(result: dict) -> None:
597
+ on_success(result)
598
+ self._finish_background_run(run_button, run_text, thread, worker)
599
+
600
+ def handle_error(message: str) -> None:
601
+ self._notify(f"Run failed for '{detector_name}': {message}")
602
+ self._finish_background_run(run_button, run_text, thread, worker)
603
+
604
+ thread.started.connect(worker.run)
605
+ worker.finished.connect(handle_success)
606
+ worker.error.connect(handle_error)
607
+ worker.finished.connect(thread.quit)
608
+ worker.error.connect(thread.quit)
609
+ thread.finished.connect(thread.deleteLater)
610
+ thread.finished.connect(worker.deleteLater)
611
+
612
+ self._active_workers.append((thread, worker))
613
+ thread.start()
614
+
615
+ def _finish_background_run(
616
+ self,
617
+ run_button: QPushButton,
618
+ run_text: str,
619
+ thread: QThread,
620
+ worker: QObject,
621
+ ) -> None:
622
+ """Restore UI state after a background run completes.
623
+
624
+ Parameters
625
+ ----------
626
+ run_button : QPushButton
627
+ Button to restore after completion.
628
+ run_text : str
629
+ Label text to restore on the button.
630
+ thread : QThread
631
+ Background thread being torn down.
632
+ worker : QObject
633
+ Worker object associated with the thread.
634
+ """
635
+ run_button.setEnabled(True)
636
+ run_button.setText(run_text)
637
+ try:
638
+ self._active_workers.remove((thread, worker))
639
+ except ValueError:
640
+ pass
641
+
642
+ def _handle_run_result(self, layer, detector_name: str, result: dict) -> None:
643
+ """Handle detector output and update the viewer."""
644
+ if not isinstance(result, dict):
645
+ return
646
+ mask = result.get("mask")
647
+ if mask is not None:
648
+ filtered_mask = self._apply_size_filter(mask)
649
+ self._add_labels_layer(layer, filtered_mask, detector_name)
650
+
651
+ def _add_labels_layer(self, source_layer, mask, detector_name: str) -> None:
652
+ """Add a labels layer for the detector mask."""
653
+ if self._viewer is None or source_layer is None:
654
+ return
655
+ name = self._spot_label_name(source_layer, detector_name)
656
+ self._viewer.add_labels(mask, name=name)
657
+ labels_layer = self._viewer.layers[name]
658
+ labels_layer.contour = 1
659
+
660
+ def _apply_size_filter(self, mask: np.ndarray) -> np.ndarray:
661
+ """Filter spots by size based on min/max settings.
662
+
663
+ Parameters
664
+ ----------
665
+ mask : numpy.ndarray
666
+ Labeled spot mask from detector.
667
+
668
+ Returns
669
+ -------
670
+ numpy.ndarray
671
+ Filtered labeled mask.
672
+ """
673
+ if self._min_size_spin is None or self._max_size_spin is None:
674
+ return mask
675
+
676
+ min_size = self._min_size_spin.value()
677
+ max_size = self._max_size_spin.value()
678
+
679
+ # If both are 0 (disabled), return original mask
680
+ if min_size == 0 and max_size == 0:
681
+ return mask
682
+
683
+ return _filter_labels_by_size(mask, min_size, max_size)
684
+
685
+ def _spot_label_name(self, source_layer, detector_name: str) -> str:
686
+ """Return a standardized spot labels layer name."""
687
+ layer_name = getattr(source_layer, "name", "")
688
+ layer_name = layer_name.strip() if isinstance(layer_name, str) else ""
689
+ if layer_name:
690
+ return f"{layer_name}_{detector_name}_spot_labels"
691
+ return f"{detector_name}_spot_labels"
692
+
693
+ def _notify(self, message: str) -> None:
694
+ """Send a warning notification to the napari console.
695
+
696
+ Parameters
697
+ ----------
698
+ message : str
699
+ Notification message to display.
700
+ """
701
+ if (
702
+ show_console_notification is not None
703
+ and Notification is not None
704
+ and NotificationSeverity is not None
705
+ ):
706
+ show_console_notification(
707
+ Notification(message, severity=NotificationSeverity.WARNING)
708
+ )
709
+
710
+ def _get_layer_by_name(self, name: str):
711
+ """Return a viewer layer with the given name, if it exists."""
712
+ if self._viewer is None:
713
+ return None
714
+ for layer in self._viewer.layers:
715
+ if layer.name == name:
716
+ return layer
717
+ return None
718
+
719
+ def _validate_label_layer(self, layer, label: str) -> bool:
720
+ """Validate that a layer is a Labels layer.
721
+
722
+ Parameters
723
+ ----------
724
+ layer : object or None
725
+ Napari layer to validate.
726
+ label : str
727
+ User-facing label for notifications.
728
+
729
+ Returns
730
+ -------
731
+ bool
732
+ True if the layer is a Labels layer.
733
+ """
734
+ if layer is None:
735
+ self._notify(f"{label} is not selected.")
736
+ return False
737
+ if Labels is not None:
738
+ if not isinstance(layer, Labels):
739
+ self._notify(f"{label} must be a Labels layer.")
740
+ return False
741
+ else:
742
+ if layer.__class__.__name__ != "Labels":
743
+ self._notify(f"{label} must be a Labels layer.")
744
+ return False
745
+ return True
746
+
747
+ def _iter_image_layers(self) -> list:
748
+ if self._viewer is None:
749
+ return []
750
+
751
+ image_layers = []
752
+ for layer in self._viewer.layers:
753
+ if Image is not None:
754
+ if isinstance(layer, Image):
755
+ image_layers.append(layer)
756
+ else:
757
+ if layer.__class__.__name__ == "Image":
758
+ image_layers.append(layer)
759
+ return image_layers
760
+
761
+ def _iter_label_layers(self) -> list:
762
+ if self._viewer is None:
763
+ return []
764
+
765
+ label_layers = []
766
+ for layer in self._viewer.layers:
767
+ if Labels is not None:
768
+ if isinstance(layer, Labels):
769
+ label_layers.append(layer)
770
+ else:
771
+ if layer.__class__.__name__ == "Labels":
772
+ label_layers.append(layer)
773
+ return label_layers
774
+
775
+
776
+ class _RunWorker(QObject):
777
+ """Worker that executes a callable in a background thread."""
778
+
779
+ finished = Signal(dict)
780
+ error = Signal(str)
781
+
782
+ def __init__(self, run_callable) -> None:
783
+ """Initialize the worker with a callable.
784
+
785
+ Parameters
786
+ ----------
787
+ run_callable : callable
788
+ Callable to execute on the worker thread.
789
+ """
790
+ super().__init__()
791
+ self._run_callable = run_callable
792
+
793
+ def run(self) -> None:
794
+ """Execute the callable and emit results."""
795
+ try:
796
+ result = self._run_callable()
797
+ except Exception as exc: # pragma: no cover - runtime error path
798
+ self.error.emit(str(exc))
799
+ return
800
+ self.finished.emit(result)