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,904 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2
+
3
+ """
4
+ Image ROI classes
5
+ =================
6
+
7
+ This module defines ROI (Region of Interest) classes and utilities for images.
8
+
9
+ The module provides:
10
+
11
+ - `ROI2DParam`: Parameter class for 2D image ROIs
12
+ - `BaseSingleImageROI`: Base class for single image ROIs
13
+ - `RectangularROI`: Rectangular ROI implementation
14
+ - `CircularROI`: Circular ROI implementation
15
+ - `PolygonalROI`: Polygonal ROI implementation
16
+ - `ImageROI`: Container for multiple image ROIs
17
+ - `create_image_roi`: Factory function for creating image ROIs
18
+ - Utility functions for coordinate handling
19
+
20
+ These classes handle ROI definitions, parameter conversion, and mask generation
21
+ for image processing operations.
22
+ """
23
+
24
+ # pylint: disable=invalid-name # Allows short reference names like x, y, ...
25
+ # pylint: disable=duplicate-code
26
+
27
+ from __future__ import annotations
28
+
29
+ import abc
30
+ import re
31
+ from collections.abc import ByteString, Mapping, Sequence
32
+ from typing import TYPE_CHECKING, Literal, Type
33
+
34
+ import guidata.dataset as gds
35
+ import numpy as np
36
+ from skimage import draw
37
+
38
+ from sigima.config import _
39
+ from sigima.objects import base
40
+
41
+ if TYPE_CHECKING:
42
+ from sigima.objects.image.object import ImageObj
43
+
44
+
45
+ def to_builtin(obj) -> str | int | float | list | dict | np.ndarray | None:
46
+ """Convert an object implementing a numeric value or collection
47
+ into the corresponding builtin/NumPy type.
48
+
49
+ Return None if conversion fails."""
50
+ try:
51
+ return int(obj) if int(obj) == float(obj) else float(obj)
52
+ except (TypeError, ValueError):
53
+ pass
54
+ if isinstance(obj, ByteString):
55
+ return str(obj)
56
+ if isinstance(obj, Sequence):
57
+ return str(obj) if len(obj) == len(str(obj)) else list(obj)
58
+ if isinstance(obj, Mapping):
59
+ return dict(obj)
60
+ if isinstance(obj, np.ndarray):
61
+ return obj
62
+ return None
63
+
64
+
65
+ def check_points(value: np.ndarray, raise_exception: bool = False) -> bool:
66
+ """Check if value is a valid 1D array of coordinates (X, Y) pairs.
67
+
68
+ Args:
69
+ value: value to check
70
+ raise_exception: if True, raise an exception on invalid value
71
+
72
+ Returns:
73
+ True if value is valid, False otherwise
74
+ """
75
+ if not np.issubdtype(value.dtype, np.floating):
76
+ if raise_exception:
77
+ raise TypeError("Coordinates must be floats")
78
+ return False
79
+ if value.ndim != 1:
80
+ if raise_exception:
81
+ raise ValueError("Coordinates must be a 1D array")
82
+ return False
83
+ if len(value) % 2 != 0:
84
+ if raise_exception:
85
+ raise ValueError("Coordinates must contain pairs (X, Y)")
86
+ return False
87
+ return True
88
+
89
+
90
+ class ROI2DParam(base.BaseROIParam["ImageObj", "BaseSingleImageROI"]):
91
+ """Image ROI parameters"""
92
+
93
+ def get_comment(self) -> str | None:
94
+ """Get comment for ROI parameters"""
95
+ return _(
96
+ "This is a set of parameters defining a <b>Region of Interest</b> "
97
+ "(ROI) in an image. The parameters can be used to create a ROI object "
98
+ "or to extract data from an image using the ROI.<br><br>"
99
+ "All values are expressed in physical coordinates (floats). "
100
+ "The conversion to pixel coordinates is done by taking into account "
101
+ "the image pixel size and origin.<br>"
102
+ )
103
+
104
+ title = gds.StringItem(_("ROI title"), default="")
105
+
106
+ # Note: the ROI coordinates are expressed in pixel coordinates (integers)
107
+ # => That is the only way to handle ROI parametrization for image objects.
108
+ # Otherwise, we would have to ask the user to systematically provide the
109
+ # physical coordinates: that would be cumbersome and error-prone.
110
+
111
+ _geometry_prop = gds.GetAttrProp("geometry")
112
+ _rfp = gds.FuncProp(_geometry_prop, lambda x: x != "rectangle")
113
+ _cfp = gds.FuncProp(_geometry_prop, lambda x: x != "circle")
114
+ _pfp = gds.FuncProp(_geometry_prop, lambda x: x != "polygon")
115
+
116
+ # Do not declare it as a static method: not supported by Python 3.9
117
+ def _lbl(name: str, index: int): # pylint: disable=no-self-argument
118
+ """Returns name<sub>index</sub>"""
119
+ return f"{name}<sub>{index}</sub>"
120
+
121
+ geometries = ("rectangle", "circle", "polygon")
122
+ geometry = gds.ChoiceItem(
123
+ _("Geometry"), list(zip(geometries, geometries)), default="rectangle"
124
+ ).set_prop("display", store=_geometry_prop, hide=True)
125
+
126
+ # Parameters for rectangular ROI geometry:
127
+ _tlcorner = gds.BeginGroup(_("Top left corner")).set_prop("display", hide=_rfp)
128
+ x0 = gds.FloatItem(_lbl("X", 0), default=0).set_prop("display", hide=_rfp)
129
+ y0 = (
130
+ gds.FloatItem(_lbl("Y", 0), default=0).set_pos(1).set_prop("display", hide=_rfp)
131
+ )
132
+ _e_tlcorner = gds.EndGroup(_("Top left corner"))
133
+ dx = gds.FloatItem("ΔX", default=0).set_prop("display", hide=_rfp)
134
+ dy = gds.FloatItem("ΔY", default=0).set_pos(1).set_prop("display", hide=_rfp)
135
+
136
+ # Parameters for circular ROI geometry:
137
+ _cgroup = gds.BeginGroup(_("Center coordinates")).set_prop("display", hide=_cfp)
138
+ xc = gds.FloatItem(_lbl("X", "C"), default=0).set_prop("display", hide=_cfp)
139
+ yc = (
140
+ gds.FloatItem(_lbl("Y", "C"), default=0)
141
+ .set_pos(1)
142
+ .set_prop("display", hide=_cfp)
143
+ )
144
+ _e_cgroup = gds.EndGroup(_("Center coordinates"))
145
+ r = gds.FloatItem(_("Radius"), default=0).set_prop("display", hide=_cfp)
146
+
147
+ # Parameters for polygonal ROI geometry:
148
+ points = gds.FloatArrayItem(_("Coordinates"), check_callback=check_points).set_prop(
149
+ "display", hide=_pfp
150
+ )
151
+
152
+ # Parameter for ROI mask behavior:
153
+ inverse = gds.BoolItem(
154
+ _("Inverse ROI logic"),
155
+ default=False,
156
+ help=_(
157
+ "When disabled (default), the ROI defines an area inside the shape on "
158
+ "which to focus (masking data outside).\n"
159
+ "When enabled, the ROI logic is inverted to focus on data outside the "
160
+ "shape (masking data inside)."
161
+ ),
162
+ )
163
+
164
+ def to_single_roi(
165
+ self, obj: ImageObj
166
+ ) -> PolygonalROI | RectangularROI | CircularROI:
167
+ """Convert parameters to single ROI
168
+
169
+ Args:
170
+ obj: image object (used for conversion of pixel to physical coordinates)
171
+
172
+ Returns:
173
+ Single ROI
174
+ """
175
+ if self.geometry == "rectangle":
176
+ return RectangularROI.from_param(obj, self)
177
+ if self.geometry == "circle":
178
+ return CircularROI.from_param(obj, self)
179
+ if self.geometry == "polygon":
180
+ return PolygonalROI.from_param(obj, self)
181
+ raise ValueError(f"Unknown ROI geometry type: {self.geometry}")
182
+
183
+ def get_suffix(self) -> str:
184
+ """Get suffix text representation for ROI extraction"""
185
+ if re.match(base.GENERIC_ROI_TITLE_REGEXP, self.title) or not self.title:
186
+ if self.geometry == "rectangle":
187
+ return f"x0={self.x0},y0={self.y0},dx={self.dx},dy={self.dy}"
188
+ if self.geometry == "circle":
189
+ return f"xc={self.xc},yc={self.yc},r={self.r}"
190
+ if self.geometry == "polygon":
191
+ return "polygon"
192
+ raise ValueError(f"Unknown ROI geometry type: {self.geometry}")
193
+ return self.title
194
+
195
+ def get_extracted_roi(self, obj: ImageObj) -> ImageROI | None:
196
+ """Get extracted ROI, i.e. the remaining ROI after extracting ROI from image.
197
+
198
+ Args:
199
+ obj: image object (used for conversion of pixel to physical coordinates)
200
+
201
+ When extracting ROIs from an image to multiple images (i.e. one image per ROI),
202
+ this method returns the ROI that has to be kept in the destination image. This
203
+ is not necessary for a rectangular ROI: the destination image is simply a crop
204
+ of the source image according to the ROI coordinates. But for a circular ROI or
205
+ a polygonal ROI, the destination image is a crop of the source image according
206
+ to the bounding box of the ROI. Thus, to avoid any loss of information, a ROI
207
+ has to be defined for the destination image: this is the ROI returned by this
208
+ method. It's simply the same as the source ROI, but with coordinates adjusted
209
+ to the destination image. One may called this ROI the "extracted ROI".
210
+ """
211
+ if self.geometry == "rectangle":
212
+ return None
213
+ single_roi = self.to_single_roi(obj)
214
+ roi = ImageROI()
215
+ roi.add_roi(single_roi)
216
+ return roi
217
+
218
+ def get_bounding_box_physical(self) -> tuple[int, int, int, int]:
219
+ """Get bounding box (physical coordinates)"""
220
+ if self.geometry == "circle":
221
+ x0, y0 = self.xc - self.r, self.yc - self.r
222
+ x1, y1 = self.xc + self.r, self.yc + self.r
223
+ elif self.geometry == "rectangle":
224
+ x0, y0, x1, y1 = self.x0, self.y0, self.x0 + self.dx, self.y0 + self.dy
225
+ else:
226
+ self.points: np.ndarray
227
+ x0, y0 = self.points[::2].min(), self.points[1::2].min()
228
+ x1, y1 = self.points[::2].max(), self.points[1::2].max()
229
+ return x0, y0, x1, y1
230
+
231
+ def get_bounding_box_indices(self, obj: ImageObj) -> tuple[int, int, int, int]:
232
+ """Get bounding box (pixel coordinates)"""
233
+ x0, y0, x1, y1 = self.get_bounding_box_physical()
234
+ ix0, iy0 = obj.physical_to_indices((x0, y0))
235
+ ix1, iy1 = obj.physical_to_indices((x1, y1))
236
+ return ix0, iy0, ix1, iy1
237
+
238
+ def get_data(self, obj: ImageObj) -> np.ndarray:
239
+ """Get data in ROI
240
+
241
+ Args:
242
+ obj: image object
243
+
244
+ Returns:
245
+ Data in ROI
246
+ """
247
+ ix0, iy0, ix1, iy1 = self.get_bounding_box_indices(obj)
248
+ ix0, iy0 = max(0, ix0), max(0, iy0)
249
+ ix1, iy1 = min(obj.data.shape[1], ix1), min(obj.data.shape[0], iy1)
250
+ return obj.data[iy0:iy1, ix0:ix1]
251
+
252
+
253
+ class BaseSingleImageROI(base.BaseSingleROI["ImageObj", ROI2DParam], abc.ABC):
254
+ """Base class for single image ROI
255
+
256
+ Args:
257
+ coords: ROI edge coordinates (floats)
258
+ indices: if True, coordinates are indices, if False, they are physical values
259
+ title: ROI title
260
+ inverse: inverse ROI logic (default: False)
261
+
262
+ .. note::
263
+
264
+ The image ROI coords are expressed in physical coordinates (floats). The
265
+ conversion to pixel coordinates is done in :class:`sigima.objects.ImageObj`
266
+ (see :meth:`sigima.objects.ImageObj.physical_to_indices`). Most of the time,
267
+ the physical coordinates are the same as the pixel coordinates, but this
268
+ is not always the case (e.g. after image binning), so it's better to keep the
269
+ physical coordinates in the ROI object: this will help reusing the ROI with
270
+ different images (e.g. with different pixel sizes).
271
+ """
272
+
273
+ def __init__(
274
+ self,
275
+ coords: np.ndarray | list[int] | list[float],
276
+ indices: bool,
277
+ title: str = "ROI",
278
+ inverse: bool = False,
279
+ ) -> None:
280
+ super().__init__(coords, indices, title)
281
+ self.inverse = inverse
282
+
283
+ def to_dict(self) -> dict:
284
+ """Convert ROI to dictionary
285
+
286
+ Returns:
287
+ Dictionary
288
+ """
289
+ result = super().to_dict()
290
+ result["inverse"] = self.inverse
291
+ return result
292
+
293
+ @classmethod
294
+ def from_dict(cls, dictdata: dict):
295
+ """Convert dictionary to ROI
296
+
297
+ Args:
298
+ dictdata: dictionary
299
+
300
+ Returns:
301
+ ROI
302
+ """
303
+ # Get inverse parameter
304
+ inverse = dictdata.get("inverse", False) # Default: normal ROI behavior
305
+ return cls(dictdata["coords"], dictdata["indices"], dictdata["title"], inverse)
306
+
307
+ @abc.abstractmethod
308
+ def get_bounding_box(self, obj: ImageObj) -> tuple[float, float, float, float]:
309
+ """Get bounding box (physical coordinates)
310
+
311
+ Args:
312
+ obj: image object
313
+ """
314
+
315
+
316
+ class PolygonalROI(BaseSingleImageROI):
317
+ """Polygonal ROI
318
+
319
+ Args:
320
+ coords: ROI edge coordinates
321
+ title: title
322
+ inverse: inverse ROI logic (default: False)
323
+
324
+ Raises:
325
+ ValueError: if number of coordinates is odd
326
+
327
+ .. note:: The image ROI coords are expressed in physical coordinates (floats)
328
+ """
329
+
330
+ def check_coords(self) -> None:
331
+ """Check if coords are valid
332
+
333
+ Raises:
334
+ ValueError: invalid coords
335
+ """
336
+ if len(self.coords) % 2 != 0:
337
+ raise ValueError("Edge indices must be pairs of X, Y values")
338
+
339
+ # pylint: disable=unused-argument
340
+ @classmethod
341
+ def from_param(cls: PolygonalROI, obj: ImageObj, param: ROI2DParam) -> PolygonalROI:
342
+ """Create ROI from parameters
343
+
344
+ Args:
345
+ obj: image object
346
+ param: parameters
347
+ """
348
+ return cls(
349
+ param.points,
350
+ indices=False,
351
+ title=param.title,
352
+ inverse=param.inverse,
353
+ )
354
+
355
+ def get_bounding_box(self, obj: ImageObj) -> tuple[float, float, float, float]:
356
+ """Get bounding box (physical coordinates)
357
+
358
+ Args:
359
+ obj: image object
360
+ """
361
+ coords = self.get_physical_coords(obj)
362
+ x_edges, y_edges = coords[::2], coords[1::2]
363
+ return min(x_edges), min(y_edges), max(x_edges), max(y_edges)
364
+
365
+ def to_mask(self, obj: ImageObj) -> np.ndarray:
366
+ """Create mask from ROI
367
+
368
+ Args:
369
+ obj: image object
370
+
371
+ Returns:
372
+ Mask (boolean array where True values are inside the ROI)
373
+ """
374
+ roi_mask = np.ones_like(obj.data, dtype=bool)
375
+ indices = self.get_indices_coords(obj)
376
+ rows = np.array(indices[1::2], dtype=float) # y coordinates
377
+ cols = np.array(indices[::2], dtype=float) # x coordinates
378
+ rr, cc = draw.polygon(rows, cols, shape=obj.data.shape)
379
+
380
+ if self.inverse:
381
+ # Inverse ROI: mask inside the polygon (True inside, False outside)
382
+ roi_mask[:] = False
383
+ roi_mask[rr, cc] = True
384
+ else:
385
+ # Normal ROI: mask outside the polygon (False inside, True outside)
386
+ roi_mask[rr, cc] = False
387
+ return roi_mask
388
+
389
+ def to_param(self, obj: ImageObj, index: int) -> ROI2DParam:
390
+ """Convert ROI to parameters
391
+
392
+ Args:
393
+ obj: object (image), for physical-indices coordinates conversion
394
+ index: ROI index
395
+ """
396
+ gtitle = base.get_generic_roi_title(index)
397
+ param = ROI2DParam(gtitle)
398
+ param.title = self.title or gtitle
399
+ param.geometry = "polygon"
400
+ param.points = np.array(self.get_physical_coords(obj))
401
+ param.inverse = self.inverse
402
+ return param
403
+
404
+
405
+ class RectangularROI(BaseSingleImageROI):
406
+ """Rectangular ROI
407
+
408
+ Args:
409
+ coords: ROI edge coordinates (x0, y0, dx, dy)
410
+ title: title
411
+ inverse: inverse ROI logic (default: False)
412
+
413
+ .. note:: The image ROI coords are expressed in physical coordinates (floats)
414
+ """
415
+
416
+ def check_coords(self) -> None:
417
+ """Check if coords are valid
418
+
419
+ Raises:
420
+ ValueError: invalid coords
421
+ """
422
+ if len(self.coords) != 4:
423
+ raise ValueError("Rectangle ROI requires 4 coordinates")
424
+
425
+ # pylint: disable=unused-argument
426
+ @classmethod
427
+ def from_param(
428
+ cls: RectangularROI, obj: ImageObj, param: ROI2DParam
429
+ ) -> RectangularROI:
430
+ """Create ROI from parameters
431
+
432
+ Args:
433
+ obj: image object
434
+ param: parameters
435
+ """
436
+ x0, y0, x1, y1 = param.get_bounding_box_physical()
437
+ return cls(
438
+ [x0, y0, x1 - x0, y1 - y0],
439
+ indices=False,
440
+ title=param.title,
441
+ inverse=param.inverse,
442
+ )
443
+
444
+ def get_bounding_box(self, obj: ImageObj) -> tuple[float, float, float, float]:
445
+ """Get bounding box (physical coordinates)
446
+
447
+ Args:
448
+ obj: image object
449
+ """
450
+ x0, y0, dx, dy = self.get_physical_coords(obj)
451
+ return x0, y0, x0 + dx, y0 + dy
452
+
453
+ def get_physical_coords(self, obj: ImageObj) -> list[float]:
454
+ """Return physical coords
455
+
456
+ Args:
457
+ obj: image object
458
+
459
+ Returns:
460
+ Physical coords
461
+ """
462
+ if self.indices:
463
+ ix0, iy0, idx, idy = self.coords
464
+ x0, y0, x1, y1 = obj.indices_to_physical([ix0, iy0, ix0 + idx, iy0 + idy])
465
+ return [x0, y0, x1 - x0, y1 - y0]
466
+ return self.coords
467
+
468
+ def set_physical_coords(self, obj: ImageObj, coords: np.ndarray) -> None:
469
+ """Set physical coords
470
+
471
+ Args:
472
+ obj: object (signal/image)
473
+ coords: physical coords
474
+ """
475
+ if self.indices:
476
+ x0, y0, dx, dy = coords
477
+ ix0, iy0, idx, idy = obj.physical_to_indices([x0, y0, x0 + dx, y0 + dy])
478
+ self.coords = np.array([ix0, iy0, idx, idy], dtype=int)
479
+ else:
480
+ self.coords = np.array(coords, dtype=float)
481
+
482
+ def get_indices_coords(self, obj: ImageObj) -> list[int]:
483
+ """Return indices coords
484
+
485
+ Args:
486
+ obj: image object
487
+
488
+ Returns:
489
+ Indices coords
490
+ """
491
+ if self.indices:
492
+ return self.coords.tolist()
493
+ ix0, iy0, ix1, iy1 = obj.physical_to_indices(self.get_bounding_box(obj))
494
+ return [ix0, iy0, ix1 - ix0, iy1 - iy0]
495
+
496
+ def set_indices_coords(self, obj: ImageObj, coords: np.ndarray) -> None:
497
+ """Set indices coords
498
+
499
+ Args:
500
+ obj: object (signal/image)
501
+ coords: indices coords
502
+ """
503
+ if self.indices:
504
+ self.coords = coords
505
+ else:
506
+ ix0, iy0, idx, idy = coords
507
+ x0, y0, x1, y1 = obj.indices_to_physical([ix0, iy0, ix0 + idx, iy0 + idy])
508
+ self.coords = np.array([x0, y0, x1 - x0, y1 - y0])
509
+
510
+ def to_mask(self, obj: ImageObj) -> np.ndarray:
511
+ """Create mask from ROI
512
+
513
+ Args:
514
+ obj: image object
515
+
516
+ Returns:
517
+ Mask (boolean array where True values are inside the ROI)
518
+ """
519
+ roi_mask = np.ones_like(obj.data, dtype=bool)
520
+ ix0, iy0, idx, idy = self.get_indices_coords(obj)
521
+ rr, cc = draw.rectangle((iy0, ix0), extent=(idy, idx), shape=obj.data.shape)
522
+
523
+ if self.inverse:
524
+ # Inverse ROI: mask inside the rectangle (True inside, False outside)
525
+ roi_mask[:] = False
526
+ roi_mask[rr, cc] = True
527
+ else:
528
+ # Normal ROI: mask outside the rectangle (False inside, True outside)
529
+ roi_mask[rr, cc] = False
530
+ return roi_mask
531
+
532
+ def to_param(self, obj: ImageObj, index: int) -> ROI2DParam:
533
+ """Convert ROI to parameters
534
+
535
+ Args:
536
+ obj: object (image), for physical-indices coordinates conversion
537
+ index: ROI index
538
+ """
539
+ gtitle = base.get_generic_roi_title(index)
540
+ param = ROI2DParam(gtitle)
541
+ param.title = self.title or gtitle
542
+ param.geometry = "rectangle"
543
+ param.x0, param.y0, param.dx, param.dy = self.get_physical_coords(obj)
544
+ param.inverse = self.inverse
545
+ return param
546
+
547
+ @staticmethod
548
+ def rect_to_coords(
549
+ x0: int | float, y0: int | float, x1: int | float, y1: int | float
550
+ ) -> np.ndarray:
551
+ """Convert rectangle to coordinates
552
+
553
+ Args:
554
+ x0: x0 (top-left corner)
555
+ y0: y0 (top-left corner)
556
+ x1: x1 (bottom-right corner)
557
+ y1: y1 (bottom-right corner)
558
+
559
+ Returns:
560
+ Rectangle coordinates (x0, y0, Δx, Δy)
561
+ """
562
+ return np.array([x0, y0, x1 - x0, y1 - y0], dtype=type(x0))
563
+
564
+
565
+ class CircularROI(BaseSingleImageROI):
566
+ """Circular ROI
567
+
568
+ Args:
569
+ coords: ROI edge coordinates (xc, yc, r)
570
+ title: title
571
+ inverse: inverse ROI logic (default: False)
572
+
573
+ .. note:: The image ROI coords are expressed in physical coordinates (floats)
574
+ """
575
+
576
+ # pylint: disable=unused-argument
577
+ @classmethod
578
+ def from_param(cls: CircularROI, obj: ImageObj, param: ROI2DParam) -> CircularROI:
579
+ """Create ROI from parameters
580
+
581
+ Args:
582
+ obj: image object
583
+ param: parameters
584
+ """
585
+ x0, y0, x1, y1 = param.get_bounding_box_physical()
586
+ ixc, iyc = (x0 + x1) * 0.5, (y0 + y1) * 0.5
587
+ ir = (x1 - x0) * 0.5
588
+ return cls(
589
+ [ixc, iyc, ir],
590
+ indices=False,
591
+ title=param.title,
592
+ inverse=param.inverse,
593
+ )
594
+
595
+ def check_coords(self) -> None:
596
+ """Check if coords are valid
597
+
598
+ Raises:
599
+ ValueError: invalid coords
600
+ """
601
+ if len(self.coords) != 3:
602
+ raise ValueError("Circle ROI requires 3 coordinates")
603
+
604
+ def get_bounding_box(self, obj: ImageObj) -> tuple[float, float, float, float]:
605
+ """Get bounding box (physical coordinates)
606
+
607
+ Args:
608
+ obj: image object
609
+ """
610
+ xc, yc, r = self.get_physical_coords(obj)
611
+ return xc - r, yc - r, xc + r, yc + r
612
+
613
+ def get_physical_coords(self, obj: ImageObj) -> np.ndarray:
614
+ """Return physical coords
615
+
616
+ Args:
617
+ obj: image object
618
+
619
+ Returns:
620
+ Physical coords
621
+ """
622
+ if self.indices:
623
+ ixc, iyc, ir = self.coords
624
+ x0, y0, x1, y1 = obj.indices_to_physical(
625
+ [ixc - ir, iyc - ir, ixc + ir, iyc + ir]
626
+ )
627
+ return [0.5 * (x0 + x1), 0.5 * (y0 + y1), 0.5 * (x1 - x0)]
628
+ return self.coords
629
+
630
+ def set_physical_coords(self, obj: ImageObj, 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
+ xc, yc, r = coords
639
+ ix0, iy0, ix1, iy1 = obj.physical_to_indices(
640
+ [xc - r, yc - r, xc + r, yc + r]
641
+ )
642
+ self.coords = np.array(
643
+ [0.5 * (ix0 + ix1), 0.5 * (iy0 + iy1), 0.5 * (ix1 - ix0)]
644
+ )
645
+ else:
646
+ self.coords = np.array(coords, dtype=float)
647
+
648
+ def get_indices_coords(self, obj: ImageObj) -> list[float]:
649
+ """Return indices coords
650
+
651
+ Args:
652
+ obj: image object
653
+
654
+ Returns:
655
+ Indices coords
656
+ """
657
+ if self.indices:
658
+ return self.coords
659
+ ix0, iy0, ix1, iy1 = obj.physical_to_indices(
660
+ self.get_bounding_box(obj), as_float=True
661
+ )
662
+ ixc, iyc = (ix0 + ix1) * 0.5, (iy0 + iy1) * 0.5
663
+ ir = (ix1 - ix0) * 0.5
664
+ return [ixc, iyc, ir]
665
+
666
+ def set_indices_coords(self, obj: ImageObj, coords: np.ndarray) -> None:
667
+ """Set indices coords
668
+
669
+ Args:
670
+ obj: object (signal/image)
671
+ coords: indices coords
672
+ """
673
+ if self.indices:
674
+ self.coords = coords
675
+ else:
676
+ ixc, iyc, ir = coords
677
+ x0, y0, x1, y1 = obj.indices_to_physical(
678
+ [ixc - ir, iyc - ir, ixc + ir, iyc + ir]
679
+ )
680
+ self.coords = np.array([0.5 * (x0 + x1), 0.5 * (y0 + y1), 0.5 * (x1 - x0)])
681
+
682
+ def to_mask(self, obj: ImageObj) -> np.ndarray:
683
+ """Create mask from ROI
684
+
685
+ Args:
686
+ obj: image object
687
+
688
+ Returns:
689
+ Mask (boolean array where True values are inside the ROI)
690
+ """
691
+ roi_mask = np.ones_like(obj.data, dtype=bool)
692
+ ixc, iyc, ir = self.get_indices_coords(obj)
693
+ yxratio = obj.dy / obj.dx
694
+ rr, cc = draw.ellipse(iyc, ixc, ir / yxratio, ir, shape=obj.data.shape)
695
+
696
+ if self.inverse:
697
+ # Inverse ROI: mask inside the circle (True inside, False outside)
698
+ roi_mask[:] = False
699
+ roi_mask[rr, cc] = True
700
+ else:
701
+ # Normal ROI: mask outside the circle (False inside, True outside)
702
+ roi_mask[rr, cc] = False
703
+ return roi_mask
704
+
705
+ def to_param(self, obj: ImageObj, index: int) -> ROI2DParam:
706
+ """Convert ROI to parameters
707
+
708
+ Args:
709
+ obj: object (image), for physical-indices coordinates conversion
710
+ index: ROI index
711
+ """
712
+ gtitle = base.get_generic_roi_title(index)
713
+ param = ROI2DParam(gtitle)
714
+ param.title = self.title or gtitle
715
+ param.geometry = "circle"
716
+ param.xc, param.yc, param.r = self.get_physical_coords(obj)
717
+ param.inverse = self.inverse
718
+ return param
719
+
720
+ @staticmethod
721
+ def rect_to_coords(
722
+ x0: int | float, y0: int | float, x1: int | float, y1: int | float
723
+ ) -> np.ndarray:
724
+ """Convert rectangle to circle coordinates
725
+
726
+ Args:
727
+ x0: x0 (top-left corner)
728
+ y0: y0 (top-left corner)
729
+ x1: x1 (bottom-right corner)
730
+ y1: y1 (bottom-right corner)
731
+
732
+ Returns:
733
+ Circle coordinates (xc, yc, r)
734
+ """
735
+ xc, yc, r = 0.5 * (x0 + x1), 0.5 * (y0 + y1), 0.5 * (x1 - x0)
736
+ return np.array([xc, yc, r], dtype=type(x0))
737
+
738
+
739
+ class ImageROI(base.BaseROI["ImageObj", BaseSingleImageROI, ROI2DParam]):
740
+ """Image Regions of Interest
741
+
742
+ Args:
743
+ inverse: if True, ROI is outside the region
744
+ """
745
+
746
+ PREFIX = "i"
747
+
748
+ @staticmethod
749
+ def get_compatible_single_roi_classes() -> list[Type[BaseSingleImageROI]]:
750
+ """Return compatible single ROI classes"""
751
+ return [RectangularROI, CircularROI, PolygonalROI]
752
+
753
+ def to_mask(self, obj: ImageObj) -> np.ndarray:
754
+ """Create mask from ROI
755
+
756
+ Args:
757
+ obj: image object
758
+
759
+ Returns:
760
+ Mask (boolean array where True values are inside the ROI)
761
+ """
762
+ if not self.single_rois:
763
+ # If no single ROIs, the mask is empty (no ROI defined)
764
+ mask = np.ones_like(obj.data, dtype=bool)
765
+ mask.fill(False)
766
+ return mask
767
+
768
+ # Check inverse values to determine combination strategy
769
+ inverse_values = [roi.inverse for roi in self.single_rois]
770
+ all_normal = not any(inverse_values) # All inverse=False (normal ROI)
771
+ all_inverse = all(inverse_values) # All inverse=True (inverse ROI)
772
+
773
+ if all_normal:
774
+ # All ROIs have inverse=False: AND them together (intersection)
775
+ mask = np.ones_like(obj.data, dtype=bool)
776
+ for roi in self.single_rois:
777
+ mask &= roi.to_mask(obj)
778
+ elif all_inverse:
779
+ # All ROIs have inverse=True: OR them together (union)
780
+ mask = np.zeros_like(obj.data, dtype=bool)
781
+ for roi in self.single_rois:
782
+ mask |= roi.to_mask(obj)
783
+ else:
784
+ # Mixed inverse values: complex combination
785
+ # Start with all True (full mask)
786
+ mask = np.ones_like(obj.data, dtype=bool)
787
+
788
+ # First apply all inverse=False ROIs (intersections)
789
+ for roi in self.single_rois:
790
+ if not roi.inverse:
791
+ mask &= roi.to_mask(obj)
792
+
793
+ # Then apply inverse=True ROIs (add their areas)
794
+ inside_mask = np.zeros_like(obj.data, dtype=bool)
795
+ for roi in self.single_rois:
796
+ if roi.inverse:
797
+ inside_mask |= roi.to_mask(obj)
798
+
799
+ # Combine: mask outside regions AND include inside regions
800
+ mask = mask | inside_mask
801
+
802
+ return mask
803
+
804
+
805
+ def create_image_roi(
806
+ geometry: Literal["rectangle", "circle", "polygon"],
807
+ coords: np.ndarray | list[float] | list[list[float]],
808
+ indices: bool = False,
809
+ title: str = "",
810
+ inverse: bool | list[bool] = False,
811
+ ) -> ImageROI:
812
+ """Create Image Regions of Interest (ROI) object.
813
+ More ROIs can be added to the object after creation, using the `add_roi` method.
814
+
815
+ Args:
816
+ geometry: ROI type ('rectangle', 'circle', 'polygon')
817
+ coords: ROI coords (physical coordinates), `[x0, y0, dx, dy]` for a rectangle,
818
+ `[xc, yc, r]` for a circle, or `[x0, y0, x1, y1, ...]` for a polygon (lists or
819
+ NumPy arrays are accepted). For multiple ROIs, nested lists or NumPy arrays are
820
+ accepted but with a common geometry type (e.g.
821
+ `[[xc1, yc1, r1], [xc2, yc2, r2], ...]` for circles).
822
+ indices: if True, coordinates are indices, if False, they are physical values
823
+ (default to False)
824
+ title: title
825
+ inverse: ROI logic behavior. Controls whether the ROI logic is inversed
826
+ (default: False, meaning normal ROI that focuses on data inside the shape).
827
+ When True, the ROI logic is inversed to focus on data outside the shape.
828
+ Can be a single boolean (applied to all ROIs) or a list of booleans
829
+ (one per ROI for individual control).
830
+
831
+ Returns:
832
+ Regions of Interest (ROI) object
833
+
834
+ Raises:
835
+ ValueError: if ROI type is unknown, if the number of coordinates is invalid,
836
+ or if the number of inverse values doesn't match the number of ROIs
837
+
838
+ Examples:
839
+ Create a single rectangle ROI (defaults to normal behavior):
840
+ >>> roi = create_image_roi("rectangle", [10, 20, 30, 40])
841
+
842
+ Create a single rectangle ROI with inverse logic explicitly:
843
+ >>> roi = create_image_roi("rectangle", [10, 20, 30, 40], inverse=True)
844
+
845
+ Create multiple rectangles with global inverse parameter:
846
+ >>> coords = [[10, 20, 30, 40], [50, 60, 70, 80]]
847
+ >>> roi = create_image_roi("rectangle", coords, inverse=False)
848
+
849
+ Create multiple rectangles with individual inverse parameters:
850
+ >>> coords = [[10, 20, 30, 40], [50, 60, 70, 80]]
851
+ >>> inverse_values = [True, False] # First inside, second outside
852
+ >>> roi = create_image_roi(
853
+ ... "rectangle", coords, inverse=inverse_values
854
+ ... )
855
+
856
+ Create polygons with varying vertex counts:
857
+ >>> polygon_coords = [[0, 0, 10, 0, 5, 8], [20, 20, 30, 20, 30, 30, 20, 30]]
858
+ >>> inverse_values = [False, True] # First outside, second inside
859
+ >>> roi = create_image_roi(
860
+ ... "polygon", polygon_coords, inverse=inverse_values
861
+ ... )
862
+ """
863
+ # Handle coordinates - try to create numpy array, fall back to list for irregular
864
+ try:
865
+ coords = np.array(coords, float)
866
+ if coords.ndim == 1:
867
+ coords = coords.reshape(1, -1)
868
+ coord_list = coords
869
+ coord_count = len(coords)
870
+ except ValueError:
871
+ # Handle irregular case: polygons with varying vertex counts
872
+ coord_list = [np.array(coord, float) for coord in coords]
873
+ coord_count = len(coord_list)
874
+
875
+ # Handle inverse parameter - can be single value or list
876
+ if isinstance(inverse, bool):
877
+ inverse_values = [inverse] * coord_count
878
+ else:
879
+ inverse_values = list(inverse)
880
+ if len(inverse_values) != coord_count:
881
+ raise ValueError(
882
+ f"Number of inverse values ({len(inverse_values)}) must "
883
+ f"match number of ROIs ({coord_count})"
884
+ )
885
+
886
+ roi = ImageROI()
887
+ if geometry == "rectangle":
888
+ if isinstance(coord_list, np.ndarray) and coord_list.shape[1] != 4:
889
+ raise ValueError("Rectangle ROI requires 4 coordinates")
890
+ for coord_row, inverse_val in zip(coord_list, inverse_values):
891
+ roi.add_roi(RectangularROI(coord_row, indices, title, inverse_val))
892
+ elif geometry == "circle":
893
+ if isinstance(coord_list, np.ndarray) and coord_list.shape[1] != 3:
894
+ raise ValueError("Circle ROI requires 3 coordinates")
895
+ for coord_row, inverse_val in zip(coord_list, inverse_values):
896
+ roi.add_roi(CircularROI(coord_row, indices, title, inverse_val))
897
+ elif geometry == "polygon":
898
+ if isinstance(coord_list, np.ndarray) and coord_list.shape[1] % 2 != 0:
899
+ raise ValueError("Polygon ROI requires pairs of X, Y coordinates")
900
+ for coord_row, inverse_val in zip(coord_list, inverse_values):
901
+ roi.add_roi(PolygonalROI(coord_row, indices, title, inverse_val))
902
+ else:
903
+ raise ValueError(f"Unknown ROI type: {geometry}")
904
+ return roi