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
|