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,815 @@
1
+ """Frontend widget for the Quantification tab."""
2
+
3
+ from dataclasses import dataclass
4
+ from qtpy.QtCore import QObject, QThread, Qt, QTimer, Signal
5
+ from qtpy.QtGui import QGuiApplication
6
+ from qtpy.QtWidgets import (
7
+ QComboBox,
8
+ QFileDialog,
9
+ QFormLayout,
10
+ QGroupBox,
11
+ QFrame,
12
+ QHBoxLayout,
13
+ QLineEdit,
14
+ QPushButton,
15
+ QScrollArea,
16
+ QSizePolicy,
17
+ QVBoxLayout,
18
+ QWidget,
19
+ )
20
+
21
+ try:
22
+ from napari.utils.notifications import (
23
+ Notification,
24
+ NotificationSeverity,
25
+ show_console_notification,
26
+ )
27
+ except Exception: # pragma: no cover - optional import for runtime
28
+ show_console_notification = None
29
+ Notification = None
30
+ NotificationSeverity = None
31
+
32
+ from .backend import QuantificationBackend
33
+ from .features import FeatureConfig, build_feature_data, get_feature_registry
34
+ from .features.base import RefreshingComboBox
35
+
36
+
37
+ @dataclass
38
+ class FeatureUIContext:
39
+ """UI context for a single feature row."""
40
+
41
+ state: FeatureConfig
42
+ section: QGroupBox
43
+ name_input: QLineEdit
44
+ type_combo: QComboBox
45
+ left_dynamic_layout: QVBoxLayout
46
+ left_layout: QVBoxLayout
47
+ right_layout: QVBoxLayout
48
+ feature_handler: object | None = None
49
+
50
+
51
+ class QuantificationTab(QWidget):
52
+ """Quantification tab UI for configuring feature extraction.
53
+
54
+ Parameters
55
+ ----------
56
+ backend : QuantificationBackend or None
57
+ Backend instance for quantification workflows.
58
+ napari_viewer : object or None
59
+ Napari viewer used to populate layer dropdowns.
60
+ """
61
+ def __init__(
62
+ self,
63
+ backend: QuantificationBackend | None = None,
64
+ napari_viewer=None,
65
+ *,
66
+ show_output_section: bool = True,
67
+ show_process_button: bool = True,
68
+ enable_rois: bool = True,
69
+ show_right_column: bool = True,
70
+ enable_thresholds: bool = True,
71
+ ) -> None:
72
+ """Initialize the quantification tab UI.
73
+
74
+ Parameters
75
+ ----------
76
+ backend : QuantificationBackend or None
77
+ Backend instance for quantification workflows.
78
+ napari_viewer : object or None
79
+ Napari viewer used to populate layer dropdowns.
80
+ show_output_section : bool, optional
81
+ Whether to show the output configuration controls.
82
+ show_process_button : bool, optional
83
+ Whether to show the process button.
84
+ enable_rois : bool, optional
85
+ Whether to show ROI configuration controls within features.
86
+ show_right_column : bool, optional
87
+ Whether to show the right-hand feature column.
88
+ enable_thresholds : bool, optional
89
+ Whether to show threshold controls within features.
90
+ """
91
+ super().__init__()
92
+ self._backend = backend or QuantificationBackend()
93
+ self._viewer = napari_viewer
94
+ self._enable_rois = enable_rois
95
+ self._show_right_column = show_right_column
96
+ self._enable_thresholds = enable_thresholds
97
+ self._feature_configs: list[FeatureUIContext] = []
98
+ self._feature_registry = get_feature_registry()
99
+ self._features_watch_timer: QTimer | None = None
100
+ self._features_last_size: tuple[int, int] | None = None
101
+ self._active_workers: list[tuple[QThread, QObject]] = []
102
+
103
+ layout = QVBoxLayout()
104
+ layout.addWidget(self._make_features_section())
105
+ if show_output_section:
106
+ layout.addWidget(self._make_output_section())
107
+ if show_process_button:
108
+ process_button = QPushButton("Process")
109
+ process_button.clicked.connect(self._process_features)
110
+ layout.addWidget(process_button)
111
+ self._process_button = process_button
112
+ layout.addStretch(1)
113
+ self.setLayout(layout)
114
+
115
+ def _make_output_section(self) -> QGroupBox:
116
+ """Build the output configuration section.
117
+
118
+ Returns
119
+ -------
120
+ QGroupBox
121
+ Group box containing output settings.
122
+ """
123
+ section = QGroupBox("Output")
124
+ section_layout = QVBoxLayout()
125
+
126
+ form_layout = QFormLayout()
127
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
128
+
129
+ self._output_path_input = QLineEdit()
130
+ self._output_path_input.setPlaceholderText("Output folder")
131
+ browse_button = QPushButton("Browse")
132
+ browse_button.clicked.connect(self._select_output_path)
133
+ output_path_row = QHBoxLayout()
134
+ output_path_row.setContentsMargins(0, 0, 0, 0)
135
+ output_path_row.addWidget(self._output_path_input)
136
+ output_path_row.addWidget(browse_button)
137
+ output_path_widget = QWidget()
138
+ output_path_widget.setLayout(output_path_row)
139
+
140
+ self._save_name_input = QLineEdit()
141
+ self._save_name_input.setPlaceholderText("Output name")
142
+ self._save_name_input.setMinimumWidth(180)
143
+ self._save_name_input.setSizePolicy(
144
+ QSizePolicy.Expanding, QSizePolicy.Fixed
145
+ )
146
+
147
+ self._format_combo = QComboBox()
148
+ self._format_combo.addItems(["xlsx", "csv"])
149
+ self._configure_combo(self._format_combo)
150
+
151
+ form_layout.addRow("Output folder", output_path_widget)
152
+ form_layout.addRow("Save name", self._save_name_input)
153
+ form_layout.addRow("Format", self._format_combo)
154
+
155
+ section_layout.addLayout(form_layout)
156
+ section.setLayout(section_layout)
157
+ return section
158
+
159
+ def _make_features_section(self) -> QGroupBox:
160
+ """Build the features configuration section.
161
+
162
+ Returns
163
+ -------
164
+ QGroupBox
165
+ Group box containing feature inputs.
166
+ """
167
+ section = QGroupBox("Features")
168
+ section.setFlat(True)
169
+ section.setStyleSheet(
170
+ "QGroupBox {"
171
+ " margin-top: 8px;"
172
+ "}"
173
+ "QGroupBox::title {"
174
+ " subcontrol-origin: margin;"
175
+ " subcontrol-position: top left;"
176
+ " padding: 0 6px;"
177
+ "}"
178
+ )
179
+
180
+ frame = QFrame()
181
+ frame.setFrameShape(QFrame.StyledPanel)
182
+ frame.setFrameShadow(QFrame.Plain)
183
+ frame.setObjectName("features-section-frame")
184
+ frame.setStyleSheet(
185
+ "QFrame#features-section-frame {"
186
+ " border: 1px solid palette(mid);"
187
+ " border-radius: 4px;"
188
+ "}"
189
+ )
190
+
191
+ scroll_area = QScrollArea()
192
+ scroll_area.setWidgetResizable(True)
193
+ scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
194
+ scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
195
+ self._features_scroll_area = scroll_area
196
+
197
+ features_container = QWidget()
198
+ self._features_container = features_container
199
+ features_container.setSizePolicy(
200
+ QSizePolicy.Expanding, QSizePolicy.Minimum
201
+ )
202
+ features_container.setMinimumWidth(200)
203
+ self._features_min_width = 200
204
+ self._features_layout = QVBoxLayout()
205
+ self._features_layout.setContentsMargins(0, 0, 0, 0)
206
+ self._features_layout.setSpacing(8)
207
+ self._features_layout.setSizeConstraint(QVBoxLayout.SetMinAndMaxSize)
208
+ features_container.setLayout(self._features_layout)
209
+ scroll_area.setWidget(features_container)
210
+
211
+ frame_layout = QVBoxLayout()
212
+ frame_layout.setContentsMargins(10, 12, 10, 10)
213
+ frame_layout.addWidget(scroll_area)
214
+ frame.setLayout(frame_layout)
215
+
216
+ section_layout = QVBoxLayout()
217
+ section_layout.setContentsMargins(8, 12, 8, 4)
218
+ section_layout.addWidget(frame)
219
+
220
+ self._add_feature_button = QPushButton("Add feature")
221
+ self._add_feature_button.clicked.connect(self._add_feature_row)
222
+ section_layout.addWidget(self._add_feature_button)
223
+ section.setLayout(section_layout)
224
+
225
+ self._add_feature_row()
226
+ self._apply_features_layout()
227
+ self._start_features_watch()
228
+ return section
229
+
230
+ def showEvent(self, event) -> None:
231
+ """Ensure layout sizing is applied on initial show.
232
+
233
+ Parameters
234
+ ----------
235
+ event : QShowEvent
236
+ Qt show event passed by the widget.
237
+ """
238
+ super().showEvent(event)
239
+ self._apply_features_layout()
240
+
241
+ def resizeEvent(self, event) -> None:
242
+ """Resize handler to keep the features list at a capped height.
243
+
244
+ Parameters
245
+ ----------
246
+ event : QResizeEvent
247
+ Qt resize event passed by the widget.
248
+ """
249
+ super().resizeEvent(event)
250
+ self._apply_features_layout()
251
+
252
+ def _add_feature_row(self, state: FeatureConfig | None = None) -> None:
253
+ """Add a new feature input row."""
254
+ if isinstance(state, bool):
255
+ state = None
256
+ index = len(self._feature_configs)
257
+ feature_section = QGroupBox(f"Feature {index}")
258
+ feature_section.setFlat(True)
259
+ feature_section.setStyleSheet(
260
+ "QGroupBox {"
261
+ " margin-top: 6px;"
262
+ "}"
263
+ "QGroupBox::title {"
264
+ " subcontrol-origin: margin;"
265
+ " subcontrol-position: top left;"
266
+ " padding: 0 6px;"
267
+ "}"
268
+ )
269
+
270
+ section_layout = QVBoxLayout()
271
+
272
+ content_layout = QHBoxLayout()
273
+ content_layout.setContentsMargins(0, 0, 0, 0)
274
+ content_layout.setSpacing(12)
275
+ content_layout.setAlignment(Qt.AlignTop)
276
+ left_layout = QVBoxLayout()
277
+ left_layout.setContentsMargins(0, 0, 0, 0)
278
+ left_layout.setSpacing(6)
279
+ right_layout = QVBoxLayout()
280
+ right_layout.setContentsMargins(0, 0, 0, 0)
281
+ right_layout.setSpacing(6)
282
+
283
+ form_layout = QFormLayout()
284
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
285
+
286
+ name_input = QLineEdit()
287
+ name_input.setPlaceholderText("Feature name")
288
+ name_input.setMinimumWidth(180)
289
+ name_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
290
+
291
+ type_combo = RefreshingComboBox(
292
+ refresh_callback=self._notify_features_changed
293
+ )
294
+ feature_types = self._feature_types()
295
+ type_combo.addItems(feature_types)
296
+ self._configure_combo(type_combo)
297
+
298
+ form_layout.addRow("Name", name_input)
299
+ form_layout.addRow("Type", type_combo)
300
+ left_layout.addLayout(form_layout)
301
+
302
+ delete_button = QPushButton("Delete")
303
+ delete_button.clicked.connect(
304
+ lambda _checked=False, section=feature_section: self._remove_feature(
305
+ section
306
+ )
307
+ )
308
+
309
+ left_dynamic_container = QWidget()
310
+ left_dynamic_container.setSizePolicy(
311
+ QSizePolicy.Expanding, QSizePolicy.Fixed
312
+ )
313
+ left_dynamic_layout = QVBoxLayout()
314
+ left_dynamic_layout.setContentsMargins(0, 0, 0, 0)
315
+ left_dynamic_layout.setSpacing(6)
316
+ left_dynamic_container.setLayout(left_dynamic_layout)
317
+ left_layout.addWidget(left_dynamic_container)
318
+ left_layout.addWidget(delete_button)
319
+
320
+ left_container = QWidget()
321
+ left_container.setLayout(left_layout)
322
+ left_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
323
+
324
+ right_container = QWidget()
325
+ right_container.setLayout(right_layout)
326
+ right_container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
327
+
328
+ self._left_container = left_container
329
+ self._right_container = right_container
330
+
331
+ content_layout.addWidget(left_container, 3)
332
+ if self._show_right_column:
333
+ content_layout.addWidget(right_container, 2)
334
+ section_layout.addLayout(content_layout)
335
+ self._apply_features_layout()
336
+ feature_section.setLayout(section_layout)
337
+ feature_section.setSizePolicy(
338
+ QSizePolicy.Expanding, QSizePolicy.Fixed
339
+ )
340
+
341
+ self._features_layout.addWidget(feature_section)
342
+ feature_type = (
343
+ state.type_name
344
+ if state is not None and state.type_name
345
+ else type_combo.currentText()
346
+ )
347
+ if state is None:
348
+ state = FeatureConfig(
349
+ name="",
350
+ type_name=feature_type,
351
+ data=build_feature_data(feature_type),
352
+ )
353
+ if feature_type in feature_types:
354
+ type_combo.blockSignals(True)
355
+ type_combo.setCurrentText(feature_type)
356
+ type_combo.blockSignals(False)
357
+ context = FeatureUIContext(
358
+ state=state,
359
+ section=feature_section,
360
+ name_input=name_input,
361
+ type_combo=type_combo,
362
+ left_dynamic_layout=left_dynamic_layout,
363
+ left_layout=left_layout,
364
+ right_layout=right_layout,
365
+ )
366
+ self._feature_configs.append(context)
367
+ name_input.setText(state.name)
368
+ name_input.textChanged.connect(
369
+ lambda text, ctx=context: self._on_feature_name_changed(ctx, text)
370
+ )
371
+ type_combo.currentTextChanged.connect(
372
+ lambda _text, ctx=context: self._on_feature_type_changed(ctx)
373
+ )
374
+ self._build_feature_handler(context, preserve_data=True)
375
+ self._notify_features_changed()
376
+ self._features_layout.activate()
377
+ QTimer.singleShot(0, self._apply_features_layout)
378
+
379
+ def _on_feature_type_changed(self, context: FeatureUIContext) -> None:
380
+ """Update a feature section when its type changes.
381
+
382
+ Parameters
383
+ ----------
384
+ context : FeatureUIContext
385
+ Feature UI context and data.
386
+ """
387
+ self._build_feature_handler(context, preserve_data=False)
388
+
389
+ def _build_feature_handler(
390
+ self,
391
+ context: FeatureUIContext,
392
+ *,
393
+ preserve_data: bool,
394
+ ) -> None:
395
+ left_dynamic_layout = context.left_dynamic_layout
396
+ self._clear_layout(left_dynamic_layout)
397
+ self._clear_layout(context.right_layout)
398
+ feature_type = context.type_combo.currentText()
399
+ context.state.type_name = feature_type
400
+ if not preserve_data:
401
+ context.state.data = build_feature_data(feature_type)
402
+
403
+ feature_handler = self._feature_handler_for_type(feature_type, context)
404
+ context.feature_handler = feature_handler
405
+ if feature_handler is not None:
406
+ feature_handler.build()
407
+ self._notify_features_changed()
408
+
409
+
410
+ def _remove_feature(self, feature_section: QGroupBox) -> None:
411
+ """Remove a feature section and renumber remaining entries.
412
+
413
+ Parameters
414
+ ----------
415
+ feature_section : QGroupBox
416
+ Feature section widget to remove.
417
+ """
418
+ context = next(
419
+ (cfg for cfg in self._feature_configs if cfg.section is feature_section),
420
+ None,
421
+ )
422
+ if context is None:
423
+ return
424
+ self._feature_configs.remove(context)
425
+ self._features_layout.removeWidget(feature_section)
426
+ feature_section.deleteLater()
427
+ self._renumber_features()
428
+ self._notify_features_changed()
429
+ self._features_layout.activate()
430
+ if hasattr(self, "_features_container"):
431
+ self._features_container.adjustSize()
432
+ QTimer.singleShot(0, self._apply_features_layout)
433
+
434
+ def _renumber_features(self) -> None:
435
+ """Renumber feature sections after insertions/removals."""
436
+ for index, context in enumerate(self._feature_configs, start=0):
437
+ context.section.setTitle(f"Feature {index}")
438
+
439
+ def _notify_features_changed(self) -> None:
440
+ """Notify feature handlers that the feature list has changed."""
441
+ for feature_cls in self._feature_registry.values():
442
+ feature_cls.update_type_options(self, self._feature_configs)
443
+ for context in self._feature_configs:
444
+ handler = context.feature_handler
445
+ if handler is not None:
446
+ handler.on_features_changed(self._feature_configs)
447
+
448
+
449
+ def _feature_types(self) -> list[str]:
450
+ """Return the available feature type names."""
451
+ return list(self._feature_registry.keys())
452
+
453
+ def load_feature_configs(self, configs: list[FeatureConfig]) -> None:
454
+ """Replace the current feature list with provided configs."""
455
+ for context in list(self._feature_configs):
456
+ self._remove_feature(context.section)
457
+ if not configs:
458
+ self._add_feature_row()
459
+ return
460
+ for config in configs:
461
+ self._add_feature_row(config)
462
+
463
+ def _select_output_path(self) -> None:
464
+ """Open a folder selection dialog for the output path."""
465
+ path = QFileDialog.getExistingDirectory(
466
+ self,
467
+ "Select output folder",
468
+ self._output_path_input.text(),
469
+ )
470
+ if path:
471
+ self._output_path_input.setText(path)
472
+
473
+ def _process_features(self) -> None:
474
+ """Trigger quantification processing for configured features."""
475
+ process = getattr(self._backend, "process", None)
476
+ if not callable(process):
477
+ return
478
+ features = list(self._feature_configs)
479
+ output_path = self._output_path_input.text()
480
+ output_name = self._save_name_input.text()
481
+ export_format = self._format_combo.currentText()
482
+ if hasattr(self, "_process_button"):
483
+ self._start_background_run(
484
+ run_button=self._process_button,
485
+ run_text="Process",
486
+ run_callable=lambda: process(
487
+ features,
488
+ output_path,
489
+ output_name,
490
+ export_format,
491
+ ),
492
+ on_success=self._handle_process_complete,
493
+ )
494
+ else:
495
+ process(features, output_path, output_name, export_format)
496
+
497
+ def _feature_handler_for_type(
498
+ self, feature_type: str, context: FeatureUIContext
499
+ ):
500
+ """Return the feature handler for a given feature type.
501
+
502
+ Parameters
503
+ ----------
504
+ feature_type : str
505
+ Selected feature type.
506
+ config : dict
507
+ Feature configuration dictionary.
508
+
509
+ Returns
510
+ -------
511
+ SenoQuantFeature or None
512
+ Feature handler instance for the selected type.
513
+ """
514
+ feature_cls = self._feature_registry.get(feature_type)
515
+ if feature_cls is None:
516
+ return None
517
+ return feature_cls(self, context)
518
+
519
+ def _configure_combo(self, combo: QComboBox) -> None:
520
+ """Apply sizing defaults to combo boxes.
521
+
522
+ Parameters
523
+ ----------
524
+ combo : QComboBox
525
+ Combo box to configure.
526
+ """
527
+ combo.setSizeAdjustPolicy(
528
+ QComboBox.AdjustToMinimumContentsLengthWithIcon
529
+ )
530
+ combo.setMinimumContentsLength(8)
531
+ combo.setMinimumWidth(140)
532
+ combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
533
+
534
+ def _clear_layout(self, layout: QVBoxLayout) -> None:
535
+ """Remove all widgets and layouts from a layout.
536
+
537
+ Parameters
538
+ ----------
539
+ layout : QVBoxLayout
540
+ Layout to clear.
541
+ """
542
+ while layout.count():
543
+ item = layout.takeAt(0)
544
+ widget = item.widget()
545
+ if widget is not None:
546
+ widget.deleteLater()
547
+ child_layout = item.layout()
548
+ if child_layout is not None:
549
+ self._clear_layout(child_layout)
550
+
551
+ def _start_background_run(
552
+ self,
553
+ *,
554
+ run_button: QPushButton,
555
+ run_text: str,
556
+ run_callable,
557
+ on_success,
558
+ ) -> None:
559
+ """Run quantification in a background thread and manage UI state."""
560
+ run_button.setEnabled(False)
561
+ run_button.setText("Running...")
562
+
563
+ thread = QThread(self)
564
+ worker = _RunWorker(run_callable)
565
+ worker.moveToThread(thread)
566
+
567
+ def handle_success(result) -> None:
568
+ on_success(result)
569
+ self._finish_background_run(run_button, run_text, thread, worker)
570
+
571
+ def handle_error(message: str) -> None:
572
+ self._notify(f"Quantification failed: {message}")
573
+ self._finish_background_run(run_button, run_text, thread, worker)
574
+
575
+ thread.started.connect(worker.run)
576
+ worker.finished.connect(handle_success)
577
+ worker.error.connect(handle_error)
578
+ worker.finished.connect(thread.quit)
579
+ worker.error.connect(thread.quit)
580
+ thread.finished.connect(thread.deleteLater)
581
+ thread.finished.connect(worker.deleteLater)
582
+
583
+ self._active_workers.append((thread, worker))
584
+ thread.start()
585
+
586
+ def _finish_background_run(
587
+ self,
588
+ run_button: QPushButton,
589
+ run_text: str,
590
+ thread: QThread,
591
+ worker: QObject,
592
+ ) -> None:
593
+ """Restore UI state after a background run completes."""
594
+ run_button.setEnabled(True)
595
+ run_button.setText(run_text)
596
+ try:
597
+ self._active_workers.remove((thread, worker))
598
+ except ValueError:
599
+ pass
600
+
601
+ def _handle_process_complete(self, result) -> None:
602
+ """Notify the user when quantification completes."""
603
+ output_root = getattr(result, "output_root", None)
604
+ if output_root:
605
+ self._notify(f"Quantification complete: {output_root}")
606
+ else:
607
+ self._notify("Quantification complete.")
608
+
609
+ def _notify(self, message: str) -> None:
610
+ """Send a user-visible notification."""
611
+ if (
612
+ show_console_notification is not None
613
+ and Notification is not None
614
+ and NotificationSeverity is not None
615
+ ):
616
+ show_console_notification(
617
+ Notification(message, severity=NotificationSeverity.WARNING)
618
+ )
619
+
620
+ def _feature_index(self, context: FeatureUIContext) -> int:
621
+ """Return the 0-based index for a feature config.
622
+
623
+ Parameters
624
+ ----------
625
+ context : FeatureUIContext
626
+ Feature UI context.
627
+
628
+ Returns
629
+ -------
630
+ int
631
+ 0-based index of the feature.
632
+ """
633
+ return self._feature_configs.index(context)
634
+
635
+ def _on_feature_name_changed(
636
+ self, context: FeatureUIContext, text: str
637
+ ) -> None:
638
+ """Store feature name updates and refresh dependent combos.
639
+
640
+ Parameters
641
+ ----------
642
+ context : FeatureUIContext
643
+ Feature UI context.
644
+ text : str
645
+ Updated name string.
646
+ """
647
+ context.state.name = text
648
+ self._notify_features_changed()
649
+
650
+ def _start_features_watch(self) -> None:
651
+ """Start a timer to monitor feature sizing changes.
652
+
653
+ The watcher polls for content size changes and reapplies layout
654
+ constraints without blocking the UI thread.
655
+ """
656
+ if self._features_watch_timer is not None:
657
+ return
658
+ self._features_watch_timer = QTimer(self)
659
+ self._features_watch_timer.setInterval(150)
660
+ self._features_watch_timer.timeout.connect(self._poll_features_geometry)
661
+ self._features_watch_timer.start()
662
+
663
+ def _poll_features_geometry(self) -> None:
664
+ """Recompute layout sizing when content size changes."""
665
+ if not hasattr(self, "_features_scroll_area"):
666
+ return
667
+ size = self._features_content_size()
668
+ if size == self._features_last_size:
669
+ return
670
+ self._features_last_size = size
671
+ self._apply_features_layout(size)
672
+
673
+ def _apply_features_layout(
674
+ self, content_size: tuple[int, int] | None = None
675
+ ) -> None:
676
+ """Apply sizing rules for the features container and scroll area.
677
+
678
+ Parameters
679
+ ----------
680
+ content_size : tuple of int or None
681
+ Optional (width, height) of the features content. If None, the
682
+ size is computed from the current layout.
683
+ """
684
+ if not hasattr(self, "_features_scroll_area"):
685
+ return
686
+ if content_size is None:
687
+ content_size = self._features_content_size()
688
+ content_width, content_height = content_size
689
+
690
+ total_min = getattr(self, "_features_min_width", 0)
691
+ if total_min <= 0 and hasattr(self, "_features_container"):
692
+ total_min = self._features_container.minimumWidth()
693
+ left_hint = 0
694
+ right_hint = 0
695
+ if hasattr(self, "_left_container") and self._left_container is not None:
696
+ try:
697
+ left_hint = self._left_container.sizeHint().width()
698
+ except RuntimeError:
699
+ self._left_container = None
700
+ if hasattr(self, "_right_container") and self._right_container is not None:
701
+ try:
702
+ right_hint = self._right_container.sizeHint().width()
703
+ except RuntimeError:
704
+ self._right_container = None
705
+ left_min = max(int(total_min * 0.6), left_hint)
706
+ right_min = max(int(total_min * 0.4), right_hint)
707
+ if self._left_container is not None:
708
+ try:
709
+ self._left_container.setMinimumWidth(left_min)
710
+ except RuntimeError:
711
+ self._left_container = None
712
+ if self._right_container is not None:
713
+ try:
714
+ self._right_container.setMinimumWidth(right_min)
715
+ except RuntimeError:
716
+ self._right_container = None
717
+
718
+ if hasattr(self, "_features_container"):
719
+ self._features_container.setMinimumHeight(0)
720
+ self._features_container.setMinimumWidth(
721
+ max(total_min, content_width)
722
+ )
723
+ self._features_container.updateGeometry()
724
+
725
+ screen = self.window().screen() if self.window() is not None else None
726
+ if screen is None:
727
+ screen = QGuiApplication.primaryScreen()
728
+ screen_height = screen.availableGeometry().height() if screen else 720
729
+ target_height = max(180, int(screen_height * 0.5))
730
+ frame = self._features_scroll_area.frameWidth() * 2
731
+ scroll_slack = 2
732
+ effective_height = content_height + scroll_slack
733
+ height = max(0, min(target_height, effective_height + frame))
734
+ self._features_scroll_area.setUpdatesEnabled(False)
735
+ self._features_scroll_area.setFixedHeight(height)
736
+ self._features_scroll_area.setUpdatesEnabled(True)
737
+ self._features_scroll_area.updateGeometry()
738
+ widget = self._features_scroll_area.widget()
739
+ if widget is not None:
740
+ widget.adjustSize()
741
+ widget.updateGeometry()
742
+ self._features_scroll_area.viewport().updateGeometry()
743
+ bar = self._features_scroll_area.verticalScrollBar()
744
+ if bar.maximum() > 0:
745
+ self._features_scroll_area.setVerticalScrollBarPolicy(
746
+ Qt.ScrollBarAsNeeded
747
+ )
748
+ else:
749
+ self._features_scroll_area.setVerticalScrollBarPolicy(
750
+ Qt.ScrollBarAlwaysOff
751
+ )
752
+ bar.setRange(0, 0)
753
+ bar.setValue(0)
754
+
755
+ def _features_content_size(self) -> tuple[int, int]:
756
+ """Compute the content size for the features layout.
757
+
758
+ Returns
759
+ -------
760
+ tuple of int
761
+ (width, height) of the content.
762
+ """
763
+ if not hasattr(self, "_features_layout"):
764
+ return (0, 0)
765
+ layout = self._features_layout
766
+ layout.activate()
767
+ margins = layout.contentsMargins()
768
+ spacing = layout.spacing()
769
+ count = layout.count()
770
+ total_height = margins.top() + margins.bottom()
771
+ max_width = 0
772
+ for index in range(count):
773
+ item = layout.itemAt(index)
774
+ widget = item.widget()
775
+ if widget is None:
776
+ item_size = item.sizeHint()
777
+ else:
778
+ widget.adjustSize()
779
+ item_size = widget.sizeHint().expandedTo(
780
+ widget.minimumSizeHint()
781
+ )
782
+ max_width = max(max_width, item_size.width())
783
+ total_height += item_size.height()
784
+ if count > 1:
785
+ total_height += spacing * (count - 1)
786
+ total_width = margins.left() + margins.right() + max_width
787
+ if hasattr(self, "_features_container"):
788
+ self._features_container.adjustSize()
789
+ container_size = self._features_container.sizeHint().expandedTo(
790
+ self._features_container.minimumSizeHint()
791
+ )
792
+ total_width = max(total_width, container_size.width())
793
+ total_height = max(total_height, container_size.height())
794
+ return (total_width, total_height)
795
+
796
+
797
+ class _RunWorker(QObject):
798
+ """Worker that executes a callable in a background thread."""
799
+
800
+ finished = Signal(object)
801
+ error = Signal(str)
802
+
803
+ def __init__(self, run_callable) -> None:
804
+ """Initialize the worker with a callable."""
805
+ super().__init__()
806
+ self._run_callable = run_callable
807
+
808
+ def run(self) -> None:
809
+ """Execute the callable and emit results."""
810
+ try:
811
+ result = self._run_callable()
812
+ except Exception as exc: # pragma: no cover - runtime error path
813
+ self.error.emit(str(exc))
814
+ return
815
+ self.finished.emit(result)