Source code for radioviz.models.roi_model

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Module for handling Region of Interest (ROI) definitions and validations.

This module provides functionality for defining different types of regions of interest,
validating their geometric properties, and calculating various attributes such as
bounding boxes, centers, and dimensions. It supports rectangle, circle, ellipse, and
polygon ROI types with appropriate validation schemas.
"""

from __future__ import annotations

import math
import sys
from dataclasses import dataclass

if sys.version_info >= (3, 11):
    from enum import StrEnum
else:
    from radioviz.models.legacy import StrEnum
from typing import Any, cast

if sys.version_info >= (3, 10):
    from typing import Tuple, TypeAlias
else:
    from typing_extensions import Tuple, TypeAlias


#: Type alias for a tuple of four floats (xmin, xmax, ymin, ymax)
QuadFloat: TypeAlias = Tuple[float, float, float, float]


[docs] class ROIType(StrEnum): """ Enumeration of supported region selection types. This enum defines the different types of regions of interest that can be created and validated within the application. """ RECTANGLE = 'Rectangle' """Rectangle selection type.""" CIRCLE = 'Circle' """Circle selection type.""" ELLIPSE = 'Ellipse' """Ellipse selection type.""" POLYGON = 'Polygon' """Polygon selection type."""
ROI_SCHEMAS = { ROIType.RECTANGLE: { 'required': {'extents', 'angle'}, }, ROIType.CIRCLE: { 'required': { 'extents', }, }, ROIType.ELLIPSE: { 'required': {'extents', 'angle'}, }, ROIType.POLYGON: { 'required': {'verts'}, }, } """ Schema definitions for validating different ROI types. Each ROI type has specific required fields that must be present in the geometry dictionary for validation to pass. """
[docs] def validate_roi(roi_type: ROIType, geom: dict[str, Any]) -> None: """ Validate the geometry of a region of interest based on its type. This function checks that all required fields are present in the geometry dictionary and performs type-specific validation for different ROI types. :param roi_type: The type of region of interest to validate :type roi_type: :class:`ROIType` :param geom: The geometry definition of the ROI :type geom: dict[str, Any] :raise ValueError: if the ROI type is unsupported or required fields are missing :raise ValueError: if the geometry doesn't meet type-specific requirements """ if roi_type not in ROI_SCHEMAS: raise ValueError(f'Unsupported ROI type: {roi_type}') schema = ROI_SCHEMAS[roi_type] required = schema['required'] missing = required - geom.keys() if missing: raise ValueError(f'Missing keys for {roi_type}: {missing}') if roi_type in [ROIType.RECTANGLE, ROIType.CIRCLE, ROIType.ELLIPSE]: _validate_rectangle_based_geometry(geom) elif roi_type == ROIType.POLYGON: _validate_polygon_based_geometry(geom)
[docs] def _validate_rectangle_based_geometry(geom: dict[str, Any]) -> None: """ Validate geometry for rectangle-based ROI types. Validates that extents are properly formatted as a list/tuple of four positive values and that angle is numeric. :param geom: The geometry definition containing extents and optional angle :type geom: dict[str, Any] :raise ValueError: if extents are invalid or angle is not numeric """ ext = geom['extents'] # a list or tuple of four non-negative elements if not ( isinstance(ext, (tuple, list)) and len(ext) == 4 and all(isinstance(v, (int, float)) and v >= 0 for v in ext) ): raise ValueError('Extents must be (xmin, xmax, ymin, ymax) >= 0') angle = geom.get('angle', 0.0) if not isinstance(angle, (int, float)): raise ValueError('Angle must be numeric')
[docs] def _validate_polygon_based_geometry(geom: dict[str, Any]) -> None: """ Validate geometry for polygon-based ROI types. Validates that vertices are properly formatted as a list/tuple of at least three points. :param geom: The geometry definition containing vertices :type geom: dict[str, Any] :raise ValueError: if vertices are invalid """ verts = geom['verts'] if not ( isinstance(verts, (tuple, list)) and len(verts) >= 3 and all( isinstance(p, (tuple, list)) and len(p) == 2 and all(isinstance(v, (int, float)) for v in p) for p in verts ) ): raise ValueError('Vertices must be a list of at least three points (x, y)')
[docs] class ROIEnclosure(StrEnum): """ Enumeration of region enclosure options. Defines whether a region is considered inside or outside the selection area. """ Inside = 'Inside' """Region is inside the selection.""" Outside = 'Outside' """Region is outside the selection."""
[docs] @dataclass(frozen=True) class ROI: """ Data class representing a Region of Interest. This class encapsulates all information about a region of interest including its type, enclosure preference, and geometric properties. """ type: ROIType """The type of the region of interest.""" enclosure: ROIEnclosure """The enclosure preference for the region.""" geometry: dict[str, Any] """The geometric definition of the ROI.""" def __post_init__(self) -> None: """ Validate the ROI after initialization. Performs validation of the ROI type and geometry to ensure they conform to expected formats and constraints. :raise ValueError: if the ROI configuration is invalid """ validate_roi(self.type, self.geometry) @property def width(self) -> float: """ Calculate the width of the ROI. For non-polygon ROIs, returns the difference between maximum and minimum x coordinates. Raises AttributeError for polygon ROIs. :return: The width of the ROI :rtype: float :raise AttributeError: if the ROI is a polygon """ if self.type != ROIType.POLYGON: x_min, x_max, _, _ = self.geometry['extents'] return cast(float, abs(x_max - x_min)) else: raise AttributeError('ROI.width cannot be calculated for polygons') @property def height(self) -> float: """ Calculate the height of the ROI. For non-polygon ROIs, returns the difference between maximum and minimum y coordinates. Raises AttributeError for polygon ROIs. :return: The height of the ROI :rtype: float :raise AttributeError: if the ROI is a polygon """ if self.type != ROIType.POLYGON: _, _, y_min, y_max = self.geometry['extents'] return cast(float, abs(y_max - y_min)) else: raise AttributeError('ROI.height cannot be calculated for polygons') @property def xy(self) -> tuple[float, float]: """ Get the x,y coordinates of the ROI. For rectangles, returns the bottom-left corner coordinates. For circles and ellipses, returns the center coordinates. Raises AttributeError for polygon ROIs. :return: The x,y coordinates of the ROI :rtype: tuple[float, float] :raise AttributeError: if the ROI is a polygon """ if self.type == ROIType.RECTANGLE: x_min, _, y_min, _ = self.geometry['extents'] return x_min, y_min elif self.type in [ROIType.ELLIPSE, ROIType.CIRCLE]: return self.center else: raise AttributeError('ROI.xy cannot be calculated for polygons') @property def center(self) -> tuple[float, float]: """ Calculate the center point of the ROI. For polygons, calculates the centroid by averaging vertex coordinates. For other types, calculates the midpoint of the bounding box. :return: The center coordinates of the ROI :rtype: tuple[float, float] """ if self.type == ROIType.POLYGON: xs, ys = zip(*self.geometry['verts']) return sum(xs) / len(xs), sum(ys) / len(ys) x_min, x_max, y_min, y_max = self.extents return (x_min + x_max) / 2, (y_min + y_max) / 2 @property def bounding_box(self) -> QuadFloat: """ Get the bounding box coordinates of the ROI. Returns the minimum and maximum x and y coordinates that define the bounding box. :return: The bounding box coordinates (xmin, xmax, ymin, ymax) :rtype: tuple[float, float, float, float] """ if self.type != ROIType.POLYGON: return cast(QuadFloat, self.geometry['extents']) else: xs, ys = zip(*self.geometry['verts']) return cast(QuadFloat, (min(xs), max(xs), min(ys), max(ys))) @property def radius(self) -> float: """ Calculate the radius of a circular ROI. Only applicable to circle ROIs. Calculates radius from the width of the bounding box. :return: The radius of the circle ROI :rtype: float :raise AttributeError: if the ROI is not a circle """ if self.type != ROIType.CIRCLE: raise AttributeError('Radius only available for circle ROI') # extents define bounding box of the circle return self.width / 2 @property def extents(self) -> QuadFloat: """ Get the extents of the ROI. Returns the extent values (xmin, xmax, ymin, ymax) of the ROI. :return: The extents of the ROI :rtype: tuple[float, float, float, float] :raise AttributeError: if the ROI does not have extents """ if 'extents' in self.geometry: return cast(QuadFloat, self.geometry['extents']) else: raise AttributeError('ROI does not have extents') @property def angle(self) -> float: """ Get the rotation angle of the ROI. Returns the rotation angle for rectangle and ellipse ROIs, defaults to 0 for others. :return: The rotation angle in degrees :rtype: float """ if self.type in [ROIType.RECTANGLE, ROIType.ELLIPSE]: return cast(float, self.geometry.get('angle', 0.0)) else: return 0.0 @property def angle_rad(self) -> float: """ Get the rotation angle of the ROI in radians. Converts the angle from degrees to radians for mathematical calculations. :return: The rotation angle in radians :rtype: float """ return math.radians(self.angle)