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
sigima/objects/base.py ADDED
@@ -0,0 +1,937 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2
+
3
+ """
4
+ Base model classes for signals and images.
5
+ """
6
+
7
+ # pylint: disable=invalid-name # Allows short reference names like x, y, ...
8
+
9
+ from __future__ import annotations
10
+
11
+ import abc
12
+ import re
13
+ import sys
14
+ from collections.abc import Generator
15
+ from copy import deepcopy
16
+ from typing import Any, Generic, Iterator, Type, TypeVar
17
+
18
+ import guidata.dataset as gds
19
+ import numpy as np
20
+ from numpy import ma
21
+
22
+ from sigima.config import _
23
+
24
+ if sys.version_info >= (3, 11):
25
+ # Use Self from typing module in Python 3.11+
26
+ from typing import Self
27
+ else:
28
+ # Use Self from typing_extensions module in Python < 3.11
29
+ from typing_extensions import Self
30
+
31
+ ROI_KEY = "_roi_"
32
+
33
+
34
+ def deepcopy_metadata(
35
+ metadata: dict[str, Any],
36
+ special_keys: set[str] | None = None,
37
+ all_metadata: bool = False,
38
+ ) -> dict[str, Any]:
39
+ """Deepcopy metadata, except keys starting with "_" (private keys)
40
+ with the exception of "_roi_" and "_ann_" keys.
41
+
42
+ Args:
43
+ metadata: Metadata dictionary to deepcopy.
44
+ special_keys: Set of keys that should not be removed even if they
45
+ start with "_".
46
+ all_metadata: if True, copy all metadata, including private keys
47
+
48
+ Returns:
49
+ A new dictionary with deepcopied metadata, excluding private keys
50
+ except those in `special_keys`.
51
+ """
52
+ if special_keys is None:
53
+ special_keys = set([ROI_KEY])
54
+ mdcopy = {}
55
+ for key, value in metadata.items():
56
+ if not key.startswith("_") or key in special_keys or all_metadata:
57
+ mdcopy[key] = deepcopy(value)
58
+ return mdcopy
59
+
60
+
61
+ class BaseProcParam(gds.DataSet):
62
+ """Base class for processing parameters."""
63
+
64
+ def apply_integer_range(self, vmin, vmax): # pylint: disable=unused-argument
65
+ """Do something in case of integer min-max range."""
66
+
67
+ def apply_float_range(self, vmin, vmax): # pylint: disable=unused-argument
68
+ """Do something in case of float min-max range."""
69
+
70
+ def set_from_datatype(self, dtype):
71
+ """Set min/max range from NumPy datatype."""
72
+ if np.issubdtype(dtype, np.integer):
73
+ info = np.iinfo(dtype)
74
+ self.apply_integer_range(info.min, info.max)
75
+ else:
76
+ info = np.finfo(dtype)
77
+ self.apply_float_range(info.min, info.max)
78
+
79
+
80
+ class BaseRandomParam(BaseProcParam):
81
+ """Random signal/image parameters."""
82
+
83
+ seed = gds.IntItem(_("Seed"), default=1)
84
+
85
+
86
+ class UniformDistributionParam(BaseRandomParam):
87
+ """Uniform-distribution signal/image parameters."""
88
+
89
+ def apply_integer_range(self, vmin, vmax):
90
+ """Do something in case of integer min-max range."""
91
+ self.vmin, self.vmax = float(vmin), float(vmax)
92
+
93
+ vmin = gds.FloatItem(
94
+ "V<sub>min</sub>", default=-0.5, help=_("Uniform distribution lower bound")
95
+ )
96
+ vmax = gds.FloatItem(
97
+ "V<sub>max</sub>", default=0.5, help=_("Uniform distribution higher bound")
98
+ ).set_pos(col=1)
99
+
100
+ def generate_title(self) -> str:
101
+ """Generate a title based on current parameters."""
102
+ return f"UniformRandom(vmin={self.vmin:g},vmax={self.vmax:g},seed={self.seed})"
103
+
104
+
105
+ class NormalDistributionParam(BaseRandomParam):
106
+ """Normal-distribution signal/image parameters."""
107
+
108
+ DEFAULT_RELATIVE_MU = 0.1
109
+ DEFAULT_RELATIVE_SIGMA = 0.02
110
+
111
+ def apply_integer_range(self, vmin, vmax):
112
+ """Do something in case of integer min-max range."""
113
+ delta = vmax - vmin
114
+ self.mu = float(vmin + self.DEFAULT_RELATIVE_MU * delta)
115
+ self.sigma = float(self.DEFAULT_RELATIVE_SIGMA * delta)
116
+
117
+ mu = gds.FloatItem(
118
+ "μ", default=DEFAULT_RELATIVE_MU, help=_("Normal distribution mean")
119
+ )
120
+ sigma = gds.FloatItem(
121
+ "σ",
122
+ default=DEFAULT_RELATIVE_SIGMA,
123
+ min=0.0,
124
+ help=_("Normal distribution standard deviation"),
125
+ ).set_pos(col=1)
126
+
127
+ def generate_title(self) -> str:
128
+ """Generate a title based on current parameters."""
129
+ return f"NormalRandom(μ={self.mu:g},σ={self.sigma:g},seed={self.seed})"
130
+
131
+
132
+ class PoissonDistributionParam(BaseRandomParam):
133
+ """Base Poisson-distribution signal/image parameters."""
134
+
135
+ DEFAULT_RELATIVE_LAMBDA = 0.1
136
+
137
+ def apply_integer_range(self, vmin, vmax):
138
+ """Adjust default λ based on integer min-max range."""
139
+ positive_span = max(0.0, float(vmax) - max(0.0, float(vmin)))
140
+ self.lam = float(max(self.DEFAULT_RELATIVE_LAMBDA * positive_span, 1.0))
141
+
142
+ lam = gds.FloatItem(
143
+ "λ",
144
+ default=DEFAULT_RELATIVE_LAMBDA,
145
+ min=0.0,
146
+ help=_("Poisson distribution mean"),
147
+ )
148
+
149
+ def generate_title(self) -> str:
150
+ """Generate a title based on current parameters."""
151
+ return f"PoissonRandom(λ={self.lam:g},seed={self.seed})"
152
+
153
+
154
+ TypeObj = TypeVar("TypeObj", bound="BaseObj")
155
+ TypeROIParam = TypeVar("TypeROIParam", bound="BaseROIParam")
156
+ TypeSingleROI = TypeVar("TypeSingleROI", bound="BaseSingleROI")
157
+ TypeROI = TypeVar("TypeROI", bound="BaseROI")
158
+
159
+
160
+ class BaseObjMeta(abc.ABCMeta, gds.DataSetMeta):
161
+ """Mixed metaclass to avoid conflicts"""
162
+
163
+
164
+ class NoDefaultOption:
165
+ """Marker class for metadata option without default value"""
166
+
167
+
168
+ class BaseObj(Generic[TypeROI], metaclass=BaseObjMeta):
169
+ """Object (signal/image) interface"""
170
+
171
+ #: Class attribute that defines a string prefix used to uniquely identify instances
172
+ #: of this class in metadata serialization. Each subclass should override this with
173
+ #: a unique identifier (e.g., "s" for signals, "i" for images).
174
+ #: This prefix is used as part of the key for storing and retrieving object-specific
175
+ #: metadata, supporting type-based serialization and deserialization.
176
+ PREFIX = "" # This is overriden in children classes
177
+
178
+ # This is overriden in children classes with a gds.DictItem instance:
179
+ metadata: dict[str, Any] = {}
180
+ annotations: str = ""
181
+
182
+ #: Class attribute that defines a tuple of valid NumPy data types supported by this
183
+ #: class. This is used to validate the data type of the object when it is set or
184
+ #: modified and to ensure that the object can handle the data correctly.
185
+ #: Subclasses should override this with a specific set of valid data types.
186
+ VALID_DTYPES = (np.float64,) # To be overriden in children classes
187
+
188
+ def __init__(self):
189
+ self.__roi_changed: bool | None = None
190
+ self._maskdata_cache: np.ndarray | None = None
191
+ self.__metadata_options_defaults: dict[str, Any] = {}
192
+ self.__roi_cache: TypeROI | None = None
193
+
194
+ @staticmethod
195
+ @abc.abstractmethod
196
+ def get_roi_class() -> Type[TypeROI]:
197
+ """Return ROI class"""
198
+
199
+ @property
200
+ @abc.abstractmethod
201
+ def data(self) -> np.ndarray | None:
202
+ """Data"""
203
+
204
+ @classmethod
205
+ def get_valid_dtypenames(cls) -> list[str]:
206
+ """Get valid data type names
207
+
208
+ Returns:
209
+ Valid data type names supported by this class
210
+ """
211
+ return [
212
+ dtname
213
+ for dtname in np.sctypeDict
214
+ if isinstance(dtname, str)
215
+ and dtname in (dtype.__name__ for dtype in cls.VALID_DTYPES)
216
+ ]
217
+
218
+ def check_data(self):
219
+ """Check if data is valid, raise an exception if that's not the case
220
+
221
+ Raises:
222
+ TypeError: if data type is not supported
223
+ """
224
+ if self.data is not None:
225
+ if self.data.dtype not in self.VALID_DTYPES:
226
+ raise TypeError(f"Unsupported data type: {self.data.dtype}")
227
+
228
+ def iterate_roi_indices(self) -> Generator[int | None, None, None]:
229
+ """Iterate over object ROI indices (if there is no ROI, yield None)"""
230
+ if self.roi is None:
231
+ yield None
232
+ else:
233
+ yield from range(len(self.roi))
234
+
235
+ @abc.abstractmethod
236
+ def get_data(self, roi_index: int | None = None) -> np.ndarray:
237
+ """
238
+ Return original data (if ROI is not defined or `roi_index` is None),
239
+ or ROI data (if both ROI and `roi_index` are defined).
240
+
241
+ Args:
242
+ roi_index: ROI index
243
+
244
+ Returns:
245
+ Data
246
+ """
247
+
248
+ @abc.abstractmethod
249
+ def copy(
250
+ self,
251
+ title: str | None = None,
252
+ dtype: np.dtype | None = None,
253
+ all_metadata: bool = False,
254
+ ) -> Self:
255
+ """Copy object.
256
+
257
+ Args:
258
+ title: title
259
+ dtype: data type
260
+ all_metadata: if True, copy all metadata, otherwise only basic metadata
261
+
262
+ Returns:
263
+ Copied object
264
+ """
265
+
266
+ @abc.abstractmethod
267
+ def set_data_type(self, dtype):
268
+ """Change data type.
269
+
270
+ Args:
271
+ dtype: data type
272
+ """
273
+
274
+ @abc.abstractmethod
275
+ def physical_to_indices(self, coords: list[float]) -> list[int]:
276
+ """Convert coordinates from physical (real world) to indices
277
+
278
+ Args:
279
+ coords: coordinates
280
+
281
+ Returns:
282
+ Indices
283
+ """
284
+
285
+ @abc.abstractmethod
286
+ def indices_to_physical(self, indices: list[int]) -> list[float]:
287
+ """Convert coordinates from indices to physical (real world)
288
+
289
+ Args:
290
+ indices: indices
291
+
292
+ Returns:
293
+ Coordinates
294
+ """
295
+
296
+ def roi_has_changed(self) -> bool:
297
+ """Return True if ROI has changed since last call to this method.
298
+
299
+ The first call to this method will return True if ROI has not yet been set,
300
+ or if ROI has been set and has changed since the last call to this method.
301
+ The next call to this method will always return False if ROI has not changed
302
+ in the meantime.
303
+
304
+ Returns:
305
+ True if ROI has changed
306
+ """
307
+ if self.__roi_changed is None:
308
+ self.__roi_changed = True
309
+ returned_value = self.__roi_changed
310
+ self.__roi_changed = False
311
+ return returned_value
312
+
313
+ @property
314
+ def roi(self) -> TypeROI | None:
315
+ """Return object regions of interest object.
316
+
317
+ Returns:
318
+ Regions of interest object
319
+ """
320
+ # If we have a cached ROI, return it
321
+ if self.__roi_cache is not None:
322
+ return self.__roi_cache
323
+
324
+ # Otherwise, try to load from metadata
325
+ roidata = self.metadata.get(ROI_KEY)
326
+ if roidata is None:
327
+ return None
328
+ if not isinstance(roidata, dict):
329
+ # Old or unsupported format: remove it
330
+ self.metadata.pop(ROI_KEY)
331
+ return None
332
+
333
+ # Create ROI from metadata and cache it
334
+ self.__roi_cache = self.get_roi_class().from_dict(roidata)
335
+ return self.__roi_cache
336
+
337
+ @roi.setter
338
+ def roi(self, roi: TypeROI | None) -> None:
339
+ """Set object regions of interest.
340
+
341
+ Args:
342
+ roi: regions of interest object
343
+ """
344
+ # Cache the ROI object
345
+ self.__roi_cache = roi
346
+
347
+ # Update metadata
348
+ if roi is None:
349
+ if ROI_KEY in self.metadata:
350
+ self.metadata.pop(ROI_KEY)
351
+ else:
352
+ self.metadata[ROI_KEY] = roi.to_dict()
353
+ self.__roi_changed = True
354
+
355
+ @property
356
+ def maskdata(self) -> np.ndarray | None:
357
+ """Return masked data (areas outside defined regions of interest)
358
+
359
+ Returns:
360
+ Masked data
361
+ """
362
+ roi_changed = self.roi_has_changed()
363
+ if self.roi is None:
364
+ if roi_changed:
365
+ self._maskdata_cache = None
366
+ elif roi_changed or self._maskdata_cache is None:
367
+ self._maskdata_cache = self.roi.to_mask(self)
368
+ return self._maskdata_cache
369
+
370
+ def get_masked_view(self) -> ma.MaskedArray:
371
+ """Return masked view for data
372
+
373
+ Returns:
374
+ Masked view
375
+ """
376
+ assert isinstance(self.data, np.ndarray)
377
+ view = self.data.view(ma.MaskedArray)
378
+ if self.maskdata is None:
379
+ view.mask = np.isnan(self.data)
380
+ else:
381
+ view.mask = self.maskdata | np.isnan(self.data)
382
+ return view
383
+
384
+ def invalidate_maskdata_cache(self) -> None:
385
+ """Invalidate mask data cache: force to rebuild it"""
386
+ self._maskdata_cache = None
387
+
388
+ def invalidate_roi_cache(self) -> None:
389
+ """Invalidate ROI cache: force to reload it from metadata"""
390
+ self.__roi_cache = None
391
+ # Also invalidate mask data cache since ROI data might have changed
392
+ self.invalidate_maskdata_cache()
393
+
394
+ def sync_roi_to_metadata(self) -> None:
395
+ """Synchronize the current ROI cache to metadata.
396
+
397
+ This should be called after modifying the ROI object directly
398
+ to ensure the changes are persisted in metadata.
399
+ """
400
+ if self.__roi_cache is not None:
401
+ self.metadata[ROI_KEY] = self.__roi_cache.to_dict()
402
+ self.__roi_changed = True
403
+ # Also invalidate mask data cache since ROI has changed
404
+ self.invalidate_maskdata_cache()
405
+
406
+ def mark_roi_as_changed(self) -> None:
407
+ """Mark the ROI as changed and invalidate dependent caches.
408
+
409
+ This should be called after modifying the ROI object directly
410
+ to ensure all dependent data (like mask cache) is properly invalidated.
411
+ """
412
+ self.__roi_changed = True
413
+ self.invalidate_maskdata_cache()
414
+ # Optionally sync to metadata immediately
415
+ self.sync_roi_to_metadata()
416
+
417
+ def update_metadata_from(self, other_metadata: dict[str, Any]) -> None:
418
+ """Update metadata from another object's metadata (merge result shapes and
419
+ annotations, and update the rest of the metadata).
420
+
421
+ Args:
422
+ other_metadata: other object metadata
423
+ """
424
+ self.metadata.update(other_metadata)
425
+ # Invalidate ROI cache since metadata might have changed ROI data
426
+ self.invalidate_roi_cache()
427
+
428
+ # Method to set the default values of metadata options:
429
+ def set_metadata_options_defaults(
430
+ self, defaults: dict[str, Any], overwrite: bool = False
431
+ ) -> None:
432
+ """Set default values for metadata options
433
+
434
+ A metadata option is a metadata entry starting with a double underscore.
435
+ It is a way to store application-specific options in object metadata.
436
+
437
+ .. note::
438
+
439
+ This will not overwrite existing metadata options
440
+ (unless `overwrite` is True).
441
+ It will only set the default values for options that are not already set.*
442
+ Use `reset_metadata_to_defaults` method to reset all metadata options
443
+ to their default values.
444
+
445
+ Args:
446
+ defaults: dictionary of default values for metadata options
447
+ overwrite: whether to overwrite existing metadata options (default: False)
448
+ """
449
+ self.__metadata_options_defaults.update(defaults)
450
+ for key, value in defaults.items():
451
+ self.set_metadata_option(key, value, overwrite)
452
+
453
+ def get_metadata_options_defaults(self) -> dict[str, Any]:
454
+ """Return default values for metadata options
455
+
456
+ A metadata option is a metadata entry starting with a double underscore.
457
+ It is a way to store application-specific options in object metadata.
458
+
459
+ Returns:
460
+ Dictionary of default values for metadata options
461
+ """
462
+ return self.__metadata_options_defaults
463
+
464
+ def get_metadata_option(self, name: str, default: Any = NoDefaultOption) -> Any:
465
+ """Return metadata option value
466
+
467
+ A metadata option is a metadata entry starting with a double underscore.
468
+ It is a way to store application-specific options in object metadata.
469
+
470
+ Args:
471
+ name: option name
472
+ default: default value if option is not set (optional)
473
+
474
+ Returns:
475
+ Option value
476
+
477
+ Raises:
478
+ ValueError: if option name is invalid
479
+ """
480
+ if (
481
+ default is not NoDefaultOption
482
+ and name not in self.__metadata_options_defaults
483
+ ):
484
+ # If default is provided, store it in defaults
485
+ # and set it as the option value
486
+ self.__metadata_options_defaults[name] = default
487
+ self.set_metadata_option(name, default, overwrite=False)
488
+ try:
489
+ value = self.metadata[f"__{name}"]
490
+ except KeyError as exc:
491
+ defaults = self.get_metadata_options_defaults()
492
+ if name in defaults:
493
+ value = defaults[name]
494
+ else:
495
+ raise ValueError(
496
+ f"Invalid metadata option name `{name}` "
497
+ f"(valid names: {', '.join(defaults.keys())})"
498
+ ) from exc
499
+ return value
500
+
501
+ def set_metadata_option(
502
+ self, name: str, value: Any, overwrite: bool = True
503
+ ) -> None:
504
+ """Set metadata option value
505
+
506
+ A metadata option is a metadata entry starting with a double underscore.
507
+ It is a way to store application-specific options in object metadata.
508
+
509
+ Args:
510
+ name: option name
511
+ value: option value
512
+ overwrite: whether to overwrite existing metadata options (default: True)
513
+
514
+ Raises:
515
+ ValueError: if option name is invalid
516
+ """
517
+ if overwrite or f"__{name}" not in self.metadata:
518
+ self.metadata[f"__{name}"] = value
519
+
520
+ def get_metadata_options(self) -> dict[str, Any]:
521
+ """Return metadata options
522
+ A metadata option is a metadata entry starting with a double underscore.
523
+
524
+ Returns:
525
+ Dictionary of metadata options (name: value)
526
+ """
527
+ options = {}
528
+ for name, value in self.metadata.items():
529
+ if name.startswith("__"):
530
+ options[name[2:]] = value
531
+ return options
532
+
533
+ def reset_metadata_to_defaults(self) -> None:
534
+ """Reset metadata to default values"""
535
+ self.metadata = {}
536
+ self.invalidate_roi_cache()
537
+ defaults = self.get_metadata_options_defaults()
538
+ for name, value in defaults.items():
539
+ self.set_metadata_option(name, value)
540
+
541
+ def save_attr_to_metadata(self, attrname: str, new_value: Any) -> None:
542
+ """Save attribute to metadata
543
+
544
+ Args:
545
+ attrname: attribute name
546
+ new_value: new value
547
+ """
548
+ value = getattr(self, attrname)
549
+ if value:
550
+ self.metadata[f"orig_{attrname}"] = value
551
+ setattr(self, attrname, new_value)
552
+
553
+ def restore_attr_from_metadata(self, attrname: str, default: Any) -> None:
554
+ """Restore attribute from metadata
555
+
556
+ Args:
557
+ attrname: attribute name
558
+ default: default value
559
+ """
560
+ value = self.metadata.pop(f"orig_{attrname}", default)
561
+ setattr(self, attrname, value)
562
+
563
+
564
+ class BaseROIParamMeta(abc.ABCMeta, gds.DataSetMeta):
565
+ """Mixed metaclass to avoid conflicts"""
566
+
567
+
568
+ class BaseROIParam(
569
+ gds.DataSet,
570
+ Generic[TypeObj, TypeSingleROI], # type: ignore
571
+ metaclass=BaseROIParamMeta,
572
+ ):
573
+ """Base class for ROI parameters"""
574
+
575
+ @abc.abstractmethod
576
+ def to_single_roi(self, obj: TypeObj) -> TypeSingleROI:
577
+ """Convert parameters to single ROI
578
+
579
+ Args:
580
+ obj: object (signal/image)
581
+
582
+ Returns:
583
+ Single ROI
584
+ """
585
+
586
+
587
+ class BaseSingleROI(Generic[TypeObj, TypeROIParam], abc.ABC): # type: ignore
588
+ """Base class for single ROI
589
+
590
+ Args:
591
+ coords: ROI edge (physical or pixel coordinates)
592
+ indices: if True, coords are indices (pixels) instead of physical coordinates
593
+ title: ROI title
594
+ """
595
+
596
+ def __init__(
597
+ self,
598
+ coords: np.ndarray | list[int] | list[float],
599
+ indices: bool,
600
+ title: str = "ROI",
601
+ ) -> None:
602
+ self.coords = np.array(coords, int if indices else float)
603
+ self.indices = indices
604
+ self.title = title
605
+ self.check_coords()
606
+
607
+ def __eq__(self, other: BaseSingleROI | None) -> bool:
608
+ """Test equality with another single ROI"""
609
+ if other is None:
610
+ return False
611
+ if not isinstance(other, BaseSingleROI):
612
+ raise TypeError(f"Cannot compare {type(self)} with {type(other)}")
613
+ return (
614
+ np.array_equal(self.coords, other.coords) and self.indices == other.indices
615
+ )
616
+
617
+ def get_physical_coords(self, obj: TypeObj) -> list[float]:
618
+ """Return physical coords
619
+
620
+ Args:
621
+ obj: object (signal/image)
622
+
623
+ Returns:
624
+ Physical coords
625
+ """
626
+ if self.indices:
627
+ return obj.indices_to_physical(self.coords.tolist())
628
+ return self.coords.tolist()
629
+
630
+ def set_physical_coords(self, obj: TypeObj, coords: np.ndarray) -> None:
631
+ """Set physical coords
632
+
633
+ Args:
634
+ obj: object (signal/image)
635
+ coords: physical coords
636
+ """
637
+ if self.indices:
638
+ self.coords = np.array(obj.physical_to_indices(coords.tolist()))
639
+ else:
640
+ self.coords = np.array(coords, float)
641
+
642
+ def get_indices_coords(self, obj: TypeObj) -> list[int]:
643
+ """Return indices coords
644
+
645
+ Args:
646
+ obj: object (signal/image)
647
+
648
+ Returns:
649
+ Indices coords
650
+ """
651
+ if self.indices:
652
+ return self.coords.tolist()
653
+ return obj.physical_to_indices(self.coords.tolist())
654
+
655
+ def set_indices_coords(self, obj: TypeObj, coords: np.ndarray) -> None:
656
+ """Set indices coords
657
+
658
+ Args:
659
+ obj: object (signal/image)
660
+ coords: indices coords
661
+ """
662
+ if self.indices:
663
+ self.coords = coords
664
+ else:
665
+ self.coords = np.array(obj.indices_to_physical(self.coords.tolist()))
666
+
667
+ @abc.abstractmethod
668
+ def check_coords(self) -> None:
669
+ """Check if coords are valid
670
+
671
+ Raises:
672
+ ValueError: invalid coords
673
+ """
674
+
675
+ @abc.abstractmethod
676
+ def to_mask(self, obj: TypeObj) -> np.ndarray:
677
+ """Create mask from ROI
678
+
679
+ Args:
680
+ obj: signal or image object
681
+
682
+ Returns:
683
+ Mask (boolean array where True values are inside the ROI)
684
+ """
685
+
686
+ @abc.abstractmethod
687
+ def to_param(self, obj: TypeObj, index: int) -> TypeROIParam:
688
+ """Convert ROI to parameters
689
+
690
+ Args:
691
+ obj: object (signal/image), for physical-indices coordinates conversion
692
+ index: ROI index
693
+ """
694
+
695
+ def to_dict(self) -> dict:
696
+ """Convert ROI to dictionary
697
+
698
+ Returns:
699
+ Dictionary
700
+ """
701
+ return {
702
+ "coords": self.coords,
703
+ "indices": self.indices,
704
+ "title": self.title,
705
+ "type": type(self).__name__,
706
+ }
707
+
708
+ @classmethod
709
+ def from_dict(cls: Type[TypeSingleROI], dictdata: dict) -> TypeSingleROI:
710
+ """Convert dictionary to ROI
711
+
712
+ Args:
713
+ dictdata: dictionary
714
+
715
+ Returns:
716
+ ROI
717
+ """
718
+ return cls(dictdata["coords"], dictdata["indices"], dictdata["title"])
719
+
720
+
721
+ class BaseROI(Generic[TypeObj, TypeSingleROI, TypeROIParam], abc.ABC): # type: ignore
722
+ """Abstract base class for ROIs (Regions of Interest)
723
+
724
+ Args:
725
+ inverse: if True, ROI is outside the region of interest
726
+ """
727
+
728
+ #: Class attribute that defines a string prefix used for identifying ROI types
729
+ #: in object metadata. This prefix is used when serializing and deserializing ROIs,
730
+ #: allowing the system to determine the appropriate ROI class for reconstruction.
731
+ #: Each ROI subclass should override this with a unique string identifier.
732
+ PREFIX = "" # This is overriden in children classes
733
+
734
+ def __init__(self) -> None:
735
+ self.single_rois: list[TypeSingleROI] = []
736
+
737
+ @staticmethod
738
+ @abc.abstractmethod
739
+ def get_compatible_single_roi_classes() -> list[Type[BaseSingleROI]]:
740
+ """Return compatible single ROI classes"""
741
+
742
+ def __len__(self) -> int:
743
+ """Return number of ROIs"""
744
+ return len(self.single_rois)
745
+
746
+ def __iter__(self) -> Iterator[TypeSingleROI]:
747
+ """Iterate over single ROIs"""
748
+ return iter(self.single_rois)
749
+
750
+ def __eq__(self, other: BaseROI | None) -> bool:
751
+ """Test equality with another ROI"""
752
+ if other is None:
753
+ return False
754
+ if not isinstance(other, BaseROI):
755
+ raise TypeError(f"Cannot compare {type(self)} with {type(other)}")
756
+ return self.single_rois == other.single_rois
757
+
758
+ def get_single_roi(self, index: int) -> TypeSingleROI:
759
+ """Return single ROI at index
760
+
761
+ Args:
762
+ index: ROI index
763
+ """
764
+ return self.single_rois[index]
765
+
766
+ def set_single_roi(self, index: int, roi: TypeSingleROI) -> None:
767
+ """Set single ROI at index
768
+
769
+ Args:
770
+ index: ROI index
771
+ roi: ROI to set
772
+ """
773
+ self.single_rois[index] = roi
774
+
775
+ def get_single_roi_title(self, index: int) -> str:
776
+ """Generate title for single ROI, based on its index, using either the
777
+ ROI title or a default generic title as fallback.
778
+
779
+ Args:
780
+ index: ROI index
781
+ """
782
+ single_roi = self.get_single_roi(index)
783
+ title = single_roi.title or get_generic_roi_title(index)
784
+ return title
785
+
786
+ def is_empty(self) -> bool:
787
+ """Return True if no ROI is defined"""
788
+ return len(self) == 0
789
+
790
+ @classmethod
791
+ def create(
792
+ cls: Type[BaseROI], single_roi: TypeSingleROI
793
+ ) -> BaseROI[TypeObj, TypeSingleROI, TypeROIParam]:
794
+ """Create Regions of Interest object from a single ROI.
795
+
796
+ Args:
797
+ single_roi: single ROI
798
+
799
+ Returns:
800
+ Regions of Interest object
801
+ """
802
+ roi = cls()
803
+ roi.add_roi(single_roi)
804
+ return roi
805
+
806
+ def copy(self) -> BaseROI[TypeObj, TypeSingleROI, TypeROIParam]:
807
+ """Return a copy of ROIs"""
808
+ return deepcopy(self)
809
+
810
+ def empty(self) -> None:
811
+ """Empty ROIs"""
812
+ self.single_rois.clear()
813
+
814
+ def combine_with(
815
+ self, other: BaseROI[TypeObj, TypeSingleROI, TypeROIParam]
816
+ ) -> BaseROI[TypeObj, TypeSingleROI, TypeROIParam]:
817
+ """Combine ROIs with another ROI object, by merging single ROIs (and ignoring
818
+ duplicate single ROIs) and returning a new combined ROI object.
819
+
820
+ Args:
821
+ other: other ROI object
822
+
823
+ Returns:
824
+ Combined ROIs object
825
+ """
826
+ if not isinstance(other, type(self)):
827
+ raise TypeError(f"Cannot combine {type(self)} with {type(other)}")
828
+ combined_roi = self.copy()
829
+ for roi in other.single_rois:
830
+ if all(s_roi != roi for s_roi in self.single_rois):
831
+ combined_roi.single_rois.append(roi)
832
+ return combined_roi
833
+
834
+ def add_roi(
835
+ self, roi: TypeSingleROI | BaseROI[TypeObj, TypeSingleROI, TypeROIParam]
836
+ ) -> None:
837
+ """Add ROI.
838
+
839
+ Args:
840
+ roi: ROI
841
+
842
+ Raises:
843
+ TypeError: if roi type is not supported (not a single ROI or a ROI)
844
+ ValueError: if `inverse` values are incompatible
845
+ """
846
+ if isinstance(roi, BaseSingleROI):
847
+ self.single_rois.append(roi)
848
+ elif isinstance(roi, BaseROI):
849
+ self.single_rois.extend(roi.single_rois)
850
+ else:
851
+ raise TypeError(f"Unsupported ROI type: {type(roi)}")
852
+
853
+ @abc.abstractmethod
854
+ def to_mask(self, obj: TypeObj) -> np.ndarray:
855
+ """Create mask from ROI
856
+
857
+ Args:
858
+ obj: signal or image object
859
+
860
+ Returns:
861
+ Mask (boolean array where True values are inside the ROI)
862
+ """
863
+
864
+ def to_params(self, obj: TypeObj) -> list[TypeROIParam]:
865
+ """Convert ROIs to a list of parameters
866
+
867
+ Args:
868
+ obj: object (signal/image), for physical to pixel conversion
869
+
870
+ Returns:
871
+ ROI parameters
872
+ """
873
+ return [iroi.to_param(obj, index=idx) for idx, iroi in enumerate(self)]
874
+
875
+ @classmethod
876
+ def from_params(
877
+ cls: Type[BaseROI],
878
+ obj: TypeObj,
879
+ params: list[TypeROIParam],
880
+ ) -> BaseROI[TypeObj, TypeSingleROI, TypeROIParam]:
881
+ """Create ROIs from parameters
882
+
883
+ Args:
884
+ obj: object (signal/image)
885
+ params: ROI parameters
886
+
887
+ Returns:
888
+ ROIs
889
+ """
890
+ roi = cls()
891
+ for param in params:
892
+ assert isinstance(param, BaseROIParam), "Invalid ROI parameter type"
893
+ roi.add_roi(param.to_single_roi(obj))
894
+ return roi
895
+
896
+ def to_dict(self) -> dict:
897
+ """Convert ROIs to dictionary
898
+
899
+ Returns:
900
+ Dictionary
901
+ """
902
+ return {
903
+ "single_rois": [roi.to_dict() for roi in self.single_rois],
904
+ }
905
+
906
+ @classmethod
907
+ def from_dict(cls: Type[TypeROI], dictdata: dict) -> TypeROI:
908
+ """Convert dictionary to ROIs
909
+
910
+ Args:
911
+ dictdata: dictionary
912
+
913
+ Returns:
914
+ ROIs
915
+ """
916
+ instance = cls()
917
+ if not all(key in dictdata for key in ["single_rois"]):
918
+ raise ValueError("Invalid ROI: dictionary must contain 'single_rois' key")
919
+ instance.single_rois = []
920
+ for single_roi in dictdata["single_rois"]:
921
+ for single_roi_class in instance.get_compatible_single_roi_classes():
922
+ if single_roi["type"] == single_roi_class.__name__:
923
+ instance.single_rois.append(single_roi_class.from_dict(single_roi))
924
+ break
925
+ else:
926
+ raise ValueError(f"Unsupported single ROI type: {single_roi['type']}")
927
+ return instance
928
+
929
+
930
+ GENERIC_ROI_TITLE_REGEXP = r"ROI(\d+)"
931
+
932
+
933
+ def get_generic_roi_title(index: int) -> None:
934
+ """Return a generic title for the ROI"""
935
+ title = f"ROI{index:02d}"
936
+ assert re.match(GENERIC_ROI_TITLE_REGEXP, title)
937
+ return title