File: roi.py

package info (click to toggle)
python-sigima 1.1.1-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 25,608 kB
  • sloc: python: 35,251; makefile: 3
file content (288 lines) | stat: -rw-r--r-- 9,420 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
Signal ROI utilities
====================

This module provides Region of Interest (ROI) classes and utilities for signal objects.

The module includes:

- `ROI1DParam`: Parameter class for 1D signal ROIs
- `SegmentROI`: Single ROI representing a segment of a signal
- `SignalROI`: Collection of signal ROIs with operations
- `create_signal_roi`: Factory function for creating signal ROI objects

These classes enable defining and working with regions of interest in 1D signal data,
supporting operations like data extraction, masking, and parameter conversion.
"""

# pylint: disable=invalid-name  # Allows short reference names like x, y, ...
# pylint: disable=duplicate-code

from __future__ import annotations

from typing import TYPE_CHECKING, Type

import guidata.dataset as gds
import numpy as np

from sigima.config import _
from sigima.objects import base

if TYPE_CHECKING:
    from sigima.objects.signal.object import SignalObj


class ROI1DParam(base.BaseROIParam["SignalObj", "SegmentROI"]):
    """Signal ROI parameters"""

    # Note: in this class, the ROI parameters are stored as X coordinates

    title = gds.StringItem(_("ROI title"), default="")
    xmin = gds.FloatItem(_("First point coordinate"), default=0.0)
    xmax = gds.FloatItem(_("Last point coordinate"), default=1.0)

    def to_single_roi(self, obj: SignalObj) -> SegmentROI:
        """Convert parameters to single ROI

        Args:
            obj: signal object

        Returns:
            Single ROI
        """
        assert isinstance(self.xmin, float) and isinstance(self.xmax, float)
        return SegmentROI([self.xmin, self.xmax], False, title=self.title)

    def get_data(self, obj: SignalObj) -> np.ndarray:
        """Get signal data in ROI

        Args:
            obj: signal object

        Returns:
            Data in ROI
        """
        assert isinstance(self.xmin, float) and isinstance(self.xmax, float)
        imin, imax = np.searchsorted(obj.x, [self.xmin, self.xmax])
        return np.array([obj.x[imin:imax], obj.y[imin:imax]])


class SegmentROI(base.BaseSingleROI["SignalObj", ROI1DParam]):
    """Segment ROI

    Args:
        coords: ROI coordinates (xmin, xmax)
        title: ROI title
    """

    # Note: in this class, the ROI parameters are stored as X indices

    def check_coords(self) -> None:
        """Check if coords are valid

        Raises:
            ValueError: invalid coords
        """
        if len(self.coords) != 2:
            raise ValueError("Invalid ROI segment coords (2 values expected)")
        if self.coords[0] >= self.coords[1]:
            raise ValueError("Invalid ROI segment coords (xmin >= xmax)")

    def get_coords_html_rows(self) -> list[tuple[str, str]]:
        """Return HTML table rows describing the segment coordinates."""
        xmin, xmax = self.coords
        coord_type = "indices" if self.indices else "physical"
        return [
            (f"X min ({coord_type})", f"{xmin:.4g}"),
            (f"X max ({coord_type})", f"{xmax:.4g}"),
        ]

    def get_coords_summary(self) -> str:
        """Return a short summary of the segment coordinates."""
        xmin, xmax = self.coords
        return f"X: [{xmin:.4g}, {xmax:.4g}]"

    def get_data(self, obj: SignalObj) -> tuple[np.ndarray, np.ndarray]:
        """Get signal data in ROI

        Args:
            obj: signal object

        Returns:
            Data in ROI
        """
        imin, imax = self.get_indices_coords(obj)
        return obj.x[imin:imax], obj.y[imin:imax]

    def to_mask(self, obj: SignalObj) -> np.ndarray:
        """Create mask from ROI

        Args:
            obj: signal object

        Returns:
            Mask (boolean array where True values are inside the ROI)
        """
        mask = np.ones_like(obj.xydata, dtype=bool)
        imin, imax = self.get_indices_coords(obj)
        mask[:, imin:imax] = False
        return mask

    # pylint: disable=unused-argument
    def to_param(self, obj: SignalObj, index: int) -> ROI1DParam:
        """Convert ROI to parameters

        Args:
            obj: object (signal), for physical-indices coordinates conversion
            index: ROI index
        """
        gtitle = base.get_generic_roi_title(index)
        param = ROI1DParam(gtitle)
        param.title = self.title or gtitle
        param.xmin, param.xmax = self.get_physical_coords(obj)
        return param


class SignalROI(base.BaseROI["SignalObj", SegmentROI, ROI1DParam]):
    """Signal Regions of Interest

    Args:
        inverse: if True, ROI is outside the region
    """

    PREFIX = "s"

    def union(self) -> SignalROI:
        """Return union of ROIs"""
        if not self.single_rois:
            return SignalROI()
        coords = np.array([roi.coords for roi in self.single_rois])
        # Merge overlapping segments:
        sorted_coords = coords[coords[:, 0].argsort()]
        merged_coords = [sorted_coords[0].tolist()]
        for current in sorted_coords[1:]:
            last = merged_coords[-1]
            if current[0] <= last[1]:  # Overlap
                last[1] = max(last[1], current[1])  # Merge
            else:
                merged_coords.append(current.tolist())
        # Create new SignalROI with merged segments:
        roi = create_signal_roi(merged_coords)
        return roi

    def clipped(self, x_min: float, x_max: float) -> SignalROI:
        """Remove parts of ROIs outside the signal range

        Args:
            x_min: signal minimum X value
            x_max: signal maximum X value

        Returns:
            SignalROI object containing ROIs clipped to the specified signal range.
        """
        new_roi = SignalROI()
        for roi in self.single_rois:
            roi_min, roi_max = roi.coords
            if roi_max < x_min or roi_min > x_max:
                # ROI completely outside signal range: skip it
                continue
            # Clip ROI to signal range:
            new_roi_min = max(roi_min, x_min)
            new_roi_max = min(roi_max, x_max)
            new_roi.add_roi(
                SegmentROI(np.array([new_roi_min, new_roi_max], float), indices=False)
            )
        return new_roi

    def inverted(self, x_min: float, x_max: float) -> SignalROI:
        """Return inverted ROI (inside/outside).

        Args:
            x_min: signal minimum X value
            x_max: signal maximum X value
        Returns:
            Inverted ROI
        """
        clipped_roi = self.clipped(x_min, x_max)
        union_roi = clipped_roi.union()
        roi_delimiter_list = np.array(
            [roi.coords for roi in union_roi.single_rois]
        ).reshape(-1)

        if len(roi_delimiter_list) == 0:
            # No ROIs: inverted ROI is the whole signal
            raise ValueError("No ROIs defined, cannot invert")
        if len(roi_delimiter_list) % 2 != 0:
            # Odd number of delimiters: add signal limits
            raise ValueError("Internal error: odd number of ROI delimiters")

        if roi_delimiter_list[0] == x_min:
            # First delimiter is signal min: remove it
            roi_delimiter_list = roi_delimiter_list[1:]
        else:
            # Add signal min as first delimiter
            roi_delimiter_list = np.insert(roi_delimiter_list, 0, x_min)

        if roi_delimiter_list[-1] == x_max:
            # Last delimiter is signal max: remove it
            roi_delimiter_list = roi_delimiter_list[:-1]
        else:
            # Add signal max as last delimiter
            roi_delimiter_list = np.append(roi_delimiter_list, x_max)

        return create_signal_roi(np.array(roi_delimiter_list).reshape(-1, 2))

    @staticmethod
    def get_compatible_single_roi_classes() -> list[Type[SegmentROI]]:
        """Return compatible single ROI classes"""
        return [SegmentROI]

    def to_mask(self, obj: SignalObj) -> np.ndarray:
        """Create mask from ROI

        Args:
            obj: signal object

        Returns:
            Mask (boolean array where True values are inside the ROI)
        """
        mask = np.ones_like(obj.xydata, dtype=bool)
        if self.single_rois:
            for roi in self.single_rois:
                mask &= roi.to_mask(obj)
        else:
            # If no single ROIs, the mask is empty (no ROI defined)
            mask[:] = False
        return mask


def create_signal_roi(
    coords: np.ndarray | list[float] | list[list[float]],
    indices: bool = False,
    title: str = "",
) -> SignalROI:
    """Create Signal Regions of Interest (ROI) object.
    More ROIs can be added to the object after creation, using the `add_roi` method.

    Args:
        coords: single ROI coordinates `[xmin, xmax]`, or multiple ROIs coordinates
         `[[xmin1, xmax1], [xmin2, xmax2], ...]` (lists or NumPy arrays)
        indices: if True, coordinates are indices, if False, they are physical values
         (default to False for signals)
        title: title

    Returns:
        Regions of Interest (ROI) object

    Raises:
        ValueError: if the number of coordinates is not even
    """
    coords = np.array(coords, float)
    if coords.ndim == 1:
        coords = coords.reshape(1, -1)
    roi = SignalROI()
    for row in coords:
        roi.add_roi(SegmentROI(row, indices=indices, title=title))
    return roi