Source code for radioviz.tools.autocrop_tool

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Autocrop tool implementation for automatic region detection and cropping.

This module provides functionality for automatically detecting regions in images
and cropping them based on segmentation algorithms. It includes UI components for
configuring segmentation parameters, visualizing detected regions, and performing
the actual cropping operation.

The tool uses Otsu's thresholding algorithm combined with region labeling to identify
areas of interest in images. Users can configure various parameters such as threshold
scaling, minimum region area, and padding to fine-tune the segmentation process.
"""

from __future__ import annotations

import re
import sys
import uuid
from dataclasses import dataclass

from radioviz.services.typing_helpers import assume_not_none, require_not_none

if sys.version_info >= (3, 11):
    from enum import StrEnum
else:
    from radioviz.models.legacy import StrEnum
from functools import partial
from typing import TYPE_CHECKING, Any, Generator, List, Optional, cast
from uuid import UUID

import numpy as np
from matplotlib.artist import Artist
from matplotlib.backend_bases import MouseEvent
from matplotlib.backends.backend_qt import NavigationToolbar2QT
from matplotlib.widgets import RectangleSelector
from PySide6.QtCore import QItemSelection, QModelIndex, QObject, QPersistentModelIndex, Qt, Signal
from PySide6.QtGui import QCloseEvent, QKeyEvent
from PySide6.QtWidgets import (
    QComboBox,
    QDialog,
    QDoubleSpinBox,
    QGridLayout,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QMessageBox,
    QProgressDialog,
    QPushButton,
    QSpinBox,
    QTableView,
    QVBoxLayout,
    QWidget,
)
from skimage.filters import threshold_otsu
from skimage.measure import label, regionprops
from skimage.segmentation import clear_border
from superqt import QIconifyIcon

if sys.version_info < (3, 9):
    from PySide6.QtWidgets import QCheckBox as QToggleSwitch
else:
    from superqt import QToggleSwitch

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.item_store import ItemStore
from radioviz.models.menu_spec import ToolActionSpec, ToolMenuSpec
from radioviz.models.overlays import OverlayKey, OverlayManager, OverlayModel, OverlayRenderer, OverlayRole, OverlaySpec
from radioviz.models.roi_model import ROI, ROIEnclosure, ROIType
from radioviz.services.action_descriptor import ActionDescriptor
from radioviz.services.color_service import Color, ColorService, to_mpl, to_qcolor
from radioviz.services.image_loader import downscale_image
from radioviz.services.item_counter import ItemCounter
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
from radioviz.views.canvas_widget import SimpleCanvas
from radioviz.views.item_model import ItemModel


[docs] def build_derived_label(base_name: str, crop_name: str, fallback_label: str | None = None) -> str: """Build the derived image label for a crop. The default autocrop behaviour names regions ``crop-<n>``; those map to derived image labels with the ``-autocrop-<n>`` suffix before the extension. Custom names are appended verbatim (prefixed with a dash) to the base image name. Blank or whitespace-only names fall back to ``fallback_label`` when provided; otherwise the default ``-autocrop`` suffix is used. :param base_name: Original image filename used as base for derived images :type base_name: str :param crop_name: The user-visible crop name :type crop_name: str :param fallback_label: Existing derived label to preserve for empty names :type fallback_label: str | None :return: Derived image label including extension :rtype: str """ clean_name = crop_name.strip() if not clean_name: return fallback_label or append_suffix_before_extension(base_name, '-autocrop') match = re.fullmatch(r'crop-(\d+)', clean_name) if match: suffix = f'-autocrop-{match.group(1)}' else: suffix = f'-{clean_name}' return append_suffix_before_extension(base_name, suffix)
[docs] @dataclass(frozen=True) class SegmentationRequest: """Represents a request for image segmentation with specified parameters. This dataclass holds all the necessary parameters for performing image segmentation including the image data, threshold settings, and processing options. """ session_id: UUID """Unique identifier for the segmentation session.""" image: np.ndarray """Input image array for segmentation.""" threshold_factor: float """Factor to multiply with Otsu threshold for binary conversion.""" downscale_factor: int """Factor by which the image is downscaled before processing.""" remove_borders: bool """Whether to remove border-connected regions.""" min_region_area: int """Minimum area threshold for regions to be considered.""" extra_padding: int """Additional padding to add around detected regions."""
[docs] @thread_worker def autocrop_segmentation_worker(request: SegmentationRequest) -> Generator[int, None, list[RectangleExtent]]: """Performs segmentation and returns a list of padded RectangleExtent. This worker function performs image segmentation using Otsu thresholding, region labeling, and filtering based on minimum area requirements. It yields progress updates during execution. :param request: Segmentation request parameters :type request: SegmentationRequest :return: List of padded rectangle extents representing detected regions :rtype: list[RectangleExtent] """ tot_step = 6 def step(i: int) -> int: return int(i / tot_step * 100) image = request.image y_max, x_max = image.shape remove_borders = request.remove_borders # ---- Step 0 yield step(0) thresh = request.threshold_factor * threshold_otsu(image) # type: ignore[no-untyped-call] # ---- Step 1 yield step(1) binary = image > thresh # ---- Step 2 yield step(2) if remove_borders: binary = clear_border(binary) # type: ignore[no-untyped-call] # ---- Step 3 yield step(3) labels_img = label(binary) # type: ignore[no-untyped-call] # ---- Step 4 yield step(4) regions = [r for r in regionprops(labels_img) if r.area > request.min_region_area] # type: ignore[no-untyped-call] if not regions: return [] # ---- Step 5 yield step(5) # ---- sorting logic (unchanged) centroids = np.array([r.centroid[::-1] for r in regions]) row_spacing = np.mean([r.bbox[2] - r.bbox[0] for r in regions]) * 3 row_indices = np.full(len(centroids), -1) sorted_idx = np.argsort(centroids[:, 1]) current_row = 0 row_anchor_y = centroids[sorted_idx[0], 1] for idx in sorted_idx: y = centroids[idx, 1] if abs(y - row_anchor_y) > row_spacing: current_row += 1 row_anchor_y = y row_indices[idx] = current_row sorted_regions = [] for row in sorted(set(row_indices)): idxs = np.where(row_indices == row)[0] row_sorted = idxs[np.argsort(centroids[idxs, 0])] sorted_regions.extend(row_sorted.tolist()) padded_bbox = [] for idx in sorted_regions: bbox = RectangleExtent.from_bbox(*regions[idx].bbox) bbox.add_padding(request.extra_padding) bbox.normalize(x_max, y_max) padded_bbox.append(bbox) # ---- Step 6 yield step(6) return padded_bbox
[docs] class AutocropToolDialog(QDialog): """Dialog window for configuring autocrop parameters and viewing results. This dialog allows users to configure segmentation parameters, view the original image, and interact with detected regions through a table view. It provides controls for previewing segmentation results and initiating the cropping process. """ def __init__(self, tool_session: 'AutocropToolSession', parent: QWidget | None = None) -> None: """Initialize the autocrop tool dialog. :param tool_session: The associated tool session :type tool_session: AutocropToolSession :param parent: Parent widget :type parent: QWidget """ super().__init__(parent) self.setModal(False) self.tool_session = tool_session self.setWindowTitle('Autocrop configuration') layout = QVBoxLayout() self.status_label = QLabel( 'Fill in the parameters below and then select either: Preview to see and edit ' 'the selected regions, or Crop to finish the procedure.' ) layout.addWidget(self.status_label) horizontal_layout = QHBoxLayout() first_column = QVBoxLayout() first_column.addWidget(QLabel('Image preview (downscaled)')) self.canvas = SimpleCanvas(width=3, height=4) self.toolbar = NavigationToolbar2QT(self.canvas) # type: ignore[no-untyped-call] first_column.addWidget(self.toolbar) first_column.addWidget(self.canvas) horizontal_layout.addLayout(first_column) second_column = QVBoxLayout() parameter_layout = QGridLayout() parameter_layout.addWidget(QLabel('Image downscaling factor'), 0, 0) self.downscale_factor = QComboBox() factors = [1, 2, 4, 8, 16] for v in factors: self.downscale_factor.addItem(str(v), v) self.downscale_factor.setCurrentText('4') self.downscale_factor.currentIndexChanged.connect(self.on_scale_factor_change) parameter_layout.addWidget(self.downscale_factor, 0, 1) parameter_layout.addWidget(QLabel('Threshold scaling'), 1, 0) self.threshold_scalig = QDoubleSpinBox() self.threshold_scalig.setMaximum(1) self.threshold_scalig.setMinimum(0.1) self.threshold_scalig.setSingleStep(0.1) self.threshold_scalig.setDecimals(1) self.threshold_scalig.setValue(0.5) self.threshold_scalig.valueChanged.connect(self.on_parameters_changed) parameter_layout.addWidget(self.threshold_scalig, 1, 1) parameter_layout.addWidget(QLabel('Remove elements on borders'), 2, 0) self.remove_border_switch = QToggleSwitch() self.remove_border_switch.setChecked(True) self.remove_border_switch.toggled.connect(self.on_parameters_changed) parameter_layout.addWidget(self.remove_border_switch, 2, 1) parameter_layout.addWidget(QLabel('Remove regions with area below'), 3, 0) self.min_area_spinbox = QSpinBox() self.min_area_spinbox.setMinimum(1) self.min_area_spinbox.setMaximum(5000) self.min_area_spinbox.setSuffix(' pixels') self.min_area_spinbox.setValue(1000) self.min_area_spinbox.valueChanged.connect(self.on_parameters_changed) parameter_layout.addWidget(self.min_area_spinbox, 3, 1) parameter_layout.addWidget(QLabel('Extra padding'), 4, 0) self.extra_padding_spinbox = QSpinBox() self.extra_padding_spinbox.setMinimum(0) self.extra_padding_spinbox.setMaximum(150) self.extra_padding_spinbox.setValue(50) self.extra_padding_spinbox.setSuffix(' pixels') self.extra_padding_spinbox.valueChanged.connect(self.on_parameters_changed) parameter_layout.addWidget(self.extra_padding_spinbox, 4, 1) second_column.addLayout(parameter_layout) table_layout = QVBoxLayout() table_layout.addWidget(QLabel('Identified regions')) self.table_view = QTableView() self.table_view.setModel(self.tool_session.table_model) header = self.table_view.horizontalHeader() header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self.table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) self.table_view.selectionModel().selectionChanged.connect(self.on_item_selected) table_layout.addWidget(self.table_view) extra_button_layout = QHBoxLayout() self.remove_crop_button = QPushButton('Remove crop') self.remove_crop_button.setEnabled(False) self.remove_crop_button.clicked.connect(self.on_remove_crop_button_clicked) extra_button_layout.addWidget(self.remove_crop_button) self.reset_view_button = QPushButton('Reset view') self.reset_view_button.setEnabled(False) self.reset_view_button.clicked.connect(self.on_reset_view_button_clicked) extra_button_layout.addWidget(self.reset_view_button) self.add_crop_button = QPushButton('Add crop') self.add_crop_button.setEnabled(True) self.add_crop_button.clicked.connect(self.on_add_crop_button_clicked) extra_button_layout.addWidget(self.add_crop_button) table_layout.addLayout(extra_button_layout) second_column.addLayout(table_layout) button_layout = QHBoxLayout() self.cancel_button = QPushButton('Cancel') self.cancel_button.clicked.connect(self.reject) button_layout.addWidget(self.cancel_button) self.preview_button = QPushButton('Preview') self.preview_button.setEnabled(False) self.preview_button.setIcon(QIconifyIcon('material-symbols-light:preview-outline')) self.preview_button.clicked.connect(self.preview) button_layout.addWidget(self.preview_button) self.done_button = QPushButton('Crop') self.done_button.setEnabled(False) self.done_button.setIcon(QIconifyIcon('material-symbols-light:crop')) self.done_button.clicked.connect(self.do_cropping) button_layout.addWidget(self.done_button) second_column.addLayout(button_layout) horizontal_layout.addLayout(second_column) layout.addLayout(horizontal_layout) self.setLayout(layout) self.image_plot: Optional[Artist] = None self.get_image() self.overlay_renderer = OverlayRenderer( self.canvas, [self.canvas.ax], self.tool_session.tool_controller.context.application_services.overlay_factory, ) self.tool_session.overlay_manager.request_to_add_overlay.connect(self.overlay_renderer.on_overlay_add) self.tool_session.overlay_manager.request_to_remove_overlay.connect(self.overlay_renderer.on_overlay_remove) self.tool_session.overlay_manager.request_to_update_overlay.connect(self.overlay_renderer.on_overlay_update)
[docs] def on_remove_crop_button_clicked(self) -> None: """Handle removal of selected crop region. This method removes the currently selected crop region from the session. """ self.tool_session.remove_selected_crop()
[docs] def on_add_crop_button_clicked(self) -> None: """Handle addition of manual crop selection. This method initiates the manual crop selection mode. """ self.tool_session.start_manual_crop_selection()
[docs] def on_reset_view_button_clicked(self) -> None: """Reset the canvas view and clear any selection.""" self.table_view.clearSelection() self.full_view() self.tool_session.deactivate_selector() self.canvas.draw_idle() # type: ignore[no-untyped-call] self.remove_crop_button.setEnabled(False) self.tool_session.currently_selected_item = None
[docs] def get_image(self) -> None: """Retrieve and display the image for processing. This method checks if image scaling is needed and displays the appropriate version of the image in the canvas. """ image = self.tool_session.window_controller.image_data h, w = image.shape h_max, w_max = self.tool_session.no_scale_max_shape if h < h_max and w < w_max: # no scaling needed. self.tool_session.image = image self.plot() self.downscale_factor.setCurrentText('1') self.downscale_factor.setEnabled(False) self.data_ready(True) else: # we need to downscale self.downscale_image()
[docs] def on_scale_factor_change(self) -> None: """Handle change in downscale factor. This method updates the displayed image when the downscale factor changes. """ self.data_ready(False) self.get_image() self.on_parameters_changed()
[docs] def downscale_image(self) -> None: """Downscale the image according to the selected factor. This method uses the downscale_image service to resize the image. """ input_image = self.tool_session.window_controller.image_data factor = self.downscale_factor.currentData() worker = downscale_image(input_image, factor) worker.returned.connect(self._on_downscale_ready) worker.start()
[docs] def _on_downscale_ready(self, image: np.ndarray) -> None: """Handle completion of image downscaling. :param image: Downscaled image :type image: numpy.ndarray """ self.tool_session.image = image self.plot() self.data_ready(True)
def data_ready(self, ready: bool) -> None: self.done_button.setEnabled(ready) self.preview_button.setEnabled(ready)
[docs] def plot(self) -> None: """Plot the current image in the canvas. This method displays the image in the matplotlib canvas. """ if self.image_plot is None: if self.tool_session.image is not None: self.canvas.ax.imshow(self.tool_session.image, interpolation='none', cmap='magma') else: self.image_plot.set(data=self.tool_session.image) self.canvas.draw_idle() # type: ignore[no-untyped-call]
[docs] def preview(self) -> None: """Preview the segmentation results. This method triggers the segmentation process with current parameters and displays the results. """ self.on_parameters_changed() self.tool_session.request_preview()
[docs] def do_cropping(self) -> None: """Perform the final cropping operation. This method initiates the cropping process with the configured parameters. """ self.on_parameters_changed() self.accept()
[docs] def on_parameters_changed(self) -> None: """Handle changes to segmentation parameters. This method updates the tool session with the current parameter values. """ params = { 'threshold_scaling': self.threshold_scalig.value(), 'downscale_factor': self.downscale_factor.currentData(), 'remove_borders': self.remove_border_switch.isChecked(), 'min_region_area': self.min_area_spinbox.value(), 'extra_padding': self.extra_padding_spinbox.value(), } self.tool_session.segmentation_parameters = params
[docs] def on_item_selected(self, selected: QItemSelection, deselected: QItemSelection) -> None: """Handle selection of items in the table view. :param selected: Selected indexes :type selected: QItemSelection :param deselected: Deselected indexes :type deselected: QItemSelection """ deselected_item = None for index in deselected.indexes(): if index.column() == 0: item_id = index.data(Qt.ItemDataRole.UserRole) if item_id is None: break deselected_item = self.tool_session.bbox_store.get_by_id(item_id) break if deselected_item is not None: self.tool_session.deactivate_selector() selected_item = None indexes = self.table_view.selectionModel().selectedRows() if indexes: item_id = indexes[0].data(Qt.ItemDataRole.UserRole) selected_item = self.tool_session.bbox_store.get_by_id(item_id) self.zoom_view(selected_item.bbox) selector = assume_not_none(selected_item.selector) self.tool_session.activate_selector(selector) self.remove_crop_button.setEnabled(True) self.reset_view_button.setEnabled(True) else: self.full_view() self.tool_session.deactivate_selector() self.canvas.draw_idle() # type: ignore[no-untyped-call] self.remove_crop_button.setEnabled(False) self.reset_view_button.setEnabled(False) self.tool_session.currently_selected_item = selected_item
[docs] def full_view(self) -> None: """Show the full image view. This method resets the canvas view to show the entire image. """ image = assume_not_none(self.tool_session.image) self.canvas.ax.set_xlim(0, image.shape[1]) self.canvas.ax.set_ylim(image.shape[0], 0)
[docs] def zoom_view(self, bbox: RectangleExtent) -> None: """Zoom the view to show a specific bounding box. :param bbox: Bounding box to zoom to :type bbox: RectangleExtent """ xmin, xmax, ymin, ymax = self.normalize_view(*bbox.expand(100, 100)) self.canvas.ax.set_xlim(xmin, xmax) self.canvas.ax.set_ylim(ymax, ymin)
[docs] def normalize_view(self, x1: float, x2: float, y1: float, y2: float) -> tuple[float, float, float, float]: """Normalize view coordinates to image boundaries. :param x1: Minimum x coordinate :type x1: float :param x2: Maximum x coordinate :type x2: float :param y1: Minimum y coordinate :type y1: float :param y2: Maximum y coordinate :type y2: float :return: Normalized coordinates :rtype: tuple[float, float, float, float] """ image = assume_not_none(self.tool_session.image) x_min, x_max = 0, image.shape[1] y_min, y_max = 0, image.shape[0] return max(float(x_min), x1), min(x_max, x2), max(float(y_min), y1), min(y_max, y2)
[docs] def on_can_remove_crop(self, enable: bool) -> None: """Enable or disable the remove crop button. :param enable: Whether to enable the button :type enable: bool """ self.remove_crop_button.setEnabled(enable)
[docs] def on_can_add_crop(self, enable: bool) -> None: """Enable or disable the add crop button. :param enable: Whether to enable the button :type enable: bool """ self.add_crop_button.setEnabled(enable)
[docs] def keyPressEvent(self, event: QKeyEvent) -> None: """Handle key press events. :param event: Key press event :type event: QKeyEvent """ if event.key() == Qt.Key.Key_Escape: if self.tool_session.mode == AutocropToolSessionMode.Adding: self.tool_session.cancel_manual_new_crop() event.accept() return super().keyPressEvent(event)
[docs] def closeEvent(self, event: QCloseEvent) -> None: """Handle window close events. :param event: Close event :type event: QCloseEvent """ if self.tool_session.has_crops(): reply = QMessageBox.question( self, 'Discard crops?', 'You have defined cropping regions.\nClosing will discard them.\n\nDo you want to continue?', QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.No: event.ignore() return event.accept()
[docs] def reject(self) -> None: """Handle rejection of the dialog. This method handles the case where the user cancels the operation. """ if self.tool_session.has_crops(): reply = QMessageBox.question( self, 'Discard crops?', 'You have defined cropping regions.\nClosing will discard them.\n\nDo you want to continue?', QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.No: return # swallow reject super().reject()
[docs] class SegmentedAreaData: """Data class representing a segmented area in an image. This class holds information about a cropped region including its name, derived label, color, bounding box, and associated visual elements. """ def __init__( self, name: str, derived_label: str, color: Color, bbox: RectangleExtent, downscale_factor: int, area_id: Optional[UUID] = None, ) -> None: """Initialize a segmented area data object. :param name: Name of the segment :type name: str :param derived_label: Label to use for derived image windows :type derived_label: str :param color: Color for visualization :type color: Color :param bbox: Bounding box coordinates :type bbox: RectangleExtent :param downscale_factor: Factor used for image downscaling :type downscale_factor: int :param area_id: Unique identifier for the segment :type area_id: Optional[UUID] """ self.id = area_id or uuid.uuid4() self.name = name self.derived_label = derived_label self.color = color self.bbox = bbox self.downscale_factor = downscale_factor self.selector: Optional[RectangleSelector] = None self.text_overlay: Optional[OverlayModel] = None self._being_removed = False
[docs] def generate_overlay(self, axis_name: str) -> None: """Generate an overlay for the segment. :param axis_name: Name of the axis to attach the overlay to :type axis_name: str """ if self.text_overlay is None: selector_overlay = OverlayModel(id=self.id, type='text', role=OverlayRole.Permanent, target_axis=axis_name) selector_overlay.geometry = dict(text=self.name, x=self.bbox.xmin, y=self.bbox.ymin - 10) selector_overlay.style = dict(color=to_mpl(self.color)) self.text_overlay = selector_overlay
[docs] def update_overlay(self) -> None: """Update the overlay with current data. This method updates the text overlay with the current name and position. """ if self.text_overlay is not None: self.text_overlay.geometry = dict(text=self.name, x=self.bbox.xmin, y=self.bbox.ymin - 10) self.text_overlay.style = dict(color=to_mpl(self.color))
SegmentedAreaStore = ItemStore[SegmentedAreaData] """Type alias for the segmented area store."""
[docs] class SegmentedAreaTableModel(ItemModel[SegmentedAreaData]): """Table model for displaying segmented areas. This model provides data for the table view showing detected regions. """ def __init__(self, store: SegmentedAreaStore, parent: Optional[QObject] = None) -> None: """Initialize the segmented area table model. :param store: Store containing segmented areas :type store: SegmentedAreaStore :param parent: Parent object :type parent: QObject """ super().__init__(store, parent) self.headers = [ 'Name', # 0 'Color', # 1 'Top-Left corner', # 2 'Bottom-Right corner', # 3 ]
[docs] def flags(self, index: QModelIndex | QPersistentModelIndex, /) -> Qt.ItemFlag: """Return the item flags for the given index. This method determines the behavior and properties of cells in the table view, such as whether they are selectable, editable, or enabled. :param index: Index of the item in the model :type index: QModelIndex :return: Item flags indicating the behavior of the cell :rtype: Qt.ItemFlag """ if not index.isValid(): return Qt.ItemFlag.NoItemFlags if len(self._store) == 0: return Qt.ItemFlag.NoItemFlags item = self._store.get(index.row()) if item is None: return Qt.ItemFlag.NoItemFlags col = index.column() if col == 0: return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
[docs] def set_data_for( self, item: SegmentedAreaData, column: int, value: Any, role: int = Qt.ItemDataRole.DisplayRole, ) -> bool: """Set data for a specific item in the model. This method handles updating the data for a segmented area item, particularly when the name column is edited. :param item: The segmented area data item to update :type item: SegmentedAreaData :param column: Column index of the data to update :type column: int :param value: New value to set :type value: Any :param role: Item role for the data :type role: Qt.ItemDataRole :return: True if the data was successfully set, False otherwise :rtype: bool """ if column == 0 and role == Qt.ItemDataRole.EditRole: self._store.update_by_id(item.id, name=value) return True return False
[docs] def data_for(self, item: SegmentedAreaData, column: int, role: int = Qt.ItemDataRole.DisplayRole) -> Any: """Get data for a specific cell in the table. :param item: The segmented area item :type item: SegmentedAreaData :param column: Column index :type column: int :param role: Item role :type role: Qt.ItemDataRole :return: Data for the cell :rtype: Any """ if role == Qt.ItemDataRole.UserRole: return item.id elif role == Qt.ItemDataRole.DisplayRole: if column == 0: return item.name elif column == 1: return f'RGB({item.color.r:.2f},{item.color.g:.2f},{item.color.b:.2f})' elif column == 2: return f'({item.bbox.xmin:.2f}, {item.bbox.ymin:.2f})' elif column == 3: return f'({item.bbox.xmax:.2f}, {item.bbox.ymax:.2f})' else: return None elif role == Qt.ItemDataRole.DecorationRole: if column == 1: return to_qcolor(item.color) else: return None else: return None
[docs] class AutocropToolSessionMode(StrEnum): """Enumeration of possible states for the autocrop tool session. This enum defines the different modes the tool session can be in during the cropping workflow. """ Idle = 'IDLE' """Session is idle, waiting for user action.""" Segmenting = 'SEGMENTING' """Session is currently performing segmentation.""" Editing = 'EDITING' """Session is editing existing selections.""" Ready = 'READY' """Session is ready to proceed with cropping.""" Adding = 'ADD_EXTRA' """Session is adding new manual crop regions.""" Cropping = 'CROPPING' """Session is performing the actual cropping operation."""
[docs] @thread_worker def crop_worker(image: np.ndarray, regions: list[SegmentedAreaData], scale: int) -> list[CropResult]: """Worker function to crop images based on regions. This function processes multiple regions and generates cropped images for each. :param image: Input image to crop :type image: numpy.ndarray :param regions: List of segmented areas to crop :type regions: list[SegmentedAreaData] :param scale: Scale factor for coordinates :type scale: int :return: List of crop results :rtype: list[CropResult] """ results = [] y_max, x_max = image.shape for r in regions: bbox = r.bbox bbox.scale_factor(scale) bbox.normalize(x_max, y_max) x0, x1, y0, y1 = tuple(np.round(bbox.to_tuple()).astype(int)) results.append( CropResult( label=r.derived_label, overlay_label=r.name, image=image[y0:y1, x0:x1], extents=bbox.to_tuple(), ) ) return results
[docs] class AutocropToolSession(BaseToolSession['AutocropToolController', 'ImageWindowController']): """Session class for managing the autocrop tool workflow. This class manages the complete workflow of the autocrop tool including parameter configuration, segmentation, region editing, and image cropping. """ no_scale_max_shape = (1024, 1024) """Maximum shape for images that don't require scaling.""" add_crop_enable = Signal(bool) """Signal emitted when add crop button state changes.""" remove_crop_enable = Signal(bool) """Signal emitted when remove crop button state changes.""" can_add = ActionDescriptor('can_add', 'add_crop_enable') """Action descriptor for add crop capability.""" can_remove = ActionDescriptor('can_remove', 'remove_crop_enable') """Action descriptor for remove crop capability.""" def __init__(self, tool_controller: AutocropToolController, window_controller: ImageWindowController): """Initialize the autocrop tool session. :param tool_controller: The associated tool controller :type tool_controller: AutocropToolController :param window_controller: The image window controller :type window_controller: ImageWindowController """ super().__init__(tool_controller, window_controller) self.dialog_window: Optional[AutocropToolDialog] = None self.selectors_edited = False self.color_service = ColorService() self.segmentation_parameters: dict[str, Any] = {} self.bbox_store = SegmentedAreaStore() self.bbox_store.add_update_listener(self.update_editable_fields) self.table_model = SegmentedAreaTableModel(store=self.bbox_store) self.item_counter = ItemCounter(0) self.selectors: list[RectangleSelector] = [] self.active_selector: Optional[RectangleSelector] = None self.image: Optional[np.ndarray] = None self.overlay_manager = OverlayManager() self.can_add = True self.can_remove = False self.currently_selected_item: Optional[SegmentedAreaData] = None self.additional_selector: Optional[RectangleSelector] = None self.mode = AutocropToolSessionMode.Idle
[docs] def has_crops(self) -> bool: """Check if there are any defined crop regions. :return: True if crops exist, False otherwise :rtype: bool """ return len(self.bbox_store) > 0
[docs] def on_start(self) -> None: """Start the tool session. This method initializes the dialog window and sets up connections. """ self.dialog_window = AutocropToolDialog(self) self.dialog_window.accepted.connect(self.request_cropping) self.dialog_window.rejected.connect(partial(self.on_cancel, 'user cancel')) self.add_crop_enable.connect(self.dialog_window.on_can_add_crop) self.remove_crop_enable.connect(self.dialog_window.on_can_remove_crop) self.dialog_window.show() self.dialog_window.on_parameters_changed() self.color_service.reset() self.item_counter.reset()
[docs] def on_cancel(self, reason: str) -> None: """Handle cancellation of the tool session. :param reason: Reason for cancellation :type reason: str """ self.cleanup()
[docs] def on_finish(self) -> None: """Finish the tool session. This method cleans up resources when the session ends. """ self.cleanup()
[docs] def request_preview(self) -> None: """Request a preview of the segmentation results. This method starts the segmentation process without actually cropping. """ self.tool_controller.context.message_requested.emit('Segmentation started', 'info', 3000) self.empty_store() self.reset_services() self._start_segmentation(for_crop=False)
[docs] def _start_segmentation_worker(self) -> None: """Start the segmentation worker thread. This method creates and starts the segmentation worker with appropriate signal connections. """ image = assume_not_none(self.image) segmentation_request = SegmentationRequest( session_id=self.session_id, image=image, threshold_factor=self.segmentation_parameters['threshold_scaling'], downscale_factor=self.segmentation_parameters['downscale_factor'], remove_borders=self.segmentation_parameters['remove_borders'], min_region_area=self.segmentation_parameters['min_region_area'], extra_padding=self.segmentation_parameters['extra_padding'], ) progress_dialog = QProgressDialog('Image segmentation', 'Cancel', 0, 100, self.dialog_window) progress_dialog.setAutoClose(True) progress_dialog.setAutoReset(True) progress_dialog.setMinimumDuration(50) worker = autocrop_segmentation_worker(request=segmentation_request) worker.yielded.connect(progress_dialog.setValue) worker.returned.connect(self.on_segmentation_finished) worker.errored.connect(self.on_segmentation_failed) worker.aborted.connect(self.on_segmentation_cancelled) # type: ignore[arg-type] worker.finished.connect(progress_dialog.close) # type: ignore[arg-type] progress_dialog.canceled.connect(worker.quit) worker.start()
[docs] def _start_segmentation(self, for_crop: bool) -> None: """Start the segmentation process. :param for_crop: Whether to prepare for cropping :type for_crop: bool """ self._pending_crop = for_crop self.mode = AutocropToolSessionMode.Segmenting self._start_segmentation_worker()
[docs] def on_segmentation_finished(self, bboxs: list[RectangleExtent]) -> None: """Handle completion of segmentation. :param bboxs: List of detected bounding boxes :type bboxs: list[RectangleExtent] """ dialog_window = assume_not_none(self.dialog_window) self.mode = AutocropToolSessionMode.Ready axs = dialog_window.canvas.ax canvas = axs.figure.canvas if len(bboxs) == 0: self.tool_controller.context.message_requested.emit('No region found in the image', 'warning', 3000) canvas.setUpdatesEnabled(False) # type: ignore[attr-defined] for bbox in bboxs: self.add_new_item(bbox) canvas.setUpdatesEnabled(True) # type: ignore[attr-defined] self.tool_controller.context.message_requested.emit('Segmentation finished', 'info', 3000) if self._pending_crop: # we go ahead directly with cropping self._do_cropping()
[docs] def on_selector_mouse_press_event(self, event: MouseEvent) -> None: """Handle mouse press events on selectors. :param event: Mouse event :type event: MouseEvent """ dialog_window = assume_not_none(self.dialog_window) if event.inaxes != dialog_window.canvas.ax: return for item in self.bbox_store: selector = item.selector if TYPE_CHECKING: assert hasattr(selector, '_contains') if selector is None: # this bbox did not have yet a selector, strange, but skip it continue if selector._contains(event): self.activate_selector(selector) break
[docs] def activate_selector(self, selector: RectangleSelector) -> None: """Activate a selector. :param selector: Selector to activate :type selector: RectangleSelector """ if self.active_selector is selector: return if self.active_selector: self.deactivate_selector() self._activate_selector(selector)
[docs] def deactivate_selector(self) -> None: """Deactivate the active selector""" if self.active_selector is None: return self.mode = AutocropToolSessionMode.Idle self.active_selector.set_active(False) self.active_selector.set_props( ls='dashed', lw=1.5, ) self.active_selector = None
[docs] def _activate_selector(self, selector: RectangleSelector) -> None: """Internal method to activate a selector. :param selector: Selector to activate :type selector: RectangleSelector """ self.mode = AutocropToolSessionMode.Editing self.active_selector = selector self.active_selector.set_active(True) self.active_selector.set_props( ls='solid', lw=2.5, )
[docs] def on_selector_mouse_release_event(self, event: MouseEvent) -> None: """Handle mouse release events on selectors. :param event: Mouse event :type event: MouseEvent """ pass
[docs] def on_selector_change(self, click: MouseEvent, release: MouseEvent) -> None: """Handle changes to selector positions. :param click: Click event :type click: MouseEvent :param release: Release event :type release: MouseEvent """ # ccheck if it is a drag event if click.xdata == release.xdata and click.ydata == release.ydata: # not a drag, just ignore return # we got a drag event # is there an active selector? It should, but let's check if self.active_selector is None: return crop_id = None # check if the active selector has the crop_id if hasattr(self.active_selector, 'crop_id'): crop_id = self.active_selector.crop_id else: # we need to look for it in the store for item in self.bbox_store: if self.active_selector is item.selector: crop_id = item.id if crop_id is None: raise RuntimeError('Crop id not found for current select') rectangle_area = RectangleExtent.from_extent(*self.active_selector.extents) self.bbox_store.update_by_id(crop_id, bbox=rectangle_area) item = self.bbox_store.get_by_id(crop_id) item.update_overlay() self.overlay_manager.request_to_remove_overlay.emit(item.text_overlay) text_overlay = assume_not_none(item.text_overlay) self.overlay_manager.request_to_add_overlay.emit(text_overlay.target_axis, text_overlay)
[docs] def on_segmentation_failed(self, e: Exception) -> None: """Handle the case the segmentation fails.""" self.cleanup() raise e
[docs] def on_segmentation_cancelled(self) -> None: """Handle the case the segmentation is aborted""" self.cleanup()
[docs] def cleanup(self) -> None: """Clean up resources and reset session state. This method resets the tool session to its idle state by clearing all stored regions, resetting services, and updating the session mode. """ self.mode = AutocropToolSessionMode.Idle self.empty_store() self.reset_services()
[docs] def empty_store(self) -> None: """Clear all stored segmentation results and their associated visual elements. This method removes all selectors and overlays from the canvas and resets the internal storage of segmentation results. """ dialog_window = assume_not_none(self.dialog_window) dialog_window.canvas.setUpdatesEnabled(False) for item in self.bbox_store: selector = assume_not_none(item.selector) selector.clear() self.overlay_manager.request_to_remove_overlay.emit(item.text_overlay) self.selectors.clear() self.bbox_store.reset() dialog_window.canvas.setUpdatesEnabled(True)
[docs] def reset_services(self) -> None: """Reset color and item counter services. This method resets the color service to its initial state and resets the item counter for generating new names. """ self.color_service.reset() self.item_counter.reset()
[docs] def request_cropping(self) -> None: """Request to initiate the cropping process. If no crop regions have been defined, this method starts segmentation to detect regions. Otherwise, it proceeds directly to cropping. """ if not self.has_crops(): self._start_segmentation(for_crop=True) else: self._do_cropping()
[docs] def _do_cropping(self) -> None: """Perform the actual cropping operation. This method initiates the cropping worker with the current image data and defined crop regions, then handles the results when they're ready. """ self.tool_controller.context.message_requested.emit('Cropping started', 'info', 3000) self.mode = AutocropToolSessionMode.Cropping worker = crop_worker( self.window_controller.image_data, self.bbox_store.all(), self.segmentation_parameters['downscale_factor'] ) worker.returned.connect(self._on_crops_ready) worker.start()
[docs] def _on_crops_ready(self, crops: list[CropResult]) -> None: """Handle completion of cropping operation. This method processes the cropped images and opens them in new windows. :param crops: List of cropped image results :type crops: list[CropResult] """ progress = QProgressDialog('Cropped image generation', 'Cancel', 0, len(crops), self.dialog_window) progress.setAutoClose(True) progress.setAutoReset(True) progress.setMinimumDuration(100) for p, crop in enumerate(crops): 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) progress.setValue(p) progress.setValue(len(crops)) self.tool_controller.context.message_requested.emit('Cropping finished', 'info', 3000) self.finish()
[docs] def remove_selected_crop(self) -> None: """Remove the currently selected crop region. This method removes the selected crop region from both the storage and the visual representation on the canvas. """ if self.currently_selected_item is None: return if self.currently_selected_item._being_removed: return self.currently_selected_item._being_removed = True # remove overlay self.overlay_manager.request_to_remove_overlay.emit(self.currently_selected_item.text_overlay) # remove selector selector = assume_not_none(self.currently_selected_item.selector) selector.clear() self.selectors.remove(selector) self.bbox_store.remove_item(self.currently_selected_item) self.currently_selected_item = None
[docs] def start_manual_crop_selection(self) -> None: """Initiate manual crop selection mode. This method prepares the interface for manually defining crop regions by creating a new rectangle selector. """ dialog_window = assume_not_none(self.dialog_window) self.mode = AutocropToolSessionMode.Adding self.can_add = False self.can_remove = False self.deactivate_selector() self.additional_selector = RectangleSelector( dialog_window.canvas.ax, interactive=False, # it will become interactive after dropping it useblit=True, props=dict(fill=False, edgecolor='yellow', linestyle='dotted'), onselect=self.finalize_manual_crop_definition, )
[docs] def cancel_manual_new_crop(self) -> None: """Cancel the current manual crop selection. This method discards the temporary selector and returns to the previous session state. """ additional_selector = assume_not_none(self.additional_selector) additional_selector.clear() self.additional_selector = None self.mode = AutocropToolSessionMode.Idle if not self.has_crops() else AutocropToolSessionMode.Ready self.can_add = True
[docs] def finalize_manual_crop_definition(self, eclick: MouseEvent, erelease: MouseEvent) -> None: """Finalize the definition of a manually selected crop region. This method processes the user-defined rectangle and adds it to the session as a new crop region. :param eclick: Mouse click event :type eclick: MouseEvent :param erelease: Mouse release event :type erelease: MouseEvent """ # get the additional selector extents, additional_selector = assume_not_none(self.additional_selector) extents = additional_selector.extents rectangle_extents = RectangleExtent(*extents) # now tear it down additional_selector.disconnect_events() additional_selector.clear() self.additional_selector = None self.add_new_item(rectangle_extents) self.mode = AutocropToolSessionMode.Idle self.can_add = True
[docs] def add_new_item(self, extents: RectangleExtent) -> None: """Add a new crop region to the session. This method creates a new segmented area data object and its associated visual elements (selector and overlay). :param extents: The bounding box coordinates for the new crop region :type extents: RectangleExtent """ # radioviz_name is a monkey patched attributes to the matplotlib axes. # it is automatically defined when the MPLCanvas is created, so it is safe # to rely on its presence, but mypy does not know this. # For this reason we need to access it using the getattr. # The default value is left out to trigger an exception in case the attribute is # not existing. dialog_window = assume_not_none(self.dialog_window) axs = dialog_window.canvas.ax crop_index = self.item_counter.next() label = f'crop-{crop_index}' derived_label = build_derived_label(self.window_controller.name, label) item = SegmentedAreaData( name=label, derived_label=derived_label, color=self.color_service.next_color(), bbox=extents, downscale_factor=self.segmentation_parameters['downscale_factor'], ) item.generate_overlay(getattr(axs, 'radioviz_name')) self.bbox_store.add(item) selector = RectangleSelector( axs, onselect=self.on_selector_change, useblit=True, interactive=True, ignore_event_outside=True, props={'edgecolor': to_mpl(item.color), 'fill': False, 'ls': 'dashed', 'lw': 1.5}, ) selector.extents = item.bbox.to_tuple() selector.set_active(False) item.selector = selector setattr(selector, 'crop_id', item.id) selector.connect_event('button_press_event', self.on_selector_mouse_press_event) # type: ignore[arg-type] selector.connect_event('button_release_event', self.on_selector_mouse_release_event) # type: ignore[arg-type] self.selectors.append(selector) self.overlay_manager.request_to_add_overlay.emit(getattr(axs, 'radioviz_name'), item.text_overlay)
[docs] def update_editable_fields(self, data: SegmentedAreaData, index: int = 0) -> None: """Handle updates to editable fields in the segmented area store. This method synchronizes changes made to segmented area data with the corresponding visual elements on the canvas and keeps derived labels aligned with user-provided crop names. :param data: The segmented area data that was updated :type data: SegmentedAreaData :param index: The index of the updated item in the store :type index: int """ new_derived_label = build_derived_label( self.window_controller.name, data.name, fallback_label=data.derived_label, ) if new_derived_label != data.derived_label: # Update derived label first; a subsequent update notification will trigger sync. self.bbox_store.update_by_id(data.id, derived_label=new_derived_label) return self._sync_crop(data)
[docs] def _sync_crop(self, item: SegmentedAreaData) -> None: """Synchronize crop data with its visual representation. This internal method ensures that the text overlay and visual elements of a segmented area are properly updated and re-added to the overlay manager. :param item: The segmented area data to synchronize :type item: SegmentedAreaData """ dialog_window = assume_not_none(self.dialog_window) if item.text_overlay is None: item.generate_overlay(getattr(dialog_window.canvas.ax, 'radioviz_name')) # now there is a text overlay, but mypy does not know it! text_overlay = assume_not_none(item.text_overlay) item.update_overlay() self.overlay_manager.request_to_remove_overlay.emit(text_overlay) self.overlay_manager.request_to_add_overlay.emit(text_overlay.target_axis, text_overlay)
[docs] class AutocropToolController(ToolController[ImageWindowController, AutocropToolSession]): """Controller class for managing the autocrop tool workflow. This controller handles the interaction between the tool and its sessions, manages the tool's state, and provides the necessary interfaces for user interactions. """ 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[AutocropToolController]) -> None: """Initialize the autocrop tool controller. :param tool_ctx: Tool context containing application services :type tool_ctx: ToolContext :param tool: The autocrop tool instance :type tool: Tool """ super().__init__(tool_ctx, tool) self.active_session: Optional[AutocropToolSession] = None 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: """Handle changes to the active image window. This method updates the tool's ability to start based on whether the current window is an image window. :param new_window_controller: The new active window controller :type new_window_controller: SubWindowController """ 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: """Check if the given window controller has an overlay that can be displayed. This method verifies whether the provided window controller represents a derived image with ROI information that can be used to create an overlay on the original image. It checks the image origin, derivation details, and references the parent image to ensure the overlay can be properly established. :param new_window_controller: The window controller to check for overlay capability :type new_window_controller: SubWindowController :return: True if the window controller can support an overlay, 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 if there are any visible crop overlays in the given window controller. This method examines the overlay manager of the specified window controller to determine if there are any crop overlays in the 'autocrop' group that are currently visible. This is used to enable or disable actions related to overlay visibility. :param new_window_controller: The window controller to check for visible overlays :type new_window_controller: SubWindowController :return: True if there are visible crop overlays, False otherwise :rtype: bool """ if new_window_controller: crop_overlays = new_window_controller.overlay_manager.get_overlay_by_group('autocrop') 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) -> AutocropToolSession: """Create a new autocrop tool session. :param window_controller: Controller for the image window :type window_controller: ImageWindowController :return: New autocrop tool session :rtype: AutocropToolSession """ session = AutocropToolSession(self, window_controller) return session
[docs] def create_dock(self, parent_window: 'QWidget') -> None: """Create a dock widget for the tool. :param parent_window: Parent widget for the dock :type parent_window: QWidget :return: None """ return None
[docs] def menu_specs(self) -> List[ToolMenuSpec]: """Get the menu specifications for the tool. :return: List of menu specifications :rtype: List[ToolMenuSpec] """ return [ ToolMenuSpec( title='Crop', order=20, entries=[ ToolMenuSpec( title='Autocrop', order=10, entries=[ ToolActionSpec( text='Start auto-procedure', triggered=self.on_autocrop_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_overlay_request(self) -> None: """Handle request to show or hide ROI overlay on the original image. This method checks if the active image is a derived image with ROI information, and either creates a new overlay or toggles the visibility of an existing one on the original image window. The overlay displays the cropping region with a label. The method retrieves the ROI from the active image's derivation information, gets the target image window, and manages the overlay creation or visibility toggle based on whether the overlay already exists. """ active_window = self.context.active_image() if not self._check_for_overlay(active_window): return # if we get here, it is because the active window is an image and its roi exits active_image = cast(ImageWindowController, assume_not_none(active_window)) 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='autocrop', ) 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 all crop overlays in the active window. This method retrieves all overlays associated with the 'autocrop' group from the active image window and hides any that are currently visible. It also updates the enable state of the corresponding action descriptor. The method first checks if there is an active window, then gets all crop overlays from the window's overlay manager. For each visible overlay, it sets the visibility to False. Finally, it updates the `can_crop_overlay_be_hidden` action descriptor to False. """ active_window = self.context.active_image() if not active_window: return crop_overlays = active_window.overlay_manager.get_overlay_by_group('autocrop') 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 label when a cropped image window name changes. This internal method is connected to the state changed signal of cropped image windows. It checks if the window is a derived image and updates the corresponding overlay label on the original image if it exists. The method compares the current window name with the overlay label and updates it if they differ, ensuring that the overlay reflects the most recent name of the cropped image window. """ image_controller = self.sender() if not isinstance(image_controller, ImageWindowController): return # let's check if this window_controller is a cropped image if not self._check_for_overlay(image_controller): return derivation = assume_not_none(image_controller.source.derivation) # let's get a reference of the target image 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) # let's check if an overlay for this cropped image already exists overlay_exists = target_image_window.overlay_manager.exists( OverlayKey(image_controller.id, OverlayRole.Permanent) ) if overlay_exists: existing_overlay = assume_not_none( target_image_window.overlay_manager.get_overlay_by_id(image_controller.id, OverlayRole.Permanent) ) overlay_label = self._overlay_label_for(image_controller) if overlay_label != existing_overlay.label: existing_overlay.label = overlay_label target_image_window.overlay_manager.request_to_update_overlay.emit(existing_overlay)
# if the overlay does not exist, it will be created with the new name at the next occasion.
[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] def on_autocrop_start(self) -> None: """Handle the start of the autocrop procedure. This method activates the tool controller to begin the autocrop workflow. """ self.activate()
[docs] class AutocropTool(Tool[AutocropToolController]): """Tool class representing the autocrop functionality. This class defines the autocrop tool with its unique identifier, name, and description for integration into the tool system. """ tool_id = 'autocrop' """Unique identifier for the autocrop tool.""" name = 'Autocrop' """Human-readable name of the tool.""" description = 'A tool to perform region cropping based on simple segmentation' """Description of what the tool does.""" overlays_to_be_registered = [ OverlaySpec('crop-with-label', OverlayRole.Permanent, draw_crop_overlay), OverlaySpec('crop-with-label', OverlayRole.Highlight, draw_highlight_crop_overlay), ] """Overlays spec to be registered"""
[docs] def create_controller(self, ctx: 'ToolContext') -> AutocropToolController: """Create a controller instance for this tool. :param ctx: Tool context containing application services :type ctx: ToolContext :return: New controller instance :rtype: AutocropToolController """ return AutocropToolController(ctx, self)