# 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
[docs]
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
[docs]
class SignalROI(base.BaseROI["SignalObj", SegmentROI, ROI1DParam]):
"""Signal Regions of Interest
Args:
inverse: if True, ROI is outside the region
"""
PREFIX = "s"
[docs]
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
[docs]
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
[docs]
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))
[docs]
@staticmethod
def get_compatible_single_roi_classes() -> list[Type[SegmentROI]]:
"""Return compatible single ROI classes"""
return [SegmentROI]
[docs]
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
[docs]
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