senoquant 1.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. senoquant/__init__.py +6 -0
  2. senoquant/_reader.py +7 -0
  3. senoquant/_widget.py +33 -0
  4. senoquant/napari.yaml +83 -0
  5. senoquant/reader/__init__.py +5 -0
  6. senoquant/reader/core.py +369 -0
  7. senoquant/tabs/__init__.py +15 -0
  8. senoquant/tabs/batch/__init__.py +10 -0
  9. senoquant/tabs/batch/backend.py +641 -0
  10. senoquant/tabs/batch/config.py +270 -0
  11. senoquant/tabs/batch/frontend.py +1283 -0
  12. senoquant/tabs/batch/io.py +326 -0
  13. senoquant/tabs/batch/layers.py +86 -0
  14. senoquant/tabs/quantification/__init__.py +1 -0
  15. senoquant/tabs/quantification/backend.py +228 -0
  16. senoquant/tabs/quantification/features/__init__.py +80 -0
  17. senoquant/tabs/quantification/features/base.py +142 -0
  18. senoquant/tabs/quantification/features/marker/__init__.py +5 -0
  19. senoquant/tabs/quantification/features/marker/config.py +69 -0
  20. senoquant/tabs/quantification/features/marker/dialog.py +437 -0
  21. senoquant/tabs/quantification/features/marker/export.py +879 -0
  22. senoquant/tabs/quantification/features/marker/feature.py +119 -0
  23. senoquant/tabs/quantification/features/marker/morphology.py +285 -0
  24. senoquant/tabs/quantification/features/marker/rows.py +654 -0
  25. senoquant/tabs/quantification/features/marker/thresholding.py +46 -0
  26. senoquant/tabs/quantification/features/roi.py +346 -0
  27. senoquant/tabs/quantification/features/spots/__init__.py +5 -0
  28. senoquant/tabs/quantification/features/spots/config.py +62 -0
  29. senoquant/tabs/quantification/features/spots/dialog.py +477 -0
  30. senoquant/tabs/quantification/features/spots/export.py +1292 -0
  31. senoquant/tabs/quantification/features/spots/feature.py +112 -0
  32. senoquant/tabs/quantification/features/spots/morphology.py +279 -0
  33. senoquant/tabs/quantification/features/spots/rows.py +241 -0
  34. senoquant/tabs/quantification/frontend.py +815 -0
  35. senoquant/tabs/segmentation/__init__.py +1 -0
  36. senoquant/tabs/segmentation/backend.py +131 -0
  37. senoquant/tabs/segmentation/frontend.py +1009 -0
  38. senoquant/tabs/segmentation/models/__init__.py +5 -0
  39. senoquant/tabs/segmentation/models/base.py +146 -0
  40. senoquant/tabs/segmentation/models/cpsam/details.json +65 -0
  41. senoquant/tabs/segmentation/models/cpsam/model.py +150 -0
  42. senoquant/tabs/segmentation/models/default_2d/details.json +69 -0
  43. senoquant/tabs/segmentation/models/default_2d/model.py +664 -0
  44. senoquant/tabs/segmentation/models/default_3d/details.json +69 -0
  45. senoquant/tabs/segmentation/models/default_3d/model.py +682 -0
  46. senoquant/tabs/segmentation/models/hf.py +71 -0
  47. senoquant/tabs/segmentation/models/nuclear_dilation/__init__.py +1 -0
  48. senoquant/tabs/segmentation/models/nuclear_dilation/details.json +26 -0
  49. senoquant/tabs/segmentation/models/nuclear_dilation/model.py +96 -0
  50. senoquant/tabs/segmentation/models/perinuclear_rings/__init__.py +1 -0
  51. senoquant/tabs/segmentation/models/perinuclear_rings/details.json +34 -0
  52. senoquant/tabs/segmentation/models/perinuclear_rings/model.py +132 -0
  53. senoquant/tabs/segmentation/stardist_onnx_utils/__init__.py +2 -0
  54. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/__init__.py +3 -0
  55. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/__init__.py +6 -0
  56. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/generate.py +470 -0
  57. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/prepare.py +273 -0
  58. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/rawdata.py +112 -0
  59. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/data/transform.py +384 -0
  60. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/__init__.py +0 -0
  61. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/blocks.py +184 -0
  62. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/losses.py +79 -0
  63. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/nets.py +165 -0
  64. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/predict.py +467 -0
  65. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/probability.py +67 -0
  66. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/internals/train.py +148 -0
  67. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/io/__init__.py +163 -0
  68. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/__init__.py +52 -0
  69. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/base_model.py +329 -0
  70. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_isotropic.py +160 -0
  71. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_projection.py +178 -0
  72. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_standard.py +446 -0
  73. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/care_upsampling.py +54 -0
  74. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/config.py +254 -0
  75. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/models/pretrained.py +119 -0
  76. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/scripts/__init__.py +0 -0
  77. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/scripts/care_predict.py +180 -0
  78. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/__init__.py +5 -0
  79. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/plot_utils.py +159 -0
  80. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/six.py +18 -0
  81. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/tf.py +644 -0
  82. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/utils/utils.py +272 -0
  83. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/csbdeep/version.py +1 -0
  84. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/docs/source/conf.py +368 -0
  85. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/setup.py +68 -0
  86. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_datagen.py +169 -0
  87. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_models.py +462 -0
  88. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tests/test_utils.py +166 -0
  89. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +34 -0
  90. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/__init__.py +30 -0
  91. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/big.py +624 -0
  92. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/bioimageio_utils.py +494 -0
  93. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/data/__init__.py +39 -0
  94. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/__init__.py +10 -0
  95. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/geom2d.py +215 -0
  96. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/geometry/geom3d.py +349 -0
  97. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/matching.py +483 -0
  98. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/__init__.py +28 -0
  99. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/base.py +1217 -0
  100. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/model2d.py +594 -0
  101. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/models/model3d.py +696 -0
  102. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/nms.py +384 -0
  103. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/__init__.py +2 -0
  104. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/plot.py +74 -0
  105. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/plot/render.py +298 -0
  106. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/rays3d.py +373 -0
  107. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/sample_patches.py +65 -0
  108. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/__init__.py +0 -0
  109. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/predict2d.py +90 -0
  110. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/scripts/predict3d.py +93 -0
  111. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/utils.py +408 -0
  112. senoquant/tabs/segmentation/stardist_onnx_utils/_stardist/version.py +1 -0
  113. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/__init__.py +45 -0
  114. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/__init__.py +17 -0
  115. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/cli.py +55 -0
  116. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/convert/core.py +285 -0
  117. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/__init__.py +15 -0
  118. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/cli.py +36 -0
  119. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/divisibility.py +193 -0
  120. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +100 -0
  121. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/receptive_field.py +182 -0
  122. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/rf_cli.py +48 -0
  123. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/valid_sizes.py +278 -0
  124. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/post/__init__.py +8 -0
  125. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/post/core.py +157 -0
  126. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/pre/__init__.py +17 -0
  127. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/pre/core.py +226 -0
  128. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/predict/__init__.py +5 -0
  129. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/predict/core.py +401 -0
  130. senoquant/tabs/settings/__init__.py +1 -0
  131. senoquant/tabs/settings/backend.py +29 -0
  132. senoquant/tabs/settings/frontend.py +19 -0
  133. senoquant/tabs/spots/__init__.py +1 -0
  134. senoquant/tabs/spots/backend.py +139 -0
  135. senoquant/tabs/spots/frontend.py +800 -0
  136. senoquant/tabs/spots/models/__init__.py +5 -0
  137. senoquant/tabs/spots/models/base.py +94 -0
  138. senoquant/tabs/spots/models/rmp/details.json +61 -0
  139. senoquant/tabs/spots/models/rmp/model.py +499 -0
  140. senoquant/tabs/spots/models/udwt/details.json +103 -0
  141. senoquant/tabs/spots/models/udwt/model.py +482 -0
  142. senoquant/utils.py +25 -0
  143. senoquant-1.0.0b1.dist-info/METADATA +193 -0
  144. senoquant-1.0.0b1.dist-info/RECORD +148 -0
  145. senoquant-1.0.0b1.dist-info/WHEEL +5 -0
  146. senoquant-1.0.0b1.dist-info/entry_points.txt +2 -0
  147. senoquant-1.0.0b1.dist-info/licenses/LICENSE +28 -0
  148. senoquant-1.0.0b1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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"