Source code for radioviz.tools.manual_crop_tool

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Manual crop tool implementation.

This module provides a complete implementation of a manual cropping workflow
for image data. It defines data structures for session results, a dialog for
user interaction, a session class that manages the selection and cropping
process, a controller that integrates the tool with the application framework,
and the tool class itself which registers overlays and exposes the functionality
to the rest of the application.
"""

from __future__ import annotations

from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, Any, List, Optional, cast
from uuid import uuid4

import numpy as np
from matplotlib.backend_bases import MouseEvent
from matplotlib.widgets import RectangleSelector
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
    QDialog,
    QDoubleSpinBox,
    QGridLayout,
    QHBoxLayout,
    QLabel,
    QLayout,
    QPushButton,
    QVBoxLayout,
)
from superqt.utils import thread_worker

from radioviz.controllers.image_window_controller import ImageWindowController
from radioviz.controllers.sub_window_controller import SubWindowController
from radioviz.models.data_model import DataOrigin, DataSource, SpatialDerivation
from radioviz.models.menu_spec import ToolActionSpec, ToolMenuSpec
from radioviz.models.overlays import OverlayKey, OverlayModel, OverlayRole, OverlaySpec
from radioviz.models.roi_model import ROI, ROIEnclosure, ROIType
from radioviz.services.action_descriptor import ActionDescriptor
from radioviz.services.color_service import to_mpl
from radioviz.services.item_counter import ItemCounter
from radioviz.services.typing_helpers import assume_not_none, require_not_none
from radioviz.services.window_manager import WindowRequest
from radioviz.tools.base_controller import ToolController
from radioviz.tools.base_tool import Tool
from radioviz.tools.crop_utils import CropResult, RectangleExtent, draw_crop_overlay, draw_highlight_crop_overlay
from radioviz.tools.naming_utils import append_suffix_before_extension
from radioviz.tools.tool_api import BaseToolSession, ToolContext

if TYPE_CHECKING:
    from PySide6.QtWidgets import QWidget


[docs] @dataclass(frozen=True) class ManualCropSessionResult: """ Result of a manual crop session. This dataclass encapsulates the label, cropped image data, and the extents of the region that was selected during a manual cropping operation. """ label: str """The label assigned to the cropped image.""" image: np.ndarray """The NumPy array containing the cropped image data.""" extents: tuple[float, float, float, float] """The (x0, x1, y0, y1) extents of the selected region in image coordinates."""
[docs] @thread_worker def manual_crop_worker( image: np.ndarray, extents: tuple[float, float, float, float], label: str, overlay_label: str ) -> CropResult: """ Perform a cropping operation in a background thread. The worker normalises the supplied extents to the image dimensions, extracts the corresponding sub‑array, and returns a :class:`CropResult`. :param image: The source image data. :type image: numpy.ndarray :param extents: The raw (x0, x1, y0, y1) extents supplied by the selector. :type extents: tuple[float, float, float, float] :param label: The label to assign to the resulting cropped image. :type label: str :param overlay_label: Short label to use for overlay annotations. :type overlay_label: str :return: A :class:`CropResult` containing the cropped image and its extents. :rtype: CropResult """ y_max, x_max = image.shape bbox = RectangleExtent.from_extent(*extents) bbox.normalize(x_max, y_max) x0, x1, y0, y1 = tuple(np.round(bbox.to_tuple()).astype(int)) return CropResult(label=label, overlay_label=overlay_label, image=image[y0:y1, x0:x1], extents=bbox.to_tuple())
[docs] class ManualCropToolDialog(QDialog): """ Dialog window for configuring manual crop parameters. The dialog displays spin boxes for the top‑left corner, width, height, and aspect ratio of the selection. It stays synchronised with the :class:`RectangleSelector` used on the image canvas. """ def __init__(self, tool_session: ManualCropToolSession, parent: QWidget | None = None) -> None: """ Initialise the dialog. :param tool_session: The active :class:`ManualCropToolSession`. :type tool_session: ManualCropToolSession :param parent: Optional parent widget. :type parent: QWidget or None """ super().__init__(parent) self.setModal(False) self.tool_session = tool_session self.setWindowTitle('Manual crop') self._syncing = False layout = QVBoxLayout() layout.addWidget( QLabel( 'Adjust the selection on the image or edit the values below.\n' 'The selection will update in both directions.' ) ) grid = QGridLayout() grid.addWidget(QLabel('Top-left X'), 0, 0) self.x_spin = QDoubleSpinBox() grid.addWidget(self.x_spin, 0, 1) grid.addWidget(QLabel('Top-left Y'), 1, 0) self.y_spin = QDoubleSpinBox() grid.addWidget(self.y_spin, 1, 1) grid.addWidget(QLabel('Width'), 2, 0) self.w_spin = QDoubleSpinBox() grid.addWidget(self.w_spin, 2, 1) grid.addWidget(QLabel('Height'), 3, 0) self.h_spin = QDoubleSpinBox() grid.addWidget(self.h_spin, 3, 1) grid.addWidget(QLabel('Aspect ratio'), 4, 0) self.aspect_label = QLabel('--') grid.addWidget(self.aspect_label, 4, 1) layout.addLayout(grid) button_layout = QHBoxLayout() self.cancel_button = QPushButton('Cancel') self.cancel_button.clicked.connect(self.reject) button_layout.addWidget(self.cancel_button) self.ok_button = QPushButton('OK') self.ok_button.clicked.connect(self.accept) button_layout.addWidget(self.ok_button) layout.addLayout(button_layout) self.setLayout(layout) layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) self._setup_spinboxes() self._connect_signals()
[docs] def _setup_spinboxes(self) -> None: """ Configure the spin boxes limits and steps based on the image size. The maximum values correspond to the image dimensions, while the step size is set to 1.0 pixel. """ h, w = self.tool_session.image_shape for spin in (self.x_spin, self.w_spin): spin.setMinimum(0) spin.setMaximum(max(0, w)) spin.setDecimals(1) spin.setSingleStep(1.0) for spin in (self.y_spin, self.h_spin): spin.setMinimum(0) spin.setMaximum(max(0, h)) spin.setDecimals(1) spin.setSingleStep(1.0)
[docs] def _connect_signals(self) -> None: """ Connect spin box value changes to the internal synchronisation handler. """ self.x_spin.valueChanged.connect(self._on_values_changed) self.y_spin.valueChanged.connect(self._on_values_changed) self.w_spin.valueChanged.connect(self._on_values_changed) self.h_spin.valueChanged.connect(self._on_values_changed)
[docs] def _on_values_changed(self) -> None: """ Propagate spin box changes to the selector. This method is called whenever any of the spin boxes emits a ``valueChanged`` signal, unless the dialog is currently synchronising values from the selector. """ if self._syncing: return self.tool_session.update_selector_from_values( self.x_spin.value(), self.y_spin.value(), self.w_spin.value(), self.h_spin.value() )
[docs] def set_values_from_extent(self, extents: tuple[float, float, float, float]) -> None: """ Update the spin boxes to reflect a new selector extent. :param extents: The (x0, x1, y0, y1) extents to display. :type extents: tuple[float, float, float, float] """ self._syncing = True try: x0, x1, y0, y1 = extents w = max(0.0, x1 - x0) h = max(0.0, y1 - y0) for spin, value in ( (self.x_spin, x0), (self.y_spin, y0), (self.w_spin, w), (self.h_spin, h), ): spin.blockSignals(True) spin.setValue(float(value)) spin.blockSignals(False) self._update_aspect_ratio(w, h) finally: self._syncing = False
[docs] def _update_aspect_ratio(self, width: float, height: float) -> None: """ Compute and display the aspect ratio of the current selection. :param width: Width of the selection in pixels. :type width: float :param height: Height of the selection in pixels. :type height: float """ if height <= 0: self.aspect_label.setText('--') return ratio = width / height self.aspect_label.setText(f'{ratio:.3f}')
[docs] class ManualCropToolSession(BaseToolSession['ManualCropToolController', 'ImageWindowController']): """ Session class for manual crop tool workflow. Manages the lifecycle of a manual cropping operation, including the creation of the selector, synchronisation with the dialog, and the background cropping task. """ def __init__(self, tool_controller: ManualCropToolController, window_controller: ImageWindowController): """ Initialise a new session. :param tool_controller: The controller that created this session. :type tool_controller: ManualCropToolController :param window_controller: The image window on which cropping is performed. :type window_controller: ImageWindowController """ super().__init__(tool_controller, window_controller) self.dialog_window: Optional[ManualCropToolDialog] = None self.selector: Optional[RectangleSelector] = None self.image_shape = window_controller.image_data.shape
[docs] def on_start(self) -> None: """ Start the session by showing the configuration dialog and the selector. """ self.dialog_window = ManualCropToolDialog(self) self.dialog_window.accepted.connect(self.request_cropping) self.dialog_window.rejected.connect(partial(self.on_cancel, 'user cancel')) self.dialog_window.show() self._setup_selector()
[docs] def on_cancel(self, reason: str) -> None: """ Cancel the session and clean up resources. :param reason: Reason for cancellation (e.g., user action). :type reason: str """ self.cleanup()
[docs] def on_finish(self) -> None: """ Finish the session and clean up resources. """ self.cleanup()
[docs] def cleanup(self) -> None: """ Remove the selector and any associated visual artefacts. """ self._teardown_selector()
[docs] def _setup_selector(self) -> None: """ Initialise the :class:`RectangleSelector` on the image canvas. The selector is centred on the image and occupies half of its width and height by default. """ if self.selector is not None: return dialog_window = assume_not_none(self.dialog_window) view = self.window_controller.require_view() ax = view.canvas.im_axes h, w = self.image_shape sel_w = w * 0.5 sel_h = h * 0.5 x0 = (w - sel_w) / 2 y0 = (h - sel_h) / 2 x1 = x0 + sel_w y1 = y0 + sel_h self.selector = RectangleSelector( ax, onselect=self.on_selector_change, interactive=True, useblit=True, props={'edgecolor': 'yellow', 'fill': False, 'ls': 'dashed', 'lw': 2}, drag_from_anywhere=True, ) self.selector.extents = (x0, x1, y0, y1) self.selector.set_active(True) ax.figure.canvas.draw_idle() dialog_window.set_values_from_extent(self.selector.extents)
[docs] def _teardown_selector(self) -> None: """ Remove the selector from the canvas and release its resources. """ if not self.selector: return try: self.selector.clear() except Exception: pass self.selector.set_active(False) self.selector.disconnect_events() if hasattr(self.selector, 'artists'): for artist in self.selector.artists: try: artist.remove() except Exception: pass self.selector = None view = self.window_controller.require_view() view.canvas.draw_idle() # type: ignore[no-untyped-call]
[docs] def on_selector_change(self, click: MouseEvent, release: MouseEvent) -> None: """ Callback invoked when the selector geometry changes. Updates the dialog spin boxes to reflect the new extents. :param click: Mouse event at the start of the selection. :type click: MouseEvent :param release: Mouse event at the end of the selection. :type release: MouseEvent """ if self.selector is None: return dialog_window = assume_not_none(self.dialog_window) dialog_window.set_values_from_extent(self.selector.extents)
[docs] def update_selector_from_values(self, x: float, y: float, w: float, h: float) -> None: """ Update the selector geometry based on spin box values. :param x: Top‑left X coordinate. :type x: float :param y: Top‑left Y coordinate. :type y: float :param w: Width of the selection. :type w: float :param h: Height of the selection. :type h: float """ if self.selector is None: return extents = self._normalize_extents(x, y, w, h) self.selector.extents = extents.to_tuple() dialog_window = assume_not_none(self.dialog_window) view = self.window_controller.require_view() view.canvas.draw_idle() # type: ignore[no-untyped-call] dialog_window.set_values_from_extent(self.selector.extents)
[docs] def _normalize_extents(self, x: float, y: float, w: float, h: float) -> RectangleExtent: """ Clamp and normalise the user‑provided extents to the image bounds. :param x: Desired top‑left X coordinate. :type x: float :param y: Desired top‑left Y coordinate. :type y: float :param w: Desired width. :type w: float :param h: Desired height. :type h: float :return: A :class:`RectangleExtent` representing the normalised region. :rtype: RectangleExtent """ img_h, img_w = self.image_shape x0 = max(0.0, min(x, img_w - 1)) y0 = max(0.0, min(y, img_h - 1)) w = max(1.0, min(w, img_w - x0)) h = max(1.0, min(h, img_h - y0)) x1 = x0 + w y1 = y0 + h return RectangleExtent.from_extent(x0, x1, y0, y1)
[docs] def request_cropping(self) -> None: """ Trigger the background cropping operation using the current selector extents. """ if self.selector is None: return extents = self.selector.extents self._teardown_selector() crop_index = self.tool_controller.item_counter.next() overlay_label = f'crop-{crop_index}' label = append_suffix_before_extension(self.window_controller.name, f'-mancrop-{crop_index}') worker = manual_crop_worker(self.window_controller.image_data, extents, label, overlay_label) worker.returned.connect(self._on_crop_ready) worker.start()
[docs] def _on_crop_ready(self, crop: CropResult) -> None: """ Handle the result of the background cropping operation. Creates a new :class:`ImageWindowController` for the cropped image, registers the overlay, and finalises the session. :param crop: The result produced by :func:`manual_crop_worker`. :type crop: CropResult """ source = DataSource( label=f'{crop.label}', origin=DataOrigin.DERIVED, parent=self.window_controller.source, derivation=SpatialDerivation( self.window_controller.id, ROI( ROIType.RECTANGLE, ROIEnclosure.Inside, geometry=dict(extents=crop.extents, angle=0.0, overlay_label=crop.overlay_label), ), transform=None, ), ) derived_metadata = self.window_controller.derive_metadata_for_child(source) image_window_controller = ImageWindowController(source=source, image_data=crop.image, metadata=derived_metadata) image_window_controller.state_changed.connect(self.tool_controller._update_crop_overlay) request = WindowRequest(controller=image_window_controller, window_type='image') self.tool_controller.request_new_window.emit(request) self.assign_payload(ManualCropSessionResult(label=crop.label, image=crop.image, extents=crop.extents)) self.finish()
[docs] class ManualCropToolController(ToolController[ImageWindowController, ManualCropToolSession]): """ Controller for the manual crop tool. Handles activation, overlay management, and interaction with the application context. It also provides the menu specifications used by the UI. """ procedure_start_enable = Signal(bool) """Signal emitted to enable/disable the procedure start action.""" procedure_can_start = ActionDescriptor('procedure_can_start', 'procedure_start_enable') """Action descriptor for determining if the procedure can start.""" is_cropped_image = Signal(bool) has_visible_crop_overlay = Signal(bool) can_show_overlay = ActionDescriptor('can_show_overlay', 'is_cropped_image') can_crop_overlay_be_hidden = ActionDescriptor('can_crop_overlay_be_hidden', 'has_visible_crop_overlay') def __init__(self, tool_ctx: ToolContext, tool: Tool[ManualCropToolController]) -> None: """ Initialise the controller. :param tool_ctx: The shared tool context. :type tool_ctx: ToolContext :param tool: The :class:`ManualCropTool` instance. :type tool: Tool """ super().__init__(tool_ctx, tool) self.active_session: Optional[ManualCropToolSession] = None self.item_counter = ItemCounter(0) self.context.on_active_image_changed(self._on_active_image_changed)
[docs] def _on_active_image_changed(self, new_window_controller: SubWindowController[Any] | None) -> None: """ React to a change in the active image window. Updates the various action descriptors based on the new window. :param new_window_controller: The newly active sub‑window controller. :type new_window_controller: SubWindowController[Any] """ super()._on_active_image_changed(new_window_controller) is_image_window = isinstance(new_window_controller, ImageWindowController) self.procedure_can_start = is_image_window self.can_show_overlay = self._check_for_overlay(new_window_controller) self.can_crop_overlay_be_hidden = self._check_for_shown_overlays(new_window_controller)
[docs] def _check_for_overlay(self, new_window_controller: SubWindowController[Any] | None) -> bool: """ Determine whether a crop overlay can be shown for the given window. :param new_window_controller: The window to inspect. :type new_window_controller: SubWindowController[Any] :return: ``True`` if an overlay can be displayed, ``False`` otherwise. :rtype: bool """ if not isinstance(new_window_controller, ImageWindowController): return False if not new_window_controller.source.origin == DataOrigin.DERIVED: return False if new_window_controller.source.derivation is None: return False ref_image_id = new_window_controller.source.derivation.parent_id ref_image = self.context.application_services.window_manager.get_windows_by_id(ref_image_id) if ref_image is None: return False return True
[docs] def _check_for_shown_overlays(self, new_window_controller: SubWindowController[Any] | None) -> bool: """ Check whether any manual‑crop overlays are currently visible. :param new_window_controller: The window to inspect. :type new_window_controller: SubWindowController[Any] :return: ``True`` if at least one overlay is visible. :rtype: bool """ if new_window_controller: crop_overlays = new_window_controller.overlay_manager.get_overlay_by_group('manualcrop') visible_overlays = [overlay for overlay in crop_overlays if overlay.visible] return len(visible_overlays) > 0 return False
[docs] def create_session(self, window_controller: ImageWindowController) -> ManualCropToolSession: """ Create a new :class:`ManualCropToolSession` for the given window. :param window_controller: The image window to operate on. :type window_controller: ImageWindowController :return: A fresh session instance. :rtype: ManualCropToolSession """ return ManualCropToolSession(self, window_controller)
[docs] def create_dock(self, parent_window: 'QWidget') -> None: """ Manual crop does not provide a dock widget. :param parent_window: The parent widget (unused). :type parent_window: QWidget :return: ``None``. :rtype: None """ return None
[docs] def menu_specs(self) -> List[ToolMenuSpec]: """ Return the menu specifications for the manual crop tool. :return: A list containing a single :class:`ToolMenuSpec` with actions. :rtype: List[ToolMenuSpec] """ return [ ToolMenuSpec( title='Crop', order=20, entries=[ ToolMenuSpec( title='Manual Crop', order=20, entries=[ ToolActionSpec( text='Start manual crop', triggered=self.on_manual_crop_start, enabled_changed_signal=self.procedure_start_enable, ), ToolActionSpec( text='Show/Hide ROI on original image', triggered=self.on_overlay_request, enabled_changed_signal=self.is_cropped_image, ), ToolActionSpec( text='Hide all crop overlays', triggered=self.hide_all_crops_overlays, enabled_changed_signal=self.has_visible_crop_overlay, ), ], ) ], ) ]
[docs] def on_manual_crop_start(self) -> None: """ Entry point for the *Start manual crop* menu action. Activates the tool, which in turn creates a new session. """ self.activate()
[docs] def on_overlay_request(self) -> None: """ Toggle the visibility of the ROI overlay on the original image. If the overlay does not exist, it is created; otherwise its visibility is toggled. """ active_image = self.context.active_image() if not self._check_for_overlay(active_image): return active_image = cast(ImageWindowController, assume_not_none(active_image)) derivation = assume_not_none(active_image.source.derivation) roi = assume_not_none(derivation.roi) target_image_window = self.context.application_services.window_manager.get_windows_by_id(derivation.parent_id) if not isinstance(target_image_window, ImageWindowController): return overlay_exists = target_image_window.overlay_manager.exists(OverlayKey(active_image.id, OverlayRole.Permanent)) if not overlay_exists: color = to_mpl(self.context.application_services.color_service.next_color()) geo = dict(xy=roi.xy, width=roi.width, height=roi.height, angle=roi.angle) style = {'edgecolor': color, 'ls': 'dashed', 'lw': 1.5, 'facecolor': 'none', 'fill': False} overlay = OverlayModel( id=active_image.id, type='crop-with-label', role=OverlayRole.Permanent, label=self._overlay_label_for(active_image), geometry=geo, style=style, group='manualcrop', ) target_image_window.overlay_manager.add('im_axes', overlay) else: target_image_window.overlay_manager.toggle_visibility(OverlayKey(active_image.id, OverlayRole.Permanent)) self.context.application_services.window_manager.show_on_top(target_image_window.id)
[docs] def hide_all_crops_overlays(self) -> None: """ Hide every manual‑crop overlay that is currently visible. """ active_window = self.context.active_image() if not active_window: return crop_overlays = active_window.overlay_manager.get_overlay_by_group('manualcrop') for overlay in crop_overlays: if overlay.visible: active_window.overlay_manager.set_visibility(overlay.id, overlay.role, False) self.can_crop_overlay_be_hidden = False
[docs] def _update_crop_overlay(self) -> None: """ Update the overlay on the original image when the derived image changes. This method is connected to the ``state_changed`` signal of the derived image window controller. """ window_controller = self.sender() if not isinstance(window_controller, ImageWindowController): return if not self._check_for_overlay(window_controller): return derivation = assume_not_none(window_controller.source.derivation) target_image_window = self.context.application_services.window_manager.get_windows_by_id(derivation.parent_id) target_image_window = require_not_none(target_image_window) overlay_exists = target_image_window.overlay_manager.exists( OverlayKey(window_controller.id, OverlayRole.Permanent) ) if overlay_exists: existing_overlay = assume_not_none( target_image_window.overlay_manager.get_overlay_by_id(window_controller.id, OverlayRole.Permanent) ) overlay_label = self._overlay_label_for(window_controller) if overlay_label != existing_overlay.label: existing_overlay.label = overlay_label target_image_window.overlay_manager.request_to_update_overlay.emit(existing_overlay)
[docs] @staticmethod def _overlay_label_for(window_controller: ImageWindowController) -> str: """ Return the overlay label for a derived crop window. :param window_controller: The derived image window controller. :type window_controller: ImageWindowController :return: The short overlay label to use. :rtype: str """ derivation = assume_not_none(window_controller.source.derivation) roi = derivation.roi return cast(str, roi.geometry.get('overlay_label', window_controller.name))
[docs] class ManualCropTool(Tool[ManualCropToolController]): """ Tool class representing the manual crop functionality. Provides the UI integration and overlay registration for manual cropping operations. """ id = uuid4() tool_id = 'manualcrop' name = 'Manual Crop' description = 'A tool to manually select and crop a region of interest' overlays_to_be_registered = [ OverlaySpec('crop-with-label', OverlayRole.Permanent, draw_crop_overlay), OverlaySpec('crop-with-label', OverlayRole.Highlight, draw_highlight_crop_overlay), ] def __init__(self) -> None: """ Initialise the manual crop tool. """ super().__init__() self._controller: Optional[ManualCropToolController] = None
[docs] def create_controller(self, ctx: 'ToolContext') -> 'ManualCropToolController': """ Create and store the :class:`ManualCropToolController` for this tool. :param ctx: The shared tool context. :type ctx: ToolContext :return: The newly created controller. :rtype: ManualCropToolController """ self._controller = ManualCropToolController(ctx, self) return self._controller