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.
- senoquant/__init__.py +6 -0
- senoquant/_reader.py +7 -0
- senoquant/_widget.py +33 -0
- senoquant/napari.yaml +83 -0
- senoquant/reader/__init__.py +5 -0
- senoquant/reader/core.py +369 -0
- senoquant/tabs/__init__.py +15 -0
- senoquant/tabs/batch/__init__.py +10 -0
- senoquant/tabs/batch/backend.py +641 -0
- senoquant/tabs/batch/config.py +270 -0
- senoquant/tabs/batch/frontend.py +1283 -0
- senoquant/tabs/batch/io.py +326 -0
- senoquant/tabs/batch/layers.py +86 -0
- senoquant/tabs/quantification/__init__.py +1 -0
- senoquant/tabs/quantification/backend.py +228 -0
- senoquant/tabs/quantification/features/__init__.py +80 -0
- senoquant/tabs/quantification/features/base.py +142 -0
- senoquant/tabs/quantification/features/marker/__init__.py +5 -0
- senoquant/tabs/quantification/features/marker/config.py +69 -0
- senoquant/tabs/quantification/features/marker/dialog.py +437 -0
- senoquant/tabs/quantification/features/marker/export.py +879 -0
- senoquant/tabs/quantification/features/marker/feature.py +119 -0
- senoquant/tabs/quantification/features/marker/morphology.py +285 -0
- senoquant/tabs/quantification/features/marker/rows.py +654 -0
- senoquant/tabs/quantification/features/marker/thresholding.py +46 -0
- senoquant/tabs/quantification/features/roi.py +346 -0
- senoquant/tabs/quantification/features/spots/__init__.py +5 -0
- senoquant/tabs/quantification/features/spots/config.py +62 -0
- senoquant/tabs/quantification/features/spots/dialog.py +477 -0
- senoquant/tabs/quantification/features/spots/export.py +1292 -0
- senoquant/tabs/quantification/features/spots/feature.py +112 -0
- senoquant/tabs/quantification/features/spots/morphology.py +279 -0
- senoquant/tabs/quantification/features/spots/rows.py +241 -0
- senoquant/tabs/quantification/frontend.py +815 -0
- senoquant/tabs/segmentation/__init__.py +1 -0
- senoquant/tabs/segmentation/backend.py +131 -0
- senoquant/tabs/segmentation/frontend.py +1009 -0
- senoquant/tabs/segmentation/models/__init__.py +5 -0
- senoquant/tabs/segmentation/models/base.py +146 -0
- senoquant/tabs/segmentation/models/cpsam/details.json +65 -0
- senoquant/tabs/segmentation/models/cpsam/model.py +150 -0
- senoquant/tabs/segmentation/models/default_2d/details.json +69 -0
- senoquant/tabs/segmentation/models/default_2d/model.py +664 -0
- senoquant/tabs/segmentation/models/default_3d/details.json +69 -0
- senoquant/tabs/segmentation/models/default_3d/model.py +682 -0
- senoquant/tabs/segmentation/models/hf.py +71 -0
- senoquant/tabs/segmentation/models/nuclear_dilation/__init__.py +1 -0
- senoquant/tabs/segmentation/models/nuclear_dilation/details.json +26 -0
- senoquant/tabs/segmentation/models/nuclear_dilation/model.py +96 -0
- senoquant/tabs/segmentation/models/perinuclear_rings/__init__.py +1 -0
- senoquant/tabs/segmentation/models/perinuclear_rings/details.json +34 -0
- senoquant/tabs/segmentation/models/perinuclear_rings/model.py +132 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/__init__.py +2 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/__init__.py +3 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/__init__.py +6 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/generate.py +470 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/prepare.py +273 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/rawdata.py +112 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/transform.py +384 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/__init__.py +0 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/blocks.py +184 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/losses.py +79 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/nets.py +165 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/predict.py +467 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/probability.py +67 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/train.py +148 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/io/__init__.py +163 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/__init__.py +52 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/base_model.py +329 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_isotropic.py +160 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_projection.py +178 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_standard.py +446 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_upsampling.py +54 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/config.py +254 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/pretrained.py +119 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/scripts/__init__.py +0 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/scripts/care_predict.py +180 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/__init__.py +5 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/plot_utils.py +159 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/six.py +18 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/tf.py +644 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/utils.py +272 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/version.py +1 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/docs/source/conf.py +368 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/setup.py +68 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_datagen.py +169 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_models.py +462 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_utils.py +166 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +34 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/__init__.py +30 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/big.py +624 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/bioimageio_utils.py +494 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/data/__init__.py +39 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/__init__.py +10 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/geom2d.py +215 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/geom3d.py +349 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/matching.py +483 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/__init__.py +28 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/base.py +1217 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/model2d.py +594 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/model3d.py +696 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/nms.py +384 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/__init__.py +2 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/plot.py +74 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/render.py +298 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/rays3d.py +373 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/sample_patches.py +65 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/__init__.py +0 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/predict2d.py +90 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/predict3d.py +93 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/utils.py +408 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/version.py +1 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/__init__.py +45 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/__init__.py +17 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/cli.py +55 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/core.py +285 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/__init__.py +15 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/cli.py +36 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/divisibility.py +193 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +100 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/receptive_field.py +182 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/rf_cli.py +48 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/valid_sizes.py +278 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/post/__init__.py +8 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/post/core.py +157 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/pre/__init__.py +17 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/pre/core.py +226 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/predict/__init__.py +5 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/predict/core.py +401 -0
- senoquant/tabs/settings/__init__.py +1 -0
- senoquant/tabs/settings/backend.py +29 -0
- senoquant/tabs/settings/frontend.py +19 -0
- senoquant/tabs/spots/__init__.py +1 -0
- senoquant/tabs/spots/backend.py +139 -0
- senoquant/tabs/spots/frontend.py +800 -0
- senoquant/tabs/spots/models/__init__.py +5 -0
- senoquant/tabs/spots/models/base.py +94 -0
- senoquant/tabs/spots/models/rmp/details.json +61 -0
- senoquant/tabs/spots/models/rmp/model.py +499 -0
- senoquant/tabs/spots/models/udwt/details.json +103 -0
- senoquant/tabs/spots/models/udwt/model.py +482 -0
- senoquant/utils.py +25 -0
- senoquant-1.0.0b1.dist-info/METADATA +193 -0
- senoquant-1.0.0b1.dist-info/RECORD +148 -0
- senoquant-1.0.0b1.dist-info/WHEEL +5 -0
- senoquant-1.0.0b1.dist-info/entry_points.txt +2 -0
- senoquant-1.0.0b1.dist-info/licenses/LICENSE +28 -0
- 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)
|