Source code for radioviz.tools.crop_utils

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Shared utilities for crop-based tools.

This module contains common data structures and overlay renderers used by
manual and automatic cropping tools.
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
from matplotlib.artist import Artist
from matplotlib.axes import Axes
from matplotlib.patches import Rectangle
from matplotlib.text import Annotation

from radioviz.models.overlays import OverlayModel, OverlayRole


[docs] @dataclass class RectangleExtent: """Represent a rectangular extent with integer coordinates. The class stores the minimum and maximum column (x) and row (y) positions of a rectangle and provides utility methods for conversion, padding, scaling and normalisation. """ xmin: float """The minimum x‑coordinate (inclusive).""" xmax: float """The maximum x‑coordinate (inclusive).""" ymin: float """The minimum y‑coordinate (inclusive).""" ymax: float """The maximum y‑coordinate (inclusive).""" def to_tuple(self) -> tuple[float, float, float, float]: return self.xmin, self.xmax, self.ymin, self.ymax
[docs] @classmethod def from_bbox(cls, minr: int, minc: int, maxr: int, maxc: int) -> 'RectangleExtent': """ Create a :class:`RectangleExtent` from bounding‑box coordinates. :param minr: Minimum row (y‑coordinate) of the bounding box (inclusive). :type minr: int :param minc: Minimum column (x‑coordinate) of the bounding box (inclusive). :type minc: int :param maxr: Maximum row (y‑coordinate) of the bounding box (inclusive). :type maxr: int :param maxc: Maximum column (x‑coordinate) of the bounding box (inclusive). :type maxc: int :return: New ``RectangleExtent`` instance. :rtype: RectangleExtent """ return cls(minc, maxc, minr, maxr)
[docs] @classmethod def from_extent(cls, xmin: float, xmax: float, ymin: float, ymax: float) -> 'RectangleExtent': """ Create a :class:`RectangleExtent` directly from extent values. :param xmin: Minimum column (x) coordinate (inclusive). :type xmin: float :param xmax: Maximum column (x) coordinate (inclusive). :type xmax: float :param ymin: Minimum row (y) coordinate (inclusive). :type ymin: float :param ymax: Maximum row (y) coordinate (inclusive). :type ymax: float :return: New ``RectangleExtent`` instance. :rtype: RectangleExtent """ return cls(xmin, xmax, ymin, ymax)
[docs] def add_padding(self, padding: int) -> None: """ Expand the rectangle by a symmetric padding. :param padding: Number of pixels (or units) to add on each side. :type padding: int """ self.xmin -= padding self.xmax += padding self.ymin -= padding self.ymax += padding
[docs] def scale_factor(self, factor: int) -> None: """ Scale the rectangle coordinates by an integer factor. :param factor: Multiplicative factor applied to all four coordinates. :type factor: int """ self.xmin *= factor self.xmax *= factor self.ymin *= factor self.ymax *= factor
[docs] def normalize(self, x_max: int, y_max: int) -> None: """ Clamp the rectangle to stay within the image bounds. :param x_max: Maximum allowed x‑coordinate (width‑1). :type x_max: int :param y_max: Maximum allowed y‑coordinate (height‑1). :type y_max: int """ if self.xmin < 0: self.xmin = 0 if self.ymin < 0: self.ymin = 0 if self.xmax > x_max: self.xmax = x_max if self.ymax > y_max: self.ymax = y_max
[docs] def expand(self, x_pad: float, y_pad: float) -> tuple[float, float, float, float]: """ Return a padded extent without mutating the instance. :param x_pad: Horizontal padding to apply to ``xmin`` and ``xmax``. :type x_pad: float :param y_pad: Vertical padding to apply to ``ymin`` and ``ymax``. :type y_pad: float :return: ``(xmin - x_pad, xmax + x_pad, ymin - y_pad, ymax + y_pad)``. :rtype: tuple[float, float, float, float] """ return self.xmin - x_pad, self.xmax + x_pad, self.ymin - y_pad, self.ymax + y_pad
[docs] @dataclass class CropResult: """Container for the outcome of a cropping operation. It stores the label associated with the crop, the cropped image data, an overlay label, and the geometric extents of the crop in the original image. """ label: str """Human‑readable label for the crop.""" overlay_label: str """Short label for overlay annotations.""" image: np.ndarray """The cropped image as a NumPy array.""" extents: tuple[float, float, float, float] """The crop extents expressed as ``(xmin, xmax, ymin, ymax)``."""
[docs] def draw_crop_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a crop overlay on a Matplotlib axes. The function creates a :class:`matplotlib.patches.Rectangle` using the geometry and style defined in the provided :class:`~radioviz.models.overlays.OverlayModel`, adds it to *axes*, and attaches an :class:`matplotlib.text.Annotation` showing the overlay label. :param overlay: Overlay model containing geometry, style and label. :type overlay: OverlayModel :param axes: Matplotlib axes on which to draw the overlay. :type axes: matplotlib.axes.Axes :return: List containing the created rectangle patch and annotation artist. :rtype: list[matplotlib.artist.Artist] """ patch = Rectangle(**overlay.geometry, **overlay.style) axes.add_patch(patch) xy = patch.xy xytext = (0, 10) annotation = Annotation( text=overlay.label or '', xy=xy, xytext=xytext, xycoords='data', textcoords='offset points', color=overlay.style['edgecolor'], fontsize=12, ) axes.add_artist(annotation) return [patch, annotation]
[docs] def draw_highlight_crop_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a highlighted crop overlay on a Matplotlib axes. If the supplied *overlay* does not already have the role :class:`~radioviz.models.overlays.OverlayRole.Highlight`, it is converted to that role before delegating to :func:`draw_crop_overlay`. :param overlay: Overlay model to be highlighted. :type overlay: OverlayModel :param axes: Matplotlib axes on which to draw the highlighted overlay. :type axes: matplotlib.axes.Axes :return: List of Matplotlib artists created by the underlying draw call. :rtype: list[matplotlib.artist.Artist] """ if overlay.role != OverlayRole.Highlight: overlay = overlay.change_role_to(OverlayRole.Highlight) return draw_crop_overlay(overlay, axes)