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,1283 @@
|
|
|
1
|
+
"""Frontend widget for the Batch tab.
|
|
2
|
+
|
|
3
|
+
This module defines the Qt UI for configuring and running batch processing.
|
|
4
|
+
The UI builds a :class:`BatchJobConfig`, then delegates execution to the
|
|
5
|
+
batch backend in a background thread.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from qtpy.QtCore import QObject, QThread, Signal
|
|
13
|
+
from qtpy.QtWidgets import (
|
|
14
|
+
QCheckBox,
|
|
15
|
+
QComboBox,
|
|
16
|
+
QDoubleSpinBox,
|
|
17
|
+
QFileDialog,
|
|
18
|
+
QFormLayout,
|
|
19
|
+
QGroupBox,
|
|
20
|
+
QHBoxLayout,
|
|
21
|
+
QDialog,
|
|
22
|
+
QLabel,
|
|
23
|
+
QLineEdit,
|
|
24
|
+
QProgressBar,
|
|
25
|
+
QPushButton,
|
|
26
|
+
QScrollArea,
|
|
27
|
+
QSizePolicy,
|
|
28
|
+
QSpinBox,
|
|
29
|
+
QVBoxLayout,
|
|
30
|
+
QWidget,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from napari.utils.notifications import (
|
|
35
|
+
Notification,
|
|
36
|
+
NotificationSeverity,
|
|
37
|
+
show_console_notification,
|
|
38
|
+
)
|
|
39
|
+
except Exception: # pragma: no cover - optional import for runtime
|
|
40
|
+
show_console_notification = None
|
|
41
|
+
Notification = None
|
|
42
|
+
NotificationSeverity = None
|
|
43
|
+
|
|
44
|
+
from .backend import BatchBackend
|
|
45
|
+
from .config import (
|
|
46
|
+
BatchChannelConfig,
|
|
47
|
+
BatchCytoplasmicConfig,
|
|
48
|
+
BatchJobConfig,
|
|
49
|
+
BatchQuantificationConfig,
|
|
50
|
+
BatchSegmentationConfig,
|
|
51
|
+
BatchSpotsConfig,
|
|
52
|
+
)
|
|
53
|
+
from .layers import BatchViewer, Image, Labels
|
|
54
|
+
from ..quantification.frontend import QuantificationTab
|
|
55
|
+
from ..segmentation.backend import SegmentationBackend
|
|
56
|
+
from ..spots.backend import SpotsBackend
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RefreshingComboBox(QComboBox):
|
|
60
|
+
"""Combo box that refreshes its items when opened."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, refresh_callback=None, parent=None) -> None:
|
|
63
|
+
"""Initialize the combo box.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
refresh_callback : callable or None, optional
|
|
68
|
+
Callable invoked before the popup opens.
|
|
69
|
+
parent : QWidget or None, optional
|
|
70
|
+
Parent widget.
|
|
71
|
+
"""
|
|
72
|
+
super().__init__(parent)
|
|
73
|
+
self._refresh_callback = refresh_callback
|
|
74
|
+
|
|
75
|
+
def showPopup(self) -> None:
|
|
76
|
+
"""Invoke the refresh callback before showing the popup."""
|
|
77
|
+
if self._refresh_callback is not None:
|
|
78
|
+
self._refresh_callback()
|
|
79
|
+
super().showPopup()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BatchTab(QWidget):
|
|
83
|
+
"""Batch processing tab for running segmentation and spot detection."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
backend: BatchBackend | None = None,
|
|
88
|
+
napari_viewer=None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Initialize the Batch tab UI.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
backend : BatchBackend or None, optional
|
|
95
|
+
Backend instance used to execute batch runs.
|
|
96
|
+
napari_viewer : object or None, optional
|
|
97
|
+
Napari viewer instance for populating layer choices.
|
|
98
|
+
"""
|
|
99
|
+
super().__init__()
|
|
100
|
+
self._viewer = napari_viewer
|
|
101
|
+
self._segmentation_backend = SegmentationBackend()
|
|
102
|
+
self._spots_backend = SpotsBackend()
|
|
103
|
+
self._backend = backend or BatchBackend(
|
|
104
|
+
segmentation_backend=self._segmentation_backend,
|
|
105
|
+
spots_backend=self._spots_backend,
|
|
106
|
+
)
|
|
107
|
+
self._active_workers: list[tuple[QThread, QObject]] = []
|
|
108
|
+
self._channel_rows: list[dict] = []
|
|
109
|
+
self._channel_configs: list[BatchChannelConfig] = []
|
|
110
|
+
self._spot_channel_rows: list[dict] = []
|
|
111
|
+
self._nuclear_settings_widgets: dict[str, object] = {}
|
|
112
|
+
self._nuclear_settings_meta: dict[str, dict] = {}
|
|
113
|
+
self._nuclear_settings_values: dict[str, object] = {}
|
|
114
|
+
self._nuclear_settings_list: list[dict] = []
|
|
115
|
+
self._cyto_settings_widgets: dict[str, object] = {}
|
|
116
|
+
self._cyto_settings_meta: dict[str, dict] = {}
|
|
117
|
+
self._cyto_settings_values: dict[str, object] = {}
|
|
118
|
+
self._cyto_settings_list: list[dict] = []
|
|
119
|
+
self._spot_settings_widgets: dict[str, object] = {}
|
|
120
|
+
self._spot_settings_meta: dict[str, dict] = {}
|
|
121
|
+
self._spot_settings_values: dict[str, object] = {}
|
|
122
|
+
self._spot_settings_list: list[dict] = []
|
|
123
|
+
self._spot_min_size_spin: QSpinBox | None = None
|
|
124
|
+
self._spot_max_size_spin: QSpinBox | None = None
|
|
125
|
+
self._add_spot_button: QPushButton | None = None
|
|
126
|
+
self._config_viewer = BatchViewer()
|
|
127
|
+
|
|
128
|
+
layout = QVBoxLayout()
|
|
129
|
+
content = QWidget()
|
|
130
|
+
content_layout = QVBoxLayout()
|
|
131
|
+
content_layout.addWidget(self._make_input_section())
|
|
132
|
+
content_layout.addWidget(self._make_channel_section())
|
|
133
|
+
content_layout.addWidget(self._make_segmentation_section())
|
|
134
|
+
content_layout.addWidget(self._make_spots_section())
|
|
135
|
+
content_layout.addWidget(self._make_quantification_section())
|
|
136
|
+
content_layout.addWidget(self._make_output_section())
|
|
137
|
+
content_layout.addStretch(1)
|
|
138
|
+
content.setLayout(content_layout)
|
|
139
|
+
|
|
140
|
+
scroll = QScrollArea()
|
|
141
|
+
scroll.setWidgetResizable(True)
|
|
142
|
+
scroll.setWidget(content)
|
|
143
|
+
self._scroll_area = scroll
|
|
144
|
+
self._apply_scroll_height()
|
|
145
|
+
layout.addWidget(scroll)
|
|
146
|
+
|
|
147
|
+
self._run_button = QPushButton("Run batch")
|
|
148
|
+
self._run_button.clicked.connect(self._run_batch)
|
|
149
|
+
layout.addWidget(self._run_button)
|
|
150
|
+
|
|
151
|
+
self._progress_bar = QProgressBar()
|
|
152
|
+
self._progress_bar.setVisible(False)
|
|
153
|
+
layout.addWidget(self._progress_bar)
|
|
154
|
+
|
|
155
|
+
self._status_label = QLabel("Ready")
|
|
156
|
+
self._status_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
157
|
+
layout.addWidget(self._status_label)
|
|
158
|
+
layout.addStretch(1)
|
|
159
|
+
self.setLayout(layout)
|
|
160
|
+
|
|
161
|
+
self._refresh_segmentation_models()
|
|
162
|
+
self._refresh_cyto_models()
|
|
163
|
+
self._refresh_detectors()
|
|
164
|
+
self._refresh_channel_choices()
|
|
165
|
+
self._refresh_spot_channel_choices()
|
|
166
|
+
self._update_processing_state()
|
|
167
|
+
|
|
168
|
+
def showEvent(self, event) -> None:
|
|
169
|
+
"""Re-apply scroll sizing when the widget is shown."""
|
|
170
|
+
super().showEvent(event)
|
|
171
|
+
self._apply_scroll_height()
|
|
172
|
+
|
|
173
|
+
def resizeEvent(self, event) -> None:
|
|
174
|
+
"""Re-apply scroll sizing when the widget is resized."""
|
|
175
|
+
super().resizeEvent(event)
|
|
176
|
+
self._apply_scroll_height()
|
|
177
|
+
|
|
178
|
+
def _make_input_section(self) -> QGroupBox:
|
|
179
|
+
"""Build the input configuration section."""
|
|
180
|
+
section = QGroupBox("Input")
|
|
181
|
+
section_layout = QVBoxLayout()
|
|
182
|
+
form_layout = QFormLayout()
|
|
183
|
+
form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
184
|
+
|
|
185
|
+
self._input_path = QLineEdit()
|
|
186
|
+
self._input_path.setPlaceholderText("Folder with images")
|
|
187
|
+
browse_button = QPushButton("Browse")
|
|
188
|
+
browse_button.clicked.connect(self._select_input_path)
|
|
189
|
+
input_row = QHBoxLayout()
|
|
190
|
+
input_row.setContentsMargins(0, 0, 0, 0)
|
|
191
|
+
input_row.addWidget(self._input_path)
|
|
192
|
+
input_row.addWidget(browse_button)
|
|
193
|
+
input_widget = QWidget()
|
|
194
|
+
input_widget.setLayout(input_row)
|
|
195
|
+
|
|
196
|
+
self._extensions = QLineEdit()
|
|
197
|
+
self._extensions.setPlaceholderText(".tif,.tiff,.ome.tif,.png,.jpg")
|
|
198
|
+
self._extensions.setText(
|
|
199
|
+
".tif,.tiff,.ome.tif,.ome.tiff,.png,.jpg,.jpeg,.czi,.nd2,.lif,.zarr"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
self._include_subfolders = QCheckBox("Include subfolders")
|
|
203
|
+
self._process_scenes = QCheckBox("Process all scenes")
|
|
204
|
+
|
|
205
|
+
profile_row = QHBoxLayout()
|
|
206
|
+
profile_row.setContentsMargins(0, 0, 0, 0)
|
|
207
|
+
load_button = QPushButton("Load profile")
|
|
208
|
+
load_button.clicked.connect(self._load_profile)
|
|
209
|
+
save_button = QPushButton("Save profile")
|
|
210
|
+
save_button.clicked.connect(self._save_profile)
|
|
211
|
+
profile_row.addWidget(load_button)
|
|
212
|
+
profile_row.addWidget(save_button)
|
|
213
|
+
profile_widget = QWidget()
|
|
214
|
+
profile_widget.setLayout(profile_row)
|
|
215
|
+
|
|
216
|
+
form_layout.addRow("Input folder", input_widget)
|
|
217
|
+
form_layout.addRow("Extensions", self._extensions)
|
|
218
|
+
form_layout.addRow("", self._include_subfolders)
|
|
219
|
+
form_layout.addRow("", self._process_scenes)
|
|
220
|
+
form_layout.addRow("Profiles", profile_widget)
|
|
221
|
+
|
|
222
|
+
section_layout.addLayout(form_layout)
|
|
223
|
+
section.setLayout(section_layout)
|
|
224
|
+
return section
|
|
225
|
+
|
|
226
|
+
def _apply_scroll_height(self) -> None:
|
|
227
|
+
"""Pin scroll area height to 75% of the parent widget."""
|
|
228
|
+
parent = self.parentWidget()
|
|
229
|
+
if parent is None:
|
|
230
|
+
return
|
|
231
|
+
height = int(parent.height() * 0.75)
|
|
232
|
+
if hasattr(self, "_scroll_area") and self._scroll_area is not None:
|
|
233
|
+
self._scroll_area.setMinimumHeight(height)
|
|
234
|
+
self._scroll_area.setMaximumHeight(height)
|
|
235
|
+
|
|
236
|
+
def _make_channel_section(self) -> QGroupBox:
|
|
237
|
+
"""Build the channel mapping section."""
|
|
238
|
+
section = QGroupBox("Channels")
|
|
239
|
+
section_layout = QVBoxLayout()
|
|
240
|
+
|
|
241
|
+
self._channels_container = QWidget()
|
|
242
|
+
self._channels_layout = QVBoxLayout()
|
|
243
|
+
self._channels_layout.setContentsMargins(0, 0, 0, 0)
|
|
244
|
+
self._channels_layout.setSpacing(6)
|
|
245
|
+
self._channels_container.setLayout(self._channels_layout)
|
|
246
|
+
|
|
247
|
+
add_button = QPushButton("Add channel")
|
|
248
|
+
add_button.clicked.connect(self._add_channel_row)
|
|
249
|
+
|
|
250
|
+
section_layout.addWidget(self._channels_container)
|
|
251
|
+
section_layout.addWidget(add_button)
|
|
252
|
+
section.setLayout(section_layout)
|
|
253
|
+
|
|
254
|
+
if not self._channel_rows:
|
|
255
|
+
self._add_channel_row()
|
|
256
|
+
return section
|
|
257
|
+
|
|
258
|
+
def _make_segmentation_section(self) -> QGroupBox:
|
|
259
|
+
"""Build the segmentation configuration section."""
|
|
260
|
+
section = QGroupBox("Segmentation")
|
|
261
|
+
section_layout = QVBoxLayout()
|
|
262
|
+
|
|
263
|
+
nuclear_layout = QFormLayout()
|
|
264
|
+
nuclear_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
265
|
+
self._nuclear_enabled = QCheckBox("Run nuclear segmentation")
|
|
266
|
+
self._nuclear_enabled.setChecked(True)
|
|
267
|
+
self._nuclear_enabled.toggled.connect(self._update_processing_state)
|
|
268
|
+
self._nuclear_model_combo = RefreshingComboBox(
|
|
269
|
+
refresh_callback=self._refresh_segmentation_models
|
|
270
|
+
)
|
|
271
|
+
self._nuclear_channel_combo = QComboBox()
|
|
272
|
+
nuclear_layout.addRow(self._nuclear_enabled)
|
|
273
|
+
nuclear_layout.addRow("Nuclear model", self._nuclear_model_combo)
|
|
274
|
+
nuclear_layout.addRow("Nuclear channel", self._nuclear_channel_combo)
|
|
275
|
+
|
|
276
|
+
self._nuclear_settings_button = QPushButton("Edit nuclear settings")
|
|
277
|
+
self._nuclear_settings_button.clicked.connect(
|
|
278
|
+
lambda: self._open_settings_dialog("nuclear")
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
self._nuclear_model_combo.currentTextChanged.connect(
|
|
282
|
+
lambda _text: self._update_nuclear_settings()
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
cyto_layout = QFormLayout()
|
|
286
|
+
cyto_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
287
|
+
self._cyto_enabled = QCheckBox("Run cytoplasmic segmentation")
|
|
288
|
+
self._cyto_enabled.setChecked(False)
|
|
289
|
+
self._cyto_enabled.toggled.connect(self._update_processing_state)
|
|
290
|
+
self._cyto_model_combo = RefreshingComboBox(
|
|
291
|
+
refresh_callback=self._refresh_cyto_models
|
|
292
|
+
)
|
|
293
|
+
self._cyto_channel_combo = QComboBox()
|
|
294
|
+
self._cyto_nuclear_combo = QComboBox()
|
|
295
|
+
self._cyto_nuclear_label = QLabel("Nuclear channel")
|
|
296
|
+
self._cyto_nuclear_optional = False
|
|
297
|
+
cyto_layout.addRow(self._cyto_enabled)
|
|
298
|
+
cyto_layout.addRow("Cytoplasmic model", self._cyto_model_combo)
|
|
299
|
+
cyto_layout.addRow("Cytoplasmic channel", self._cyto_channel_combo)
|
|
300
|
+
cyto_layout.addRow(self._cyto_nuclear_label, self._cyto_nuclear_combo)
|
|
301
|
+
|
|
302
|
+
self._cyto_settings_button = QPushButton("Edit cytoplasmic settings")
|
|
303
|
+
self._cyto_settings_button.clicked.connect(
|
|
304
|
+
lambda: self._open_settings_dialog("cyto")
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
self._cyto_model_combo.currentTextChanged.connect(
|
|
308
|
+
lambda _text: self._update_cyto_settings()
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
section_layout.addLayout(nuclear_layout)
|
|
312
|
+
section_layout.addWidget(self._nuclear_settings_button)
|
|
313
|
+
section_layout.addLayout(cyto_layout)
|
|
314
|
+
section_layout.addWidget(self._cyto_settings_button)
|
|
315
|
+
section.setLayout(section_layout)
|
|
316
|
+
return section
|
|
317
|
+
|
|
318
|
+
def _make_spots_section(self) -> QGroupBox:
|
|
319
|
+
"""Build the spot detection configuration section."""
|
|
320
|
+
section = QGroupBox("Spot detection")
|
|
321
|
+
section_layout = QVBoxLayout()
|
|
322
|
+
form_layout = QFormLayout()
|
|
323
|
+
form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
324
|
+
|
|
325
|
+
self._spots_enabled = QCheckBox("Run spot detection")
|
|
326
|
+
self._spots_enabled.setChecked(True)
|
|
327
|
+
self._spots_enabled.toggled.connect(self._update_processing_state)
|
|
328
|
+
self._spot_detector_combo = RefreshingComboBox(
|
|
329
|
+
refresh_callback=self._refresh_detectors
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
form_layout.addRow(self._spots_enabled)
|
|
333
|
+
form_layout.addRow("Spot detector", self._spot_detector_combo)
|
|
334
|
+
self._spot_settings_button = QPushButton("Edit spot settings")
|
|
335
|
+
self._spot_settings_button.clicked.connect(
|
|
336
|
+
lambda: self._open_settings_dialog("spot")
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
self._spot_channels_container = QWidget()
|
|
340
|
+
self._spot_channels_layout = QVBoxLayout()
|
|
341
|
+
self._spot_channels_layout.setContentsMargins(0, 0, 0, 0)
|
|
342
|
+
self._spot_channels_layout.setSpacing(6)
|
|
343
|
+
self._spot_channels_container.setLayout(self._spot_channels_layout)
|
|
344
|
+
self._add_spot_button = QPushButton("Add spot channel")
|
|
345
|
+
self._add_spot_button.clicked.connect(self._add_spot_channel_row)
|
|
346
|
+
|
|
347
|
+
self._spot_detector_combo.currentTextChanged.connect(
|
|
348
|
+
lambda _text: self._update_spot_settings()
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
section_layout.addLayout(form_layout)
|
|
352
|
+
section_layout.addWidget(self._spot_settings_button)
|
|
353
|
+
|
|
354
|
+
# Add size filter section
|
|
355
|
+
size_filter_layout = QFormLayout()
|
|
356
|
+
self._spot_min_size_spin = QSpinBox()
|
|
357
|
+
self._spot_min_size_spin.setRange(0, 100000)
|
|
358
|
+
self._spot_min_size_spin.setValue(0)
|
|
359
|
+
|
|
360
|
+
self._spot_max_size_spin = QSpinBox()
|
|
361
|
+
self._spot_max_size_spin.setRange(0, 100000)
|
|
362
|
+
self._spot_max_size_spin.setValue(0)
|
|
363
|
+
|
|
364
|
+
size_filter_layout.addRow("Minimum spot size (px)", self._spot_min_size_spin)
|
|
365
|
+
size_filter_layout.addRow("Maximum spot size (px)", self._spot_max_size_spin)
|
|
366
|
+
section_layout.addLayout(size_filter_layout)
|
|
367
|
+
|
|
368
|
+
section_layout.addWidget(self._spot_channels_container)
|
|
369
|
+
section_layout.addWidget(self._add_spot_button)
|
|
370
|
+
section.setLayout(section_layout)
|
|
371
|
+
self._refresh_spot_channel_choices()
|
|
372
|
+
return section
|
|
373
|
+
|
|
374
|
+
def _make_quantification_section(self) -> QGroupBox:
|
|
375
|
+
"""Build the quantification configuration section."""
|
|
376
|
+
section = QGroupBox("Quantification")
|
|
377
|
+
section_layout = QVBoxLayout()
|
|
378
|
+
self._quant_enabled = QCheckBox("Run quantification")
|
|
379
|
+
self._quant_enabled.setChecked(True)
|
|
380
|
+
self._quant_enabled.toggled.connect(self._update_processing_state)
|
|
381
|
+
self._quant_tab = QuantificationTab(
|
|
382
|
+
napari_viewer=self._config_viewer,
|
|
383
|
+
show_output_section=False,
|
|
384
|
+
show_process_button=False,
|
|
385
|
+
enable_rois=False,
|
|
386
|
+
show_right_column=False,
|
|
387
|
+
enable_thresholds=False,
|
|
388
|
+
)
|
|
389
|
+
section_layout.addWidget(self._quant_enabled)
|
|
390
|
+
section_layout.addWidget(self._quant_tab)
|
|
391
|
+
section.setLayout(section_layout)
|
|
392
|
+
return section
|
|
393
|
+
|
|
394
|
+
def _make_output_section(self) -> QGroupBox:
|
|
395
|
+
"""Build the output configuration section."""
|
|
396
|
+
section = QGroupBox("Output")
|
|
397
|
+
section_layout = QVBoxLayout()
|
|
398
|
+
form_layout = QFormLayout()
|
|
399
|
+
form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
400
|
+
|
|
401
|
+
self._output_path = QLineEdit()
|
|
402
|
+
self._output_path.setPlaceholderText("Output folder")
|
|
403
|
+
browse_button = QPushButton("Browse")
|
|
404
|
+
browse_button.clicked.connect(self._select_output_path)
|
|
405
|
+
output_row = QHBoxLayout()
|
|
406
|
+
output_row.setContentsMargins(0, 0, 0, 0)
|
|
407
|
+
output_row.addWidget(self._output_path)
|
|
408
|
+
output_row.addWidget(browse_button)
|
|
409
|
+
output_widget = QWidget()
|
|
410
|
+
output_widget.setLayout(output_row)
|
|
411
|
+
|
|
412
|
+
self._output_format = QComboBox()
|
|
413
|
+
self._output_format.addItems(["tif", "npy"])
|
|
414
|
+
|
|
415
|
+
self._quant_format = QComboBox()
|
|
416
|
+
self._quant_format.addItems(["xlsx", "csv"])
|
|
417
|
+
|
|
418
|
+
self._overwrite = QCheckBox("Overwrite existing outputs")
|
|
419
|
+
|
|
420
|
+
form_layout.addRow("Output folder", output_widget)
|
|
421
|
+
form_layout.addRow("Segmentation format", self._output_format)
|
|
422
|
+
form_layout.addRow("Quantification format", self._quant_format)
|
|
423
|
+
form_layout.addRow("", self._overwrite)
|
|
424
|
+
|
|
425
|
+
section_layout.addLayout(form_layout)
|
|
426
|
+
section.setLayout(section_layout)
|
|
427
|
+
return section
|
|
428
|
+
|
|
429
|
+
def _select_input_path(self) -> None:
|
|
430
|
+
"""Open a folder picker for the input path."""
|
|
431
|
+
path = QFileDialog.getExistingDirectory(self, "Select input folder")
|
|
432
|
+
if path:
|
|
433
|
+
self._input_path.setText(path)
|
|
434
|
+
|
|
435
|
+
def _select_output_path(self) -> None:
|
|
436
|
+
"""Open a folder picker for the output path."""
|
|
437
|
+
path = QFileDialog.getExistingDirectory(self, "Select output folder")
|
|
438
|
+
if path:
|
|
439
|
+
self._output_path.setText(path)
|
|
440
|
+
|
|
441
|
+
def _refresh_segmentation_models(self) -> None:
|
|
442
|
+
"""Refresh available nuclear segmentation models."""
|
|
443
|
+
names = self._segmentation_backend.list_model_names(task="nuclear")
|
|
444
|
+
self._nuclear_model_combo.clear()
|
|
445
|
+
if names:
|
|
446
|
+
self._nuclear_model_combo.addItems(names)
|
|
447
|
+
self._nuclear_model_combo.setEnabled(True)
|
|
448
|
+
else:
|
|
449
|
+
self._nuclear_model_combo.addItem("(no models)")
|
|
450
|
+
self._nuclear_model_combo.setEnabled(False)
|
|
451
|
+
self._update_nuclear_settings()
|
|
452
|
+
|
|
453
|
+
def _refresh_cyto_models(self) -> None:
|
|
454
|
+
"""Refresh available cytoplasmic segmentation models."""
|
|
455
|
+
names = self._segmentation_backend.list_model_names(task="cytoplasmic")
|
|
456
|
+
self._cyto_model_combo.clear()
|
|
457
|
+
if names:
|
|
458
|
+
self._cyto_model_combo.addItems(names)
|
|
459
|
+
self._cyto_model_combo.setEnabled(True)
|
|
460
|
+
else:
|
|
461
|
+
self._cyto_model_combo.addItem("(no models)")
|
|
462
|
+
self._cyto_model_combo.setEnabled(False)
|
|
463
|
+
self._update_cyto_settings()
|
|
464
|
+
|
|
465
|
+
def _refresh_detectors(self) -> None:
|
|
466
|
+
"""Refresh available spot detectors."""
|
|
467
|
+
names = self._spots_backend.list_detector_names()
|
|
468
|
+
self._spot_detector_combo.clear()
|
|
469
|
+
if names:
|
|
470
|
+
self._spot_detector_combo.addItems(names)
|
|
471
|
+
self._spot_detector_combo.setEnabled(True)
|
|
472
|
+
else:
|
|
473
|
+
self._spot_detector_combo.addItem("(no detectors)")
|
|
474
|
+
self._spot_detector_combo.setEnabled(False)
|
|
475
|
+
self._update_spot_settings()
|
|
476
|
+
|
|
477
|
+
def _add_spot_channel_row(self) -> None:
|
|
478
|
+
"""Add a new spot-channel row to the UI."""
|
|
479
|
+
if not hasattr(self, "_spot_channels_layout"):
|
|
480
|
+
return
|
|
481
|
+
row_widget = QWidget()
|
|
482
|
+
row_layout = QHBoxLayout()
|
|
483
|
+
row_layout.setContentsMargins(0, 0, 0, 0)
|
|
484
|
+
combo = QComboBox()
|
|
485
|
+
delete_button = QPushButton("Delete")
|
|
486
|
+
row_layout.addWidget(combo)
|
|
487
|
+
row_layout.addWidget(delete_button)
|
|
488
|
+
row_widget.setLayout(row_layout)
|
|
489
|
+
|
|
490
|
+
row = {"widget": row_widget, "combo": combo, "delete_button": delete_button}
|
|
491
|
+
self._spot_channel_rows.append(row)
|
|
492
|
+
self._spot_channels_layout.addWidget(row_widget)
|
|
493
|
+
|
|
494
|
+
delete_button.clicked.connect(lambda: self._remove_spot_channel_row(row))
|
|
495
|
+
combo.currentTextChanged.connect(self._refresh_config_viewer)
|
|
496
|
+
self._refresh_spot_channel_choices()
|
|
497
|
+
|
|
498
|
+
def _remove_spot_channel_row(self, row: dict) -> None:
|
|
499
|
+
"""Remove a spot-channel row from the UI."""
|
|
500
|
+
widget = row.get("widget")
|
|
501
|
+
if widget is not None:
|
|
502
|
+
widget.setParent(None)
|
|
503
|
+
if row in self._spot_channel_rows:
|
|
504
|
+
self._spot_channel_rows.remove(row)
|
|
505
|
+
self._refresh_spot_channel_choices()
|
|
506
|
+
|
|
507
|
+
def _refresh_spot_channel_choices(self) -> None:
|
|
508
|
+
"""Refresh spot-channel combo options based on channel map."""
|
|
509
|
+
if not hasattr(self, "_spot_channels_layout"):
|
|
510
|
+
return
|
|
511
|
+
names = [config.name for config in self._channel_configs] or ["0"]
|
|
512
|
+
for row in self._spot_channel_rows:
|
|
513
|
+
combo = row["combo"]
|
|
514
|
+
current = combo.currentText()
|
|
515
|
+
combo.clear()
|
|
516
|
+
combo.addItems(names)
|
|
517
|
+
if current:
|
|
518
|
+
index = combo.findText(current)
|
|
519
|
+
if index != -1:
|
|
520
|
+
combo.setCurrentIndex(index)
|
|
521
|
+
if not self._spot_channel_rows:
|
|
522
|
+
self._add_spot_channel_row()
|
|
523
|
+
self._refresh_config_viewer()
|
|
524
|
+
|
|
525
|
+
def _add_channel_row(self, config: BatchChannelConfig | None = None) -> None:
|
|
526
|
+
"""Add a channel mapping row.
|
|
527
|
+
|
|
528
|
+
Parameters
|
|
529
|
+
----------
|
|
530
|
+
config : BatchChannelConfig or None, optional
|
|
531
|
+
Pre-populated channel config. When None, a default row is created.
|
|
532
|
+
"""
|
|
533
|
+
if isinstance(config, bool):
|
|
534
|
+
config = None
|
|
535
|
+
if config is None:
|
|
536
|
+
config = BatchChannelConfig(name="", index=len(self._channel_rows))
|
|
537
|
+
|
|
538
|
+
row_widget = QWidget()
|
|
539
|
+
row_layout = QHBoxLayout()
|
|
540
|
+
row_layout.setContentsMargins(0, 0, 0, 0)
|
|
541
|
+
|
|
542
|
+
name_input = QLineEdit()
|
|
543
|
+
name_input.setPlaceholderText("Channel name")
|
|
544
|
+
name_input.setText(config.name)
|
|
545
|
+
index_input = QSpinBox()
|
|
546
|
+
index_input.setMinimum(0)
|
|
547
|
+
index_input.setMaximum(4096)
|
|
548
|
+
index_input.setValue(config.index)
|
|
549
|
+
delete_button = QPushButton("Delete")
|
|
550
|
+
|
|
551
|
+
row_layout.addWidget(name_input)
|
|
552
|
+
row_layout.addWidget(index_input)
|
|
553
|
+
row_layout.addWidget(delete_button)
|
|
554
|
+
row_widget.setLayout(row_layout)
|
|
555
|
+
|
|
556
|
+
row = {
|
|
557
|
+
"widget": row_widget,
|
|
558
|
+
"name": name_input,
|
|
559
|
+
"index": index_input,
|
|
560
|
+
}
|
|
561
|
+
self._channel_rows.append(row)
|
|
562
|
+
self._channels_layout.addWidget(row_widget)
|
|
563
|
+
|
|
564
|
+
name_input.textChanged.connect(self._sync_channel_map)
|
|
565
|
+
index_input.valueChanged.connect(self._sync_channel_map)
|
|
566
|
+
delete_button.clicked.connect(lambda: self._remove_channel_row(row))
|
|
567
|
+
|
|
568
|
+
self._sync_channel_map()
|
|
569
|
+
|
|
570
|
+
def _remove_channel_row(self, row: dict) -> None:
|
|
571
|
+
"""Remove a channel mapping row."""
|
|
572
|
+
widget = row.get("widget")
|
|
573
|
+
if widget is not None:
|
|
574
|
+
widget.setParent(None)
|
|
575
|
+
if row in self._channel_rows:
|
|
576
|
+
self._channel_rows.remove(row)
|
|
577
|
+
self._sync_channel_map()
|
|
578
|
+
|
|
579
|
+
def _sync_channel_map(self) -> None:
|
|
580
|
+
"""Sync UI channel rows into BatchChannelConfig objects."""
|
|
581
|
+
configs: list[BatchChannelConfig] = []
|
|
582
|
+
for row in self._channel_rows:
|
|
583
|
+
name = row["name"].text().strip()
|
|
584
|
+
index = row["index"].value()
|
|
585
|
+
if not name:
|
|
586
|
+
name = f"{index}"
|
|
587
|
+
configs.append(BatchChannelConfig(name=name, index=index))
|
|
588
|
+
self._channel_configs = configs
|
|
589
|
+
self._refresh_channel_choices()
|
|
590
|
+
if hasattr(self, "_spot_channels_layout"):
|
|
591
|
+
self._refresh_spot_channel_choices()
|
|
592
|
+
self._refresh_config_viewer()
|
|
593
|
+
|
|
594
|
+
def _refresh_channel_choices(self) -> None:
|
|
595
|
+
"""Refresh combo boxes that depend on channel mapping."""
|
|
596
|
+
names = [config.name for config in self._channel_configs]
|
|
597
|
+
|
|
598
|
+
def populate_combo(
|
|
599
|
+
combo: QComboBox,
|
|
600
|
+
*,
|
|
601
|
+
include_none: bool = False,
|
|
602
|
+
none_label: str = "(none)",
|
|
603
|
+
) -> None:
|
|
604
|
+
current = combo.currentText()
|
|
605
|
+
combo.clear()
|
|
606
|
+
items: list[str] = []
|
|
607
|
+
if include_none:
|
|
608
|
+
items.append(none_label)
|
|
609
|
+
if names:
|
|
610
|
+
items.extend(names)
|
|
611
|
+
elif not include_none:
|
|
612
|
+
items.append("0")
|
|
613
|
+
if not items:
|
|
614
|
+
items.append(none_label)
|
|
615
|
+
combo.addItems(items)
|
|
616
|
+
if current:
|
|
617
|
+
index = combo.findText(current)
|
|
618
|
+
if index != -1:
|
|
619
|
+
combo.setCurrentIndex(index)
|
|
620
|
+
|
|
621
|
+
if getattr(self, "_nuclear_channel_combo", None) is not None:
|
|
622
|
+
populate_combo(self._nuclear_channel_combo)
|
|
623
|
+
if getattr(self, "_cyto_channel_combo", None) is not None:
|
|
624
|
+
populate_combo(self._cyto_channel_combo)
|
|
625
|
+
if getattr(self, "_cyto_nuclear_combo", None) is not None:
|
|
626
|
+
populate_combo(
|
|
627
|
+
self._cyto_nuclear_combo,
|
|
628
|
+
include_none=self._cyto_nuclear_optional,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
def _refresh_config_viewer(self) -> None:
|
|
632
|
+
"""Refresh the quantification preview viewer shim."""
|
|
633
|
+
layers: list[object] = []
|
|
634
|
+
for config in self._channel_configs:
|
|
635
|
+
# Add placeholder image layers so quantification UI can list names.
|
|
636
|
+
layers.append(Image(None, config.name))
|
|
637
|
+
if getattr(self, "_nuclear_enabled", None) is not None and self._nuclear_enabled.isChecked():
|
|
638
|
+
nuclear_model = self._nuclear_model_combo.currentText()
|
|
639
|
+
nuclear_channel = self._nuclear_channel_combo.currentText()
|
|
640
|
+
if nuclear_model and nuclear_channel and not nuclear_model.startswith("("):
|
|
641
|
+
label_name = f"{nuclear_channel}_{nuclear_model}_nuc_labels"
|
|
642
|
+
layers.append(Labels(None, label_name))
|
|
643
|
+
if getattr(self, "_cyto_enabled", None) is not None and self._cyto_enabled.isChecked():
|
|
644
|
+
cyto_model = self._cyto_model_combo.currentText()
|
|
645
|
+
cyto_channel = self._cyto_channel_combo.currentText()
|
|
646
|
+
if cyto_model and cyto_channel and not cyto_model.startswith("("):
|
|
647
|
+
label_name = f"{cyto_channel}_{cyto_model}_cyto_labels"
|
|
648
|
+
layers.append(Labels(None, label_name))
|
|
649
|
+
if getattr(self, "_spots_enabled", None) is not None and self._spots_enabled.isChecked():
|
|
650
|
+
spot_detector = self._spot_detector_combo.currentText()
|
|
651
|
+
if spot_detector and not spot_detector.startswith("("):
|
|
652
|
+
for label_name in _spot_label_names(self._spot_channel_rows, spot_detector):
|
|
653
|
+
layers.append(Labels(None, label_name))
|
|
654
|
+
self._config_viewer.set_layers(layers)
|
|
655
|
+
|
|
656
|
+
def _update_nuclear_settings(self) -> None:
|
|
657
|
+
"""Refresh nuclear model settings from the selected model."""
|
|
658
|
+
model_name = self._nuclear_model_combo.currentText()
|
|
659
|
+
self._nuclear_settings_widgets.clear()
|
|
660
|
+
self._nuclear_settings_meta.clear()
|
|
661
|
+
if not model_name or model_name.startswith("("):
|
|
662
|
+
return
|
|
663
|
+
model = self._segmentation_backend.get_model(model_name)
|
|
664
|
+
settings = model.list_settings()
|
|
665
|
+
self._nuclear_settings_list = list(settings)
|
|
666
|
+
self._nuclear_settings_meta = {
|
|
667
|
+
item.get("key", item.get("label", "")): item for item in settings
|
|
668
|
+
}
|
|
669
|
+
self._nuclear_settings_values = _defaults_from_settings(settings)
|
|
670
|
+
|
|
671
|
+
def _update_cyto_settings(self) -> None:
|
|
672
|
+
"""Refresh cytoplasmic model settings from the selected model."""
|
|
673
|
+
model_name = self._cyto_model_combo.currentText()
|
|
674
|
+
self._cyto_settings_widgets.clear()
|
|
675
|
+
self._cyto_settings_meta.clear()
|
|
676
|
+
if not model_name or model_name.startswith("("):
|
|
677
|
+
self._cyto_nuclear_combo.setEnabled(False)
|
|
678
|
+
if hasattr(self, "_cyto_nuclear_label"):
|
|
679
|
+
self._cyto_nuclear_label.setText("Nuclear channel")
|
|
680
|
+
self._cyto_nuclear_optional = False
|
|
681
|
+
return
|
|
682
|
+
model = self._segmentation_backend.get_model(model_name)
|
|
683
|
+
settings = model.list_settings()
|
|
684
|
+
self._cyto_settings_list = list(settings)
|
|
685
|
+
self._cyto_settings_meta = {
|
|
686
|
+
item.get("key", item.get("label", "")): item for item in settings
|
|
687
|
+
}
|
|
688
|
+
self._cyto_settings_values = _defaults_from_settings(settings)
|
|
689
|
+
modes = model.cytoplasmic_input_modes()
|
|
690
|
+
supports_nuclear = "nuclear+cytoplasmic" in modes
|
|
691
|
+
if supports_nuclear:
|
|
692
|
+
optional = model.cytoplasmic_nuclear_optional()
|
|
693
|
+
suffix = "optional" if optional else "required"
|
|
694
|
+
if hasattr(self, "_cyto_nuclear_label"):
|
|
695
|
+
self._cyto_nuclear_label.setText(f"Nuclear channel ({suffix})")
|
|
696
|
+
self._cyto_nuclear_combo.setEnabled(True)
|
|
697
|
+
self._cyto_nuclear_optional = optional
|
|
698
|
+
else:
|
|
699
|
+
if hasattr(self, "_cyto_nuclear_label"):
|
|
700
|
+
self._cyto_nuclear_label.setText("Nuclear channel")
|
|
701
|
+
self._cyto_nuclear_combo.setEnabled(False)
|
|
702
|
+
self._cyto_nuclear_optional = False
|
|
703
|
+
self._refresh_channel_choices()
|
|
704
|
+
|
|
705
|
+
def _update_spot_settings(self) -> None:
|
|
706
|
+
"""Refresh spot detector settings from the selected detector."""
|
|
707
|
+
detector_name = self._spot_detector_combo.currentText()
|
|
708
|
+
self._spot_settings_widgets.clear()
|
|
709
|
+
self._spot_settings_meta.clear()
|
|
710
|
+
if not detector_name or detector_name.startswith("("):
|
|
711
|
+
return
|
|
712
|
+
detector = self._spots_backend.get_detector(detector_name)
|
|
713
|
+
settings = detector.list_settings()
|
|
714
|
+
self._spot_settings_list = list(settings)
|
|
715
|
+
self._spot_settings_meta = {
|
|
716
|
+
item.get("key", item.get("label", "")): item for item in settings
|
|
717
|
+
}
|
|
718
|
+
self._spot_settings_values = _defaults_from_settings(settings)
|
|
719
|
+
|
|
720
|
+
def _open_settings_dialog(self, kind: str) -> None:
|
|
721
|
+
"""Open a settings dialog for model/detector configuration.
|
|
722
|
+
|
|
723
|
+
Parameters
|
|
724
|
+
----------
|
|
725
|
+
kind : {"nuclear", "cyto", "spot"}
|
|
726
|
+
Settings group to edit.
|
|
727
|
+
"""
|
|
728
|
+
if kind == "nuclear":
|
|
729
|
+
title = "Nuclear settings"
|
|
730
|
+
settings = list(self._nuclear_settings_list)
|
|
731
|
+
widgets = self._nuclear_settings_widgets
|
|
732
|
+
values = self._nuclear_settings_values
|
|
733
|
+
meta = self._nuclear_settings_meta
|
|
734
|
+
elif kind == "cyto":
|
|
735
|
+
title = "Cytoplasmic settings"
|
|
736
|
+
settings = list(self._cyto_settings_list)
|
|
737
|
+
widgets = self._cyto_settings_widgets
|
|
738
|
+
values = self._cyto_settings_values
|
|
739
|
+
meta = self._cyto_settings_meta
|
|
740
|
+
else:
|
|
741
|
+
title = "Spot settings"
|
|
742
|
+
settings = list(self._spot_settings_list)
|
|
743
|
+
widgets = self._spot_settings_widgets
|
|
744
|
+
values = self._spot_settings_values
|
|
745
|
+
meta = self._spot_settings_meta
|
|
746
|
+
|
|
747
|
+
dialog = QDialog(self)
|
|
748
|
+
dialog.setWindowTitle(title)
|
|
749
|
+
dialog_layout = QVBoxLayout()
|
|
750
|
+
form_layout = QFormLayout()
|
|
751
|
+
widgets.clear()
|
|
752
|
+
for setting in settings:
|
|
753
|
+
setting_type = setting.get("type")
|
|
754
|
+
label = setting.get("label", setting.get("key", "Setting"))
|
|
755
|
+
key = setting.get("key", label)
|
|
756
|
+
default = setting.get("default", 0)
|
|
757
|
+
if setting_type == "float":
|
|
758
|
+
widget = QDoubleSpinBox()
|
|
759
|
+
decimals = int(setting.get("decimals", 1))
|
|
760
|
+
widget.setDecimals(decimals)
|
|
761
|
+
widget.setRange(
|
|
762
|
+
float(setting.get("min", 0.0)),
|
|
763
|
+
float(setting.get("max", 1.0)),
|
|
764
|
+
)
|
|
765
|
+
widget.setSingleStep(0.1)
|
|
766
|
+
widget.setValue(float(values.get(key, default)))
|
|
767
|
+
elif setting_type == "int":
|
|
768
|
+
widget = QSpinBox()
|
|
769
|
+
widget.setRange(
|
|
770
|
+
int(setting.get("min", 0)),
|
|
771
|
+
int(setting.get("max", 100)),
|
|
772
|
+
)
|
|
773
|
+
widget.setSingleStep(1)
|
|
774
|
+
widget.setValue(int(values.get(key, default)))
|
|
775
|
+
elif setting_type == "bool":
|
|
776
|
+
widget = QCheckBox()
|
|
777
|
+
widget.setChecked(bool(values.get(key, default)))
|
|
778
|
+
widget.toggled.connect(
|
|
779
|
+
lambda _checked, m=widgets, meta_ref=meta: self._apply_setting_dependencies(m, meta_ref)
|
|
780
|
+
)
|
|
781
|
+
else:
|
|
782
|
+
widget = QLabel("Unsupported setting type")
|
|
783
|
+
widgets[key] = widget
|
|
784
|
+
form_layout.addRow(label, widget)
|
|
785
|
+
dialog_layout.addLayout(form_layout)
|
|
786
|
+
self._apply_setting_dependencies(widgets, meta)
|
|
787
|
+
close_button = QPushButton("Close")
|
|
788
|
+
close_button.clicked.connect(dialog.accept)
|
|
789
|
+
dialog_layout.addWidget(close_button)
|
|
790
|
+
dialog.setLayout(dialog_layout)
|
|
791
|
+
dialog.exec()
|
|
792
|
+
values.update(self._collect_settings(widgets))
|
|
793
|
+
|
|
794
|
+
def _apply_setting_dependencies(self, settings_widgets: dict, settings_meta: dict) -> None:
|
|
795
|
+
"""Enable/disable settings based on dependency metadata."""
|
|
796
|
+
for key, setting in settings_meta.items():
|
|
797
|
+
widget = settings_widgets.get(key)
|
|
798
|
+
if widget is None:
|
|
799
|
+
continue
|
|
800
|
+
enabled_by = setting.get("enabled_by")
|
|
801
|
+
disabled_by = setting.get("disabled_by")
|
|
802
|
+
if enabled_by:
|
|
803
|
+
controller = settings_widgets.get(enabled_by)
|
|
804
|
+
if isinstance(controller, QCheckBox):
|
|
805
|
+
widget.setEnabled(controller.isChecked())
|
|
806
|
+
if disabled_by:
|
|
807
|
+
controller = settings_widgets.get(disabled_by)
|
|
808
|
+
if isinstance(controller, QCheckBox):
|
|
809
|
+
widget.setEnabled(not controller.isChecked())
|
|
810
|
+
|
|
811
|
+
@staticmethod
|
|
812
|
+
def _collect_settings(settings_widgets: dict) -> dict:
|
|
813
|
+
"""Collect values from settings widgets into a dictionary."""
|
|
814
|
+
values = {}
|
|
815
|
+
for key, widget in settings_widgets.items():
|
|
816
|
+
try:
|
|
817
|
+
if hasattr(widget, "value"):
|
|
818
|
+
values[key] = widget.value()
|
|
819
|
+
elif isinstance(widget, QCheckBox):
|
|
820
|
+
values[key] = widget.isChecked()
|
|
821
|
+
except RuntimeError:
|
|
822
|
+
# Widget was deleted; ignore stale references.
|
|
823
|
+
continue
|
|
824
|
+
return values
|
|
825
|
+
|
|
826
|
+
def _update_processing_state(self) -> None:
|
|
827
|
+
"""Enable/disable UI sections based on checkbox states."""
|
|
828
|
+
nuclear_enabled = self._nuclear_enabled.isChecked()
|
|
829
|
+
cyto_enabled = self._cyto_enabled.isChecked()
|
|
830
|
+
spot_enabled = self._spots_enabled.isChecked()
|
|
831
|
+
self._nuclear_model_combo.setEnabled(nuclear_enabled)
|
|
832
|
+
self._nuclear_channel_combo.setEnabled(nuclear_enabled)
|
|
833
|
+
self._nuclear_settings_button.setEnabled(nuclear_enabled)
|
|
834
|
+
self._cyto_model_combo.setEnabled(cyto_enabled)
|
|
835
|
+
self._cyto_channel_combo.setEnabled(cyto_enabled)
|
|
836
|
+
self._cyto_nuclear_combo.setEnabled(cyto_enabled)
|
|
837
|
+
self._cyto_settings_button.setEnabled(cyto_enabled)
|
|
838
|
+
self._spot_detector_combo.setEnabled(spot_enabled)
|
|
839
|
+
self._spot_settings_button.setEnabled(spot_enabled)
|
|
840
|
+
if self._spot_min_size_spin is not None:
|
|
841
|
+
self._spot_min_size_spin.setEnabled(spot_enabled)
|
|
842
|
+
if self._spot_max_size_spin is not None:
|
|
843
|
+
self._spot_max_size_spin.setEnabled(spot_enabled)
|
|
844
|
+
if self._add_spot_button is not None:
|
|
845
|
+
self._add_spot_button.setEnabled(spot_enabled)
|
|
846
|
+
for row in self._spot_channel_rows:
|
|
847
|
+
combo = row.get("combo")
|
|
848
|
+
if combo is not None:
|
|
849
|
+
combo.setEnabled(spot_enabled)
|
|
850
|
+
delete_button = row.get("delete_button")
|
|
851
|
+
if delete_button is not None:
|
|
852
|
+
delete_button.setEnabled(spot_enabled)
|
|
853
|
+
self._quant_tab.setEnabled(self._quant_enabled.isChecked())
|
|
854
|
+
|
|
855
|
+
self._refresh_config_viewer()
|
|
856
|
+
|
|
857
|
+
def _run_batch(self) -> None:
|
|
858
|
+
"""Validate inputs and launch the batch job."""
|
|
859
|
+
input_path = self._input_path.text().strip()
|
|
860
|
+
if not input_path:
|
|
861
|
+
self._notify("Select an input folder.")
|
|
862
|
+
return
|
|
863
|
+
if not Path(input_path).exists():
|
|
864
|
+
self._notify("Input folder does not exist.")
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
output_path = self._output_path.text().strip()
|
|
868
|
+
if not output_path:
|
|
869
|
+
output_path = str(Path(input_path) / "batch-output")
|
|
870
|
+
self._output_path.setText(output_path)
|
|
871
|
+
|
|
872
|
+
nuclear_model = None
|
|
873
|
+
if self._nuclear_enabled.isChecked() and self._nuclear_model_combo.isEnabled():
|
|
874
|
+
nuclear_model = self._nuclear_model_combo.currentText().strip()
|
|
875
|
+
if nuclear_model.startswith("("):
|
|
876
|
+
nuclear_model = None
|
|
877
|
+
|
|
878
|
+
spot_detector = None
|
|
879
|
+
if self._spots_enabled.isChecked() and self._spot_detector_combo.isEnabled():
|
|
880
|
+
spot_detector = self._spot_detector_combo.currentText().strip()
|
|
881
|
+
if spot_detector.startswith("("):
|
|
882
|
+
spot_detector = None
|
|
883
|
+
|
|
884
|
+
cyto_model = None
|
|
885
|
+
if self._cyto_enabled.isChecked() and self._cyto_model_combo.isEnabled():
|
|
886
|
+
cyto_model = self._cyto_model_combo.currentText().strip()
|
|
887
|
+
if cyto_model.startswith("("):
|
|
888
|
+
cyto_model = None
|
|
889
|
+
|
|
890
|
+
quant_features = (
|
|
891
|
+
list(self._quant_tab._feature_configs)
|
|
892
|
+
if self._quant_enabled.isChecked()
|
|
893
|
+
else []
|
|
894
|
+
)
|
|
895
|
+
if (
|
|
896
|
+
not nuclear_model
|
|
897
|
+
and not cyto_model
|
|
898
|
+
and not spot_detector
|
|
899
|
+
and not quant_features
|
|
900
|
+
):
|
|
901
|
+
self._notify("Enable segmentation, spots, or quantification.")
|
|
902
|
+
return
|
|
903
|
+
|
|
904
|
+
extensions = [
|
|
905
|
+
ext.strip()
|
|
906
|
+
for ext in self._extensions.text().split(",")
|
|
907
|
+
if ext.strip()
|
|
908
|
+
]
|
|
909
|
+
|
|
910
|
+
spot_channels = [
|
|
911
|
+
row["combo"].currentText().strip()
|
|
912
|
+
for row in self._spot_channel_rows
|
|
913
|
+
if row.get("combo") is not None and row["combo"].currentText().strip()
|
|
914
|
+
]
|
|
915
|
+
if self._spots_enabled.isChecked() and not spot_channels:
|
|
916
|
+
self._notify("Select at least one spot channel.")
|
|
917
|
+
return
|
|
918
|
+
|
|
919
|
+
job = self._build_job_config()
|
|
920
|
+
quant_contexts = (
|
|
921
|
+
list(self._quant_tab._feature_configs)
|
|
922
|
+
if self._quant_enabled.isChecked()
|
|
923
|
+
else []
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
# Create a worker that can report progress
|
|
927
|
+
worker = _RunWorker(lambda progress_cb: self._backend.process_folder(
|
|
928
|
+
job.input_path,
|
|
929
|
+
job.output_path,
|
|
930
|
+
channel_map=job.channel_map,
|
|
931
|
+
nuclear_model=job.nuclear.model if job.nuclear.enabled else None,
|
|
932
|
+
nuclear_channel=job.nuclear.channel or None,
|
|
933
|
+
nuclear_settings=job.nuclear.settings,
|
|
934
|
+
cyto_model=job.cytoplasmic.model if job.cytoplasmic.enabled else None,
|
|
935
|
+
cyto_channel=job.cytoplasmic.channel or None,
|
|
936
|
+
cyto_nuclear_channel=job.cytoplasmic.nuclear_channel or None,
|
|
937
|
+
cyto_settings=job.cytoplasmic.settings,
|
|
938
|
+
spot_detector=job.spots.detector if job.spots.enabled else None,
|
|
939
|
+
spot_channels=job.spots.channels,
|
|
940
|
+
spot_settings=job.spots.settings,
|
|
941
|
+
quantification_features=quant_contexts,
|
|
942
|
+
quantification_format=job.quantification.format,
|
|
943
|
+
quantification_tab=(
|
|
944
|
+
self._quant_tab if self._quant_enabled.isChecked() else None
|
|
945
|
+
),
|
|
946
|
+
extensions=job.extensions,
|
|
947
|
+
include_subfolders=job.include_subfolders,
|
|
948
|
+
output_format=job.output_format,
|
|
949
|
+
overwrite=job.overwrite,
|
|
950
|
+
process_all_scenes=job.process_all_scenes,
|
|
951
|
+
progress_callback=progress_cb,
|
|
952
|
+
))
|
|
953
|
+
|
|
954
|
+
self._start_background_run(
|
|
955
|
+
run_button=self._run_button,
|
|
956
|
+
run_text="Run batch",
|
|
957
|
+
worker=worker,
|
|
958
|
+
on_success=self._handle_batch_complete,
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
def _build_job_config(self) -> BatchJobConfig:
|
|
962
|
+
"""Build a BatchJobConfig from the current UI state."""
|
|
963
|
+
nuclear_settings = self._collect_settings(self._nuclear_settings_widgets)
|
|
964
|
+
if not nuclear_settings:
|
|
965
|
+
nuclear_settings = self._nuclear_settings_values
|
|
966
|
+
cyto_settings = self._collect_settings(self._cyto_settings_widgets)
|
|
967
|
+
if not cyto_settings:
|
|
968
|
+
cyto_settings = self._cyto_settings_values
|
|
969
|
+
spot_settings = self._collect_settings(self._spot_settings_widgets)
|
|
970
|
+
if not spot_settings:
|
|
971
|
+
spot_settings = self._spot_settings_values
|
|
972
|
+
spot_channels = [
|
|
973
|
+
row["combo"].currentText().strip()
|
|
974
|
+
for row in self._spot_channel_rows
|
|
975
|
+
if row.get("combo") is not None and row["combo"].currentText().strip()
|
|
976
|
+
]
|
|
977
|
+
quant_features = (
|
|
978
|
+
[context.state for context in self._quant_tab._feature_configs]
|
|
979
|
+
if self._quant_enabled.isChecked()
|
|
980
|
+
else []
|
|
981
|
+
)
|
|
982
|
+
return BatchJobConfig(
|
|
983
|
+
input_path=self._input_path.text().strip(),
|
|
984
|
+
output_path=self._output_path.text().strip(),
|
|
985
|
+
extensions=[
|
|
986
|
+
ext.strip()
|
|
987
|
+
for ext in self._extensions.text().split(",")
|
|
988
|
+
if ext.strip()
|
|
989
|
+
],
|
|
990
|
+
include_subfolders=self._include_subfolders.isChecked(),
|
|
991
|
+
process_all_scenes=self._process_scenes.isChecked(),
|
|
992
|
+
overwrite=self._overwrite.isChecked(),
|
|
993
|
+
output_format=self._output_format.currentText(),
|
|
994
|
+
channel_map=list(self._channel_configs),
|
|
995
|
+
nuclear=BatchSegmentationConfig(
|
|
996
|
+
enabled=self._nuclear_enabled.isChecked(),
|
|
997
|
+
model=self._nuclear_model_combo.currentText(),
|
|
998
|
+
channel=self._nuclear_channel_combo.currentText(),
|
|
999
|
+
settings=nuclear_settings,
|
|
1000
|
+
),
|
|
1001
|
+
cytoplasmic=BatchCytoplasmicConfig(
|
|
1002
|
+
enabled=self._cyto_enabled.isChecked(),
|
|
1003
|
+
model=self._cyto_model_combo.currentText(),
|
|
1004
|
+
channel=self._cyto_channel_combo.currentText(),
|
|
1005
|
+
nuclear_channel=(
|
|
1006
|
+
""
|
|
1007
|
+
if self._cyto_nuclear_combo.currentText().strip() == "(none)"
|
|
1008
|
+
else self._cyto_nuclear_combo.currentText()
|
|
1009
|
+
),
|
|
1010
|
+
settings=cyto_settings,
|
|
1011
|
+
),
|
|
1012
|
+
spots=BatchSpotsConfig(
|
|
1013
|
+
enabled=self._spots_enabled.isChecked(),
|
|
1014
|
+
detector=self._spot_detector_combo.currentText(),
|
|
1015
|
+
channels=spot_channels,
|
|
1016
|
+
settings=spot_settings,
|
|
1017
|
+
min_size=self._spot_min_size_spin.value() if self._spot_min_size_spin else 0,
|
|
1018
|
+
max_size=self._spot_max_size_spin.value() if self._spot_max_size_spin else 0,
|
|
1019
|
+
),
|
|
1020
|
+
quantification=BatchQuantificationConfig(
|
|
1021
|
+
enabled=self._quant_enabled.isChecked(),
|
|
1022
|
+
format=self._quant_format.currentText(),
|
|
1023
|
+
features=quant_features,
|
|
1024
|
+
),
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
def _apply_job_config(self, job: BatchJobConfig) -> None:
|
|
1028
|
+
"""Populate the UI from a BatchJobConfig."""
|
|
1029
|
+
self._refresh_segmentation_models()
|
|
1030
|
+
self._refresh_cyto_models()
|
|
1031
|
+
self._refresh_detectors()
|
|
1032
|
+
self._input_path.setText(job.input_path)
|
|
1033
|
+
self._output_path.setText(job.output_path)
|
|
1034
|
+
self._extensions.setText(",".join(job.extensions))
|
|
1035
|
+
self._include_subfolders.setChecked(job.include_subfolders)
|
|
1036
|
+
self._process_scenes.setChecked(job.process_all_scenes)
|
|
1037
|
+
self._overwrite.setChecked(job.overwrite)
|
|
1038
|
+
self._output_format.setCurrentText(job.output_format)
|
|
1039
|
+
self._quant_format.setCurrentText(job.quantification.format)
|
|
1040
|
+
|
|
1041
|
+
self._clear_channel_rows()
|
|
1042
|
+
for config in job.channel_map:
|
|
1043
|
+
self._add_channel_row(config)
|
|
1044
|
+
|
|
1045
|
+
self._nuclear_enabled.setChecked(job.nuclear.enabled)
|
|
1046
|
+
self._set_combo_value(self._nuclear_model_combo, job.nuclear.model)
|
|
1047
|
+
self._set_combo_value(self._nuclear_channel_combo, job.nuclear.channel)
|
|
1048
|
+
self._nuclear_settings_values = dict(job.nuclear.settings)
|
|
1049
|
+
|
|
1050
|
+
self._cyto_enabled.setChecked(job.cytoplasmic.enabled)
|
|
1051
|
+
self._set_combo_value(self._cyto_model_combo, job.cytoplasmic.model)
|
|
1052
|
+
self._set_combo_value(self._cyto_channel_combo, job.cytoplasmic.channel)
|
|
1053
|
+
if not job.cytoplasmic.nuclear_channel:
|
|
1054
|
+
if self._cyto_nuclear_combo.findText("(none)") != -1:
|
|
1055
|
+
self._set_combo_value(self._cyto_nuclear_combo, "(none)")
|
|
1056
|
+
else:
|
|
1057
|
+
self._set_combo_value(
|
|
1058
|
+
self._cyto_nuclear_combo, job.cytoplasmic.nuclear_channel
|
|
1059
|
+
)
|
|
1060
|
+
self._cyto_settings_values = dict(job.cytoplasmic.settings)
|
|
1061
|
+
|
|
1062
|
+
self._spots_enabled.setChecked(job.spots.enabled)
|
|
1063
|
+
self._set_combo_value(self._spot_detector_combo, job.spots.detector)
|
|
1064
|
+
self._spot_settings_values = dict(job.spots.settings)
|
|
1065
|
+
if self._spot_min_size_spin is not None:
|
|
1066
|
+
self._spot_min_size_spin.setValue(job.spots.min_size)
|
|
1067
|
+
if self._spot_max_size_spin is not None:
|
|
1068
|
+
self._spot_max_size_spin.setValue(job.spots.max_size)
|
|
1069
|
+
self._clear_spot_channel_rows()
|
|
1070
|
+
for channel in job.spots.channels:
|
|
1071
|
+
self._add_spot_channel_row()
|
|
1072
|
+
if self._spot_channel_rows:
|
|
1073
|
+
self._set_combo_value(self._spot_channel_rows[-1]["combo"], channel)
|
|
1074
|
+
|
|
1075
|
+
self._quant_enabled.setChecked(job.quantification.enabled)
|
|
1076
|
+
self._quant_tab.load_feature_configs(job.quantification.features)
|
|
1077
|
+
self._refresh_channel_choices()
|
|
1078
|
+
self._refresh_spot_channel_choices()
|
|
1079
|
+
self._refresh_config_viewer()
|
|
1080
|
+
|
|
1081
|
+
def _save_profile(self) -> None:
|
|
1082
|
+
"""Save the current configuration to a JSON profile."""
|
|
1083
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
1084
|
+
self,
|
|
1085
|
+
"Save batch profile",
|
|
1086
|
+
str(Path.cwd() / "batch-profile.json"),
|
|
1087
|
+
"JSON (*.json)",
|
|
1088
|
+
)
|
|
1089
|
+
if not path:
|
|
1090
|
+
return
|
|
1091
|
+
job = self._build_job_config()
|
|
1092
|
+
job.save(path)
|
|
1093
|
+
self._notify(f"Saved profile to {path}")
|
|
1094
|
+
|
|
1095
|
+
def _load_profile(self) -> None:
|
|
1096
|
+
"""Load a configuration from a JSON profile."""
|
|
1097
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
1098
|
+
self,
|
|
1099
|
+
"Load batch profile",
|
|
1100
|
+
str(Path.cwd()),
|
|
1101
|
+
"JSON (*.json)",
|
|
1102
|
+
)
|
|
1103
|
+
if not path:
|
|
1104
|
+
return
|
|
1105
|
+
job = BatchJobConfig.load(path)
|
|
1106
|
+
self._apply_job_config(job)
|
|
1107
|
+
self._notify(f"Loaded profile from {path}")
|
|
1108
|
+
|
|
1109
|
+
def _clear_channel_rows(self) -> None:
|
|
1110
|
+
"""Remove all channel rows from the UI."""
|
|
1111
|
+
for row in list(self._channel_rows):
|
|
1112
|
+
widget = row.get("widget")
|
|
1113
|
+
if widget is not None:
|
|
1114
|
+
widget.setParent(None)
|
|
1115
|
+
self._channel_rows = []
|
|
1116
|
+
self._channel_configs = []
|
|
1117
|
+
|
|
1118
|
+
def _clear_spot_channel_rows(self) -> None:
|
|
1119
|
+
"""Remove all spot channel rows from the UI."""
|
|
1120
|
+
for row in list(self._spot_channel_rows):
|
|
1121
|
+
widget = row.get("widget")
|
|
1122
|
+
if widget is not None:
|
|
1123
|
+
widget.setParent(None)
|
|
1124
|
+
self._spot_channel_rows = []
|
|
1125
|
+
|
|
1126
|
+
@staticmethod
|
|
1127
|
+
def _set_combo_value(combo: QComboBox, value: str) -> None:
|
|
1128
|
+
"""Set a combo box value if the item exists."""
|
|
1129
|
+
if not value:
|
|
1130
|
+
return
|
|
1131
|
+
index = combo.findText(value)
|
|
1132
|
+
if index != -1:
|
|
1133
|
+
combo.setCurrentIndex(index)
|
|
1134
|
+
|
|
1135
|
+
def _start_background_run(
|
|
1136
|
+
self,
|
|
1137
|
+
*,
|
|
1138
|
+
run_button: QPushButton,
|
|
1139
|
+
run_text: str,
|
|
1140
|
+
worker: "_RunWorker",
|
|
1141
|
+
on_success,
|
|
1142
|
+
) -> None:
|
|
1143
|
+
"""Start a background thread to execute the batch job."""
|
|
1144
|
+
run_button.setEnabled(False)
|
|
1145
|
+
run_button.setText("Running...")
|
|
1146
|
+
self._status_label.setText("Running batch...")
|
|
1147
|
+
self._progress_bar.setVisible(True)
|
|
1148
|
+
self._progress_bar.setValue(0)
|
|
1149
|
+
|
|
1150
|
+
thread = QThread()
|
|
1151
|
+
worker.moveToThread(thread)
|
|
1152
|
+
worker.progress.connect(self._update_progress)
|
|
1153
|
+
worker.finished.connect(lambda result: on_success(result))
|
|
1154
|
+
worker.finished.connect(
|
|
1155
|
+
lambda: self._finish_background_run(run_button, run_text, thread, worker)
|
|
1156
|
+
)
|
|
1157
|
+
worker.failed.connect(
|
|
1158
|
+
lambda message: self._notify(f"Batch run failed: {message}")
|
|
1159
|
+
)
|
|
1160
|
+
worker.failed.connect(
|
|
1161
|
+
lambda: self._finish_background_run(run_button, run_text, thread, worker)
|
|
1162
|
+
)
|
|
1163
|
+
thread.started.connect(worker.run)
|
|
1164
|
+
thread.start()
|
|
1165
|
+
self._active_workers.append((thread, worker))
|
|
1166
|
+
|
|
1167
|
+
def _finish_background_run(
|
|
1168
|
+
self,
|
|
1169
|
+
run_button: QPushButton,
|
|
1170
|
+
run_text: str,
|
|
1171
|
+
thread: QThread,
|
|
1172
|
+
worker: QObject,
|
|
1173
|
+
) -> None:
|
|
1174
|
+
"""Restore UI state and clean up worker threads."""
|
|
1175
|
+
run_button.setEnabled(True)
|
|
1176
|
+
run_button.setText(run_text)
|
|
1177
|
+
self._status_label.setText("Ready")
|
|
1178
|
+
self._progress_bar.setVisible(False)
|
|
1179
|
+
self._progress_bar.setValue(0)
|
|
1180
|
+
thread.quit()
|
|
1181
|
+
thread.wait()
|
|
1182
|
+
try:
|
|
1183
|
+
self._active_workers.remove((thread, worker))
|
|
1184
|
+
except ValueError:
|
|
1185
|
+
pass
|
|
1186
|
+
|
|
1187
|
+
def _update_progress(self, current: int, total: int, message: str) -> None:
|
|
1188
|
+
"""Update progress bar and status label."""
|
|
1189
|
+
if total > 0:
|
|
1190
|
+
percent = int((current / total) * 100)
|
|
1191
|
+
self._progress_bar.setValue(percent)
|
|
1192
|
+
self._status_label.setText(message)
|
|
1193
|
+
|
|
1194
|
+
def _handle_batch_complete(self, summary) -> None:
|
|
1195
|
+
"""Handle successful completion of a batch run."""
|
|
1196
|
+
message = (
|
|
1197
|
+
f"Batch complete: {summary.processed} processed, "
|
|
1198
|
+
f"{summary.failed} failed, {summary.skipped} skipped."
|
|
1199
|
+
)
|
|
1200
|
+
self._notify(message)
|
|
1201
|
+
|
|
1202
|
+
def _notify(self, message: str) -> None:
|
|
1203
|
+
"""Send a user-visible notification and update the status label."""
|
|
1204
|
+
if (
|
|
1205
|
+
show_console_notification is not None
|
|
1206
|
+
and Notification is not None
|
|
1207
|
+
and NotificationSeverity is not None
|
|
1208
|
+
):
|
|
1209
|
+
show_console_notification(
|
|
1210
|
+
Notification(message, severity=NotificationSeverity.WARNING)
|
|
1211
|
+
)
|
|
1212
|
+
self._status_label.setText(message)
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
class _RunWorker(QObject):
|
|
1216
|
+
"""Worker wrapper for background batch execution."""
|
|
1217
|
+
|
|
1218
|
+
finished = Signal(object)
|
|
1219
|
+
failed = Signal(str)
|
|
1220
|
+
progress = Signal(int, int, str) # current, total, message
|
|
1221
|
+
|
|
1222
|
+
def __init__(self, run_callable) -> None:
|
|
1223
|
+
"""Initialize the worker.
|
|
1224
|
+
|
|
1225
|
+
Parameters
|
|
1226
|
+
----------
|
|
1227
|
+
run_callable : callable
|
|
1228
|
+
Callable invoked on the worker thread. Should accept a
|
|
1229
|
+
progress callback function as its argument.
|
|
1230
|
+
"""
|
|
1231
|
+
super().__init__()
|
|
1232
|
+
self._run_callable = run_callable
|
|
1233
|
+
|
|
1234
|
+
def run(self) -> None:
|
|
1235
|
+
"""Execute the job and emit result or error."""
|
|
1236
|
+
try:
|
|
1237
|
+
result = self._run_callable(self._emit_progress)
|
|
1238
|
+
except Exception as exc: # pragma: no cover - runtime error path
|
|
1239
|
+
self.failed.emit(str(exc))
|
|
1240
|
+
return
|
|
1241
|
+
self.finished.emit(result)
|
|
1242
|
+
|
|
1243
|
+
def _emit_progress(self, current: int, total: int, message: str) -> None:
|
|
1244
|
+
"""Emit progress updates from the worker thread."""
|
|
1245
|
+
self.progress.emit(current, total, message)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _defaults_from_settings(settings: list[dict]) -> dict[str, object]:
|
|
1249
|
+
"""Extract default values from a list of model settings."""
|
|
1250
|
+
values: dict[str, object] = {}
|
|
1251
|
+
for setting in settings:
|
|
1252
|
+
key = setting.get("key") or setting.get("label") or "Setting"
|
|
1253
|
+
values[key] = setting.get("default", 0)
|
|
1254
|
+
return values
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _spot_label_names(rows: list[dict], detector_name: str = "") -> list[str]:
|
|
1258
|
+
"""Build label layer names for spot channels."""
|
|
1259
|
+
labels: list[str] = []
|
|
1260
|
+
for row in rows:
|
|
1261
|
+
combo = row.get("combo")
|
|
1262
|
+
if combo is None:
|
|
1263
|
+
continue
|
|
1264
|
+
name = combo.currentText().strip()
|
|
1265
|
+
if not name:
|
|
1266
|
+
continue
|
|
1267
|
+
if detector_name:
|
|
1268
|
+
labels.append(f"{_sanitize_label(name)}_{detector_name}_spot_labels")
|
|
1269
|
+
else:
|
|
1270
|
+
labels.append(f"{_sanitize_label(name)}_spot_labels")
|
|
1271
|
+
return labels
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _sanitize_label(name: str) -> str:
|
|
1275
|
+
"""Sanitize a label name for display and export."""
|
|
1276
|
+
safe = []
|
|
1277
|
+
for char in name.strip():
|
|
1278
|
+
if char.isalnum():
|
|
1279
|
+
safe.append(char)
|
|
1280
|
+
else:
|
|
1281
|
+
safe.append("_")
|
|
1282
|
+
result = "".join(safe).strip("_")
|
|
1283
|
+
return result or "spots"
|