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,119 @@
1
+ """Marker feature UI."""
2
+
3
+ from pathlib import Path
4
+
5
+ from qtpy.QtWidgets import QDialog, QPushButton
6
+
7
+ from ..base import SenoQuantFeature
8
+ from ..roi import ROISection
9
+ from .config import MarkerFeatureData
10
+ from .dialog import MarkerChannelsDialog
11
+ from .export import export_marker
12
+
13
+
14
+ class MarkerFeature(SenoQuantFeature):
15
+ """Marker feature controls."""
16
+
17
+ feature_type = "Markers"
18
+ order = 10
19
+
20
+ def build(self) -> None:
21
+ """Build the marker feature UI."""
22
+ self._build_channels_section()
23
+ data = self._state.data
24
+ if getattr(self._tab, "_enable_rois", True):
25
+ if isinstance(data, MarkerFeatureData):
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
+ self._ui["channels_button"] = button
51
+ self._update_channels_button_label()
52
+
53
+ def _open_channels_dialog(self) -> None:
54
+ """Open the channels configuration dialog."""
55
+ dialog = self._ui.get("channels_dialog")
56
+ if dialog is None or not isinstance(dialog, QDialog):
57
+ dialog = MarkerChannelsDialog(self)
58
+ dialog.accepted.connect(self._update_channels_button_label)
59
+ self._ui["channels_dialog"] = dialog
60
+ dialog.show()
61
+ dialog.raise_()
62
+ dialog.activateWindow()
63
+
64
+ def _update_channels_button_label(self) -> None:
65
+ """Update the channels button label based on saved data."""
66
+ button = self._ui.get("channels_button")
67
+ if button is None:
68
+ return
69
+ data = self._state.data
70
+ if isinstance(data, MarkerFeatureData) and (
71
+ data.channels or data.segmentations
72
+ ):
73
+ button.setText("Edit channels")
74
+ else:
75
+ button.setText("Add channels")
76
+
77
+ def _get_image_layer_by_name(self, name: str):
78
+ """Return the image layer with the provided name.
79
+
80
+ Parameters
81
+ ----------
82
+ name : str
83
+ Image layer name.
84
+
85
+ Returns
86
+ -------
87
+ object or None
88
+ Matching image layer or None if not found.
89
+ """
90
+ viewer = self._tab._viewer
91
+ if viewer is None or not name:
92
+ return None
93
+ for layer in viewer.layers:
94
+ if layer.__class__.__name__ == "Image" and layer.name == name:
95
+ return layer
96
+ return None
97
+
98
+ def export(self, temp_dir: Path, export_format: str):
99
+ """Export marker outputs into a temporary directory.
100
+
101
+ Parameters
102
+ ----------
103
+ temp_dir : Path
104
+ Temporary directory where outputs should be written.
105
+ export_format : str
106
+ File format requested by the user (``"csv"`` or ``"xlsx"``).
107
+
108
+ Returns
109
+ -------
110
+ iterable of Path
111
+ Paths to files produced by the export routine.
112
+ """
113
+ return export_marker(
114
+ self._state,
115
+ temp_dir,
116
+ viewer=self._tab._viewer,
117
+ export_format=export_format,
118
+ enable_thresholds=getattr(self._tab, "_enable_thresholds", True),
119
+ )
@@ -0,0 +1,285 @@
1
+ """Morphological descriptor extraction from regionprops.
2
+
3
+ This module provides utilities to extract additional morphological properties
4
+ from segmented regions. It focuses on float-valued descriptors that describe
5
+ cell/nucleus shape, size, and topology.
6
+
7
+ Descriptors include:
8
+ - Area and perimeter metrics
9
+ - Shape descriptors (eccentricity, solidity, etc.)
10
+ - Axis ratios and moments
11
+ - Convexity and aspect ratios
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import warnings
17
+
18
+ import numpy as np
19
+ from skimage.measure import regionprops_table
20
+
21
+ # Float-valued morphological properties to extract from regionprops.
22
+ # Note: Array-valued properties (moments, etc.) are excluded.
23
+ MORPHOLOGY_PROPERTIES = (
24
+ "area", # Number of pixels in the region
25
+ "eccentricity", # Eccentricity of the ellipse fitted to the region
26
+ "extent", # Ratio of region area to bounding box area
27
+ "feret_diameter_max", # Maximum Feret diameter
28
+ "major_axis_length", # Major axis of the ellipse fitted to the region
29
+ "minor_axis_length", # Minor axis of the ellipse fitted to the region
30
+ "orientation", # Angle between the major axis and horizontal
31
+ "perimeter", # Perimeter estimated by the Freeman chain code
32
+ "perimeter_crofton", # Crofton perimeter (Euclidean-like estimate)
33
+ "solidity", # Ratio of region area to convex hull area
34
+ )
35
+
36
+ NDIM_2D = 2
37
+ NDIM_3D = 3
38
+
39
+
40
+ def _collect_simple_properties(
41
+ props: dict,
42
+ label_ids: np.ndarray,
43
+ ) -> dict[str, np.ndarray]:
44
+ """Extract simple (non-array) properties from regionprops.
45
+
46
+ Parameters
47
+ ----------
48
+ props : dict
49
+ Output from regionprops_table.
50
+ label_ids : numpy.ndarray
51
+ Label ids for indexing.
52
+
53
+ Returns
54
+ -------
55
+ dict of str to numpy.ndarray
56
+ Simple property arrays keyed by property name.
57
+
58
+ """
59
+ result: dict[str, np.ndarray] = {}
60
+ for prop_name, prop_values in props.items():
61
+ if prop_name == "label":
62
+ continue
63
+
64
+ if isinstance(prop_values, list) and prop_values:
65
+ first_val = prop_values[0]
66
+ if isinstance(first_val, (np.ndarray, list, tuple)):
67
+ continue
68
+
69
+ try:
70
+ prop_array = np.asarray(prop_values, dtype=float)
71
+ if prop_array.size == len(label_ids):
72
+ result[f"morph_{prop_name}"] = prop_array
73
+ except (ValueError, TypeError):
74
+ continue
75
+
76
+ return result
77
+
78
+
79
+ def _compute_derived_metrics(
80
+ result: dict[str, np.ndarray],
81
+ ) -> None:
82
+ """Compute derived morphological metrics in-place.
83
+
84
+ Parameters
85
+ ----------
86
+ result : dict of str to numpy.ndarray
87
+ Morphological properties to augment with derived metrics.
88
+
89
+ Notes
90
+ -----
91
+ Circularity is 2D-only: 4*pi*area / perimeter^2. It is only computed
92
+ when perimeter is available (which indicates 2D data).
93
+ Aspect ratio is 2D-only and only computed when major/minor axis lengths
94
+ are present in the result.
95
+
96
+ """
97
+ # Get area/volume (whichever exists after dimensionality-based renaming)
98
+ if "morph_area" in result:
99
+ area_or_volume = result["morph_area"]
100
+ elif "morph_volume" in result:
101
+ area_or_volume = result["morph_volume"]
102
+ else:
103
+ return
104
+
105
+ # Circularity is 2D-only: 4*pi*area / perimeter^2
106
+ if "morph_perimeter" in result:
107
+ perim = result["morph_perimeter"]
108
+ circularity = np.divide(
109
+ 4 * np.pi * area_or_volume,
110
+ perim ** 2,
111
+ out=np.full_like(area_or_volume, np.nan),
112
+ where=perim != 0,
113
+ )
114
+ result["morph_circularity"] = circularity
115
+
116
+ # Aspect ratio: only computed if major/minor axis lengths are present (2D only)
117
+ if (
118
+ "morph_major_axis_length" in result
119
+ and "morph_minor_axis_length" in result
120
+ ):
121
+ major = result["morph_major_axis_length"]
122
+ minor = result["morph_minor_axis_length"]
123
+ aspect_ratio = np.divide(
124
+ major,
125
+ minor,
126
+ out=np.full_like(major, np.nan),
127
+ where=minor != 0,
128
+ )
129
+ result["morph_aspect_ratio"] = aspect_ratio
130
+
131
+
132
+ def _compute_physical_area(
133
+ result: dict[str, np.ndarray],
134
+ pixel_sizes: np.ndarray,
135
+ ) -> None:
136
+ """Add physical area/volume measurements to result in-place.
137
+
138
+ Parameters
139
+ ----------
140
+ result : dict of str to numpy.ndarray
141
+ Morphological properties to augment.
142
+ pixel_sizes : numpy.ndarray
143
+ Per-axis pixel sizes in micrometers.
144
+
145
+ """
146
+ # Determine if we have area (2D) or volume (3D)
147
+ if "morph_area" in result:
148
+ area_or_volume = result["morph_area"]
149
+ is_volume = False
150
+ elif "morph_volume" in result:
151
+ area_or_volume = result["morph_volume"]
152
+ is_volume = True
153
+ else:
154
+ return
155
+
156
+ pixels = area_or_volume
157
+ ndim = len(pixel_sizes)
158
+
159
+ try:
160
+ if ndim == NDIM_2D and not is_volume:
161
+ area_phys = pixels * (pixel_sizes[0] * pixel_sizes[1])
162
+ result["morph_area_um2"] = area_phys
163
+ elif ndim == NDIM_3D and is_volume:
164
+ volume_phys = pixels * (
165
+ pixel_sizes[0] * pixel_sizes[1] * pixel_sizes[2]
166
+ )
167
+ result["morph_volume_um3"] = volume_phys
168
+ except (TypeError, ValueError):
169
+ pass
170
+
171
+
172
+ def extract_morphology(
173
+ labels: np.ndarray,
174
+ label_ids: np.ndarray,
175
+ pixel_sizes: np.ndarray | None = None,
176
+ ) -> dict[str, np.ndarray]:
177
+ """Extract morphological descriptors for each labeled region.
178
+
179
+ Parameters
180
+ ----------
181
+ labels : numpy.ndarray
182
+ Label image with integer ids.
183
+ label_ids : numpy.ndarray
184
+ Specific label ids to extract properties for.
185
+ pixel_sizes : numpy.ndarray or None, optional
186
+ Per-axis pixel sizes in micrometers. When provided, physical
187
+ measurements are computed.
188
+
189
+ Returns
190
+ -------
191
+ dict of str to numpy.ndarray
192
+ Morphological descriptors. Keys are property names and values are
193
+ arrays with one entry per label id.
194
+
195
+ Notes
196
+ -----
197
+ Properties that depend on regionprops (e.g., eccentricity, solidity)
198
+ are only available for 2D images. For 3D, only simple properties like
199
+ volume are available.
200
+
201
+ The "area" property is renamed to "volume" for 3D images.
202
+
203
+ Some properties may not be available depending on the scikit-image
204
+ version. Missing properties are silently skipped.
205
+
206
+ """
207
+ # For 3D, some properties are not available. Try with all properties first,
208
+ # and fall back to basic properties if it fails.
209
+ try:
210
+ props = regionprops_table(
211
+ labels,
212
+ properties=MORPHOLOGY_PROPERTIES,
213
+ )
214
+ except (ValueError, RuntimeError):
215
+ # Fall back to basic properties for 3D
216
+ try:
217
+ props = regionprops_table(
218
+ labels,
219
+ properties=("area",),
220
+ )
221
+ except (ValueError, RuntimeError) as exc:
222
+ warnings.warn(
223
+ f"Failed to extract morphological properties: {exc}",
224
+ RuntimeWarning,
225
+ stacklevel=2,
226
+ )
227
+ return {}
228
+
229
+ result = _collect_simple_properties(props, label_ids)
230
+
231
+ # For 3D images, rename "area" to "volume"
232
+ if labels.ndim == NDIM_3D and "morph_area" in result:
233
+ result["morph_volume"] = result.pop("morph_area")
234
+
235
+ _compute_derived_metrics(result)
236
+
237
+ if pixel_sizes is not None:
238
+ _compute_physical_area(result, pixel_sizes)
239
+
240
+ return result
241
+
242
+
243
+ def add_morphology_columns(
244
+ rows: list[dict[str, float]],
245
+ labels: np.ndarray,
246
+ label_ids: np.ndarray,
247
+ pixel_sizes: np.ndarray | None = None,
248
+ ) -> list[str]:
249
+ """Add morphological descriptor columns to output rows.
250
+
251
+ Parameters
252
+ ----------
253
+ rows : list of dict
254
+ Output row dictionaries to update in-place.
255
+ labels : numpy.ndarray
256
+ Label image with integer ids.
257
+ label_ids : numpy.ndarray
258
+ Label ids corresponding to the output rows.
259
+ pixel_sizes : numpy.ndarray or None, optional
260
+ Per-axis pixel sizes in micrometers.
261
+
262
+ Returns
263
+ -------
264
+ list of str
265
+ List of column names added to the rows.
266
+
267
+ Notes
268
+ -----
269
+ This function modifies ``rows`` in-place and returns the list of new
270
+ column names that were added for header generation.
271
+
272
+ """
273
+ morphology_data = extract_morphology(labels, label_ids, pixel_sizes)
274
+
275
+ if not morphology_data or not rows:
276
+ return []
277
+
278
+ column_names: list[str] = []
279
+ for col_name, col_values in morphology_data.items():
280
+ column_names.append(col_name)
281
+ for row, value in zip(rows, col_values, strict=True):
282
+ row[col_name] = float(value) if not np.isnan(value) else value
283
+
284
+ return column_names
285
+