# 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)