senoquant 1.0.0b2__py3-none-any.whl → 1.0.0b3__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 (47) hide show
  1. senoquant/__init__.py +6 -2
  2. senoquant/_reader.py +1 -1
  3. senoquant/reader/core.py +201 -18
  4. senoquant/tabs/batch/backend.py +18 -3
  5. senoquant/tabs/batch/frontend.py +8 -4
  6. senoquant/tabs/quantification/features/marker/dialog.py +26 -6
  7. senoquant/tabs/quantification/features/marker/export.py +97 -24
  8. senoquant/tabs/quantification/features/marker/rows.py +2 -2
  9. senoquant/tabs/quantification/features/spots/dialog.py +41 -11
  10. senoquant/tabs/quantification/features/spots/export.py +163 -10
  11. senoquant/tabs/quantification/frontend.py +2 -2
  12. senoquant/tabs/segmentation/frontend.py +46 -9
  13. senoquant/tabs/segmentation/models/cpsam/model.py +1 -1
  14. senoquant/tabs/segmentation/models/default_2d/model.py +22 -77
  15. senoquant/tabs/segmentation/models/default_3d/model.py +8 -74
  16. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +0 -0
  17. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +13 -13
  18. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/stardist_libs.py +171 -0
  19. senoquant/tabs/spots/frontend.py +42 -5
  20. senoquant/tabs/spots/models/ufish/details.json +17 -0
  21. senoquant/tabs/spots/models/ufish/model.py +129 -0
  22. senoquant/tabs/spots/ufish_utils/__init__.py +13 -0
  23. senoquant/tabs/spots/ufish_utils/core.py +357 -0
  24. senoquant/utils.py +1 -1
  25. senoquant-1.0.0b3.dist-info/METADATA +161 -0
  26. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b3.dist-info}/RECORD +41 -28
  27. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b3.dist-info}/top_level.txt +1 -0
  28. ufish/__init__.py +1 -0
  29. ufish/api.py +778 -0
  30. ufish/model/__init__.py +0 -0
  31. ufish/model/loss.py +62 -0
  32. ufish/model/network/__init__.py +0 -0
  33. ufish/model/network/spot_learn.py +50 -0
  34. ufish/model/network/ufish_net.py +204 -0
  35. ufish/model/train.py +175 -0
  36. ufish/utils/__init__.py +0 -0
  37. ufish/utils/img.py +418 -0
  38. ufish/utils/log.py +8 -0
  39. ufish/utils/spot_calling.py +115 -0
  40. senoquant/tabs/spots/models/rmp/details.json +0 -61
  41. senoquant/tabs/spots/models/rmp/model.py +0 -499
  42. senoquant/tabs/spots/models/udwt/details.json +0 -103
  43. senoquant/tabs/spots/models/udwt/model.py +0 -482
  44. senoquant-1.0.0b2.dist-info/METADATA +0 -193
  45. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b3.dist-info}/WHEEL +0 -0
  46. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b3.dist-info}/entry_points.txt +0 -0
  47. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b3.dist-info}/licenses/LICENSE +0 -0
@@ -19,6 +19,9 @@ from senoquant.tabs.segmentation.stardist_onnx_utils.onnx_framework import (
19
19
  normalize,
20
20
  predict_tiled,
21
21
  )
22
+ from senoquant.tabs.segmentation.stardist_onnx_utils.onnx_framework.stardist_libs import (
23
+ ensure_stardist_libs,
24
+ )
22
25
  from senoquant.tabs.segmentation.stardist_onnx_utils.onnx_framework.inspect import (
23
26
  make_probe_image,
24
27
  )
@@ -240,7 +243,7 @@ class StarDistOnnxModel(SenoQuantSegmentationModel):
240
243
  Parameters
241
244
  ----------
242
245
  layer : object or None
243
- Napari layer to convert.
246
+ napari layer to convert.
244
247
  required : bool
245
248
  Whether a missing layer should raise an error.
246
249
 
@@ -263,6 +266,9 @@ class StarDistOnnxModel(SenoQuantSegmentationModel):
263
266
  session = self._sessions.get(model_path)
264
267
  if session is None or providers_override is not None:
265
268
  providers = providers_override or self._preferred_providers()
