senoquant 1.0.0b2__py3-none-any.whl → 1.0.0b4__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 (57) hide show
  1. senoquant/__init__.py +6 -2
  2. senoquant/_reader.py +1 -1
  3. senoquant/_widget.py +9 -1
  4. senoquant/reader/core.py +201 -18
  5. senoquant/tabs/__init__.py +2 -0
  6. senoquant/tabs/batch/backend.py +76 -27
  7. senoquant/tabs/batch/frontend.py +127 -25
  8. senoquant/tabs/quantification/features/marker/dialog.py +26 -6
  9. senoquant/tabs/quantification/features/marker/export.py +97 -24
  10. senoquant/tabs/quantification/features/marker/rows.py +2 -2
  11. senoquant/tabs/quantification/features/spots/dialog.py +41 -11
  12. senoquant/tabs/quantification/features/spots/export.py +163 -10
  13. senoquant/tabs/quantification/frontend.py +2 -2
  14. senoquant/tabs/segmentation/frontend.py +46 -9
  15. senoquant/tabs/segmentation/models/cpsam/model.py +1 -1
  16. senoquant/tabs/segmentation/models/default_2d/model.py +22 -77
  17. senoquant/tabs/segmentation/models/default_3d/model.py +8 -74
  18. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +0 -0
  19. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +13 -13
  20. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/stardist_libs.py +171 -0
  21. senoquant/tabs/spots/frontend.py +96 -5
  22. senoquant/tabs/spots/models/rmp/details.json +3 -9
  23. senoquant/tabs/spots/models/rmp/model.py +341 -266
  24. senoquant/tabs/spots/models/ufish/details.json +32 -0
  25. senoquant/tabs/spots/models/ufish/model.py +327 -0
  26. senoquant/tabs/spots/ufish_utils/__init__.py +13 -0
  27. senoquant/tabs/spots/ufish_utils/core.py +387 -0
  28. senoquant/tabs/visualization/__init__.py +1 -0
  29. senoquant/tabs/visualization/backend.py +306 -0
  30. senoquant/tabs/visualization/frontend.py +1113 -0
  31. senoquant/tabs/visualization/plots/__init__.py +80 -0
  32. senoquant/tabs/visualization/plots/base.py +152 -0
  33. senoquant/tabs/visualization/plots/double_expression.py +187 -0
  34. senoquant/tabs/visualization/plots/spatialplot.py +156 -0
  35. senoquant/tabs/visualization/plots/umap.py +140 -0
  36. senoquant/utils.py +1 -1
  37. senoquant-1.0.0b4.dist-info/METADATA +162 -0
  38. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/RECORD +53 -30
  39. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/top_level.txt +1 -0
  40. ufish/__init__.py +1 -0
  41. ufish/api.py +778 -0
  42. ufish/model/__init__.py +0 -0
  43. ufish/model/loss.py +62 -0
  44. ufish/model/network/__init__.py +0 -0
  45. ufish/model/network/spot_learn.py +50 -0
  46. ufish/model/network/ufish_net.py +204 -0
  47. ufish/model/train.py +175 -0
  48. ufish/utils/__init__.py +0 -0
  49. ufish/utils/img.py +418 -0
  50. ufish/utils/log.py +8 -0
  51. ufish/utils/spot_calling.py +115 -0
  52. senoquant/tabs/spots/models/udwt/details.json +0 -103
  53. senoquant/tabs/spots/models/udwt/model.py +0 -482
  54. senoquant-1.0.0b2.dist-info/METADATA +0 -193
  55. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/WHEEL +0 -0
  56. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/entry_points.txt +0 -0
  57. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "ufish",
