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,112 @@
1
+ """Spots feature UI."""
2
+
3
+ from pathlib import Path
4
+
5
+ from qtpy.QtWidgets import QCheckBox, QDialog, QPushButton
6
+
7
+ from ..base import SenoQuantFeature
8
+ from ..roi import ROISection
9
+ from .config import SpotsFeatureData
10
+ from .dialog import SpotsChannelsDialog
11
+ from .export import export_spots
12
+
13
+
14
+ class SpotsFeature(SenoQuantFeature):
15
+ """Spots feature controls."""
16
+
17
+ feature_type = "Spots"
18
+ order = 20
19
+
20
+ def build(self) -> None:
21
+ """Build the spots feature UI."""
22
+ self._build_channels_section()
23
+ data = self._state.data
24
+ if getattr(self._tab, "_enable_rois", True):
25
+ if isinstance(data, SpotsFeatureData):
26
+ roi_section = ROISection(self._tab, self._context, data.rois)
27
+ else:
28
+ roi_section = ROISection(self._tab, self._context, [])
29
+ roi_section.build()
30
+ self._ui["roi_section"] = roi_section
31
+
32
+ def on_features_changed(self, configs: list) -> None:
33
+ """Update ROI titles when feature ordering changes.
34
+
35
+ Parameters
36
+ ----------
37
+ configs : list of FeatureUIContext
38
+ Current feature contexts.
39
+ """
40
+ roi_section = self._ui.get("roi_section")
41
+ if roi_section is not None:
42
+ roi_section.update_titles()
43
+
44
+ def _build_channels_section(self) -> None:
45
+ """Build the channels button that opens the popup dialog."""
46
+ left_dynamic_layout = self._context.left_dynamic_layout
47
+ button = QPushButton("Add channels")
48
+ button.clicked.connect(self._open_channels_dialog)
49
+ left_dynamic_layout.addWidget(button)
50
+ data = self._state.data
51
+ checkbox = QCheckBox("Export colocalization")
52
+ checkbox.setChecked(
53
+ isinstance(data, SpotsFeatureData) and data.export_colocalization
54
+ )
55
+ checkbox.toggled.connect(self._set_export_colocalization)
56
+ left_dynamic_layout.addWidget(checkbox)
57
+ self._ui["channels_button"] = button
58
+ self._ui["colocalization_checkbox"] = checkbox
59
+ self._update_channels_button_label()
60
+
61
+ def _set_export_colocalization(self, checked: bool) -> None:
62
+ """Store colocalization export preference."""
63
+ data = self._state.data
64
+ if not isinstance(data, SpotsFeatureData):
65
+ return
66
+ data.export_colocalization = checked
67
+
68
+ def _open_channels_dialog(self) -> None:
69
+ """Open the channels configuration dialog."""
70
+ dialog = self._ui.get("channels_dialog")
71
+ if dialog is None or not isinstance(dialog, QDialog):
72
+ dialog = SpotsChannelsDialog(self)
73
+ dialog.accepted.connect(self._update_channels_button_label)
74
+ self._ui["channels_dialog"] = dialog
75
+ dialog.show()
76
+ dialog.raise_()
77
+ dialog.activateWindow()
78
+
79
+ def _update_channels_button_label(self) -> None:
80
+ """Update the channels button label based on saved data."""
81
+ button = self._ui.get("channels_button")
82
+ if button is None:
83
+ return
84
+ data = self._state.data
85
+ if isinstance(data, SpotsFeatureData) and (
86
+ data.channels or data.segmentations
87
+ ):
88
+ button.setText("Edit channels")
89
+ else:
90
+ button.setText("Add channels")
91
+
92
+ def export(self, temp_dir: Path, export_format: str):
93
+ """Export spots outputs into a temporary directory.
94
+
95
+ Parameters
96
+ ----------
97
+ temp_dir : Path
98
+ Temporary directory where outputs should be written.
99
+ export_format : str
100
+ File format requested by the user (``"csv"`` or ``"xlsx"``).
101
+
102
+ Returns
103
+ -------
104
+ iterable of Path
105
+ Paths to files produced by the export routine.
106
+ """
107
+ return export_spots(
108
+ self._state,
109
+ temp_dir,
110
+ viewer=self._tab._viewer,
111
+ export_format=export_format,
112
+ )
@@ -0,0 +1,279 @@
1
+ """Morphological descriptor extraction for spots cells table.
2
+
3
+ This module provides utilities to extract morphological properties
4
+ from cell segmentations used in spots analysis. Morphology is only
5
+ added to the cells table, not the spots table.
6
+
7
+ Descriptors include area/volume, shape metrics, and perimeter.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import warnings
13
+
14
+ import numpy as np
15
+ from skimage.measure import regionprops_table
16
+
17
+ # Float-valued morphological properties to extract from regionprops.
18
+ MORPHOLOGY_PROPERTIES = (
19
+ "area", # Number of pixels in the region
20
+ "eccentricity", # Eccentricity of the ellipse fitted to the region
21
+ "extent", # Ratio of region area to bounding box area
22
+ "feret_diameter_max", # Maximum Feret diameter
23
+ "major_axis_length", # Major axis of the ellipse fitted to the region
24
+ "minor_axis_length", # Minor axis of the ellipse fitted to the region
25
+ "orientation", # Angle between the major axis and horizontal
26
+ "perimeter", # Perimeter estimated by the Freeman chain code
27
+ "perimeter_crofton", # Crofton perimeter (Euclidean-like estimate)
28
+ "solidity", # Ratio of region area to convex hull area
29
+ )
30
+
31
+ NDIM_2D = 2
32
+ NDIM_3D = 3
33
+
34
+
35
+ def _collect_simple_properties(
36
+ props: dict,
37
+ label_ids: np.ndarray,
38
+ ) -> dict[str, np.ndarray]:
39
+ """Extract simple (non-array) properties from regionprops.
40
+
41
+ Parameters
42
+ ----------
43
+ props : dict
44
+ Output from regionprops_table.
45
+ label_ids : numpy.ndarray
46
+ Label ids for indexing.
47
+
48
+ Returns
49
+ -------
50
+ dict of str to numpy.ndarray
51
+ Simple property arrays keyed by property name.
52
+
53
+ """
54
+ result: dict[str, np.ndarray] = {}
55
+ for prop_name, prop_values in props.items():
56
+ if prop_name == "label":
57
+ continue
58
+
59
+ if isinstance(prop_values, list) and prop_values:
60
+ first_val = prop_values[0]
61
+ if isinstance(first_val, (np.ndarray, list, tuple)):
62
+ continue
63
+
64
+ try:
65
+ prop_array = np.asarray(prop_values, dtype=float)
66
+ if prop_array.size == len(label_ids):
67
+ result[f"morph_{prop_name}"] = prop_array
68
+ except (ValueError, TypeError):
69
+ continue
70
+
71
+ return result
72
+
73
+
74
+ def _compute_derived_metrics(
75
+ result: dict[str, np.ndarray],
76
+ ) -> None:
77
+ """Compute derived morphological metrics in-place.
78
+
79
+ Parameters
80
+ ----------
81
+ result : dict of str to numpy.ndarray
82
+ Morphological properties to augment with derived metrics.
83
+
84
+ Notes
85
+ -----
86
+ Circularity is 2D-only: 4*pi*area / perimeter^2. It is only computed
87
+ when perimeter is available (which indicates 2D data).
88
+ Aspect ratio is 2D-only and only computed when major/minor axis lengths
89
+ are present in the result.
90
+
91
+ """
92
+ # Get area/volume (whichever exists after dimensionality-based renaming)
93
+ if "morph_area" in result:
94
+ area_or_volume = result["morph_area"]
95
+ elif "morph_volume" in result:
96
+ area_or_volume = result["morph_volume"]
97
+ else:
98
+ return
99
+
100
+ # Circularity is 2D-only: 4*pi*area / perimeter^2
101
+ if "morph_perimeter" in result:
102
+ perim = result["morph_perimeter"]
103
+ circularity = np.divide(
104
+ 4 * np.pi * area_or_volume,
105
+ perim ** 2,
106
+ out=np.full_like(area_or_volume, np.nan),
107
+ where=perim != 0,
108
+ )
109
+ result["morph_circularity"] = circularity
110
+
111
+ # Aspect ratio: only computed if major/minor axis lengths are present (2D only)
112
+ if (
113
+ "morph_major_axis_length" in result
114
+ and "morph_minor_axis_length" in result
115
+ ):
116
+ major = result["morph_major_axis_length"]
117
+ minor = result["morph_minor_axis_length"]
118
+ aspect_ratio = np.divide(
119
+ major,
120
+ minor,
121
+ out=np.full_like(major, np.nan),
122
+ where=minor != 0,
123
+ )
124
+ result["morph_aspect_ratio"] = aspect_ratio
125
+
126
+
127
+ def _compute_physical_area(
128
+ result: dict[str, np.ndarray],
129
+ pixel_sizes: np.ndarray,
130
+ ) -> None:
131
+ """Add physical area/volume measurements to result in-place.
132
+
133
+ Parameters
134
+ ----------
135
+ result : dict of str to numpy.ndarray
136
+ Morphological properties to augment.
137
+ pixel_sizes : numpy.ndarray
138
+ Per-axis pixel sizes in micrometers.
139
+
140
+ """
141
+ # Determine if we have area (2D) or volume (3D)
142
+ if "morph_area" in result:
143
+ area_or_volume = result["morph_area"]
144
+ is_volume = False
145
+ elif "morph_volume" in result:
146
+ area_or_volume = result["morph_volume"]
147
+ is_volume = True
148
+ else:
149
+ return
150
+
151
+ pixels = area_or_volume
152
+ ndim = len(pixel_sizes)
153
+
154
+ try:
155
+ if ndim == NDIM_2D and not is_volume:
156
+ area_phys = pixels * (pixel_sizes[0] * pixel_sizes[1])
157
+ result["morph_area_um2"] = area_phys
158
+ elif ndim == NDIM_3D and is_volume:
159
+ volume_phys = pixels * (
160
+ pixel_sizes[0] * pixel_sizes[1] * pixel_sizes[2]
161
+ )
162
+ result["morph_volume_um3"] = volume_phys
163
+ except (TypeError, ValueError):
164
+ pass
165
+
166
+
167
+ def extract_morphology(
168
+ labels: np.ndarray,
169
+ label_ids: np.ndarray,
170
+ pixel_sizes: np.ndarray | None = None,
171
+ ) -> dict[str, np.ndarray]:
172
+ """Extract morphological descriptors for each labeled region.
173
+
174
+ Parameters
175
+ ----------
176
+ labels : numpy.ndarray
177
+ Label image with integer ids.
178
+ label_ids : numpy.ndarray
179
+ Specific label ids to extract properties for.
180
+ pixel_sizes : numpy.ndarray or None, optional
181
+ Per-axis pixel sizes in micrometers. When provided, physical
182
+ measurements are computed.
183
+
184
+ Returns
185
+ -------
186
+ dict of str to numpy.ndarray
187
+ Morphological descriptors. Keys are property names and values are
188
+ arrays with one entry per label id.
189
+
190
+ Notes
191
+ -----
192
+ Properties that depend on regionprops (e.g., eccentricity, solidity)
193
+ are only available for 2D images. For 3D, only simple properties like
194
+ volume are available.
195
+
196
+ The "area" property is renamed to "volume" for 3D images.
197
+
198
+ Some properties may not be available depending on the scikit-image
199
+ version. Missing properties are silently skipped.
200
+
201
+ """
202
+ # For 3D, some properties are not available. Try with all properties first,
203
+ # and fall back to basic properties if it fails.
204
+ try:
205
+ props = regionprops_table(
206
+ labels,
207
+ properties=MORPHOLOGY_PROPERTIES,
208
+ )
209
+ except (ValueError, RuntimeError):
210
+ # Fall back to basic properties for 3D
211
+ try:
212
+ props = regionprops_table(
213
+ labels,
214
+ properties=("area",),
215
+ )
216
+ except (ValueError, RuntimeError) as exc:
217
+ warnings.warn(
218
+ f"Failed to extract morphological properties: {exc}",
219
+ RuntimeWarning,
220
+ stacklevel=2,
221
+ )
222
+ return {}
223
+
224
+ result = _collect_simple_properties(props, label_ids)
225
+
226
+ # For 3D images, rename "area" to "volume"
227
+ if labels.ndim == NDIM_3D and "morph_area" in result:
228
+ result["morph_volume"] = result.pop("morph_area")
229
+
230
+ _compute_derived_metrics(result)
231
+
232
+ if pixel_sizes is not None:
233
+ _compute_physical_area(result, pixel_sizes)
234
+
235
+ return result
236
+
237
+
238
+ def add_morphology_columns(
239
+ rows: list[dict],
240
+ labels: np.ndarray,
241
+ label_ids: np.ndarray,
242
+ pixel_sizes: np.ndarray | None = None,
243
+ ) -> list[str]:
244
+ """Add morphological descriptor columns to output rows.
245
+
246
+ Parameters
247
+ ----------
248
+ rows : list of dict
249
+ Output row dictionaries to update in-place.
250
+ labels : numpy.ndarray
251
+ Label image with integer ids.
252
+ label_ids : numpy.ndarray
253
+ Label ids corresponding to the output rows.
254
+ pixel_sizes : numpy.ndarray or None, optional
255
+ Per-axis pixel sizes in micrometers.
256
+
257
+ Returns
258
+ -------
259
+ list of str
260
+ List of column names added to the rows.
261
+
262
+ Notes
263
+ -----
264
+ This function modifies ``rows`` in-place and returns the list of new
265
+ column names that were added for header generation.
266
+
267
+ """
268
+ morphology_data = extract_morphology(labels, label_ids, pixel_sizes)
269
+
270
+ if not morphology_data or not rows:
271
+ return []
272
+
273
+ column_names: list[str] = []
274
+ for col_name, col_values in morphology_data.items():
275
+ column_names.append(col_name)
276
+ for row, value in zip(rows, col_values, strict=True):
277
+ row[col_name] = float(value) if not np.isnan(value) else value
278
+
279
+ return column_names
@@ -0,0 +1,241 @@
1
+ """Spots channels dialog rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from qtpy.QtWidgets import (
8
+ QFormLayout,
9
+ QGroupBox,
10
+ QLineEdit,
11
+ QPushButton,
12
+ QSizePolicy,
13
+ QVBoxLayout,
14
+ QWidget,
15
+ )
16
+
17
+ from ..base import RefreshingComboBox
18
+ from .config import SpotsChannelConfig, SpotsSegmentationConfig
19
+
20
+ if TYPE_CHECKING:
21
+ from .dialog import SpotsChannelsDialog
22
+
23
+
24
+ class SpotsSegmentationRow(QGroupBox):
25
+ """Segmentation row widget for spots segmentation filters."""
26
+
27
+ def __init__(
28
+ self, dialog: SpotsChannelsDialog, data: SpotsSegmentationConfig
29
+ ) -> None:
30
+ """Initialize a segmentation row widget.
31
+
32
+ Parameters
33
+ ----------
34
+ dialog : SpotsChannelsDialog
35
+ Parent dialog instance.
36
+ data : SpotsSegmentationConfig
37
+ Segmentation configuration data.
38
+ """
39
+ super().__init__()
40
+ self._dialog = dialog
41
+ self._tab = dialog._tab
42
+ self.data = data
43
+
44
+ self.setFlat(True)
45
+ self.setStyleSheet(
46
+ "QGroupBox {"
47
+ " margin-top: 6px;"
48
+ "}"
49
+ "QGroupBox::title {"
50
+ " subcontrol-origin: margin;"
51
+ " subcontrol-position: top left;"
52
+ " padding: 0 6px;"
53
+ "}"
54
+ )
55
+
56
+ layout = QVBoxLayout()
57
+ layout.setContentsMargins(8, 8, 8, 8)
58
+ layout.setSpacing(6)
59
+
60
+ form_layout = QFormLayout()
61
+ form_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
62
+ labels_combo = RefreshingComboBox(
63
+ refresh_callback=lambda combo_ref=None: self._dialog._refresh_labels_combo(
64
+ labels_combo, filter_type="cellular"
65
+ )
66
+ )
67
+ self._tab._configure_combo(labels_combo)
68
+ labels_combo.currentTextChanged.connect(
69
+ lambda text: self._set_data("label", text)
70
+ )
71
+
72
+ form_layout.addRow("Segmentation", labels_combo)
73
+ layout.addLayout(form_layout)
74
+
75
+ delete_button = QPushButton("Delete")
76
+ delete_button.clicked.connect(
77
+ lambda: self._dialog._remove_segmentation(self)
78
+ )
79
+ layout.addWidget(delete_button)
80
+
81
+ self._labels_combo = labels_combo
82
+ self.setLayout(layout)
83
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
84
+ self._restore_state()
85
+
86
+ def update_title(self, index: int) -> None:
87
+ """Update the title label for the segmentation row.
88
+
89
+ Parameters
90
+ ----------
91
+ index : int
92
+ 0-based index used in the title.
93
+ """
94
+ self.setTitle(f"Segmentation {index}")
95
+
96
+ def _set_data(self, key: str, value) -> None:
97
+ """Update the segmentation data model.
98
+
99
+ Parameters
100
+ ----------
101
+ key : str
102
+ Data field name to update.
103
+ value : object
104
+ Value to assign to the field.
105
+ """
106
+ setattr(self.data, key, value)
107
+
108
+ def _restore_state(self) -> None:
109
+ """Restore UI state from stored segmentation data.
110
+
111
+ Notes
112
+ -----
113
+ This sets the labels combo to the stored label name when available.
114
+ """
115
+ label_name = self.data.label
116
+ if label_name:
117
+ self._labels_combo.setCurrentText(label_name)
118
+ return
119
+
120
+
121
+ class SpotsChannelRow(QGroupBox):
122
+ """Channel row widget for spots feature channels."""
123
+
124
+ def __init__(
125
+ self, dialog: SpotsChannelsDialog, data: SpotsChannelConfig
126
+ ) -> None:
127
+ """Initialize a channel row widget.
128
+
129
+ Parameters
130
+ ----------
131
+ dialog : SpotsChannelsDialog
132
+ Parent dialog instance.
133
+ data : SpotsChannelConfig
134
+ Channel configuration data.
135
+ """
136
+ super().__init__()
137
+ self._dialog = dialog
138
+ self._tab = dialog._tab
139
+ self.data = data
140
+
141
+ self.setFlat(True)
142
+ self.setStyleSheet(
143
+ "QGroupBox {"
144
+ " margin-top: 6px;"
145
+ "}"
146
+ "QGroupBox::title {"
147
+ " subcontrol-origin: margin;"
148
+ " subcontrol-position: top left;"
149
+ " padding: 0 6px;"
150
+ "}"
151
+ )
152
+
153
+ layout = QVBoxLayout()
154
+ layout.setContentsMargins(8, 8, 8, 8)
155
+ layout.setSpacing(6)
156
+
157
+ channel_form = QFormLayout()
158
+ channel_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
159
+ name_input = QLineEdit()
160
+ name_input.setPlaceholderText("Channel name")
161
+ name_input.setMinimumWidth(160)
162
+ name_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
163
+ name_input.textChanged.connect(
164
+ lambda text: self._set_data("name", text)
165
+ )
166
+ channel_combo = RefreshingComboBox(
167
+ refresh_callback=lambda combo_ref=None: self._dialog._refresh_image_combo(
168
+ channel_combo
169
+ )
170
+ )
171
+ self._tab._configure_combo(channel_combo)
172
+ channel_combo.currentTextChanged.connect(
173
+ lambda text: self._set_data("channel", text)
174
+ )
175
+ segmentation_combo = RefreshingComboBox(
176
+ refresh_callback=lambda combo_ref=None: self._dialog._refresh_labels_combo(
177
+ segmentation_combo, filter_type="spots"
178
+ )
179
+ )
180
+ self._tab._configure_combo(segmentation_combo)
181
+ segmentation_combo.currentTextChanged.connect(
182
+ lambda text: self._set_data("spots_segmentation", text)
183
+ )
184
+
185
+ channel_form.addRow("Name", name_input)
186
+ channel_form.addRow("Channel", channel_combo)
187
+ channel_form.addRow("Spots segmentation", segmentation_combo)
188
+ layout.addLayout(channel_form)
189
+
190
+ delete_button = QPushButton("Delete")
191
+ delete_button.clicked.connect(lambda: self._dialog._remove_channel(self))
192
+ layout.addWidget(delete_button)
193
+
194
+ self.setLayout(layout)
195
+
196
+ self._channel_combo = channel_combo
197
+ self._name_input = name_input
198
+ self._segmentation_combo = segmentation_combo
199
+
200
+ self._restore_state()
201
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
202
+
203
+ def update_title(self, index: int) -> None:
204
+ """Update the title label for the channel row.
205
+
206
+ Parameters
207
+ ----------
208
+ index : int
209
+ 0-based index used in the title.
210
+ """
211
+ self.setTitle(f"Channel {index}")
212
+
213
+ def _set_data(self, key: str, value) -> None:
214
+ """Update the channel data model.
215
+
216
+ Parameters
217
+ ----------
218
+ key : str
219
+ Data key to update.
220
+ value : object
221
+ New value to store.
222
+ """
223
+ setattr(self.data, key, value)
224
+
225
+ def _restore_state(self) -> None:
226
+ """Restore UI state from stored channel data.
227
+
228
+ Notes
229
+ -----
230
+ Populates name, channel, and segmentation combos when values are
231
+ present in the configuration.
232
+ """
233
+ channel_label = self.data.name
234
+ if channel_label:
235
+ self._name_input.setText(channel_label)
236
+ channel_name = self.data.channel
237
+ if channel_name:
238
+ self._channel_combo.setCurrentText(channel_name)
239
+ segmentation_name = self.data.spots_segmentation
240
+ if segmentation_name:
241
+ self._segmentation_combo.setCurrentText(segmentation_name)