269
+ preload = getattr(ort, "preload_dlls", None)
270
+ if callable(preload):
271
+ preload()
266
272
  session = ort.InferenceSession(
267
273
  str(model_path),
268
274
  providers=providers,
@@ -505,85 +511,13 @@ class StarDistOnnxModel(SenoQuantSegmentationModel):
505
511
  libraries are absent, allowing Python utilities to import.
506
512
  """
507
513
  utils_root = self._get_utils_root()
508
- csbdeep_root = utils_root / "_csbdeep"
509
- if csbdeep_root.exists():
510
- csbdeep_path = str(csbdeep_root)
511
- if csbdeep_path not in sys.path:
512
- sys.path.insert(0, csbdeep_path)
513
-
514
514
  stardist_pkg = (
515
515
  "senoquant.tabs.segmentation.stardist_onnx_utils._stardist"
516
516
  )
517
- if stardist_pkg not in sys.modules:
518
- pkg = types.ModuleType(stardist_pkg)
519
- pkg.__path__ = [str(utils_root / "_stardist")]
520
- sys.modules[stardist_pkg] = pkg
521
-
522
- base_pkg = f"{stardist_pkg}.lib"
523
- lib_dirs = [utils_root / "_stardist" / "lib"]
524
- for entry in list(sys.path):
525
- if not entry:
526
- continue
527
- try:
528
- candidate = (
529
- Path(entry)
530
- / "senoquant"
531
- / "tabs"
532
- / "segmentation"
533
- / "stardist_onnx_utils"
534
- / "_stardist"
535
- / "lib"
536
- )
537
- except Exception:
538
- continue
539
- if candidate.exists():
540
- lib_dirs.append(candidate)
541
-
542
- if base_pkg in sys.modules:
543
- pkg = sys.modules[base_pkg]
544
- pkg.__path__ = [str(p) for p in lib_dirs]
545
- else:
546
- pkg = types.ModuleType(base_pkg)
547
- pkg.__path__ = [str(p) for p in lib_dirs]
548
- sys.modules[base_pkg] = pkg
549
-
550
- def _stub(*_args, **_kwargs):
551
- raise RuntimeError("StarDist compiled ops are unavailable.")
552
-
553
- has_2d = False
554
- has_3d = False
555
- for lib_dir in lib_dirs:
556
- has_2d = has_2d or any(lib_dir.glob("stardist2d*.so")) or any(
557
- lib_dir.glob("stardist2d*.pyd")
558
- )
559
- has_3d = has_3d or any(lib_dir.glob("stardist3d*.so")) or any(
560
- lib_dir.glob("stardist3d*.pyd")
561
- )
517
+ has_2d, has_3d = ensure_stardist_libs(utils_root, stardist_pkg)
562
518
  self._has_stardist_2d_lib = has_2d
563
519
  self._has_stardist_3d_lib = has_3d
564
520
 
565
- mod2d = f"{base_pkg}.stardist2d"
566
- if has_2d and mod2d in sys.modules:
567
- if getattr(sys.modules[mod2d], "__file__", None) is None:
568
- del sys.modules[mod2d]
569
- if not has_2d and mod2d not in sys.modules:
570
- module = types.ModuleType(mod2d)
571
- module.c_star_dist = _stub
572
- module.c_non_max_suppression_inds_old = _stub
573
- module.c_non_max_suppression_inds = _stub
574
- sys.modules[mod2d] = module
575
-
576
- mod3d = f"{base_pkg}.stardist3d"
577
- if has_3d and mod3d in sys.modules:
578
- if getattr(sys.modules[mod3d], "__file__", None) is None:
579
- del sys.modules[mod3d]
580
- if not has_3d and mod3d not in sys.modules:
581
- module = types.ModuleType(mod3d)
582
- module.c_star_dist3d = _stub
583
- module.c_polyhedron_to_label = _stub
584
- module.c_non_max_suppression_inds = _stub
585
- sys.modules[mod3d] = module
586
-
587
521
  def _get_rays_class(self):
588
522
  """Load and cache the StarDist Rays_GoldenSpiral class."""
589
523
  if self._rays_class is not None:
@@ -53,6 +53,17 @@ def make_probe_image(
53
53
  if model_path is None or input_layout is None:
54
54
  return probe
55
55
 
56
+ div_by = None
57
+ if div_by_cache is not None:
58
+ div_by = div_by_cache.get(model_path)
59
+ if div_by is None:
60
+ try:
61
+ div_by = infer_div_by(model_path, ndim=image.ndim)
62
+ except Exception:
63
+ div_by = None
64
+ if div_by_cache is not None and div_by is not None:
65
+ div_by_cache[model_path] = div_by
66
+
56
67
  patterns = None
57
68
  if valid_size_cache is not None:
58
69
  patterns = valid_size_cache.get(model_path)
@@ -68,24 +79,13 @@ def make_probe_image(
68
79
  if valid_size_cache is not None:
69
80
  valid_size_cache[model_path] = patterns
70
81
 
71
- div_by = None
72
- if div_by_cache is not None:
73
- div_by = div_by_cache.get(model_path)
74
- if div_by is None:
75
- try:
76
- div_by = infer_div_by(model_path, ndim=image.ndim)
77
- except Exception:
78
- div_by = None
79
- if div_by_cache is not None and div_by is not None:
80
- div_by_cache[model_path] = div_by
81
-
82
82
  desired = list(probe.shape)
83
83
  if patterns:
84
84
  desired = [
85
85
  max(1, snap_size(int(size), patterns[axis]))
86
86
  for axis, size in enumerate(desired)
87
87
  ]
88
- elif div_by:
88
+ if div_by:
89
89
  desired = [
90
90
  max(int(d), (int(size) // int(d)) * int(d)) if d else int(size)
91
91
  for size, d in zip(desired, div_by)
@@ -97,4 +97,4 @@ def make_probe_image(
97
97
  pads = [(0, max(0, d - s)) for s, d in zip(probe.shape, desired)]
98
98
  if any(pad_after > 0 for _, pad_after in pads):
99
99
  probe = np.pad(probe, pads, mode="reflect")
100
- return probe
100
+ return probe
@@ -0,0 +1,171 @@
1
+ """Helpers for locating and loading StarDist compiled extensions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.machinery
6
+ import importlib.util
7
+ import sys
8
+ import types
9
+ from pathlib import Path
10
+
11
+
12
+ def ensure_stardist_libs( # noqa: C901, PLR0912, PLR0915
13
+ utils_root: Path,
14
+ stardist_pkg: str,
15
+ ) -> tuple[bool, bool]:
16
+ """Ensure StarDist compiled extensions can be imported.
17
+
18
+ Parameters
19
+ ----------
20
+ utils_root : pathlib.Path
21
+ Root path to the ``stardist_onnx_utils`` package.
22
+ stardist_pkg : str
23
+ Fully-qualified package name for the vendored StarDist package.
24
+
25
+ Returns
26
+ -------
27
+ tuple[bool, bool]
28
+ Tuple of ``(has_2d, has_3d)`` indicating which compiled
29
+ extensions are available.
30
+
31
+ """
32
+ csbdeep_root = utils_root / "_csbdeep"
33
+ if csbdeep_root.exists():
34
+ csbdeep_path = str(csbdeep_root)
35
+ if csbdeep_path not in sys.path:
36
+ sys.path.insert(0, csbdeep_path)
37
+
38
+ if stardist_pkg not in sys.modules:
39
+ pkg = types.ModuleType(stardist_pkg)
40
+ pkg.__path__ = [str(utils_root / "_stardist")]
41
+ sys.modules[stardist_pkg] = pkg
42
+
43
+ base_pkg = f"{stardist_pkg}.lib"
44
+ lib_dirs: list[Path] = []
45
+ seen_dirs: set[Path] = set()
46
+
47
+ def _append_if_exists(path: Path) -> None:
48
+ if not path.exists():
49
+ return
50
+ resolved = path.resolve()
51
+ if resolved in seen_dirs:
52
+ return
53
+ seen_dirs.add(resolved)
54
+ lib_dirs.append(resolved)
55
+
56
+ _append_if_exists(utils_root / "_stardist" / "lib")
57
+ for entry in list(sys.path):
58
+ if not entry:
59
+ continue
60
+ try:
61
+ legacy_candidate = (
62
+ Path(entry)
63
+ / "senoquant"
64
+ / "tabs"
65
+ / "segmentation"
66
+ / "stardist_onnx_utils"
67
+ / "_stardist"
68
+ / "lib"
69
+ )
70
+ ext_candidate = Path(entry) / "senoquant_stardist_ext" / "lib"
71
+ except (TypeError, ValueError, OSError):
72
+ continue
73
+ _append_if_exists(legacy_candidate)
74
+ _append_if_exists(ext_candidate)
75
+
76
+ if base_pkg in sys.modules:
77
+ pkg = sys.modules[base_pkg]
78
+ pkg.__path__ = [str(p) for p in lib_dirs]
79
+ else:
80
+ pkg = types.ModuleType(base_pkg)
81
+ pkg.__path__ = [str(p) for p in lib_dirs]
82
+ sys.modules[base_pkg] = pkg
83
+
84
+ for module_name in (f"{base_pkg}.stardist2d", f"{base_pkg}.stardist3d"):
85
+ try:
86
+ spec = importlib.util.find_spec(module_name)
87
+ except (ImportError, AttributeError, ValueError):
88
+ spec = None
89
+ if spec and spec.origin:
90
+ try:
91
+ candidate = Path(spec.origin).parent
92
+ except (TypeError, OSError):
93
+ candidate = None
94
+ if candidate is not None and candidate.exists():
95
+ lib_dirs.append(candidate)
96
+
97
+ mod2d = f"{base_pkg}.stardist2d"
98
+ mod3d = f"{base_pkg}.stardist3d"
99
+
100
+ def _stub(*_args: object, **_kwargs: object) -> None:
101
+ msg = "StarDist compiled ops are unavailable."
102
+ raise RuntimeError(msg)
103
+
104
+ def _module_available(module_name: str) -> bool:
105
+ module = sys.modules.get(module_name)
106
+ if module is not None:
107
+ return getattr(module, "__file__", None) is not None
108
+ try:
109
+ spec = importlib.util.find_spec(module_name)
110
+ except (ImportError, AttributeError, ValueError):
111
+ return False
112
+ return bool(spec and spec.origin)
113
+
114
+ def _try_load_dll(module_name: str, dll_path: Path) -> bool:
115
+ try:
116
+ loader = importlib.machinery.ExtensionFileLoader(
117
+ module_name,
118
+ str(dll_path),
119
+ )
120
+ spec = importlib.util.spec_from_file_location(
121
+ module_name,
122
+ str(dll_path),
123
+ loader=loader,
124
+ )
125
+ if spec is None or spec.loader is None:
126
+ return False
127
+ module = importlib.util.module_from_spec(spec)
128
+ spec.loader.exec_module(module)
129
+ sys.modules[module_name] = module
130
+ except (ImportError, OSError, AttributeError, ValueError):
131
+ return False
132
+ else:
133
+ return True
134
+
135
+ if not _module_available(mod2d):
136
+ for lib_dir in lib_dirs:
137
+ candidates = sorted(lib_dir.glob("stardist2d*.dll"))
138
+ if candidates and _try_load_dll(mod2d, candidates[0]):
139
+ break
140
+ if not _module_available(mod3d):
141
+ for lib_dir in lib_dirs:
142
+ candidates = sorted(lib_dir.glob("stardist3d*.dll"))
143
+ if candidates and _try_load_dll(mod3d, candidates[0]):
144
+ break
145
+
146
+ has_2d = _module_available(mod2d)
147
+ has_3d = _module_available(mod3d)
148
+
149
+ if has_2d and mod2d in sys.modules and getattr(
150
+ sys.modules[mod2d], "__file__", None,
151
+ ) is None:
152
+ del sys.modules[mod2d]
153
+ if not has_2d and mod2d not in sys.modules:
154
+ module = types.ModuleType(mod2d)
155
+ module.c_star_dist = _stub # type: ignore[attr-defined]
156
+ module.c_non_max_suppression_inds_old = _stub # type: ignore[attr-defined]
157
+ module.c_non_max_suppression_inds = _stub # type: ignore[attr-defined]
158
+ sys.modules[mod2d] = module
159
+
160
+ if has_3d and mod3d in sys.modules and getattr(
161
+ sys.modules[mod3d], "__file__", None,
162
+ ) is None:
163
+ del sys.modules[mod3d]
164
+ if not has_3d and mod3d not in sys.modules:
165
+ module = types.ModuleType(mod3d)
166
+ module.c_star_dist3d = _stub # type: ignore[attr-defined]
167
+ module.c_polyhedron_to_label = _stub # type: ignore[attr-defined]
168
+ module.c_non_max_suppression_inds = _stub # type: ignore[attr-defined]
169
+ sys.modules[mod3d] = module
170
+
171
+ return has_2d, has_3d
@@ -118,7 +118,7 @@ class SpotsTab(QWidget):
118
118
  backend : SpotsBackend or None
119
119
  Backend instance used to discover and load detectors.
120
120
  napari_viewer : object or None
121
- Napari viewer used to populate layer choices.
121
+ napari viewer used to populate layer choices.
122
122
  """
123
123
 
124
124
  def __init__(
@@ -491,11 +491,15 @@ class SpotsTab(QWidget):
491
491
  detector = self._backend.get_detector(detector_name)
492
492
  layer = self._get_layer_by_name(self._layer_combo.currentText())
493
493
  settings = self._collect_settings()
494
+
495
+ def run_detector() -> dict:
496
+ return detector.run(layer=layer, settings=settings)
497
+
494
498
  self._start_background_run(
495
499
  run_button=self._run_button,
496
500
  run_text="Run",
497
501
  detector_name=detector_name,
498
- run_callable=lambda: detector.run(layer=layer, settings=settings),
502
+ run_callable=run_detector,
499
503
  on_success=lambda result: self._handle_run_result(
500
504
  layer, detector_name, result
501
505
  ),
@@ -653,8 +657,41 @@ class SpotsTab(QWidget):
653
657
  if self._viewer is None or source_layer is None:
654
658
  return
655
659
  name = self._spot_label_name(source_layer, detector_name)
656
- self._viewer.add_labels(mask, name=name)
657
- labels_layer = self._viewer.layers[name]
660
+ source_metadata = getattr(source_layer, "metadata", {})
661
+ merged_metadata: dict[str, object] = {}
662
+ if isinstance(source_metadata, dict):
663
+ merged_metadata.update(source_metadata)
664
+ merged_metadata["task"] = "spots"
665
+
666
+ labels_layer = None
667
+ if Labels is not None and hasattr(self._viewer, "add_layer"):
668
+ # Add a fully configured Labels layer object to avoid name-based lookup.
669
+ labels_layer = Labels(
670
+ mask,
671
+ name=name,
672
+ metadata=merged_metadata,
673
+ )
674
+ added_layer = self._viewer.add_layer(labels_layer)
675
+ if added_layer is not None:
676
+ labels_layer = added_layer
677
+ elif hasattr(self._viewer, "add_labels"):
678
+ try:
679
+ labels_layer = self._viewer.add_labels(
680
+ mask,
681
+ name=name,
682
+ metadata=merged_metadata,
683
+ )
684
+ except TypeError:
685
+ labels_layer = self._viewer.add_labels(mask, name=name)
686
+
687
+ if labels_layer is None:
688
+ return
689
+
690
+ layer_metadata = getattr(labels_layer, "metadata", {})
691
+ if isinstance(layer_metadata, dict):
692
+ merged_metadata.update(layer_metadata)
693
+ merged_metadata["task"] = "spots"
694
+ labels_layer.metadata = merged_metadata
658
695
  labels_layer.contour = 1
659
696
 
660
697
  def _apply_size_filter(self, mask: np.ndarray) -> np.ndarray:
@@ -722,7 +759,7 @@ class SpotsTab(QWidget):
722
759
  Parameters
723
760
  ----------
724
761
  layer : object or None
725
- Napari layer to validate.
762
+ napari layer to validate.
726
763
  label : str
727
764
  User-facing label for notifications.
728
765
 
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "ufish",
3
+ "description": "U-FISH local-maxima seeded watershed detector",
4
+ "version": "0.1.0",
5
+ "order": 0,
6
+ "settings": [
7
+ {
8
+ "key": "threshold",
9
+ "label": "Threshold",
10
+ "type": "float",
11
+ "decimals": 2,
12
+ "min": 0.0,
13
+ "max": 1.0,
14
+ "default": 0.5
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,129 @@
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.segmentation import watershed
10
+
11
+ from ..base import SenoQuantSpotDetector
12
+ from senoquant.utils import layer_data_asarray
13
+ from senoquant.tabs.spots.ufish_utils import UFishConfig, enhance_image
14
+
15
+
16
+ DEFAULT_THRESHOLD = 0.5
17
+ USE_LAPLACE_FOR_PEAKS = False
18
+
19
+
20
+ def _clamp_threshold(value: float) -> float:
21
+ """Clamp threshold to the inclusive [0.0, 1.0] range."""
22
+ return float(np.clip(value, 0.0, 1.0))
23
+
24
+
25
+ def _normalize_unit(image: np.ndarray) -> np.ndarray:
26
+ """Normalize to float32 in [0, 1] using 0.05/99.95 percentiles."""
27
+ data = np.asarray(image, dtype=np.float32)
28
+ low, high = np.nanpercentile(data, [0.05, 99.95])
29
+ low = float(low)
30
+ high = float(high)
31
+ if high <= low:
32
+ return np.zeros_like(data, dtype=np.float32)
33
+ normalized = (data - low) / (high - low)
34
+ return np.clip(normalized, 0.0, 1.0).astype(np.float32, copy=False)
35
+
36
+
37
+ def _markers_from_local_maxima(
38
+ enhanced: np.ndarray,
39
+ threshold: float,
40
+ use_laplace: bool = True,
41
+ ) -> np.ndarray:
42
+ """Build marker labels from U-FISH local maxima calls."""
43
+ connectivity = max(1, min(2, enhanced.ndim))
44
+ response = (
45
+ laplace(enhanced.astype(np.float32, copy=False))
46
+ if use_laplace
47
+ else np.asarray(enhanced, dtype=np.float32)
48
+ )
49
+ mask = local_maxima(response, connectivity=connectivity)
50
+ mask = mask & (response > threshold)
51
+
52
+ markers = np.zeros(enhanced.shape, dtype=np.int32)
53
+ coords = np.argwhere(mask)
54
+ if coords.size == 0:
55
+ return markers
56
+
57
+ max_indices = np.asarray(enhanced.shape) - 1
58
+ coords = np.clip(coords, 0, max_indices)
59
+ markers[tuple(coords.T)] = 1
60
+
61
+ structure = ndi.generate_binary_structure(enhanced.ndim, 1)
62
+ marker_labels, _num = ndi.label(markers > 0, structure=structure)
63
+ return marker_labels.astype(np.int32, copy=False)
64
+
65
+
66
+ def _segment_from_markers(
67
+ enhanced: np.ndarray,
68
+ markers: np.ndarray,
69
+ threshold: float,
70
+ ) -> np.ndarray:
71
+ """Run watershed from local-maxima markers inside threshold foreground."""
72
+ foreground = enhanced > threshold
73
+ if not np.any(foreground):
74
+ return np.zeros_like(enhanced, dtype=np.int32)
75
+
76
+ seeded_markers = markers * foreground.astype(np.int32, copy=False)
77
+ if not np.any(seeded_markers > 0):
78
+ return np.zeros_like(enhanced, dtype=np.int32)
79
+
80
+ labels = watershed(
81
+ -enhanced.astype(np.float32, copy=False),
82
+ markers=seeded_markers,
83
+ mask=foreground,
84
+ )
85
+ return labels.astype(np.int32, copy=False)
86
+
87
+
88
+ class UFishDetector(SenoQuantSpotDetector):
89
+ """Spot detector using U-FISH local maxima and watershed expansion."""
90
+
91
+ def __init__(self, models_root=None) -> None:
92
+ super().__init__("ufish", models_root=models_root)
93
+
94
+ def run(self, **kwargs) -> dict:
95
+ """Run U-FISH seeded watershed and return instance labels."""
96
+ layer = kwargs.get("layer")
97
+ if layer is None:
98
+ return {"mask": None, "points": None}
99
+ if getattr(layer, "rgb", False):
100
+ raise ValueError("U-FISH detector requires single-channel images.")
101
+
102
+ settings = kwargs.get("settings", {}) or {}
103
+ threshold = _clamp_threshold(float(settings.get("threshold", DEFAULT_THRESHOLD)))
104
+ use_laplace = USE_LAPLACE_FOR_PEAKS
105
+
106
+ data = layer_data_asarray(layer)
107
+ if data.ndim not in (2, 3):
108
+ raise ValueError("U-FISH detector expects 2D images or 3D stacks.")
109
+
110
+ # Percentile normalize the data
111
+ data = _normalize_unit(data)
112
+
113
+ enhanced = enhance_image(np.asarray(data, dtype=np.float32), config=UFishConfig())
114
+ enhanced = np.asarray(enhanced, dtype=np.float32)
115
+
116
+ # Re-normalize after enhancement
117
+ enhanced = _normalize_unit(enhanced)
118
+
119
+ markers = _markers_from_local_maxima(
120
+ enhanced,
121
+ threshold,
122
+ use_laplace=use_laplace,
123
+ )
124
+ labels = _segment_from_markers(
125
+ enhanced,
126
+ markers,
127
+ threshold,
128
+ )
129
+ return {"mask": labels}
@@ -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"]