sigima 0.0.1.dev0__py3-none-any.whl → 1.0.0__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 (264) hide show
  1. sigima/__init__.py +142 -2
  2. sigima/client/__init__.py +105 -0
  3. sigima/client/base.py +780 -0
  4. sigima/client/remote.py +469 -0
  5. sigima/client/stub.py +814 -0
  6. sigima/client/utils.py +90 -0
  7. sigima/config.py +444 -0
  8. sigima/data/logo/Sigima.svg +135 -0
  9. sigima/data/tests/annotations.json +798 -0
  10. sigima/data/tests/curve_fitting/exponential_fit.txt +511 -0
  11. sigima/data/tests/curve_fitting/gaussian_fit.txt +100 -0
  12. sigima/data/tests/curve_fitting/piecewiseexponential_fit.txt +1022 -0
  13. sigima/data/tests/curve_fitting/polynomial_fit.txt +100 -0
  14. sigima/data/tests/curve_fitting/twohalfgaussian_fit.txt +1000 -0
  15. sigima/data/tests/curve_formats/bandwidth.txt +201 -0
  16. sigima/data/tests/curve_formats/boxcar.npy +0 -0
  17. sigima/data/tests/curve_formats/datetime.txt +1001 -0
  18. sigima/data/tests/curve_formats/dynamic_parameters.txt +4000 -0
  19. sigima/data/tests/curve_formats/fw1e2.txt +301 -0
  20. sigima/data/tests/curve_formats/fwhm.txt +319 -0
  21. sigima/data/tests/curve_formats/multiple_curves.csv +29 -0
  22. sigima/data/tests/curve_formats/noised_saw.mat +0 -0
  23. sigima/data/tests/curve_formats/oscilloscope.csv +111 -0
  24. sigima/data/tests/curve_formats/other/other2/recursive2.txt +5 -0
  25. sigima/data/tests/curve_formats/other/recursive1.txt +5 -0
  26. sigima/data/tests/curve_formats/paracetamol.npy +0 -0
  27. sigima/data/tests/curve_formats/paracetamol.txt +1010 -0
  28. sigima/data/tests/curve_formats/paracetamol_dx_dy.csv +1000 -0
  29. sigima/data/tests/curve_formats/paracetamol_dy.csv +1001 -0
  30. sigima/data/tests/curve_formats/pulse1.npy +0 -0
  31. sigima/data/tests/curve_formats/pulse2.npy +0 -0
  32. sigima/data/tests/curve_formats/simple.txt +5 -0
  33. sigima/data/tests/curve_formats/spectrum.mca +2139 -0
  34. sigima/data/tests/curve_formats/square2.npy +0 -0
  35. sigima/data/tests/curve_formats/step.npy +0 -0
  36. sigima/data/tests/fabry-perot1.jpg +0 -0
  37. sigima/data/tests/fabry-perot2.jpg +0 -0
  38. sigima/data/tests/flower.npy +0 -0
  39. sigima/data/tests/image_formats/NF 180338201.scor-data +11003 -0
  40. sigima/data/tests/image_formats/binary_image.npy +0 -0
  41. sigima/data/tests/image_formats/binary_image.png +0 -0
  42. sigima/data/tests/image_formats/centroid_test.npy +0 -0
  43. sigima/data/tests/image_formats/coordinated_text/complex_image.txt +10011 -0
  44. sigima/data/tests/image_formats/coordinated_text/complex_ref_image.txt +10010 -0
  45. sigima/data/tests/image_formats/coordinated_text/image.txt +15 -0
  46. sigima/data/tests/image_formats/coordinated_text/image2.txt +14 -0
  47. sigima/data/tests/image_formats/coordinated_text/image_no_unit_no_label.txt +14 -0
  48. sigima/data/tests/image_formats/coordinated_text/image_with_nan.txt +15 -0
  49. sigima/data/tests/image_formats/coordinated_text/image_with_unit.txt +14 -0
  50. sigima/data/tests/image_formats/fiber.csv +480 -0
  51. sigima/data/tests/image_formats/fiber.jpg +0 -0
  52. sigima/data/tests/image_formats/fiber.png +0 -0
  53. sigima/data/tests/image_formats/fiber.txt +480 -0
  54. sigima/data/tests/image_formats/gaussian_spot_with_noise.npy +0 -0
  55. sigima/data/tests/image_formats/mr-brain.dcm +0 -0
  56. sigima/data/tests/image_formats/noised_gaussian.mat +0 -0
  57. sigima/data/tests/image_formats/sif_reader/nd_lum_image_no_glue.sif +0 -0
  58. sigima/data/tests/image_formats/sif_reader/raman1.sif +0 -0
  59. sigima/data/tests/image_formats/tiling.txt +10 -0
  60. sigima/data/tests/image_formats/uint16.tiff +0 -0
  61. sigima/data/tests/image_formats/uint8.tiff +0 -0
  62. sigima/data/tests/laser_beam/TEM00_z_13.jpg +0 -0
  63. sigima/data/tests/laser_beam/TEM00_z_18.jpg +0 -0
  64. sigima/data/tests/laser_beam/TEM00_z_23.jpg +0 -0
  65. sigima/data/tests/laser_beam/TEM00_z_30.jpg +0 -0
  66. sigima/data/tests/laser_beam/TEM00_z_35.jpg +0 -0
  67. sigima/data/tests/laser_beam/TEM00_z_40.jpg +0 -0
  68. sigima/data/tests/laser_beam/TEM00_z_45.jpg +0 -0
  69. sigima/data/tests/laser_beam/TEM00_z_50.jpg +0 -0
  70. sigima/data/tests/laser_beam/TEM00_z_55.jpg +0 -0
  71. sigima/data/tests/laser_beam/TEM00_z_60.jpg +0 -0
  72. sigima/data/tests/laser_beam/TEM00_z_65.jpg +0 -0
  73. sigima/data/tests/laser_beam/TEM00_z_70.jpg +0 -0
  74. sigima/data/tests/laser_beam/TEM00_z_75.jpg +0 -0
  75. sigima/data/tests/laser_beam/TEM00_z_80.jpg +0 -0
  76. sigima/enums.py +195 -0
  77. sigima/io/__init__.py +123 -0
  78. sigima/io/base.py +311 -0
  79. sigima/io/common/__init__.py +5 -0
  80. sigima/io/common/basename.py +164 -0
  81. sigima/io/common/converters.py +189 -0
  82. sigima/io/common/objmeta.py +181 -0
  83. sigima/io/common/textreader.py +58 -0
  84. sigima/io/convenience.py +157 -0
  85. sigima/io/enums.py +17 -0
  86. sigima/io/ftlab.py +395 -0
  87. sigima/io/image/__init__.py +9 -0
  88. sigima/io/image/base.py +177 -0
  89. sigima/io/image/formats.py +1016 -0
  90. sigima/io/image/funcs.py +414 -0
  91. sigima/io/signal/__init__.py +9 -0
  92. sigima/io/signal/base.py +129 -0
  93. sigima/io/signal/formats.py +290 -0
  94. sigima/io/signal/funcs.py +723 -0
  95. sigima/objects/__init__.py +260 -0
  96. sigima/objects/base.py +937 -0
  97. sigima/objects/image/__init__.py +88 -0
  98. sigima/objects/image/creation.py +556 -0
  99. sigima/objects/image/object.py +524 -0
  100. sigima/objects/image/roi.py +904 -0
  101. sigima/objects/scalar/__init__.py +57 -0
  102. sigima/objects/scalar/common.py +215 -0
  103. sigima/objects/scalar/geometry.py +502 -0
  104. sigima/objects/scalar/table.py +784 -0
  105. sigima/objects/shape.py +290 -0
  106. sigima/objects/signal/__init__.py +133 -0
  107. sigima/objects/signal/constants.py +27 -0
  108. sigima/objects/signal/creation.py +1428 -0
  109. sigima/objects/signal/object.py +444 -0
  110. sigima/objects/signal/roi.py +274 -0
  111. sigima/params.py +405 -0
  112. sigima/proc/__init__.py +96 -0
  113. sigima/proc/base.py +381 -0
  114. sigima/proc/decorator.py +330 -0
  115. sigima/proc/image/__init__.py +513 -0
  116. sigima/proc/image/arithmetic.py +335 -0
  117. sigima/proc/image/base.py +260 -0
  118. sigima/proc/image/detection.py +519 -0
  119. sigima/proc/image/edges.py +329 -0
  120. sigima/proc/image/exposure.py +406 -0
  121. sigima/proc/image/extraction.py +458 -0
  122. sigima/proc/image/filtering.py +219 -0
  123. sigima/proc/image/fourier.py +147 -0
  124. sigima/proc/image/geometry.py +661 -0
  125. sigima/proc/image/mathops.py +340 -0
  126. sigima/proc/image/measurement.py +195 -0
  127. sigima/proc/image/morphology.py +155 -0
  128. sigima/proc/image/noise.py +107 -0
  129. sigima/proc/image/preprocessing.py +182 -0
  130. sigima/proc/image/restoration.py +235 -0
  131. sigima/proc/image/threshold.py +217 -0
  132. sigima/proc/image/transformations.py +393 -0
  133. sigima/proc/signal/__init__.py +376 -0
  134. sigima/proc/signal/analysis.py +206 -0
  135. sigima/proc/signal/arithmetic.py +551 -0
  136. sigima/proc/signal/base.py +262 -0
  137. sigima/proc/signal/extraction.py +60 -0
  138. sigima/proc/signal/features.py +310 -0
  139. sigima/proc/signal/filtering.py +484 -0
  140. sigima/proc/signal/fitting.py +276 -0
  141. sigima/proc/signal/fourier.py +259 -0
  142. sigima/proc/signal/mathops.py +420 -0
  143. sigima/proc/signal/processing.py +580 -0
  144. sigima/proc/signal/stability.py +175 -0
  145. sigima/proc/title_formatting.py +227 -0
  146. sigima/proc/validation.py +272 -0
  147. sigima/tests/__init__.py +7 -0
  148. sigima/tests/common/__init__.py +0 -0
  149. sigima/tests/common/arithmeticparam_unit_test.py +26 -0
  150. sigima/tests/common/basename_unit_test.py +126 -0
  151. sigima/tests/common/client_unit_test.py +412 -0
  152. sigima/tests/common/converters_unit_test.py +77 -0
  153. sigima/tests/common/decorator_unit_test.py +176 -0
  154. sigima/tests/common/examples_unit_test.py +104 -0
  155. sigima/tests/common/kernel_normalization_unit_test.py +242 -0
  156. sigima/tests/common/roi_basic_unit_test.py +73 -0
  157. sigima/tests/common/roi_geometry_unit_test.py +171 -0
  158. sigima/tests/common/scalar_builder_unit_test.py +142 -0
  159. sigima/tests/common/scalar_unit_test.py +991 -0
  160. sigima/tests/common/shape_unit_test.py +183 -0
  161. sigima/tests/common/stat_unit_test.py +138 -0
  162. sigima/tests/common/title_formatting_unit_test.py +338 -0
  163. sigima/tests/common/tools_coordinates_unit_test.py +60 -0
  164. sigima/tests/common/transformations_unit_test.py +178 -0
  165. sigima/tests/common/validation_unit_test.py +205 -0
  166. sigima/tests/conftest.py +129 -0
  167. sigima/tests/data.py +998 -0
  168. sigima/tests/env.py +280 -0
  169. sigima/tests/guiutils.py +163 -0
  170. sigima/tests/helpers.py +532 -0
  171. sigima/tests/image/__init__.py +28 -0
  172. sigima/tests/image/binning_unit_test.py +128 -0
  173. sigima/tests/image/blob_detection_unit_test.py +312 -0
  174. sigima/tests/image/centroid_unit_test.py +170 -0
  175. sigima/tests/image/check_2d_array_unit_test.py +63 -0
  176. sigima/tests/image/contour_unit_test.py +172 -0
  177. sigima/tests/image/convolution_unit_test.py +178 -0
  178. sigima/tests/image/datatype_unit_test.py +67 -0
  179. sigima/tests/image/edges_unit_test.py +155 -0
  180. sigima/tests/image/enclosingcircle_unit_test.py +88 -0
  181. sigima/tests/image/exposure_unit_test.py +223 -0
  182. sigima/tests/image/fft2d_unit_test.py +189 -0
  183. sigima/tests/image/filtering_unit_test.py +166 -0
  184. sigima/tests/image/geometry_unit_test.py +654 -0
  185. sigima/tests/image/hough_circle_unit_test.py +147 -0
  186. sigima/tests/image/imageobj_unit_test.py +737 -0
  187. sigima/tests/image/morphology_unit_test.py +71 -0
  188. sigima/tests/image/noise_unit_test.py +57 -0
  189. sigima/tests/image/offset_correction_unit_test.py +72 -0
  190. sigima/tests/image/operation_unit_test.py +518 -0
  191. sigima/tests/image/peak2d_limits_unit_test.py +41 -0
  192. sigima/tests/image/peak2d_unit_test.py +133 -0
  193. sigima/tests/image/profile_unit_test.py +159 -0
  194. sigima/tests/image/projections_unit_test.py +121 -0
  195. sigima/tests/image/restoration_unit_test.py +141 -0
  196. sigima/tests/image/roi2dparam_unit_test.py +53 -0
  197. sigima/tests/image/roi_advanced_unit_test.py +588 -0
  198. sigima/tests/image/roi_grid_unit_test.py +279 -0
  199. sigima/tests/image/spectrum2d_unit_test.py +40 -0
  200. sigima/tests/image/threshold_unit_test.py +91 -0
  201. sigima/tests/io/__init__.py +0 -0
  202. sigima/tests/io/addnewformat_unit_test.py +125 -0
  203. sigima/tests/io/convenience_funcs_unit_test.py +470 -0
  204. sigima/tests/io/coordinated_text_format_unit_test.py +495 -0
  205. sigima/tests/io/datetime_csv_unit_test.py +198 -0
  206. sigima/tests/io/imageio_formats_test.py +41 -0
  207. sigima/tests/io/ioregistry_unit_test.py +69 -0
  208. sigima/tests/io/objmeta_unit_test.py +87 -0
  209. sigima/tests/io/readobj_unit_test.py +130 -0
  210. sigima/tests/io/readwriteobj_unit_test.py +67 -0
  211. sigima/tests/signal/__init__.py +0 -0
  212. sigima/tests/signal/analysis_unit_test.py +135 -0
  213. sigima/tests/signal/check_1d_arrays_unit_test.py +169 -0
  214. sigima/tests/signal/convolution_unit_test.py +404 -0
  215. sigima/tests/signal/datetime_unit_test.py +176 -0
  216. sigima/tests/signal/fft1d_unit_test.py +303 -0
  217. sigima/tests/signal/filters_unit_test.py +403 -0
  218. sigima/tests/signal/fitting_unit_test.py +929 -0
  219. sigima/tests/signal/fwhm_unit_test.py +111 -0
  220. sigima/tests/signal/noise_unit_test.py +128 -0
  221. sigima/tests/signal/offset_correction_unit_test.py +34 -0
  222. sigima/tests/signal/operation_unit_test.py +489 -0
  223. sigima/tests/signal/peakdetection_unit_test.py +145 -0
  224. sigima/tests/signal/processing_unit_test.py +657 -0
  225. sigima/tests/signal/pulse/__init__.py +112 -0
  226. sigima/tests/signal/pulse/crossing_times_unit_test.py +123 -0
  227. sigima/tests/signal/pulse/plateau_detection_unit_test.py +102 -0
  228. sigima/tests/signal/pulse/pulse_unit_test.py +1824 -0
  229. sigima/tests/signal/roi_advanced_unit_test.py +392 -0
  230. sigima/tests/signal/signalobj_unit_test.py +603 -0
  231. sigima/tests/signal/stability_unit_test.py +431 -0
  232. sigima/tests/signal/uncertainty_unit_test.py +611 -0
  233. sigima/tests/vistools.py +1030 -0
  234. sigima/tools/__init__.py +59 -0
  235. sigima/tools/checks.py +290 -0
  236. sigima/tools/coordinates.py +308 -0
  237. sigima/tools/datatypes.py +26 -0
  238. sigima/tools/image/__init__.py +97 -0
  239. sigima/tools/image/detection.py +451 -0
  240. sigima/tools/image/exposure.py +77 -0
  241. sigima/tools/image/extraction.py +48 -0
  242. sigima/tools/image/fourier.py +260 -0
  243. sigima/tools/image/geometry.py +190 -0
  244. sigima/tools/image/preprocessing.py +165 -0
  245. sigima/tools/signal/__init__.py +86 -0
  246. sigima/tools/signal/dynamic.py +254 -0
  247. sigima/tools/signal/features.py +135 -0
  248. sigima/tools/signal/filtering.py +171 -0
  249. sigima/tools/signal/fitting.py +1171 -0
  250. sigima/tools/signal/fourier.py +466 -0
  251. sigima/tools/signal/interpolation.py +70 -0
  252. sigima/tools/signal/peakdetection.py +126 -0
  253. sigima/tools/signal/pulse.py +1626 -0
  254. sigima/tools/signal/scaling.py +50 -0
  255. sigima/tools/signal/stability.py +258 -0
  256. sigima/tools/signal/windowing.py +90 -0
  257. sigima/worker.py +79 -0
  258. sigima-1.0.0.dist-info/METADATA +233 -0
  259. sigima-1.0.0.dist-info/RECORD +262 -0
  260. {sigima-0.0.1.dev0.dist-info → sigima-1.0.0.dist-info}/licenses/LICENSE +29 -29
  261. sigima-0.0.1.dev0.dist-info/METADATA +0 -60
  262. sigima-0.0.1.dev0.dist-info/RECORD +0 -6
  263. {sigima-0.0.1.dev0.dist-info → sigima-1.0.0.dist-info}/WHEEL +0 -0
  264. {sigima-0.0.1.dev0.dist-info → sigima-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,502 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2
+
3
+ """
4
+ Geometry results
5
+ ================
6
+
7
+ Geometry results are compute-friendly result containers for geometric outputs.
8
+
9
+ This module defines the `GeometryResult` class and related utilities:
10
+
11
+ - `GeometryResult`: geometric outputs (points, segments, circles, ...)
12
+ - `KindShape`: enumeration of geometric shape types
13
+ - Utility functions for geometry operations (concatenation, filtering, etc.)
14
+
15
+ Each result object is a simple data container with no behavior or methods:
16
+
17
+ - It contains the result of a 1-to-0 processing function
18
+ (e.g. `sigima.proc.image.contour_shape()`), i.e. a computation function that takes a
19
+ signal or image object (`SignalObj` or `ImageObj`) as input and produces a geometric
20
+ output (`GeometryResult`).
21
+
22
+ - The result may consist of multiple rows, each corresponding to a different ROI.
23
+
24
+ .. note::
25
+
26
+ No UI/HTML, no DataLab-specific metadata here. Adapters/formatters live in
27
+ DataLab. These classes are JSON-friendly via `to_dict()`/`from_dict()`.
28
+
29
+ Conventions
30
+ -----------
31
+
32
+ Conventions regarding ROI and geometry are as follows:
33
+
34
+ - ROI indexing:
35
+
36
+ - `NO_ROI = -1` sentinel is used for "full image / no ROI" rows.
37
+ - Per-ROI rows use non-negative indices (0-based).
38
+
39
+ - Geometry coordinates (physical units):
40
+
41
+ - `"point"` / `"marker"`: `[x, y]`
42
+ - `"segment"`: `[x0, y0, x1, y1]`
43
+ - `"rectangle"`: `[x0, y0, width, height]`
44
+ - `"circle"`: `[x0, y0, radius]`
45
+ - `"ellipse"`: `[x0, y0, a, b, theta]` # theta in radians
46
+ - `"polygon"`: `[x0, y0, x1, y1, ..., xn, yn]` (rows may be NaN-padded)
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import dataclasses
52
+ import enum
53
+ from typing import Iterable
54
+
55
+ import numpy as np
56
+ import pandas as pd
57
+
58
+ from sigima.objects.scalar.common import (
59
+ NO_ROI,
60
+ DataFrameManager,
61
+ DisplayPreferencesManager,
62
+ ResultHtmlGenerator,
63
+ )
64
+
65
+
66
+ class KindShape(str, enum.Enum):
67
+ """Geometric shape types."""
68
+
69
+ POINT = "point"
70
+ SEGMENT = "segment"
71
+ CIRCLE = "circle"
72
+ ELLIPSE = "ellipse"
73
+ RECTANGLE = "rectangle"
74
+ POLYGON = "polygon"
75
+ MARKER = "marker"
76
+
77
+ @classmethod
78
+ def values(cls) -> list[str]:
79
+ """Return all shape type values."""
80
+ return [e.value for e in cls]
81
+
82
+
83
+ @dataclasses.dataclass(frozen=True)
84
+ class GeometryResult:
85
+ """Geometric outputs, optionally per-ROI.
86
+
87
+ Args:
88
+ title: Human-readable title for this geometric output set.
89
+ kind: Shape kind (`KindShape` member or its string value).
90
+ coords: 2-D array (N, K) with coordinates per row. K depends on `kind`
91
+ and may be NaN-padded (e.g., for polygons).
92
+ roi_indices: Optional 1-D array (N,) mapping rows to ROI indices.
93
+ Use NO_ROI (-1) for the "full signal/image / no ROI" row.
94
+ attrs: Optional algorithmic context (e.g. thresholds, method variant).
95
+
96
+ Raises:
97
+ ValueError: If dimensions are inconsistent or fields are invalid.
98
+
99
+ .. important::
100
+ **Coordinate System**: GeometryResult coordinates are stored in **physical
101
+ units** (e.g., mm, µm), not pixel coordinates. The conversion from pixel to
102
+ physical coordinates is performed automatically when creating GeometryResult
103
+ objects from image measurements using
104
+ :func:`~sigima.proc.image.base.compute_geometry_from_obj`.
105
+
106
+ This ensures that geometric measurements are:
107
+
108
+ * **Scale-independent**: Results remain valid when images are resized
109
+ * **Physically meaningful**: Measurements have real-world significance
110
+ * **Consistent**: Same geometric features yield same results across different
111
+ images
112
+
113
+ .. note::
114
+
115
+ Coordinate conventions are as follows:
116
+
117
+ - `KindShape.POINT`: `[x, y]`
118
+ - `KindShape.SEGMENT`: `[x0, y0, x1, y1]`
119
+ - `KindShape.RECTANGLE`: `[x0, y0, width, height]`
120
+ - `KindShape.CIRCLE`: `[x0, y0, radius]`
121
+ - `KindShape.ELLIPSE`: `[x0, y0, a, b, theta]` # theta in radians
122
+ - `KindShape.POLYGON`: `[x0, y0, x1, y1, ..., xn, yn]` (rows may be NaN-padded)
123
+
124
+ All coordinate values and dimensions (width, height, radius, semi-axes) are
125
+ expressed in the image's physical units as defined by the image calibration.
126
+
127
+ See Also:
128
+ :func:`~sigima.proc.image.base.compute_geometry_from_obj`: Function that
129
+ creates GeometryResult objects with automatic coordinate conversion from
130
+ pixel to physical units.
131
+ """
132
+
133
+ title: str
134
+ kind: KindShape
135
+ coords: np.ndarray
136
+ roi_indices: np.ndarray | None = None
137
+ attrs: dict[str, object] = dataclasses.field(default_factory=dict)
138
+
139
+ def __post_init__(self) -> None:
140
+ """Validate fields after initialization."""
141
+ # --- kind validation/coercion (smooth migration) ---
142
+ k = object.__getattribute__(self, "kind")
143
+ if isinstance(k, str):
144
+ try:
145
+ k = KindShape(k) # coerce "ellipse" -> KindShape.ELLIPSE
146
+ except ValueError as exc:
147
+ raise ValueError(f"Unsupported geometry kind: {k!r}") from exc
148
+ object.__setattr__(self, "kind", k)
149
+ elif not isinstance(k, KindShape):
150
+ raise ValueError("kind must be a KindShape or its string value")
151
+ if not isinstance(self.title, str) or not self.title:
152
+ raise ValueError("title must be a non-empty string")
153
+ if not isinstance(self.coords, np.ndarray) or self.coords.ndim != 2:
154
+ raise ValueError("coords must be a 2-D numpy array")
155
+ if k == KindShape.POINT and self.coords.shape[1] != 2:
156
+ raise ValueError("coords for 'point' must be (N,2)")
157
+ if k == KindShape.SEGMENT and self.coords.shape[1] != 4:
158
+ raise ValueError("coords for 'segment' must be (N,4)")
159
+ if k == KindShape.CIRCLE and self.coords.shape[1] != 3:
160
+ raise ValueError("coords for 'circle' must be (N,3)")
161
+ if k == KindShape.ELLIPSE and self.coords.shape[1] != 5:
162
+ raise ValueError("coords for 'ellipse' must be (N,5)")
163
+ if k == KindShape.RECTANGLE and self.coords.shape[1] != 4:
164
+ raise ValueError("coords for 'rectangle' must be (N,4)")
165
+ if k == KindShape.POLYGON and self.coords.shape[1] % 2 != 0:
166
+ raise ValueError("coords for 'polygon' must be (N,2M) for M vertices")
167
+ if self.roi_indices is not None:
168
+ if (
169
+ not isinstance(self.roi_indices, np.ndarray)
170
+ or self.roi_indices.ndim != 1
171
+ ):
172
+ raise ValueError("roi_indices must be a 1-D numpy array if provided")
173
+ if len(self.roi_indices) != len(self.coords):
174
+ raise ValueError("roi_indices length must match number of coord rows")
175
+
176
+ @property
177
+ def name(self) -> str:
178
+ """Get the unique identifier name for this geometry result.
179
+
180
+ Returns:
181
+ The string value of the kind attribute, which serves as a unique
182
+ name identifier for this geometry result type.
183
+ """
184
+ return self.kind.value
185
+
186
+ # -------- Factory methods --------
187
+
188
+ @classmethod
189
+ def from_coords(
190
+ cls,
191
+ title: str,
192
+ kind: KindShape,
193
+ coords: np.ndarray,
194
+ roi_indices: np.ndarray | None = None,
195
+ *,
196
+ attrs: dict[str, object] | None = None,
197
+ ) -> GeometryResult:
198
+ """Create a GeometryResult from raw data.
199
+
200
+ Args:
201
+ title: Human-readable title for this geometric output.
202
+ kind: Shape kind (e.g. "point", "segment").
203
+ coords: 2-D array (N, K) with coordinates per row.
204
+ roi_indices: Optional 1-D array (N,) mapping rows to ROI indices.
205
+ attrs: Optional algorithmic context (e.g. thresholds, method variant).
206
+
207
+ Returns:
208
+ A GeometryResult instance.
209
+ """
210
+ return cls(
211
+ title,
212
+ kind,
213
+ np.asarray(coords, float),
214
+ None if roi_indices is None else np.asarray(roi_indices, int),
215
+ {} if attrs is None else dict(attrs),
216
+ )
217
+
218
+ # -------- JSON-friendly (de)serialization (no DataLab metadata coupling) -----
219
+
220
+ def to_dict(self) -> dict:
221
+ """Convert the GeometryResult to a dictionary."""
222
+ return {
223
+ "schema": 1,
224
+ "title": self.title,
225
+ "kind": self.kind.value,
226
+ "coords": self.coords.tolist(),
227
+ "roi_indices": None
228
+ if self.roi_indices is None
229
+ else self.roi_indices.tolist(),
230
+ "attrs": dict(self.attrs) if self.attrs else {},
231
+ }
232
+
233
+ @staticmethod
234
+ def from_dict(d: dict) -> GeometryResult:
235
+ """Convert a dictionary to a GeometryResult."""
236
+ return GeometryResult(
237
+ title=d["title"],
238
+ kind=KindShape(d["kind"]),
239
+ coords=np.asarray(d["coords"], dtype=float),
240
+ roi_indices=None
241
+ if d.get("roi_indices") is None
242
+ else np.asarray(d["roi_indices"], dtype=int),
243
+ attrs=dict(d.get("attrs", {})),
244
+ )
245
+
246
+ # -------- Pandas DataFrame interop --------
247
+
248
+ @property
249
+ def headers(self) -> list[str]:
250
+ """Get column headers for the coordinates.
251
+
252
+ Returns:
253
+ List of column headers
254
+ """
255
+ # Create headers based on the shape type
256
+ kind = self.kind.value
257
+
258
+ # Define headers based on shape type
259
+ headers_map = {
260
+ "point": ["x", "y"],
261
+ "marker": ["x", "y"],
262
+ "segment": ["x0", "y0", "x1", "y1"],
263
+ "rectangle": ["x", "y", "width", "height"],
264
+ "circle": ["x", "y", "r"],
265
+ "ellipse": ["x", "y", "a", "b", "θ"],
266
+ }
267
+
268
+ if kind in headers_map:
269
+ return headers_map[kind]
270
+
271
+ num_coords = self.coords.shape[1]
272
+
273
+ if kind == "polygon":
274
+ headers = []
275
+ for i in range(0, num_coords, 2):
276
+ headers.extend([f"x{i // 2}", f"y{i // 2}"])
277
+ return headers[:num_coords]
278
+
279
+ # Generic headers for unknown shapes
280
+ return [f"coord_{i}" for i in range(num_coords)]
281
+
282
+ def to_dataframe(self, visible_only: bool = False):
283
+ """Convert the result to a pandas DataFrame.
284
+
285
+ Args:
286
+ visible_only: If True, include only visible headers based on display
287
+ preferences. Default is False.
288
+
289
+ Returns:
290
+ DataFrame with an optional 'roi_index' column.
291
+ If visible_only is True, only columns with visible headers are included.
292
+ """
293
+ df = pd.DataFrame(self.coords, columns=self.headers)
294
+ visible_headers = self.get_visible_headers()
295
+
296
+ # For segments, add a length column
297
+ if self.kind == KindShape.SEGMENT:
298
+ lengths = self.segments_lengths()
299
+ # Name the length column "Δx" if y0 == y1 for all rows,
300
+ # "Δy" if x0 == x1 for all rows, else "length"
301
+ if np.allclose(self.coords[:, 1], self.coords[:, 3]):
302
+ length_name = "Δx"
303
+ elif np.allclose(self.coords[:, 0], self.coords[:, 2]):
304
+ length_name = "Δy"
305
+ else:
306
+ length_name = "length"
307
+ df[length_name] = lengths
308
+ visible_headers = [length_name] # always show length for segments
309
+
310
+ if self.roi_indices is not None:
311
+ df.insert(0, "roi_index", self.roi_indices)
312
+
313
+ # Filter to visible columns if requested
314
+ if visible_only:
315
+ df = DataFrameManager.apply_visible_only_filter(df, visible_headers)
316
+
317
+ return df
318
+
319
+ def get_display_preferences(self) -> dict[str, bool]:
320
+ """Get display preferences for coordinate headers.
321
+
322
+ Returns:
323
+ Dictionary mapping header names to visibility (True=visible, False=hidden).
324
+ By default, all coordinates are visible unless specified in attrs.
325
+ """
326
+ return DisplayPreferencesManager.get_display_preferences(
327
+ self, self.headers, "hidden_coords"
328
+ )
329
+
330
+ def set_display_preferences(self, preferences: dict[str, bool]) -> None:
331
+ """Set display preferences for coordinate headers.
332
+
333
+ Args:
334
+ preferences: Dictionary mapping header names to visibility
335
+ (True=visible, False=hidden)
336
+ """
337
+ DisplayPreferencesManager.set_display_preferences(
338
+ self, preferences, self.headers, "hidden_coords"
339
+ )
340
+
341
+ def get_visible_headers(self) -> list[str]:
342
+ """Get list of currently visible headers based on display preferences.
343
+
344
+ Returns:
345
+ List of header names that should be displayed
346
+ """
347
+ return DisplayPreferencesManager.get_visible_headers(
348
+ self, self.headers, "hidden_coords"
349
+ )
350
+
351
+ # -------- User-oriented methods --------
352
+
353
+ def __len__(self) -> int:
354
+ """Return the number of coordinates (rows) in the result."""
355
+ return self.coords.shape[0]
356
+
357
+ def rows(self, roi: int | None = None) -> np.ndarray:
358
+ """Return coords for all rows (this ROI or full-image row).
359
+
360
+ Args:
361
+ roi: Optional ROI index to filter rows.
362
+
363
+ Returns:
364
+ 2-D array of shape (M, K) with coordinates for the selected rows.
365
+ """
366
+ if self.roi_indices is None:
367
+ return self.coords
368
+ target = NO_ROI if roi is None else int(roi)
369
+ return self.coords[self.roi_indices == target]
370
+
371
+ # Optional convenience for common kinds:
372
+ def segments_lengths(self) -> np.ndarray:
373
+ """For kind='segment': return vector of segment lengths."""
374
+ if self.kind != KindShape.SEGMENT:
375
+ raise ValueError("segments_lengths requires kind='segment'")
376
+ dx = self.coords[:, 2] - self.coords[:, 0]
377
+ dy = self.coords[:, 3] - self.coords[:, 1]
378
+ return np.sqrt(dx * dx + dy * dy)
379
+
380
+ def circles_radii(self) -> np.ndarray:
381
+ """For kind='circle': return radii."""
382
+ if self.kind != KindShape.CIRCLE:
383
+ raise ValueError("circles_radii requires kind='circle'")
384
+ return self.coords[:, 2]
385
+
386
+ def ellipse_axes_angles(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
387
+ """For kind='ellipse': return (a, b, theta)."""
388
+ if self.kind != KindShape.ELLIPSE:
389
+ raise ValueError("ellipse_axes_angles requires kind='ellipse'")
390
+ return self.coords[:, 2], self.coords[:, 3], self.coords[:, 4]
391
+
392
+ def to_html(
393
+ self,
394
+ obj=None,
395
+ visible_only: bool = True,
396
+ transpose_single_row: bool = True,
397
+ **kwargs,
398
+ ) -> str:
399
+ """Convert the result to HTML format.
400
+
401
+ Args:
402
+ obj: Optional SignalObj or ImageObj for ROI title extraction
403
+ visible_only: If True, include only visible headers based on display
404
+ preferences. Default is False.
405
+ transpose_single_row: If True, transpose when there's only one row
406
+ **kwargs: Additional arguments passed to DataFrame.to_html()
407
+
408
+ Returns:
409
+ HTML representation of the result
410
+ """
411
+ return ResultHtmlGenerator.generate_html(
412
+ self, obj, visible_only, transpose_single_row, **kwargs
413
+ )
414
+
415
+
416
+ # ===========================
417
+ # Geometry utility functions
418
+ # ===========================
419
+
420
+
421
+ def concat_geometries(
422
+ title: str,
423
+ items: Iterable[GeometryResult],
424
+ *,
425
+ kind: KindShape | None = None,
426
+ ) -> GeometryResult:
427
+ """Concatenate multiple GeometryResult objects of the same kind.
428
+
429
+ Args:
430
+ title: Title for the concatenated result.
431
+ items: Iterable of GeometryResult objects to concatenate.
432
+ kind: Optional kind label for the concatenated result.
433
+
434
+ Returns:
435
+ GeometryResult with concatenated data and updated metadata.
436
+ """
437
+ items = list(items)
438
+ if not items:
439
+ return GeometryResult(
440
+ title=title, kind=KindShape.POINT, coords=np.zeros((0, 2), float)
441
+ )
442
+ k = kind if kind is not None else items[0].kind
443
+ for it in items:
444
+ if it.kind != k:
445
+ raise ValueError(
446
+ "All GeometryResult objects must share the same kind to concatenate"
447
+ )
448
+ max_k = max(it.coords.shape[1] for it in items) if items else 0
449
+ # right-pad with NaNs to match width
450
+ padded = []
451
+ for it in items:
452
+ c = it.coords
453
+ if c.shape[1] < max_k:
454
+ pad = np.full((c.shape[0], max_k - c.shape[1]), np.nan, dtype=float)
455
+ c = np.hstack([c, pad])
456
+ padded.append(c)
457
+ coords = np.vstack(padded) if padded else np.zeros((0, max_k))
458
+ if any(it.roi_indices is not None for it in items):
459
+ parts = [
460
+ (
461
+ it.roi_indices
462
+ if it.roi_indices is not None
463
+ else np.full((len(it.coords),), NO_ROI, int)
464
+ )
465
+ for it in items
466
+ ]
467
+ roi = np.concatenate(parts) if len(parts) else None
468
+ else:
469
+ roi = None
470
+ return GeometryResult(title=title, kind=k, coords=coords, roi_indices=roi)
471
+
472
+
473
+ def filter_geometry_by_roi(res: GeometryResult, roi: int | None) -> GeometryResult:
474
+ """Filter shapes by ROI index. If roi is None, keeps NO_ROI rows.
475
+
476
+ Args:
477
+ res: The GeometryResult to filter.
478
+ roi: The ROI index to filter by, or None to keep all.
479
+
480
+ Returns:
481
+ A filtered GeometryResult.
482
+ """
483
+ if res.roi_indices is None:
484
+ keep_all = roi in (None, NO_ROI)
485
+ coords = res.coords if keep_all else np.zeros((0, res.coords.shape[1]))
486
+ indices = None if keep_all else np.zeros((0,), int)
487
+ return GeometryResult(
488
+ title=res.title,
489
+ kind=res.kind,
490
+ coords=coords,
491
+ roi_indices=indices,
492
+ attrs=dict(res.attrs),
493
+ )
494
+ target = NO_ROI if roi is None else int(roi)
495
+ mask = res.roi_indices == target
496
+ return GeometryResult(
497
+ title=res.title,
498
+ kind=res.kind,
499
+ coords=res.coords[mask],
500
+ roi_indices=res.roi_indices[mask],
501
+ attrs=dict(res.attrs),
502
+ )