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,654 @@
1
+ """Marker channels dialog rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import numpy as np
8
+
9
+ from qtpy.QtCore import Qt
10
+ from qtpy.QtWidgets import (
11
+ QCheckBox,
12
+ QComboBox,
13
+ QDoubleSpinBox,
14
+ QFormLayout,
15
+ QGroupBox,
16
+ QHBoxLayout,
17
+ QLineEdit,
18
+ QPushButton,
19
+ QSizePolicy,
20
+ QVBoxLayout,
21
+ QWidget,
22
+ )
23
+
24
+ from senoquant.utils import layer_data_asarray
25
+ from ..base import RefreshingComboBox
26
+ from .thresholding import THRESHOLD_METHODS, compute_threshold
27
+ from .config import MarkerChannelConfig, MarkerSegmentationConfig
28
+
29
+ if TYPE_CHECKING:
30
+ from .dialog import MarkerChannelsDialog
31
+
32
+ try:
33
+ from superqt import QDoubleRangeSlider as RangeSlider
34
+ except ImportError: # pragma: no cover - fallback when superqt is unavailable
35
+ try:
36
+ from superqt import QRangeSlider as RangeSlider
37
+ except ImportError: # pragma: no cover
38
+ RangeSlider = None
39
+
40
+
41
+ class MarkerSegmentationRow(QGroupBox):
42
+ """Segmentation row widget for marker segmentations."""
43
+
44
+ def __init__(
45
+ self, dialog: MarkerChannelsDialog, data: MarkerSegmentationConfig
46
+ ) -> None:
47
+ """Initialize a segmentation row widget.
48
+
49
+ Parameters
50
+ ----------
51
+ dialog : MarkerChannelsDialog
52
+ Parent dialog instance.
53
+ data : MarkerSegmentationConfig
54
+ Segmentation configuration data.
55
+ """
56
+ super().__init__()
57
+ self._dialog = dialog
58
+ self._tab = dialog._tab
59
+ self.data = data
60
+
61
+ self.setFlat(True)
62
+ self.setStyleSheet(
63
+ "QGroupBox {"
64
+ " margin-top: 6px;"
65
+ "}"
66
+ "QGroupBox::title {"
67
+ " subcontrol-origin: margin;"
68
+ " subcontrol-position: top left;"
69
+ " padding: 0 6px;"
70
+ "}"
71
+ )
72
+
73
+ layout = QVBoxLayout()
74
+ layout.setContentsMargins(8, 8, 8, 8)
75
+ layout.setSpacing(6)
76
+
77
+ form_layout = QFormLayout()
78
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
79
+ labels_combo = RefreshingComboBox(
80
+ refresh_callback=lambda combo_ref=None: self._dialog._refresh_labels_combo(
81
+ labels_combo
82
+ )
83
+ )
84
+ self._tab._configure_combo(labels_combo)
85
+ labels_combo.currentTextChanged.connect(
86
+ lambda text: self._set_data("label", text)
87
+ )
88
+ form_layout.addRow("Labels", labels_combo)
89
+ layout.addLayout(form_layout)
90
+
91
+ delete_button = QPushButton("Delete")
92
+ delete_button.clicked.connect(
93
+ lambda: self._dialog._remove_segmentation(self)
94
+ )
95
+ layout.addWidget(delete_button)
96
+
97
+ self._labels_combo = labels_combo
98
+ self.setLayout(layout)
99
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
100
+ self._restore_state()
101
+
102
+ def update_title(self, index: int) -> None:
103
+ """Update the title label for the segmentation row.
104
+
105
+ Parameters
106
+ ----------
107
+ index : int
108
+ 0-based index used in the title.
109
+ """
110
+ self.setTitle(f"Segmentation {index}")
111
+
112
+ def _set_data(self, key: str, value) -> None:
113
+ """Update the segmentation data model."""
114
+ setattr(self.data, key, value)
115
+
116
+ def _restore_state(self) -> None:
117
+ """Restore UI state from stored segmentation data."""
118
+ label_name = self.data.label
119
+ if label_name:
120
+ self._labels_combo.setCurrentText(label_name)
121
+
122
+
123
+ class MarkerChannelRow(QGroupBox):
124
+ """Channel row widget for marker feature channels."""
125
+
126
+ def __init__(
127
+ self, dialog: MarkerChannelsDialog, data: MarkerChannelConfig
128
+ ) -> None:
129
+ """Initialize a channel row widget.
130
+
131
+ Parameters
132
+ ----------
133
+ dialog : MarkerChannelsDialog
134
+ Parent dialog instance.
135
+ data : MarkerChannelConfig
136
+ Channel configuration data.
137
+ """
138
+ super().__init__()
139
+ self._dialog = dialog
140
+ self._feature = dialog._feature
141
+ self._tab = dialog._tab
142
+ self.data = data
143
+ self._threshold_updating = False
144
+ self._thresholds_enabled = getattr(self._tab, "_enable_thresholds", True)
145
+
146
+ self.setFlat(True)
147
+ self.setStyleSheet(
148
+ "QGroupBox {"
149
+ " margin-top: 6px;"
150
+ "}"
151
+ "QGroupBox::title {"
152
+ " subcontrol-origin: margin;"
153
+ " subcontrol-position: top left;"
154
+ " padding: 0 6px;"
155
+ "}"
156
+ )
157
+
158
+ layout = QVBoxLayout()
159
+ layout.setContentsMargins(8, 8, 8, 8)
160
+ layout.setSpacing(6)
161
+
162
+ channel_form = QFormLayout()
163
+ channel_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
164
+ name_input = QLineEdit()
165
+ name_input.setPlaceholderText("Channel name")
166
+ name_input.setMinimumWidth(160)
167
+ name_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
168
+ name_input.textChanged.connect(
169
+ lambda text: self._set_data("name", text)
170
+ )
171
+ channel_combo = RefreshingComboBox(
172
+ refresh_callback=lambda combo_ref=None: self._dialog._refresh_image_combo(
173
+ channel_combo
174
+ )
175
+ )
176
+ self._tab._configure_combo(channel_combo)
177
+ channel_combo.currentTextChanged.connect(self._on_channel_changed)
178
+ channel_form.addRow("Name", name_input)
179
+ channel_form.addRow("Channel", channel_combo)
180
+ layout.addLayout(channel_form)
181
+
182
+ threshold_checkbox = QCheckBox("Set threshold")
183
+ threshold_checkbox.setEnabled(False)
184
+ threshold_checkbox.toggled.connect(self._toggle_threshold)
185
+ layout.addWidget(threshold_checkbox)
186
+
187
+ threshold_container = QWidget()
188
+ threshold_layout = QHBoxLayout()
189
+ threshold_layout.setContentsMargins(0, 0, 0, 0)
190
+ threshold_slider = self._make_range_slider()
191
+ if hasattr(threshold_slider, "valueChanged"):
192
+ threshold_slider.valueChanged.connect(self._on_threshold_slider_changed)
193
+ threshold_min_spin = QDoubleSpinBox()
194
+ threshold_min_spin.setDecimals(2)
195
+ threshold_min_spin.setMinimumWidth(80)
196
+ threshold_min_spin.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
197
+ threshold_min_spin.valueChanged.connect(
198
+ lambda value: self._on_threshold_spin_changed("min", value)
199
+ )
200
+
201
+ threshold_max_spin = QDoubleSpinBox()
202
+ threshold_max_spin.setDecimals(2)
203
+ threshold_max_spin.setMinimumWidth(80)
204
+ threshold_max_spin.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
205
+ threshold_max_spin.valueChanged.connect(
206
+ lambda value: self._on_threshold_spin_changed("max", value)
207
+ )
208
+
209
+ threshold_slider.setEnabled(False)
210
+ threshold_slider.setVisible(False)
211
+ threshold_min_spin.setEnabled(False)
212
+ threshold_max_spin.setEnabled(False)
213
+ threshold_layout.addWidget(threshold_min_spin)
214
+ threshold_layout.addWidget(threshold_slider, 1)
215
+ threshold_layout.addWidget(threshold_max_spin)
216
+ threshold_container.setLayout(threshold_layout)
217
+ threshold_container.setVisible(False)
218
+ layout.addWidget(threshold_container)
219
+
220
+ auto_threshold_container = QWidget()
221
+ auto_threshold_layout = QHBoxLayout()
222
+ auto_threshold_layout.setContentsMargins(0, 0, 0, 0)
223
+ auto_threshold_combo = QComboBox()
224
+ auto_threshold_combo.addItems(
225
+ ["Manual", *list(THRESHOLD_METHODS.keys())]
226
+ )
227
+ self._tab._configure_combo(auto_threshold_combo)
228
+ auto_threshold_combo.currentTextChanged.connect(
229
+ self._on_threshold_method_changed
230
+ )
231
+ auto_threshold_button = QPushButton("Auto threshold")
232
+ auto_threshold_button.clicked.connect(self._run_auto_threshold)
233
+ auto_threshold_layout.addWidget(auto_threshold_combo, 1)
234
+ auto_threshold_layout.addWidget(auto_threshold_button)
235
+ auto_threshold_container.setLayout(auto_threshold_layout)
236
+ auto_threshold_container.setVisible(False)
237
+ layout.addWidget(auto_threshold_container)
238
+
239
+ delete_button = QPushButton("Delete")
240
+ delete_button.clicked.connect(lambda: self._dialog._remove_channel(self))
241
+ layout.addWidget(delete_button)
242
+
243
+ self.setLayout(layout)
244
+
245
+ self._channel_combo = channel_combo
246
+ self._name_input = name_input
247
+ self._threshold_checkbox = threshold_checkbox
248
+ self._threshold_slider = threshold_slider
249
+ self._threshold_container = threshold_container
250
+ self._threshold_min_spin = threshold_min_spin
251
+ self._threshold_max_spin = threshold_max_spin
252
+ self._auto_threshold_container = auto_threshold_container
253
+ self._auto_threshold_combo = auto_threshold_combo
254
+ self._auto_threshold_button = auto_threshold_button
255
+ self._auto_thresholding = False
256
+ self._threshold_min_bound: float | None = None
257
+ self._threshold_max_bound: float | None = None
258
+
259
+ if not self._thresholds_enabled:
260
+ threshold_checkbox.setVisible(False)
261
+ threshold_container.setVisible(False)
262
+ auto_threshold_container.setVisible(False)
263
+ threshold_checkbox.setEnabled(False)
264
+ auto_threshold_button.setEnabled(False)
265
+
266
+ self._restore_state()
267
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
268
+
269
+ def update_title(self, index: int) -> None:
270
+ """Update the title label for the channel row.
271
+
272
+ Parameters
273
+ ----------
274
+ index : int
275
+ 0-based index used in the title.
276
+ """
277
+ self.setTitle(f"Channel {index}")
278
+
279
+ def _set_data(self, key: str, value) -> None:
280
+ """Update the channel data model.
281
+
282
+ Parameters
283
+ ----------
284
+ key : str
285
+ Data key to update.
286
+ value : object
287
+ New value to store.
288
+ """
289
+ setattr(self.data, key, value)
290
+
291
+ def _restore_state(self) -> None:
292
+ """Restore UI state from stored channel data."""
293
+ channel_label = self.data.name
294
+ if channel_label:
295
+ self._name_input.setText(channel_label)
296
+ channel_name = self.data.channel
297
+ if channel_name:
298
+ self._channel_combo.setCurrentText(channel_name)
299
+ method = self.data.threshold_method or "Manual"
300
+ self._auto_threshold_combo.setCurrentText(method)
301
+ enabled = bool(self.data.threshold_enabled)
302
+ self._threshold_checkbox.setChecked(enabled)
303
+ self._on_channel_changed(self._channel_combo.currentText())
304
+ if not self._thresholds_enabled:
305
+ self._set_data("threshold_enabled", False)
306
+ self._set_data("threshold_method", "Manual")
307
+ self._set_data("threshold_min", None)
308
+ self._set_data("threshold_max", None)
309
+
310
+ def _layer_has_data(self, layer) -> bool:
311
+ data = getattr(layer, "data", None)
312
+ if data is None:
313
+ return False
314
+ try:
315
+ array = np.asarray(data)
316
+ except Exception:
317
+ return False
318
+ if array.size == 0:
319
+ return False
320
+ if array.dtype == object and array.size == 1 and array.flat[0] is None:
321
+ return False
322
+ return True
323
+
324
+ def _disable_threshold_controls(self) -> None:
325
+ self._threshold_min_bound = None
326
+ self._threshold_max_bound = None
327
+ self._threshold_checkbox.blockSignals(True)
328
+ self._threshold_checkbox.setChecked(False)
329
+ self._threshold_checkbox.blockSignals(False)
330
+ self._set_data("threshold_enabled", False)
331
+ self._threshold_checkbox.setEnabled(False)
332
+ self._auto_threshold_button.setEnabled(False)
333
+ self._set_threshold_controls(False)
334
+
335
+ def _on_channel_changed(self, text: str | None = None) -> None:
336
+ """Update threshold controls when channel selection changes.
337
+
338
+ Parameters
339
+ ----------
340
+ text : str
341
+ Newly selected channel name.
342
+ """
343
+ if text is None:
344
+ text = self._channel_combo.currentText()
345
+ self._set_data("channel", text)
346
+ if not self._thresholds_enabled:
347
+ self._disable_threshold_controls()
348
+ return
349
+ layer = self._feature._get_image_layer_by_name(text)
350
+ if layer is None or not self._layer_has_data(layer):
351
+ self._disable_threshold_controls()
352
+ return
353
+ self._threshold_checkbox.setEnabled(True)
354
+ self._set_threshold_range(
355
+ self._threshold_slider,
356
+ layer,
357
+ self._threshold_min_spin,
358
+ self._threshold_max_spin,
359
+ )
360
+ self._set_threshold_controls(self._threshold_checkbox.isChecked())
361
+
362
+ def _toggle_threshold(self, enabled: bool) -> None:
363
+ """Toggle threshold controls for this channel.
364
+
365
+ Parameters
366
+ ----------
367
+ enabled : bool
368
+ Whether threshold controls should be enabled.
369
+ """
370
+ if not self._thresholds_enabled:
371
+ return
372
+ self._set_data("threshold_enabled", enabled)
373
+ self._set_threshold_controls(enabled)
374
+
375
+ def _set_threshold_controls(self, enabled: bool) -> None:
376
+ """Show or hide threshold controls.
377
+
378
+ Parameters
379
+ ----------
380
+ enabled : bool
381
+ Whether to show threshold controls.
382
+ """
383
+ if not self._thresholds_enabled:
384
+ enabled = False
385
+ self._threshold_slider.setEnabled(enabled)
386
+ self._threshold_slider.setVisible(enabled)
387
+ self._threshold_min_spin.setEnabled(enabled)
388
+ self._threshold_max_spin.setEnabled(enabled)
389
+ self._threshold_container.setVisible(enabled)
390
+ self._auto_threshold_container.setVisible(enabled)
391
+ self._auto_threshold_combo.setEnabled(enabled)
392
+ self._auto_threshold_button.setEnabled(
393
+ enabled and self._auto_threshold_combo.currentText() != "Manual"
394
+ )
395
+
396
+ def _on_threshold_method_changed(self, text: str) -> None:
397
+ """Handle changes to the thresholding method selection."""
398
+ if not self._thresholds_enabled:
399
+ return
400
+ self._set_data("threshold_method", text)
401
+ if text == "Manual":
402
+ self._auto_threshold_button.setEnabled(False)
403
+ return
404
+ self._auto_threshold_button.setEnabled(
405
+ self._threshold_checkbox.isChecked()
406
+ )
407
+
408
+ def _on_threshold_slider_changed(self, values) -> None:
409
+ """Sync spin boxes when the slider range changes.
410
+
411
+ Parameters
412
+ ----------
413
+ values : tuple
414
+ Updated (min, max) slider values.
415
+ """
416
+ if values is None or self._threshold_updating:
417
+ return
418
+ self._threshold_updating = True
419
+ self._threshold_min_spin.blockSignals(True)
420
+ self._threshold_max_spin.blockSignals(True)
421
+ self._threshold_min_spin.setValue(values[0])
422
+ self._threshold_max_spin.setValue(values[1])
423
+ self._threshold_min_spin.blockSignals(False)
424
+ self._threshold_max_spin.blockSignals(False)
425
+ self._threshold_updating = False
426
+ self._set_data("threshold_min", float(values[0]))
427
+ self._set_data("threshold_max", float(values[1]))
428
+ self._update_layer_contrast_limits(values)
429
+ self._ensure_manual_threshold_mode()
430
+
431
+ def _on_threshold_spin_changed(self, which: str, value: float) -> None:
432
+ """Sync the slider when a spin box value changes.
433
+
434
+ Parameters
435
+ ----------
436
+ which : str
437
+ Identifier for the spin box ("min" or "max").
438
+ value : float
439
+ New spin box value.
440
+ """
441
+ if self._threshold_updating:
442
+ return
443
+ min_val = self._threshold_min_spin.value()
444
+ max_val = self._threshold_max_spin.value()
445
+ if min_val > max_val:
446
+ if which == "min":
447
+ max_val = min_val
448
+ self._threshold_max_spin.blockSignals(True)
449
+ self._threshold_max_spin.setValue(max_val)
450
+ self._threshold_max_spin.blockSignals(False)
451
+ else:
452
+ min_val = max_val
453
+ self._threshold_min_spin.blockSignals(True)
454
+ self._threshold_min_spin.setValue(min_val)
455
+ self._threshold_min_spin.blockSignals(False)
456
+ self._threshold_updating = True
457
+ self._set_slider_values(
458
+ self._threshold_slider, (min_val, max_val)
459
+ )
460
+ self._threshold_updating = False
461
+ self._set_data("threshold_min", float(min_val))
462
+ self._set_data("threshold_max", float(max_val))
463
+ self._update_layer_contrast_limits((min_val, max_val))
464
+ self._ensure_manual_threshold_mode()
465
+
466
+ def _run_auto_threshold(self) -> None:
467
+ """Compute an automatic threshold and update the range controls."""
468
+ if not self._thresholds_enabled:
469
+ return
470
+ layer = self._feature._get_image_layer_by_name(
471
+ self._channel_combo.currentText()
472
+ )
473
+ if layer is None or not self._layer_has_data(layer):
474
+ return
475
+ method = self._auto_threshold_combo.currentText() or "Otsu"
476
+ if method == "Manual":
477
+ return
478
+ try:
479
+ threshold = compute_threshold(layer_data_asarray(layer), method)
480
+ except Exception:
481
+ return
482
+ min_val = self._threshold_min_bound
483
+ max_val = self._threshold_max_bound
484
+ if min_val is None or max_val is None:
485
+ self._set_threshold_range(
486
+ self._threshold_slider,
487
+ layer,
488
+ self._threshold_min_spin,
489
+ self._threshold_max_spin,
490
+ )
491
+ min_val = self._threshold_min_bound
492
+ max_val = self._threshold_max_bound
493
+ if min_val is None or max_val is None:
494
+ return
495
+ threshold = min(max(threshold, min_val), max_val)
496
+ self._auto_thresholding = True
497
+ try:
498
+ self._threshold_updating = True
499
+ self._set_slider_values(
500
+ self._threshold_slider, (threshold, max_val)
501
+ )
502
+ self._threshold_min_spin.blockSignals(True)
503
+ self._threshold_min_spin.setValue(threshold)
504
+ self._threshold_min_spin.blockSignals(False)
505
+ self._threshold_max_spin.blockSignals(True)
506
+ self._threshold_max_spin.setValue(max_val)
507
+ self._threshold_max_spin.blockSignals(False)
508
+ self._threshold_updating = False
509
+ self._set_data("threshold_min", float(threshold))
510
+ self._set_data("threshold_max", float(max_val))
511
+ self._update_layer_contrast_limits((threshold, max_val))
512
+ finally:
513
+ self._auto_thresholding = False
514
+
515
+ def _ensure_manual_threshold_mode(self) -> None:
516
+ """Switch to manual mode after user-adjusted threshold changes."""
517
+ if not self._thresholds_enabled:
518
+ return
519
+ if not self._threshold_checkbox.isChecked():
520
+ return
521
+ if self._auto_thresholding:
522
+ return
523
+ if self._auto_threshold_combo.currentText() == "Manual":
524
+ return
525
+ self._auto_threshold_combo.blockSignals(True)
526
+ self._auto_threshold_combo.setCurrentText("Manual")
527
+ self._auto_threshold_combo.blockSignals(False)
528
+ self._set_data("threshold_method", "Manual")
529
+ self._auto_threshold_button.setEnabled(False)
530
+
531
+ def _update_layer_contrast_limits(self, values) -> None:
532
+ """Sync the image layer contrast limits with the threshold values.
533
+
534
+ Parameters
535
+ ----------
536
+ values : tuple
537
+ (min, max) values to apply as contrast limits.
538
+ """
539
+ layer = self._feature._get_image_layer_by_name(
540
+ self._channel_combo.currentText()
541
+ )
542
+ if layer is None:
543
+ return
544
+ try:
545
+ layer.contrast_limits = [float(values[0]), float(values[1])]
546
+ except Exception:
547
+ return
548
+
549
+ def _make_range_slider(self):
550
+ """Create a horizontal range slider if available.
551
+
552
+ Returns
553
+ -------
554
+ QWidget
555
+ Range slider widget or a placeholder QWidget when unavailable.
556
+ """
557
+ if RangeSlider is None:
558
+ return QWidget()
559
+ try:
560
+ return RangeSlider(Qt.Horizontal)
561
+ except TypeError:
562
+ slider = RangeSlider()
563
+ slider.setOrientation(Qt.Horizontal)
564
+ return slider
565
+
566
+ def _set_slider_values(self, slider, values) -> None:
567
+ """Set the range values on a slider.
568
+
569
+ Parameters
570
+ ----------
571
+ slider : QWidget
572
+ Range slider widget.
573
+ values : tuple
574
+ (min, max) values to apply to the slider.
575
+ """
576
+ if hasattr(slider, "setValue"):
577
+ try:
578
+ slider.setValue(values)
579
+ return
580
+ except TypeError:
581
+ pass
582
+ if hasattr(slider, "setValues"):
583
+ slider.setValues(values)
584
+
585
+ def _set_threshold_range(
586
+ self, slider, layer, min_spin: QDoubleSpinBox | None,
587
+ max_spin: QDoubleSpinBox | None
588
+ ) -> None:
589
+ """Set slider bounds using the selected image layer.
590
+
591
+ Parameters
592
+ ----------
593
+ slider : QWidget
594
+ Range slider widget.
595
+ layer : object
596
+ Napari image layer providing intensity bounds.
597
+ min_spin : QDoubleSpinBox or None
598
+ Spin box that displays the minimum threshold value.
599
+ max_spin : QDoubleSpinBox or None
600
+ Spin box that displays the maximum threshold value.
601
+ """
602
+ if not hasattr(slider, "setMinimum"):
603
+ return
604
+ if not self._layer_has_data(layer):
605
+ self._disable_threshold_controls()
606
+ return
607
+ min_val, max_val = self._get_threshold_bounds(layer)
608
+ if hasattr(slider, "setRange"):
609
+ slider.setRange(min_val, max_val)
610
+ else:
611
+ slider.setMinimum(min_val)
612
+ slider.setMaximum(max_val)
613
+ self._set_slider_values(slider, (min_val, max_val))
614
+ if min_spin is not None:
615
+ min_spin.blockSignals(True)
616
+ min_spin.setRange(min_val, max_val)
617
+ min_spin.setValue(min_val)
618
+ min_spin.blockSignals(False)
619
+ if max_spin is not None:
620
+ max_spin.blockSignals(True)
621
+ max_spin.setRange(min_val, max_val)
622
+ max_spin.setValue(max_val)
623
+ max_spin.blockSignals(False)
624
+
625
+ def _get_threshold_bounds(self, layer) -> tuple[float, float]:
626
+ """Return threshold bounds based on the layer contrast range.
627
+
628
+ Parameters
629
+ ----------
630
+ layer : object
631
+ Napari image layer providing contrast bounds and data.
632
+
633
+ Returns
634
+ -------
635
+ tuple of float
636
+ Minimum and maximum bounds for the threshold controls.
637
+
638
+ Notes
639
+ -----
640
+ The computed bounds are cached on the row instance to avoid repeated
641
+ scans of large images when auto-thresholding runs.
642
+ """
643
+ contrast = getattr(layer, "contrast_limits_range", None)
644
+ if contrast is not None and len(contrast) == 2:
645
+ min_val, max_val = float(contrast[0]), float(contrast[1])
646
+ else:
647
+ data = layer_data_asarray(layer)
648
+ min_val = float(np.nanmin(data))
649
+ max_val = float(np.nanmax(data))
650
+ if min_val == max_val:
651
+ max_val = min_val + 1.0
652
+ self._threshold_min_bound = min_val
653
+ self._threshold_max_bound = max_val
654
+ return min_val, max_val
@@ -0,0 +1,46 @@
1
+ """Thresholding helpers for marker features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from skimage import filters
7
+
8
+ THRESHOLD_METHODS = {
9
+ "Otsu": filters.threshold_otsu,
10
+ "Yen": filters.threshold_yen,
11
+ "Li": filters.threshold_li,
12
+ "Isodata": filters.threshold_isodata,
13
+ "Triangle": filters.threshold_triangle,
14
+ }
15
+
16
+
17
+ def compute_threshold(data, method: str) -> float:
18
+ """Compute a threshold value for the given image data.
19
+
20
+ Parameters
21
+ ----------
22
+ data : array-like
23
+ Image data to threshold.
24
+ method : str
25
+ Thresholding method name.
26
+
27
+ Returns
28
+ -------
29
+ float
30
+ Threshold value.
31
+
32
+ Raises
33
+ ------
34
+ ValueError
35
+ If the method is unknown or the data is empty.
36
+ """
37
+ if method not in THRESHOLD_METHODS:
38
+ raise ValueError(f"Unknown threshold method: {method}")
39
+ array = np.asarray(data)
40
+ if array.size == 0:
41
+ raise ValueError("No image data available for thresholding.")
42
+ if not np.isfinite(array).all():
43
+ array = array[np.isfinite(array)]
44
+ if array.size == 0:
45
+ raise ValueError("No finite image data available for thresholding.")
46
+ return float(THRESHOLD_METHODS[method](array))