3
+ "description": "U-FISH local-maxima seeded watershed detector",
4
+ "version": "0.1.0",
5
+ "order": 1,
6
+ "settings": [
7
+ {
8
+ "key": "denoise_enabled",
9
+ "label": "Denoise input",
10
+ "type": "bool",
11
+ "default": true
12
+ },
13
+ {
14
+ "key": "spot_size",
15
+ "label": "Spot size",
16
+ "type": "float",
17
+ "decimals": 2,
18
+ "min": 0.25,
19
+ "max": 4.0,
20
+ "default": 1.0
21
+ },
22
+ {
23
+ "key": "threshold",
24
+ "label": "Threshold",
25
+ "type": "float",
26
+ "decimals": 2,
27
+ "min": 0.0,
28
+ "max": 1.0,
29
+ "default": 0.5
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,327 @@
1
+ """U-FISH local-maxima seeded watershed detector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from scipy import ndimage as ndi
7
+ from skimage.filters import laplace
8
+ from skimage.morphology import local_maxima
9
+ from skimage.restoration import denoise_wavelet
10
+ from skimage.segmentation import watershed
11
+
12
+ from ..base import SenoQuantSpotDetector
13
+ from senoquant.utils import layer_data_asarray
14
+ from senoquant.tabs.spots.ufish_utils import UFishConfig, enhance_image
15
+
16
+
17
+ DEFAULT_THRESHOLD = 0.5
18
+ USE_LAPLACE_FOR_PEAKS = False
19
+ DEFAULT_DENOISE_ENABLED = False
20
+ DEFAULT_SPOT_SIZE = 1.0
21
+ MIN_SPOT_SIZE = 0.25
22
+ MAX_SPOT_SIZE = 4.0
23
+ EPS = 1e-6
24
+ NOISE_FLOOR_SIGMA = 1.5
25
+ MIN_SCALE_SIGMA = 5.0
26
+ SIGNAL_SCALE_QUANTILE = 99.9
27
+ INPUT_LOW_PERCENTILE = 0.05
28
+ INPUT_HIGH_PERCENTILE = 99.95
29
+
30
+
31
+ def _clamp_threshold(value: float) -> float:
32
+ """Clamp threshold to the inclusive [0.0, 1.0] range."""
33
+ return float(np.clip(value, 0.0, 1.0))
34
+
35
+
36
+ def _normalize_input_percentile(image: np.ndarray) -> np.ndarray:
37
+ """Normalize input image to [0, 1] via percentile clipping."""
38
+ data = np.asarray(image, dtype=np.float32)
39
+ finite_mask = np.isfinite(data)
40
+ if not np.any(finite_mask):
41
+ return np.zeros_like(data, dtype=np.float32)
42
+
43
+ valid = data[finite_mask]
44
+ low, high = np.nanpercentile(valid, [INPUT_LOW_PERCENTILE, INPUT_HIGH_PERCENTILE])
45
+ low = float(low)
46
+ high = float(high)
47
+ if (not np.isfinite(low)) or (not np.isfinite(high)) or high <= low:
48
+ return np.zeros_like(data, dtype=np.float32)
49
+
50
+ normalized = (data - low) / (high - low)
51
+ normalized = np.clip(normalized, 0.0, 1.0)
52
+ normalized = np.where(finite_mask, normalized, 0.0)
53
+ return normalized.astype(np.float32, copy=False)
54
+
55
+
56
+ def _normalize_enhanced_unit(image: np.ndarray) -> np.ndarray:
57
+ """Normalize enhanced image to [0, 1] with robust background suppression."""
58
+ data = np.asarray(image, dtype=np.float32)
59
+ finite_mask = np.isfinite(data)
60
+ if not np.any(finite_mask):
61
+ return np.zeros_like(data, dtype=np.float32)
62
+
63
+ valid = data[finite_mask]
64
+ background = float(np.nanmedian(valid))
65
+ sigma = 1.4826 * float(np.nanmedian(np.abs(valid - background)))
66
+
67
+ if (not np.isfinite(sigma)) or sigma <= EPS:
68
+ sigma = float(np.nanstd(valid))
69
+ if (not np.isfinite(sigma)) or sigma <= EPS:
70
+ return np.zeros_like(data, dtype=np.float32)
71
+
72
+ # Gate out most background fluctuations before scaling.
73
+ noise_floor = background + (NOISE_FLOOR_SIGMA * sigma)
74
+ residual = np.clip(data - noise_floor, 0.0, None)
75
+ residual = np.where(finite_mask, residual, 0.0)
76
+
77
+ positive = residual[residual > 0.0]
78
+ if positive.size == 0:
79
+ return np.zeros_like(data, dtype=np.float32)
80
+ high = float(np.nanpercentile(positive, SIGNAL_SCALE_QUANTILE))
81
+ if (not np.isfinite(high)) or high <= EPS:
82
+ high = float(np.nanmax(positive))
83
+ if (not np.isfinite(high)) or high <= EPS:
84
+ return np.zeros_like(data, dtype=np.float32)
85
+
86
+ scale = max(high, MIN_SCALE_SIGMA * sigma, EPS)
87
+ normalized = np.clip(residual / scale, 0.0, 1.0)
88
+ return normalized.astype(np.float32, copy=False)
89
+
90
+
91
+ def _clamp_spot_size(value: float) -> float:
92
+ """Clamp spot-size control to a safe positive range."""
93
+ return float(np.clip(value, MIN_SPOT_SIZE, MAX_SPOT_SIZE))
94
+
95
+
96
+ def _spot_size_to_detection_scale(spot_size: float) -> float:
97
+ """Convert user spot-size control to internal image scaling.
98
+
99
+ spot_size > 1 means detect larger spots (zoom out input),
100
+ spot_size < 1 means detect smaller spots (zoom in input).
101
+ """
102
+ return 1.0 / _clamp_spot_size(spot_size)
103
+
104
+
105
+ def _denoise_input(
106
+ image: np.ndarray,
107
+ *,
108
+ enabled: bool,
109
+ ) -> np.ndarray:
110
+ """Optionally denoise image to suppress tiny bright peaks.
111
+
112
+ Uses wavelet denoising with BayesShrink.
113
+ """
114
+ if not enabled:
115
+ return image.astype(np.float32, copy=False)
116
+ data = image.astype(np.float32, copy=False)
117
+ if data.ndim == 2:
118
+ denoised = denoise_wavelet(
119
+ data,
120
+ method="BayesShrink",
121
+ mode="soft",
122
+ rescale_sigma=True,
123
+ channel_axis=None,
124
+ )
125
+ return np.asarray(denoised, dtype=np.float32)
126
+
127
+ denoised = np.empty_like(data, dtype=np.float32)
128
+ for z in range(data.shape[0]):
129
+ denoised[z] = np.asarray(
130
+ denoise_wavelet(
131
+ data[z],
132
+ method="BayesShrink",
133
+ mode="soft",
134
+ rescale_sigma=True,
135
+ channel_axis=None,
136
+ ),
137
+ dtype=np.float32,
138
+ )
139
+ return denoised.astype(np.float32, copy=False)
140
+
141
+
142
+ def _scale_image_for_detection(
143
+ image: np.ndarray,
144
+ scale: float,
145
+ ) -> np.ndarray:
146
+ """Rescale image before U-FISH inference.
147
+
148
+ For 3D stacks, scale is applied to y/x only and z is preserved.
149
+ """
150
+ if abs(scale - 1.0) < 1e-6:
151
+ return image.astype(np.float32, copy=False)
152
+ if image.ndim == 2:
153
+ target_shape = tuple(max(1, int(round(dim * scale))) for dim in image.shape)
154
+ else:
155
+ target_shape = (
156
+ image.shape[0],
157
+ max(1, int(round(image.shape[1] * scale))),
158
+ max(1, int(round(image.shape[2] * scale))),
159
+ )
160
+ zoom_factors = tuple(
161
+ target / source for target, source in zip(target_shape, image.shape)
162
+ )
163
+ scaled = ndi.zoom(
164
+ image.astype(np.float32, copy=False),
165
+ zoom=zoom_factors,
166
+ order=1,
167
+ mode="nearest",
168
+ )
169
+ return scaled.astype(np.float32, copy=False)
170
+
171
+
172
+ def _fit_to_shape(array: np.ndarray, target_shape: tuple[int, ...]) -> np.ndarray:
173
+ """Crop/pad array to exactly match target shape."""
174
+ if array.shape == target_shape:
175
+ return array
176
+
177
+ src_slices = tuple(slice(0, min(src, tgt)) for src, tgt in zip(array.shape, target_shape))
178
+ cropped = array[src_slices]
179
+ if cropped.shape == target_shape:
180
+ return cropped
181
+
182
+ fitted = np.zeros(target_shape, dtype=array.dtype)
183
+ dst_slices = tuple(slice(0, dim) for dim in cropped.shape)
184
+ fitted[dst_slices] = cropped
185
+ return fitted
186
+
187
+ def _restore_image_to_input_scale(
188
+ image: np.ndarray,
189
+ original_shape: tuple[int, ...],
190
+ ) -> np.ndarray:
191
+ """Restore floating-point image to original input scale."""
192
+ if image.shape == original_shape:
193
+ return image.astype(np.float32, copy=False)
194
+ zoom_factors = tuple(
195
+ target / source for target, source in zip(original_shape, image.shape)
196
+ )
197
+ restored = ndi.zoom(
198
+ image.astype(np.float32, copy=False),
199
+ zoom=zoom_factors,
200
+ order=1,
201
+ mode="nearest",
202
+ )
203
+ restored = _fit_to_shape(restored, original_shape)
204
+ return restored.astype(np.float32, copy=False)
205
+
206
+
207
+ def _markers_from_local_maxima(
208
+ enhanced: np.ndarray,
209
+ threshold: float,
210
+ use_laplace: bool = True,
211
+ ) -> np.ndarray:
212
+ """Build marker labels from U-FISH local maxima calls."""
213
+ connectivity = max(1, min(2, enhanced.ndim))
214
+ response = (
215
+ laplace(enhanced.astype(np.float32, copy=False))
216
+ if use_laplace
217
+ else np.asarray(enhanced, dtype=np.float32)
218
+ )
219
+ mask = local_maxima(response, connectivity=connectivity)
220
+ mask = mask & (response > threshold)
221
+
222
+ markers = np.zeros(enhanced.shape, dtype=np.int32)
223
+ coords = np.argwhere(mask)
224
+ if coords.size == 0:
225
+ return markers
226
+
227
+ max_indices = np.asarray(enhanced.shape) - 1
228
+ coords = np.clip(coords, 0, max_indices)
229
+ markers[tuple(coords.T)] = 1
230
+
231
+ structure = ndi.generate_binary_structure(enhanced.ndim, 1)
232
+ marker_labels, _num = ndi.label(markers > 0, structure=structure)
233
+ return marker_labels.astype(np.int32, copy=False)
234
+
235
+
236
+ def _segment_from_markers(
237
+ enhanced: np.ndarray,
238
+ markers: np.ndarray,
239
+ threshold: float,
240
+ ) -> np.ndarray:
241
+ """Run watershed from local-maxima markers inside threshold foreground."""
242
+ foreground = enhanced > threshold
243
+ if not np.any(foreground):
244
+ return np.zeros_like(enhanced, dtype=np.int32)
245
+
246
+ seeded_markers = markers * foreground.astype(np.int32, copy=False)
247
+ if not np.any(seeded_markers > 0):
248
+ return np.zeros_like(enhanced, dtype=np.int32)
249
+
250
+ labels = watershed(
251
+ -enhanced.astype(np.float32, copy=False),
252
+ markers=seeded_markers,
253
+ mask=foreground,
254
+ )
255
+ return labels.astype(np.int32, copy=False)
256
+
257
+
258
+ class UFishDetector(SenoQuantSpotDetector):
259
+ """Spot detector using U-FISH local maxima and watershed expansion."""
260
+
261
+ def __init__(self, models_root=None) -> None:
262
+ super().__init__("ufish", models_root=models_root)
263
+
264
+ def run(self, **kwargs) -> dict:
265
+ """Run U-FISH seeded watershed and return instance labels."""
266
+ layer = kwargs.get("layer")
267
+ if layer is None:
268
+ return {"mask": None, "points": None}
269
+ if getattr(layer, "rgb", False):
270
+ raise ValueError("U-FISH detector requires single-channel images.")
271
+
272
+ settings = kwargs.get("settings", {}) or {}
273
+ threshold = _clamp_threshold(float(settings.get("threshold", DEFAULT_THRESHOLD)))
274
+ use_laplace = USE_LAPLACE_FOR_PEAKS
275
+ denoise_enabled = bool(settings.get("denoise_enabled", DEFAULT_DENOISE_ENABLED))
276
+ spot_size = _clamp_spot_size(
277
+ float(settings.get("spot_size", DEFAULT_SPOT_SIZE))
278
+ )
279
+ scale = _spot_size_to_detection_scale(spot_size)
280
+
281
+ data = layer_data_asarray(layer)
282
+ if data.ndim not in (2, 3):
283
+ raise ValueError("U-FISH detector expects 2D images or 3D stacks.")
284
+
285
+ data = _normalize_input_percentile(data)
286
+ denoised = _denoise_input(
287
+ data,
288
+ enabled=denoise_enabled,
289
+ )
290
+ scaled_input = _scale_image_for_detection(denoised, scale)
291
+
292
+ enhanced_raw = enhance_image(
293
+ np.asarray(scaled_input, dtype=np.float32),
294
+ config=UFishConfig(),
295
+ )
296
+ enhanced_raw = np.asarray(enhanced_raw, dtype=np.float32)
297
+
298
+ # Re-normalize after enhancement
299
+ enhanced_normalized = _normalize_enhanced_unit(enhanced_raw)
300
+
301
+ # Segment in original resolution to avoid blocky label upsampling artifacts.
302
+ enhanced_for_seg = _restore_image_to_input_scale(
303
+ enhanced_normalized,
304
+ data.shape,
305
+ )
306
+
307
+ markers = _markers_from_local_maxima(
308
+ enhanced_for_seg,
309
+ threshold,
310
+ use_laplace=use_laplace,
311
+ )
312
+ labels = _segment_from_markers(
313
+ enhanced_for_seg,
314
+ markers,
315
+ threshold,
316
+ )
317
+ # debug_enhanced = _restore_image_to_input_scale(enhanced_raw, data.shape)
318
+ # debug_enhanced_normalized = enhanced_for_seg
319
+ return {
320
+ "mask": labels,
321
+ # "debug_images": {
322
+ # # "debug_normalized_image": normalized.astype(np.float32, copy=False),
323
+ # "debug_denoised_image": denoised.astype(np.float32, copy=False),
324
+ # "debug_enhanced_image": debug_enhanced,
325
+ # "debug_enhanced_image_normalized": debug_enhanced_normalized,
326
+ # },
327
+ }
@@ -0,0 +1,13 @@
1
+ """Public UFish utility API for spot enhancement.
2
+
3
+ This package exposes a minimal stable surface used by the Spots tab:
4
+
5
+ ``UFishConfig``
6
+ Configuration dataclass for model initialization and weight loading.
7
+ ``enhance_image``
8
+ Convenience function that runs UFish enhancement on an input image.
9
+ """
10
+
11
+ from .core import UFishConfig, enhance_image
12
+
13
+ __all__ = ["UFishConfig", "enhance_image"]