Source code for radioviz.tools.region_tool

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Region tool module for radioviz application.

This module provides functionality for selecting regions of interest in images,
calculating statistics for those regions, and displaying histograms of the selected
regions. It includes tools for rectangle, circle, ellipse, and polygon selection
types, along with associated UI components and data management.

The module defines controllers, views, models, and utility functions for handling
region selection and analysis operations within the radioviz application framework.

Typical Workflow for Region Creation:
1. User clicks "Add Region" button in the tool dock and selects a region type
2. A RegionSelectionDialog appears with options for region type, rotation, and enclosure
3. The RegionToolController creates a RegionToolSession with an ImageWindowController
4. A RegionSelector is initialized on the image canvas based on the selected region type
5. User performs selection on the image canvas (drag for rectangles/ellipses, click for polygons)
6. Once valid selection is made, the RegionSelectionDialog enables the "Done" button
7. User clicks "Done" which triggers RegionToolSession.start_region_computation()
8. A worker is created in a separate thread to compute statistics
9. The worker calculates histogram, mean, std dev, min, max, and area values
10. Results are returned to the RegionToolSession which finalizes the region creation
11. The new RegionData is added to the item store and displayed in the table

Typical Workflow for Region Removal:
1. User selects a region in the table view
2. User clicks the "Delete Region" button
3. RegionToolController.on_delete_clicked() is called
4. The selected RegionData is passed to RegionToolController.remove_item()
5. The item is marked as being removed to prevent re-entrancy
6. Associated overlay elements are removed from the image canvas
7. If a RegionWindowController exists, it is closed
8. The RegionData is removed from the item store

Typical Workflow for Workspace Serialization:
1. The `to_workspace` method is called to generate a workspace specification.
2. A `ToolWorkspace` object is created, capturing the current state and items of the tool.
3. The workspace specification includes optional data based on the `include_data` parameter.
4. The current selected item and all items in the item store are serialized into the workspace.

Typical Workflow for Workspace Restoration:
1. The `from_workspace` method is invoked with a `ToolWorkspace` specification.
2. The tool's state is reconstructed using the workspace specification.
3. Each item in the specification is restored, resolving references using the provided context.
4. The item store is reordered based on the order specified in the workspace.
5. The selected item is restored to match the workspace state.

Key Actors:
- ToolDockWidget: Provides UI controls and manages table interactions
- RegionToolController: Coordinates between UI and backend logic
- RegionSelectionDialog: Handles user configuration of region parameters
- RegionToolSession: Manages the lifecycle of a region selection operation
- RegionSelector: Handles the actual selection on the image canvas
- RegionData: Stores region information and statistics
- RegionWindowController: Manages the region detail window
"""

from __future__ import annotations

import pathlib
import sys
import uuid
from copy import deepcopy
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable, List, Optional, Protocol, Union, cast
from uuid import UUID

import h5py
import numpy as np
from matplotlib.artist import Artist
from matplotlib.axes import Axes
from matplotlib.backend_bases import KeyEvent, MouseEvent
from matplotlib.backends.backend_qt import NavigationToolbar2QT
from matplotlib.patches import Ellipse, Patch, PathPatch, Polygon, Rectangle
from matplotlib.path import Path
from matplotlib.text import Annotation
from matplotlib.widgets import (
    EllipseSelector,
    PolygonSelector,
    RectangleSelector,
)
from PySide6.QtCore import (
    QItemSelection,
    QItemSelectionModel,
    QModelIndex,
    QObject,
    QPersistentModelIndex,
    Qt,
    QTimer,
    Signal,
)
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import (
    QApplication,
    QDialog,
    QGridLayout,
    QHBoxLayout,
    QLabel,
    QMenu,
    QPushButton,
    QTableView,
    QToolBar,
    QToolButton,
    QVBoxLayout,
    QWidget,
)
from superqt import QEnumComboBox, 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, SubWindowEnum
from radioviz.geometry.mask import geometry_to_mask
from radioviz.geometry.polygon import normalize_polygon_ccw
from radioviz.models.data_model import DataOrigin, DataSource, DataStorage
from radioviz.models.item_store import ItemStore
from radioviz.models.menu_spec import ToolActionSpec, ToolMenuSpec
from radioviz.models.overlays import OverlayModel, OverlayRole, OverlaySpec
from radioviz.models.roi_model import ROIEnclosure, ROIType
from radioviz.services.action_descriptor import ActionDescriptor
from radioviz.services.color_service import Color, to_mpl, to_qcolor
from radioviz.services.export_services import ExportSpec
from radioviz.services.item_counter import ItemCounter
from radioviz.services.save_services import SaveSpec
from radioviz.services.signal_blocker import SignalBlocked
from radioviz.services.typing_helpers import require_not_none
from radioviz.services.window_factory import WindowSpec
from radioviz.services.window_manager import WindowRequest
from radioviz.services.workspace_manager import (
    ToolWorkspace,
    WorkspaceReferenceManager,
    enforce_uuid,
    workspace_spec_as_mapping,
)
from radioviz.tools.base_controller import ToolController
from radioviz.tools.base_dock import ToolDockWidget
from radioviz.tools.base_tool import Tool, ToolEvent
from radioviz.tools.tool_api import BaseToolSession, ToolContext, ToolSessionResult, WorkspaceRestoreContext
from radioviz.views.canvas_widget import SimpleCanvas
from radioviz.views.image_window import CanvasWindow
from radioviz.views.item_model import ItemModel

region_type_icons = {
    ROIType.RECTANGLE: 'tabler:rectangle',
    ROIType.CIRCLE: 'tabler:player-record',
    ROIType.ELLIPSE: 'tabler:oval-vertical',
    ROIType.POLYGON: 'tabler:polygon',
}
"""Dictionary mapping region types to their corresponding icons."""


[docs] @dataclass class RegionDataWorkspaceSpec: id: UUID name: str image_controller_id: Optional[UUID] = None region_controller_id: Optional[UUID] = None color: tuple[float, float, float] = (0.0, 0.0, 0.0) selection_type: str = str(ROIType.RECTANGLE) enclosure: str = str(ROIEnclosure.Inside) bins: Optional[Union[int, str]] = None region_geometry: dict[str, Any] = field(default_factory=dict) show_overlay: bool = True show_label: bool = True # data mask: Optional[np.ndarray] = None histogram: Optional[np.ndarray] = None bin_edges: Optional[np.ndarray] = None mean_value: Optional[float] = None std_dev_value: Optional[float] = None max_value: Optional[float] = None min_value: Optional[float] = None area: Optional[float] = None
[docs] class RegionData: """ Data class representing a region of interest. Stores information about a selected region including its properties, statistics, and associated controllers. """ def __init__( self, name: str, color: Color, selection_type: ROIType = ROIType.RECTANGLE, region_controller: Optional[RegionWindowController] = None, image_controller: Optional[ImageWindowController] = None, region_geometry: Optional[dict[str, Any]] = None, mask: Optional[np.ndarray] = None, session_id: Optional[UUID] = None, region_id: Optional[UUID] = None, enclosure: Optional[ROIEnclosure] = None, histogram: Optional[np.ndarray] = None, bin_edges: Optional[np.ndarray] = None, bins: Optional[Union[int, str]] = None, mean_value: Optional[float] = None, std_dev_value: Optional[float] = None, max_value: Optional[float] = None, min_value: Optional[float] = None, area: Optional[float] = None, show_overlay: Optional[bool] = True, show_label: Optional[bool] = True, ) -> None: """ Initialize a RegionData instance. :param name: Name of the region :type name: str :param color: Color of the region :type color: Color :param selection_type: Type of selection (default: RECTANGLE) :type selection_type: ROIType :param region_controller: Controller for the region window :type region_controller: Optional[RegionWindowController] :param image_controller: Controller for the image window :type image_controller: Optional[ImageWindowController] :param mask: Binary mask of the region :type mask: Optional[np.ndarray] :param session_id: Session identifier :type session_id: Optional[UUID] :param region_id: Unique identifier for the region :type region_id: Optional[UUID] :param enclosure: Enclosure type (Inside/Outside) :type enclosure: Optional[ROIEnclosure] :param histogram: Histogram data :type histogram: Optional[np.ndarray] :param bin_edges: Bin edges for histogram :type bin_edges: Optional[np.ndarray] :param bins: Number of bins or bin specification :type bins: Optional[Union[int, str]] :param mean_value: Mean value of region :type mean_value: Optional[float] :param std_dev_value: Standard deviation of region :type std_dev_value: Optional[float] :param max_value: Maximum value in region :type max_value: Optional[float] :param min_value: Minimum value in region :type min_value: Optional[float] :param area: Area of the region :type area: Optional[float] """ self.id = region_id or uuid.uuid4() self.name = name self.selection_type = ROIType(selection_type) self.region_controller = region_controller # Reference to the region window controller self.image_controller = image_controller self.color = color self.region_geometry = region_geometry or {} self.mask = mask self.session_id = session_id self.histogram = histogram self.bins = bins self.bin_edges = bin_edges self.mean_value = mean_value self.std_dev_value = std_dev_value self.area = area self.max_value = max_value self.min_value = min_value self.enclosure = ROIEnclosure(enclosure) if enclosure else ROIEnclosure.Inside self.show_overlay = show_overlay or True self.show_label = show_label or True self._being_removed = False self._has_data = False self._requested_region_controller_id: Optional[UUID] = None
[docs] def assign_kwargs(self, **kwargs: Any) -> None: """ Assign keyword arguments to instance attributes. :param kwargs: Keyword arguments to assign :type kwargs: dict """ for key, value in kwargs.items(): if hasattr(self, key): setattr(self, key, value)
[docs] def require_image_controller(self) -> 'ImageWindowController': """ Return the image controller or raise if missing. :return: Associated image window controller :rtype: ImageWindowController :raises RuntimeError: If no image controller is linked """ return require_not_none(self.image_controller, f'Region {self.name} image controller')
[docs] def require_region_controller(self) -> 'RegionWindowController': """ Return the region controller or raise if missing. :return: Associated region window controller :rtype: RegionWindowController :raises RuntimeError: If no region controller is linked """ return require_not_none(self.region_controller, f'Region {self.name} region controller')
def to_workspace_spec(self, include_data: bool) -> RegionDataWorkspaceSpec: spec = RegionDataWorkspaceSpec( id=self.id, name=self.name, image_controller_id=self.require_image_controller().id, color=to_mpl(self.color), selection_type=str(self.selection_type), enclosure=str(self.enclosure), bins=self.bins, region_geometry=self.region_geometry, show_overlay=self.show_overlay, show_label=self.show_label, ) if include_data: data_attrs = [ 'mask', 'histogram', 'bin_edges', 'mean_value', 'std_dev_value', 'area', 'max_value', 'min_value', ] for attr in data_attrs: setattr(spec, attr, getattr(self, attr)) return spec
[docs] class RegionDataCompleteProtocol(Protocol): """ Protocol describing a region with fully populated histogram data. This protocol expresses the invariant that histogram arrays are available after a session completes, without adding runtime checks. """ name: str color: Color selection_type: ROIType enclosure: ROIEnclosure area: Optional[float] region_controller: Optional['RegionWindowController'] histogram: np.ndarray bin_edges: np.ndarray
[docs] def assume_complete_region(region: RegionData) -> RegionDataCompleteProtocol: """ Treat a region as complete without runtime checks. :param region: The region data assumed to be complete :type region: RegionData :return: A view of the region with non-optional histogram arrays :rtype: RegionDataCompleteProtocol """ return cast(RegionDataCompleteProtocol, region)
[docs] class RegionSelector(QObject): """ Temporary, editable selector for region selection. Lives only for the duration of a ToolSession. """ valid_selection = Signal(bool) """Signal emitted when selection validity changes.""" has_valid_selection = ActionDescriptor(action_name='has_valid_selection', signal_name='valid_selection') """Action descriptor for valid selection state.""" def __init__(self, ax: Axes, region_type: ROIType) -> None: """ Initialize the region selector. :param ax: Matplotlib axes to draw on :type ax: Axes :param region_type: Type of region to select :type region_type: ROIType """ super().__init__() self.ax = ax self.region_type = region_type self.selector: Optional[Union[RectangleSelector, EllipseSelector, PolygonSelector]] self._previously_focused_widget = QApplication.focusWidget() self.has_valid_selection = False
[docs] def _restore_focus(self) -> None: """Restore focus to previously focused widget.""" if self._previously_focused_widget: self._previously_focused_widget.setFocus() self._previously_focused_widget = None
# ------------------------------------------------------------------
[docs] def activate(self) -> None: """ Activate the region selector. Sets up the appropriate matplotlib selector based on region type. """ canvas = self.ax.figure.canvas canvas.setFocus() # type: ignore[attr-defined] canvas.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # type: ignore[attr-defined] rect_props = { 'facecolor': 'red', 'edgecolor': 'black', 'ls': '--', 'lw': 4, 'fill': True, 'alpha': 0.5, } polygon_props = { 'color': 'red', 'ls': '--', 'lw': 4, } if self.region_type == ROIType.RECTANGLE: self.selector = RectangleSelector( self.ax, onselect=self._on_select, interactive=True, useblit=True, props=rect_props, drag_from_anywhere=True, ) elif self.region_type == ROIType.ELLIPSE: self.selector = EllipseSelector( self.ax, onselect=self._on_select, interactive=True, useblit=True, props=rect_props, drag_from_anywhere=True, ) elif self.region_type == ROIType.POLYGON: self.selector = PolygonSelector( self.ax, onselect=self._on_polygon_select, # type: ignore[arg-type] useblit=True, props=polygon_props, ) elif self.region_type == ROIType.CIRCLE: # Circle = Ellipse with equal aspect constraint self.selector = EllipseSelector( self.ax, onselect=self._on_select, interactive=True, useblit=True, props=rect_props, drag_from_anywhere=True, ) self.selector.add_state('square') self.selector.connect_event('key_press_event', self.on_key_pressed) # type: ignore[arg-type] self.ax.figure.canvas.draw_idle()
# ------------------------------------------------------------------
[docs] def on_key_pressed(self, event: KeyEvent) -> None: """ Handle key press events. :param event: Key press event :type event: KeyEvent """ if event.key in ['escape', 'esc']: self.has_valid_selection = False
[docs] def deactivate(self) -> None: """ Deactivate the region selector. Cleans up resources and removes the selector from the canvas. """ self.has_valid_selection = False 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: artist.remove() self.selector = None self.ax.figure.canvas.draw_idle() self._restore_focus()
[docs] def _on_select(self, eclick: MouseEvent, erelease: MouseEvent) -> None: """ Handle mouse selection for rectangle and ellipse. :param eclick: Mouse click event :type eclick: MouseEvent :param erelease: Mouse release event :type erelease: MouseEvent """ # Drag → valid selection if (eclick.xdata, eclick.ydata) != (erelease.xdata, erelease.ydata): self.has_valid_selection = True return # Click without drag → possibly cancel try: selector = self.selector if selector is None: contains = False else: contains = getattr(selector, '_contains', lambda _ev: False)(eclick) except Exception: contains = False if not contains: self.has_valid_selection = False
[docs] def _on_polygon_select(self, verts: list[tuple[float, float]]) -> None: """ Handle polygon selection. :param verts: Vertices of the polygon :type verts: list """ self.has_valid_selection = True
[docs] def geometry(self) -> tuple[float, float, float, float] | list[tuple[float, float]] | None: """ Get the geometry of the current selection. :return: Selection geometry :rtype: Union[tuple, list, None] :raise RuntimeError: If region type is unsupported """ selector = self.selector if selector is None: return None if isinstance(selector, (RectangleSelector, EllipseSelector)): return selector.extents # (x1, x2, y1, y2) if isinstance(selector, PolygonSelector): return selector.verts raise RuntimeError('Unsupported region type')
RegionStore = ItemStore[RegionData] """Type alias for ItemStore with RegionData elements."""
[docs] class RegionTableModel(ItemModel[RegionData]): """ Table model for displaying region data in a table view. Provides data for displaying region information in a QTableView. """ def __init__(self, store: RegionStore, parent: Optional[QObject] = None) -> None: """ Initialize the region table model. :param store: Store containing region data :type store: RegionStore :param parent: Parent object :type parent: QObject """ super().__init__(store, parent) self.headers = [ 'Name', # 0 'Color', # 1 'Show overlay', # 2 'Show label', # 3 'Selection Type', # 4 'Enclosure', # 5 'Mean', # 6 'Min', # 7 'Max', # 8 'Std dev', # 9 'Area', # 10 ]
[docs] def flags(self, index: QModelIndex | QPersistentModelIndex, /) -> Qt.ItemFlag: """ Return the item flags for the given index. :param index: Index of the item :type index: QModelIndex :return: Item flags for the specified index :rtype: Qt.ItemFlag """ if not index.isValid(): return Qt.ItemFlag.NoItemFlags if len(self._store) == 0: return Qt.ItemFlag.NoItemFlags region_data = self._store.get(index.row()) if region_data is None: return Qt.ItemFlag.NoItemFlags col = index.column() if col == 0: return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsSelectable if col == 2: return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsSelectable if col == 3: if region_data.show_overlay: return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsSelectable else: return Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsSelectable return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
[docs] def set_data_for( self, item: RegionData, column: int, value: Any, role: int = Qt.ItemDataRole.DisplayRole, ) -> bool: """ Set data for a specific region cell. :param item: Region data object :type item: RegionData :param column: Column index :type column: int :param value: Value to set :type value: Any :param role: Item data role :type role: Qt.ItemDataRole :return: True if successful, False otherwise :rtype: bool """ if column == 0 and role == Qt.ItemDataRole.EditRole: self._store.update_by_id(item.id, name=value) return True if column == 2 and role == Qt.ItemDataRole.CheckStateRole: show_overlay = Qt.CheckState(value) == Qt.CheckState.Checked show_label = True if show_overlay else False self._store.update_by_id(item.id, show_overlay=show_overlay, show_label=show_label) return True if column == 3 and role == Qt.ItemDataRole.CheckStateRole: self._store.update_by_id(item.id, show_label=Qt.CheckState(value) == Qt.CheckState.Checked) return True return False
[docs] def data_for(self, item: RegionData, column: int, role: int = Qt.ItemDataRole.DisplayRole) -> Any: """ Get data for a specific cell in the table. :param item: Region data item :type item: RegionData :param column: Column index :type column: int :param role: Item data role :type role: Qt.ItemDataRole :return: Data for the cell :rtype: Any """ if role == Qt.ItemDataRole.UserRole: return item.id if role == Qt.ItemDataRole.CheckStateRole: if column == 2: return Qt.CheckState.Checked if item.show_overlay else Qt.CheckState.Unchecked elif column == 3: return Qt.CheckState.Checked if item.show_label else Qt.CheckState.Unchecked else: return None 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 'Visible' if item.show_overlay else 'Hidden' elif column == 3: return 'Visible' if item.show_label else 'Hidden' elif column == 4: return item.selection_type.value elif column == 5: return ROIEnclosure(item.enclosure).value elif column == 6: return f'{item.mean_value:.2f}' elif column == 7: return f'{item.min_value:.2f}' elif column == 8: return f'{item.max_value:.2f}' elif column == 9: return f'{item.std_dev_value:.2f}' elif column == 10: return f'{item.area:.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 RegionSelectionDialog(QDialog): """ Dialog for selecting region parameters. Provides UI for configuring region selection parameters like type, rotation, and enclosure. """ update_params = Signal(dict) """Signal emitted when parameters are updated.""" change_rotation = Signal(bool) """Signal emitted when rotation is changed.""" region_type_changed = Signal(object) """Signal emitted when region type is changed.""" def __init__(self, selection_type: ROIType, parent: Optional[QWidget] = None) -> None: """ Initialize the region selection dialog. :param selection_type: Initial selection type :type selection_type: ROIType :param parent: Parent widget :type parent: QWidget """ super().__init__(parent) self.setModal(False) self.selection_type = selection_type self.setWindowTitle('Select region...') self.setMinimumHeight(250) layout = QVBoxLayout() label_text = 'Click and drag on the image canvas to make the region selection.\n' if self.selection_type == ROIType.POLYGON: label_text += 'Click on the image canvas to drop the vertex of the polygonal selection.\n' label_text += 'After the initial selection you can still edit the selection by grabbing one of the handles.' self.status_label = QLabel(label_text) self.status_label.setWordWrap(True) self.status_label.setMinimumWidth(500) layout.addWidget(self.status_label) param_layout = QGridLayout() toolbar_label = QLabel('Region type:') param_layout.addWidget(toolbar_label, 0, 0) toolbar = QToolBar(self) group = QActionGroup(toolbar) group.setExclusive(True) for region_type in ROIType: action = QAction(region_type.value, toolbar) action.setCheckable(True) action.setChecked(self.selection_type == region_type) action.setIcon(QIconifyIcon(region_type_icons[region_type])) action.triggered.connect(partial(self.on_region_changed, region_type)) group.addAction(action) toolbar.addAction(action) param_layout.addWidget(toolbar, 0, 1) rotation_label = QLabel('Activate rotation:') param_layout.addWidget(rotation_label, 1, 0) self.rotation_switch = QToggleSwitch() self.rotation_switch.toggled.connect(self.change_rotation) self.rotation_switch.setEnabled(self.is_rotation_meaningful()) param_layout.addWidget(self.rotation_switch, 1, 1) enclosure_label = QLabel('Inside or outside:') param_layout.addWidget(enclosure_label, 2, 0) self.enclosure = QEnumComboBox() self.enclosure.setEnumClass(ROIEnclosure) self.enclosure.currentIndexChanged.connect(self.on_parameters_changed) param_layout.addWidget(self.enclosure, 2, 1) layout.addLayout(param_layout) self.params = {'enclosure': self.enclosure.currentText()} button_layout = QHBoxLayout() self.cancel_button = QPushButton('Cancel') self.cancel_button.clicked.connect(self.reject) button_layout.addWidget(self.cancel_button) self.done_button = QPushButton('Done') self.done_button.setEnabled(False) self.done_button.clicked.connect(self.accept) button_layout.addWidget(self.done_button) layout.addLayout(button_layout) self.setLayout(layout)
[docs] def on_parameters_changed(self) -> None: """Handle parameter changes.""" self.params['enclosure'] = self.enclosure.currentText() self.update_params.emit(self.params)
[docs] def on_region_changed(self, region_type: ROIType) -> None: """ Handle region type changes. :param region_type: New region type :type region_type: ROIType """ self.rotation_switch.setEnabled(region_type != ROIType.POLYGON) self.region_type_changed.emit(region_type)
[docs] def is_rotation_meaningful(self) -> bool: """ Check if rotation is meaningful for the current selection type. :return: True if rotation is meaningful :rtype: bool """ return self.selection_type != ROIType.POLYGON
[docs] class RegionToolDock(ToolDockWidget['RegionToolController']): """ Dock widget for the region tool. Provides UI controls for adding and managing regions. """ initial_visibility = True """Initial visibility of the dock widget.""" enable_for_window_type = SubWindowEnum.ALL """Window types where this dock is enabled.""" dock_position = Qt.DockWidgetArea.BottomDockWidgetArea """Position of the dock widget.""" item_selection_changed = Signal(object, object) """Signal emitted when item selection changes.""" controller: RegionToolController def __init__(self, parent: QWidget, controller: RegionToolController) -> None: """ Initialize the region tool dock. :param parent: Parent widget :type parent: QWidget :param controller: Region tool controller :type controller: RegionToolController """ super().__init__('Region tool', parent, controller) if not isinstance(self.controller, RegionToolController): raise TypeError('The controller must be an instance of RegionToolController') self.table_view = QTableView() self.table_view.setModel(self.controller.table_model) self.table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) self.table_view.selectionModel().selectionChanged.connect(self.on_item_selected) self._actions: dict[ROIType, QAction] = {} self.add_button = QToolButton(self) self.add_button.setText('Add Region') self.add_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) self.add_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) self.selection_dialog: Optional[RegionSelectionDialog] = None menu = QMenu(self) for type_ in ROIType: action = QAction(f'Add {type_.value}') action.setData(type_) action.triggered.connect(self._on_region_action_triggered) action.setIcon(QIconifyIcon(region_type_icons[type_])) action.setStatusTip(f'Add {type_.value}') menu.addAction(action) self._actions[type_] = action self.add_button.setMenu(menu) self._set_default_region_action(ROIType.RECTANGLE) self.add_button.setEnabled(False) self.delete_button = QPushButton('Delete Region') self.delete_button.clicked.connect(self.delete_item) self.delete_button.setEnabled(False) button_layout = QVBoxLayout() button_layout.addWidget(self.add_button) button_layout.addWidget(self.delete_button) button_layout.addStretch() table_layout = QVBoxLayout() table_layout.addWidget(self.table_view) self.main_widget = QWidget() self.main_layout = QHBoxLayout(self.main_widget) self.main_layout.addLayout(button_layout) self.main_layout.addLayout(table_layout) self.setWidget(self.main_widget)
[docs] def _on_region_action_triggered(self) -> None: """Handle region action triggers.""" # noinspection PyInvalidCast action = cast(QAction, self.sender()) region_type = ROIType(action.data()) self.add_button.setDefaultAction(action) self.start_add_new_region(region_type)
[docs] def start_add_new_region(self, region_type: ROIType) -> None: """ Start adding a new region of specified type. :param region_type: Type of region to add :type region_type: ROIType """ self.controller.region_requested_type = region_type self.controller.activate()
[docs] def _set_default_region_action(self, region_type: ROIType) -> None: """ Set the default region action. :param region_type: Default region type :type region_type: ROIType """ action = self._actions[region_type] self.add_button.setDefaultAction(action)
[docs] def handle_event(self, event: ToolEvent) -> None: """ Handle tool events. :param event: Tool event :type event: ToolEvent """ if event.event == 'window_controller_changed': self.on_window_controller_changed(event.payload) elif event.event == 'create_selection_dialog': self.create_selection_dialog() elif event.event == 'destroy_selection_dialog': if self.selection_dialog: with SignalBlocked(self.selection_dialog): self.selection_dialog.close() self.selection_dialog = None elif event.event == 'selector_has_valid_selection': if self.selection_dialog: self.selection_dialog.done_button.setEnabled(event.payload)
[docs] def create_selection_dialog(self) -> None: """Create and show the selection dialog.""" if self.selection_dialog is not None: self.selection_dialog = None region_type = self.controller.require_region_requested_type() self.selection_dialog = RegionSelectionDialog(region_type, parent=self) self.selection_dialog.change_rotation.connect(self.controller.activate_rotation) self.selection_dialog.rejected.connect(self.controller.on_cancelled_region_definition) self.selection_dialog.accepted.connect(self.controller.on_confirmed_region_definition) self.selection_dialog.region_type_changed.connect(self.controller.on_region_type_change) self.selection_dialog.update_params.connect(self.controller.set_parameters) self.selection_dialog.on_parameters_changed() self.selection_dialog.show()
[docs] def on_item_selected(self, selected: QItemSelection, deselected: QItemSelection) -> None: """ Handle item selection changes. :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.controller.item_store.get_by_id(item_id) break selected_item = None indexes = self.table_view.selectionModel().selectedRows() if indexes: item_id = indexes[0].data(Qt.ItemDataRole.UserRole) selected_item = self.controller.item_store.get_by_id(item_id) delete_enable = True else: delete_enable = False self.set_can_remove(delete_enable) self.item_selection_changed.emit(selected_item, deselected_item)
[docs] def set_can_remove(self, delete_enable: bool) -> None: """ Set whether deletion is enabled. :param delete_enable: Whether deletion is enabled :type delete_enable: bool """ self.delete_button.setEnabled(delete_enable) self.controller.can_remove = delete_enable
[docs] def on_window_controller_changed(self, is_image_controller: bool) -> None: """ Handle window controller changes. :param is_image_controller: Whether the controller is an image controller :type is_image_controller: bool """ self.add_button.setEnabled(is_image_controller)
[docs] def delete_item(self) -> None: """Handle delete item button click.""" self.controller.on_delete_clicked()
[docs] def on_request_to_change_selection( self, index: QModelIndex, flags: QItemSelectionModel.SelectionFlag, ) -> None: """ Change the selection in the table view. Selects the specified index in the table view using the provided selection flags and scrolls to the selected index to ensure it is visible. :param index: The index to select :type index: QModelIndex :param flags: Flags specifying how the selection should be changed :type flags: QItemSelectionModel.SelectionFlag """ self.table_view.selectionModel().select(index, flags) self.table_view.scrollTo(index)
[docs] @dataclass(frozen=True) class RegionComputationRequest: """ Request for region computation. Contains all the information needed to compute statistics for a region. """ session_id: UUID """Unique identifier for the session.""" image: np.ndarray """Image data.""" mask: np.ndarray """Binary mask of the region.""" enclosure: ROIEnclosure """Enclosure type (Inside/Outside).""" bins: Union[int, str] """Number of bins or bin specification for histogram."""
@thread_worker def calculate_histogram(request: RegionComputationRequest) -> dict[str, Any]: image = request.image mask = request.mask if request.enclosure == ROIEnclosure.Outside: mask = ~mask values = image[mask] histogram, bin_edges = np.histogram(values, bins=request.bins) results = dict( session_id=request.session_id, histogram=histogram, bin_edges=bin_edges, bins=request.bins, mean_value=np.mean(values), std_dev_value=np.std(values), min_value=np.min(values), max_value=np.max(values), area=np.count_nonzero(mask), enclosure=request.enclosure, mask=mask, ) return results
[docs] class RegionToolSession(BaseToolSession['RegionToolController', ImageWindowController]): """ Session for region tool operations. Manages the lifecycle of a region selection operation. """ def __init__( self, tool_controller: 'RegionToolController', window_controller: ImageWindowController, input_data: Optional[RegionData] = None, ) -> None: """ Initialize the region tool session. :param tool_controller: Tool controller :type tool_controller: RegionToolController :param window_controller: Window controller :type window_controller: ImageWindowController """ if not isinstance(tool_controller, RegionToolController): raise TypeError('The controller must be an instance of RegionToolController') if not isinstance(window_controller, ImageWindowController): raise TypeError('The window controller must be an instance of ImageWindowController') super().__init__(tool_controller, window_controller) self._input_data = input_data self.current_region: Optional[RegionData] = None self.region_parameters: dict[str, Any] = {} self.region_type = self.tool_controller.region_requested_type self.region_selector: Optional[RegionSelector] = None self._overlay_model: Optional[OverlayModel] = None
[docs] def on_start(self) -> None: """ Start the region tool session. Initializes the session by either defining an interactive region selection or setting initial conditions based on input data. If data is already available, it triggers computation completion directly. """ if self._input_data is None: self._on_interactive_definition() else: self._define_initial_condition() if not self._input_data._has_data: self.start_region_computation() else: self._overlay_model = self.overlay_from_geometry() results = {'session_id': self.session_id} keys = ['histogram', 'bin_edges', 'mean_value', 'std_dev_value', 'min_value', 'max_value', 'area'] for key in keys: results[key] = getattr(self.current_region, key) QTimer.singleShot(0, lambda results=results: self.on_computation_completed(results))
[docs] def _define_initial_condition(self) -> None: """ Define initial conditions for the not interactive region tool session. Sets up the region parameters and assigns the input data to the current region. """ input_data = self._require_input_data() self.region_selector = None self.region_parameters['enclosure'] = input_data.enclosure self.region_parameters['shape'] = self.window_controller.image_data.shape self.current_region = input_data
[docs] def _on_interactive_definition(self) -> None: """ Define region interactively. Initiates the interactive region selection process by creating a new region and setting up the selector on the image canvas. """ self.tool_controller.context.message_requested.emit('Region extraction procedure on going...', 'info', 3000) self.tool_controller.emit_event('create_selection_dialog') self.current_region = RegionData( name=f'region-{self.tool_controller.item_counter.next()}', color=self.tool_controller.context.get_color_service().next_color(), selection_type=self._require_region_type(), session_id=self.session_id, ) self.region_parameters['shape'] = self.window_controller.image_data.shape view = self.window_controller.require_view() self.region_selector = RegionSelector(view.canvas.im_axes, self._require_region_type()) self.region_selector.valid_selection.connect(self.on_valid_selection_change) self.region_selector.activate()
[docs] def on_valid_selection_change(self, valid: bool) -> None: """ Handle selection validity changes. :param valid: Whether selection is valid :type valid: bool """ self.tool_controller.emit_event('selector_has_valid_selection', payload=valid)
[docs] def teardown_selector(self) -> None: """Teardown the region selector.""" if self.region_selector is not None: self.region_selector.deactivate()
[docs] def create_selector(self) -> None: """ Create a new region selector. Recreates the selector with the current region type. """ self.region_type = self.tool_controller.region_requested_type if self.current_region: self.current_region.selection_type = self._require_region_type() view = self.window_controller.require_view() self.region_selector = RegionSelector(view.canvas.im_axes, self._require_region_type()) self.region_selector.valid_selection.connect(self.on_valid_selection_change) self.region_selector.activate()
[docs] def on_cancel(self, reason: str) -> None: """ Handle cancellation of the session. :param reason: Reason for cancellation :type reason: str """ self.cleanup()
[docs] def on_finish(self) -> None: """ Handle completion of the session. Cleans up resources and notifies the controller. """ self.assign_payload(self.current_region) self.tool_controller.context.message_requested.emit('Region extraction procedure finished', 'info', 3000) self.cleanup()
[docs] def cleanup(self) -> None: """Clean up resources used by the session.""" if self.region_selector: self.region_selector.deactivate() self.region_selector = None self.tool_controller.emit_event('destroy_selection_dialog')
[docs] def overlay_from_geometry(self) -> OverlayModel: """ Create an overlay model from the current geometry. :return: Overlay model representing the selection :rtype: OverlayModel :raise TypeError: If selection type is unsupported """ current_region = self._require_current_region() geometry = current_region.region_geometry selection_type = current_region.selection_type id_ = current_region.id label = current_region.name color = to_mpl(current_region.color) overlay = OverlayModel(id=id_, role=OverlayRole.Permanent, label=label) overlay.extra_props = deepcopy(self.region_parameters) overlay.style = {'edgecolor': color, 'ls': 'solid', 'lw': 3, 'facecolor': 'none', 'fill': False} overlay.visible = current_region.show_overlay if selection_type in [ROIType.RECTANGLE, ROIType.ELLIPSE, ROIType.CIRCLE]: xmin, xmax, ymin, ymax = geometry['extents'] width, height = (xmax - xmin), (ymax - ymin) cx, cy = geometry['center'] angle = geometry['angle'] overlay.type = 'rectangle' xy = (cx - width / 2, cy - height / 2) if selection_type != ROIType.RECTANGLE: overlay.type = 'ellipse' xy = (cx, cy) overlay.geometry = {'xy': xy, 'width': width, 'height': height, 'angle': angle} return overlay if selection_type == ROIType.POLYGON: verts = geometry['verts'] overlay.type = 'polygon' overlay.geometry = dict(xy=list(verts), closed=True) return overlay raise TypeError(f'Unsupported selection type: {type(selection_type)}')
[docs] def create_label_overlay(self) -> OverlayModel: """ Create an overlay for the region label.h5. """ current_region = self._require_current_region() id_ = current_region.id label = current_region.name color = to_mpl(current_region.color) geometry = current_region.region_geometry selection_type = current_region.selection_type if selection_type in [ROIType.RECTANGLE, ROIType.ELLIPSE, ROIType.CIRCLE]: xmin, xmax, ymin, ymax = geometry['extents'] elif selection_type == ROIType.POLYGON: verts = np.array(geometry['verts']) xmin = float(np.min(verts[:, 0])) ymin = float(np.min(verts[:, 1])) else: raise TypeError(f'Unsupported selection type: {type(selection_type)}') overlay = OverlayModel(id=id_, role=OverlayRole.Label, label=label) overlay.type = 'region_label' overlay.geometry = dict(text=label, xy=(xmin, ymin), xycoords='data', textcoords='offset pixels') overlay.style = {'color': color, 'ha': 'right', 'va': 'top', 'size': 12, 'clip_on': False} overlay.extra_props = {'offset': (-8, -8)} overlay.visible = current_region.show_label return overlay
[docs] def start_region_computation(self) -> None: """ Start the region computation process in a separated thread.""" current_region = self._require_current_region() if self.region_selector is not None: # interactive session! # assign the geometry to the current region current_region.region_geometry = self._get_geometry_from_selector() self._overlay_model = self.overlay_from_geometry() mask = geometry_to_mask( current_region.selection_type, current_region.region_geometry, self.window_controller.image_data.shape, ) computation_request = RegionComputationRequest( session_id=self.session_id, image=self.window_controller.image_data, mask=mask, enclosure=self.region_parameters['enclosure'], bins='auto', ) worker = calculate_histogram(computation_request) worker.returned.connect(self.on_computation_completed) worker.errored.connect(self.on_computation_failed) worker.start()
[docs] def on_computation_completed(self, results: dict[str, Any]) -> None: """ Handle completed computation. :param results: Computation results :type results: dict[str, Any] """ if not self.is_active() or results['session_id'] != self.session_id: self.cancel() return current_region = self._require_current_region() overlay_model = self._require_overlay_model() current_region.assign_kwargs(**results) current_region.image_controller = self.window_controller self.window_controller.overlay_manager.add('im_axes', overlay_model) self.window_controller.overlay_manager.add('im_axes', self.create_label_overlay()) self.finish()
[docs] def on_computation_failed(self, e: Exception) -> None: """ Handle computation failure. :param e: Exception that occurred :type e: Exception :raise Exception: Re-raises the exception """ self.cancel(str(e)) raise e
[docs] def on_computation_cancelled(self) -> None: """Handle computation cancellation.""" self.cancel()
def _get_geometry_from_selector(self) -> dict[str, Any]: selector = self._require_region_selector().selector if selector is None: raise RuntimeError('Region selector has no underlying matplotlib selector.') geometry: dict[str, Any] = {} if isinstance(selector, (EllipseSelector, RectangleSelector)): geometry['extents'] = selector.extents geometry['center'] = selector.center selection_artist = getattr(selector, '_selection_artist', None) geometry['angle'] = selection_artist.angle if selection_artist is not None else 0.0 elif isinstance(selector, PolygonSelector): geometry['verts'] = selector.verts else: raise TypeError(f'Unsupported selector type: {type(selector)}') return geometry
[docs] def _require_region_type(self) -> ROIType: """ Return the currently requested region type or raise if missing. :return: The requested region type :rtype: ROIType :raises RuntimeError: If no region type is set """ return require_not_none(self.region_type, 'Region type')
[docs] def _require_input_data(self) -> RegionData: """ Return the input data provided to the session or raise if missing. :return: Input region data :rtype: RegionData :raises RuntimeError: If no input data has been provided """ return require_not_none(self._input_data, 'RegionToolSession input data')
[docs] def _require_current_region(self) -> RegionData: """ Return the current region or raise if missing. :return: Current region data :rtype: RegionData :raises RuntimeError: If no current region is set """ return require_not_none(self.current_region, 'Current region')
[docs] def _require_region_selector(self) -> RegionSelector: """ Return the region selector or raise if missing. :return: The active region selector :rtype: RegionSelector :raises RuntimeError: If no selector is active """ return require_not_none(self.region_selector, 'Region selector')
[docs] def _require_overlay_model(self) -> OverlayModel: """ Return the overlay model or raise if missing. :return: The overlay model associated with the current region :rtype: OverlayModel :raises RuntimeError: If the overlay model is not available """ return require_not_none(self._overlay_model, 'Overlay model')
[docs] class RegionToolController(ToolController[ImageWindowController, RegionToolSession]): """ Controller for the region tool. Manages the interaction between the UI and the underlying region processing logic. """ add_enabled_changed = Signal(bool) """Signal emitted when add enabled state changes.""" remove_enabled_changed = Signal(bool) """Signal emitted when remove enabled state changes.""" ack_session_completed = Signal(UUID) """Signal emitted when the results of the tool session are committed.""" request_to_change_selection = Signal(object, object) """Signal emitted when the controller request a change in the selected element""" can_add = ActionDescriptor() """Action descriptor for add enabled state.""" can_remove = ActionDescriptor() """Action descriptor for remove enabled state.""" restore_completed = Signal(object) """Signal emitted when the restore of the tool is completed.""" def __init__(self, tool_ctx: ToolContext, tool: Tool[RegionToolController]) -> None: """ Initialize the region tool controller. :param tool_ctx: Tool context :type tool_ctx: ToolContext :param tool: Tool instance :type tool: Tool """ super().__init__(tool_ctx, tool) self.item_store = RegionStore() self.item_store.add_update_listener(self.update_editable_fields) self.table_model = RegionTableModel(self.item_store, self) self.item_counter = ItemCounter(0) self.active_session: Optional[RegionToolSession] = None self.current_selected_item: Optional[RegionData] = None # this is the item selected in the table self.region_requested_type: Optional[ROIType] = None
[docs] def create_session( self, window_controller: ImageWindowController, input_data: Optional[RegionData] = None ) -> RegionToolSession: """ Create a new region tool session. :param window_controller: Window controller :type window_controller: ImageWindowController :param input_data: Input data in case of workspace restore :type input_data: RegionData :return: New region tool session :rtype: RegionToolSession """ session = RegionToolSession(self, window_controller, input_data) session.finished.connect(self.deactivate) session.returned.connect(self.finalize_region) session.canceled.connect(self.deactivate) return session
[docs] def require_region_requested_type(self) -> ROIType: """ Return the requested region type or raise if missing. :return: Requested region type :rtype: ROIType :raises RuntimeError: If the region type has not been set """ return require_not_none(self.region_requested_type, 'Region requested type')
[docs] def create_dock(self, parent_window: QWidget) -> RegionToolDock: """ Create and configure a dock widget for the region tool. :param parent_window: Parent window for the dock widget :type parent_window: QWidget :return: Configured region tool dock widget :rtype: RegionToolDock """ dock = RegionToolDock(parent_window, self) dock.event_emitted.connect(self.on_dock_event) dock.item_selection_changed.connect(self.on_item_selection_changed) self.request_to_change_selection.connect(dock.on_request_to_change_selection) self.controller_event.connect(dock.handle_event) return dock
[docs] def _on_active_image_changed(self, new_window_controller: SubWindowController[Any] | None) -> None: """ Handle changes to the active image window controller. Updates the add enabled state based on whether the new controller is an image controller. :param new_window_controller: The new window controller :type new_window_controller: SubWindowController """ super()._on_active_image_changed(new_window_controller) is_image_controller = isinstance(new_window_controller, ImageWindowController) self.can_add = is_image_controller # not sure if we need it self.emit_event('window_controller_changed', is_image_controller)
[docs] def on_item_selection_changed(self, selected: RegionData | None, deselected: RegionData | None) -> None: """ Handle changes to item selection in the table. Removes highlight from deselected item and adds highlight to selected item. Activates the region window for the selected item. :param selected: The newly selected region data :type selected: RegionData | None :param deselected: The previously selected region data :type deselected: RegionData | None """ if deselected is not None: self.current_selected_item = deselected deselected.require_image_controller().overlay_manager.remove_highlight(deselected.id) if selected is not None: self.current_selected_item = selected self._activate_region_window(selected.require_region_controller()) if selected.show_overlay: selected.require_image_controller().overlay_manager.add_highlight(selected.id)
[docs] def on_delete_clicked(self) -> None: """ Handle delete button click event. Removes the currently selected region item. """ if self.current_selected_item: self.remove_item(self.current_selected_item)
[docs] def _activate_region_window(self, window_controller: RegionWindowController) -> None: """ Activate the region window for the given controller. :param window_controller: The region window controller to activate :type window_controller: RegionWindowController """ self.context.activate_image(window_controller)
[docs] def on_dock_event(self, event: ToolEvent) -> None: """ Handle events from the dock widget. :param event: The tool event :type event: ToolEvent """ return
[docs] def start_add_item(self, region_type: ROIType) -> None: """ Start adding a new item of the specified region type. :param region_type: The type of region to add :type region_type: ROIType """ self.region_requested_type = region_type self.activate()
[docs] def activate_rotation(self, active: bool) -> None: """ Activate or deactivate rotation for the current selection. :param active: Whether to activate rotation :type active: bool """ if not self.active_session: return if not self.active_session.region_selector: return selector = self.active_session.region_selector.selector if selector is None: return if active: selector.add_state('rotate') else: selector.remove_state('rotate')
[docs] def on_region_type_change(self, new_region_type: ROIType) -> None: """ Handle changes to the region type during selection. :param new_region_type: The new region type :type new_region_type: ROIType """ if self.active_session is None or self.region_requested_type == new_region_type: return self.active_session.teardown_selector() self.region_requested_type = new_region_type self.active_session.create_selector()
[docs] def menu_specs(self) -> List[ToolMenuSpec]: """ Get the menu specifications for the region tool. :return: List of menu specifications :rtype: List[ToolMenuSpec] """ return [ ToolMenuSpec( title='Analysis', order=10, entries=[ ToolMenuSpec( title='Region', order=20, entries=[ ToolMenuSpec( title='Add...', entries=[ ToolActionSpec( text=region_type.value, triggered=partial(self.start_add_item, region_type), enabled_changed_signal=self.add_enabled_changed, ) for region_type in ROIType ], ), ToolActionSpec( text='Remove Region', triggered=self.on_delete_clicked, enabled_changed_signal=self.remove_enabled_changed, ), ], ) ], ) ]
[docs] def on_cancelled_region_definition(self) -> None: """ Handle cancellation of region definition. Cancels the active session if one exists. """ if self.active_session: self.active_session.cancel()
[docs] def on_confirmed_region_definition(self) -> None: """ Handle confirmation of region definition. Starts region computation if an active session exists. """ if self.active_session: self.active_session.start_region_computation()
[docs] def set_parameters(self, params: dict[str, Any]) -> None: """ Set region parameters. :param params: Dictionary of parameters to update :type params: dict[str, Any] """ if self.active_session: self.active_session.region_parameters.update(params)
[docs] def finalize_region(self, session_result: ToolSessionResult) -> None: """ Finalize the creation of a new region. Creates a region window controller, connects signals, and emits a window request. Adds the region to the item store. """ region_data: RegionData = cast(RegionData, session_result.payload) image_controller = region_data.require_image_controller() source = DataSource('roi', DataOrigin.DERIVED, image_controller.source) region_window_controller = RegionWindowController(source, region_data) region_window_controller.request_region_removal.connect(self.remove_item) image_controller.about_to_close.connect(partial(self._on_image_about_to_close, image_controller)) request = WindowRequest( controller=region_window_controller, window_type='region', requested_id=region_data._requested_region_controller_id, ) self.request_new_window.emit(request) self.item_store.add(region_data) self._sync_region(region_data) self.ack_session_completed.emit(session_result.session_id)
[docs] def _on_image_about_to_close(self, image_controller: ImageWindowController) -> None: """ Handle image window closing event. Removes all regions associated with the closing image controller. :param image_controller: The image controller that is about to close :type image_controller: ImageWindowController """ items_to_remove = [i for i in self.item_store if i.image_controller is image_controller] for item in items_to_remove: self.remove_item(item)
[docs] def remove_item(self, item: RegionData) -> None: """ Remove a region item from the store and clean up associated resources. :param item: The region data item to remove :type item: RegionData """ if item is None: return if item._being_removed: return item._being_removed = True image_controller = item.require_image_controller() image_controller.overlay_manager.remove_id(item.id) image_controller.overlay_manager.remove_id(item.id, OverlayRole.Highlight) image_controller.overlay_manager.remove_id(item.id, OverlayRole.Label) if item.region_controller is not None: item.region_controller.close() self.item_store.remove_item(item)
[docs] def update_editable_fields(self, data: RegionData, index: int = 0) -> None: """ Update editable fields when a region is updated in the store. This method is triggered by update events and synchronizes the region data with its associated controllers and overlays. """ self._sync_region(data)
[docs] def _sync_region(self, region_data: RegionData) -> None: """ Synchronize region data with its associated controllers and overlays. Updates the region name in the controller and label.h5 overlay, and synchronizes the visibility settings for permanent, highlight, and label.h5 overlays. If the region is currently selected, ensures the highlight overlay is properly managed. """ region_controller = region_data.require_region_controller() image_controller = region_data.require_image_controller() # update the name region_controller.name_changed.emit(region_data.name) label_overlay = image_controller.overlay_manager.get_overlay_by_id(region_data.id, OverlayRole.Label) if label_overlay is not None: label_overlay.geometry['text'] = region_data.name image_controller.overlay_manager.update(label_overlay) # update visibility image_controller.overlay_manager.set_visibility(region_data.id, OverlayRole.Permanent, region_data.show_overlay) image_controller.overlay_manager.set_visibility(region_data.id, OverlayRole.Highlight, region_data.show_overlay) image_controller.overlay_manager.set_visibility(region_data.id, OverlayRole.Label, region_data.show_label) if self.current_selected_item and self.current_selected_item.id == region_data.id: if region_data.show_overlay: image_controller.overlay_manager.add_highlight(region_data.id)
[docs] def count_dependencies_for_image(self, window_controller: SubWindowController[Any]) -> int: """ Count region items attached to *window_controller*. """ if not isinstance(window_controller, ImageWindowController): return 0 return len([item for item in self.item_store if item.image_controller is window_controller])
[docs] def invalidate_dependencies_for_image(self, window_controller: SubWindowController[Any]) -> int: """ Remove region items attached to *window_controller*. """ if not isinstance(window_controller, ImageWindowController): return 0 to_remove = [item for item in self.item_store if item.image_controller is window_controller] for item in to_remove: self.remove_item(item) return len(to_remove)
[docs] class RegionSubWindow(CanvasWindow['RegionWindowController', SimpleCanvas]): """ Sub-window for displaying region histograms. This window shows the histogram of a selected region of interest. """ def __init__(self, controller: 'RegionWindowController', parent: Optional[QWidget] = None) -> None: """ Initialize the region sub-window. :param controller: The region window controller :type controller: RegionWindowController :param parent: Parent widget :type parent: QWidget """ super().__init__(controller=controller, parent=parent) self.content_widget = QWidget() layout = QVBoxLayout(self.content_widget) self.toolbar = NavigationToolbar2QT(self.canvas, self.content_widget) # type: ignore[no-untyped-call] layout.addWidget(self.toolbar) layout.addWidget(self.canvas) self.setWidget(self.content_widget) self.set_window_title() self.plot_histogram()
[docs] def create_canvas(self) -> SimpleCanvas: """ Create the canvas used for region histograms. :return: A configured :class:`SimpleCanvas` instance for region plots. :rtype: SimpleCanvas """ return SimpleCanvas(width=5, height=4, dpi=100)
[docs] def plot_histogram(self) -> None: """ Plot the histogram of the region data. Clears the current canvas and draws the histogram with appropriate labels and styling based on the region data. """ self.canvas.ax.clear() self.canvas.ax.stairs( self.controller.region_data.histogram, self.controller.region_data.bin_edges, fill=True, alpha=0.5, color=to_mpl(self.controller.region_data.color), label=self.controller.region_data.name, ) self.canvas.ax.set_title(f'Histogram {self.controller.region_data.name}') self.canvas.ax.set_xlabel('Signal intensity') self.canvas.ax.set_ylabel('Counts') self.canvas.fig.tight_layout() self.canvas.draw() # type: ignore[no-untyped-call]
[docs] def set_window_title(self) -> None: """ Set the window title based on controller name and dirty state. Updates the window title to include an asterisk (*) suffix if the controller indicates that the data has been modified but not yet saved. The title is derived from the region's name property. .. seealso:: :meth:`on_data_state_changed` """ window_title = f'Region: {self.controller.region_data.name}' if self.controller.is_dirty(): window_title += '*' self.setWindowTitle(window_title)
[docs] def on_name_changed(self, new_name: str) -> None: """Handle a change of region name. :param new_name: The new region name :type new_name: str """ self.set_window_title() self.canvas.ax.set_title(f'Histogram {new_name}') self.canvas.draw_idle() # type: ignore[no-untyped-call]
[docs] def on_data_state_changed(self) -> None: """ Update the window title when data state changes. This method is called when the underlying data state changes, such as when the data is modified or saved. It updates the window title to reflect the current persistence status. .. seealso:: :meth:`set_window_title` """ self.set_window_title()
[docs] def export_to_path(self, path: pathlib.Path) -> None: """ Export the current image display to a file. Saves the current figure displayed in the canvas to the specified path using matplotlib's savefig functionality. :param path: The file path where the image should be saved :type path: pathlib.Path """ self.canvas.figure.savefig(path)
[docs] class RegionWindowController(SubWindowController[RegionSubWindow]): """ Controller for the region sub-window. Manages the interaction between the region sub-window view and the underlying region data model. """ sub_window_type = SubWindowEnum.RegionWindow request_region_removal = Signal(object) # emits RegionData name_changed = Signal(str) def __init__( self, source: DataSource, region_data: RegionData, storage: Optional[DataStorage] = None, view: Optional['RegionSubWindow'] = None, ) -> None: """ Initialize the region window controller. :param region_data: The region data to display :type region_data: RegionData :param view: Optional view instance :type view: Optional[RegionSubWindow] """ super().__init__(source, storage, view) self.region_data = assume_complete_region(region_data) self.region_data.region_controller = self self.name = self.region_data.name self.state_changed.connect(self.application_services.app_state.changed) self.save_specs = [ SaveSpec( key='region-data', label='Region data', filter='CSV (*.csv);;HDF5 (*.h5)', default_suffix='csv', suffix_map={'CSV (*.csv)': 'csv', 'HDF5 (*.h5)': 'h5'}, writer=self.save_region_data, ) ] self.export_specs = [ ExportSpec( key='full-canvas', label='Full canvas', filter='Supported image formats (*.eps *.jpg *.jpeg *.pdf *.pgf *.png *.ps *.raw *rgba *.svg *.svgz *.tif *.tiff *.webp)', default_suffix='png', suffix_map={}, writer=self.export_to_path, ) ]
[docs] def get_save_specs(self) -> list[SaveSpec]: """ Get the available save specifications for this region window controller. :return: List of save specifications :rtype: list[SaveSpec] """ return self.save_specs
[docs] def default_file_name(self) -> str: """ Get the default file name for saving region data. :return: Default file name based on region name :rtype: str """ return self.region_data.name
[docs] def save_region_data(self, path: pathlib.Path) -> None: """ Save region data to the specified path. Dispatches to appropriate save methods based on file extension. :param path: Path to save the data :type path: pathlib.Path :raise Exception: If saving fails """ save_dispatcher: dict[str, Callable[[pathlib.Path], None]] = { '.csv': self._save_region_data_csv, '.h5': self._save_region_data_hdf5, } try: save_dispatcher[path.suffix.lower()](path) self.mark_saved(path) except Exception as e: raise e
[docs] def set_view(self, view: RegionSubWindow) -> None: super().set_view(view) self.name_changed.connect(self.require_view().on_name_changed)
[docs] def _save_region_data_csv(self, path: pathlib.Path) -> None: """ Save region data to CSV format. Writes metadata and histogram data to a CSV file with bin centers and histogram values. :param path: Path to save the CSV file :type path: pathlib.Path """ region = self.region_data with path.open('w', newline='') as f: f.write(f'# name = {region.name}\n') f.write(f'# region_type = {str(region.selection_type)}\n') f.write(f'# enclosure = {str(region.enclosure)}\n') f.write(f'# area = {region.area}\n') f.write('# WARNING: bin centers and not bin edges') f.write('bin_centres,histograms\n') centers = 0.5 * (region.bin_edges[:-1] + region.bin_edges[1:]) np.savetxt( f, np.column_stack([centers, region.histogram]), delimiter=',', )
[docs] def _save_region_data_hdf5(self, path: pathlib.Path) -> None: """ Save region data to HDF5 format. Creates an HDF5 file with dataset containing bin edges and histogram values, along with attributes for region metadata. :param path: Path to save the HDF5 file :type path: pathlib.Path """ region = self.region_data with h5py.File(path, 'w') as f: grp = f.create_group('region') grp.create_dataset('bins', data=region.bin_edges) grp.create_dataset('histograms', data=region.histogram) grp.attrs['name'] = region.name grp.attrs['region_type'] = str(region.selection_type) grp.attrs['enclosure'] = str(region.enclosure) grp.attrs['area'] = str(region.area)
[docs] def get_export_specs(self) -> list[ExportSpec]: """Get the list of available export specifications for the image. :return: List of export specifications or empty list if no image data exists :rtype: list[ExportSpec] """ return self.export_specs
[docs] def export_to_path(self, path: pathlib.Path) -> None: """Export the current view to the specified path. :param path: The file path where the export will be saved :type path: Path """ if self.view is None: return self.view.export_to_path(path)
[docs] def can_export_as(self) -> bool: """Check if the current image can be exported. :return: True if the image can be exported, False otherwise :rtype: bool """ return True
[docs] def on_view_closed(self) -> None: """ Handle view closure event. Emits a signal indicating that the region should be removed. """ self.request_region_removal.emit(self.region_data)
[docs] def draw_region_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a region overlay on matplotlib axes. Creates and adds a patch to the axes based on the overlay type and geometry. :param overlay: The overlay model containing drawing information :type overlay: OverlayModel :param axes: Matplotlib axes to draw on :type axes: Axes :return: List of artists created :rtype: list[Artist] :raise TypeError: If overlay type is unknown """ PatchType: type[Patch] if overlay.type == 'rectangle': PatchType = Rectangle elif overlay.type == 'ellipse': PatchType = Ellipse elif overlay.type == 'polygon': PatchType = Polygon else: raise TypeError(f'Unknown overlay type: {overlay.type}') new_patch = PatchType(**overlay.geometry, **overlay.style) axes.add_patch(new_patch) return [new_patch]
[docs] def draw_region_label(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a region label.h5 overlay. :param overlay: The overlay model containing label.h5 configuration :type overlay: OverlayModel :param axes: The matplotlib axes to draw on :type axes: Axes :return: List of artists created for the overlay :rtype: list[Artist] """ offset = overlay.extra_props.get('offset', (0, 0)) overlay.geometry['xytext'] = offset new_annotation = Annotation(**overlay.geometry, **overlay.style) axes.add_artist(new_annotation) return [new_annotation]
[docs] def draw_permanent_region_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a permanent region overlay. This is a wrapper around draw_region_overlay for permanent overlays. :param overlay: The overlay model containing drawing information :type overlay: OverlayModel :param axes: Matplotlib axes to draw on :type axes: Axes :return: List of artists created :rtype: list[Artist] """ return draw_region_overlay(overlay, axes)
[docs] def draw_highlight_region_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a highlighted region overlay. Draws a highlighted version of the region with special styling for highlighting. :param overlay: The overlay model containing drawing information :type overlay: OverlayModel :param axes: Matplotlib axes to draw on :type axes: Axes :return: List of artists created :rtype: list[Artist] :raise TypeError: If overlay type is unknown """ new_style = dict(fill=True, facecolor=overlay.style['edgecolor'], alpha=0.5, linewidth=15) is_inside = overlay.extra_props['enclosure'] == ROIEnclosure.Inside if is_inside: if overlay.role != OverlayRole.Highlight: new_overlay = overlay.change_role_to(OverlayRole.Highlight) else: new_overlay = overlay # it means it was already copied new_overlay.style.update(new_style) return draw_region_overlay(new_overlay, axes) else: PatchType: type[Patch] if overlay.type == 'rectangle': PatchType = Rectangle elif overlay.type == 'ellipse': PatchType = Ellipse elif overlay.type == 'polygon': PatchType = Polygon else: raise TypeError(f'Unknown overlay type: {overlay.type}') overlay.style.update(new_style) inside_patch = PatchType(**overlay.geometry, **overlay.style) inside_path = inside_patch.get_path().transformed(inside_patch.get_transform()) y1, x1 = overlay.extra_props['shape'] y0, x0 = 0, 0 outside_path = Path( [ (x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0), ], codes=[Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY], ) ccw_vertices = normalize_polygon_ccw(np.asarray(inside_path.vertices)) vertices = np.concatenate([outside_path.vertices, ccw_vertices[::-1]]) codes = np.concatenate([outside_path.codes, inside_path.codes]) path_patch = PathPatch(Path(vertices, codes), **overlay.style) axes.add_patch(path_patch) return [path_patch]
[docs] def draw_temporary_region_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a temporary region overlay. Draws a dashed line representation of a temporary selection. :param overlay: The overlay model containing drawing information :type overlay: OverlayModel :param axes: Matplotlib axes to draw on :type axes: Axes :return: List of artists created :rtype: list[Artist] """ new_style = dict(linestyle='dashed') if overlay.role != OverlayRole.Temporary: new_overlay = overlay.change_role_to(OverlayRole.Temporary) else: new_overlay = overlay new_overlay.style.update(new_style) return draw_region_overlay(new_overlay, axes)
[docs] class RegionTool(Tool[RegionToolController]): id = uuid.uuid4() tool_id = 'region' name = 'Region Tool' description = 'A tool to analyse region of interests' windows_to_be_registered = [WindowSpec('region', RegionSubWindow, False)] overlays_to_be_registered = [ OverlaySpec('rectangle', OverlayRole.Permanent, draw_region_overlay), OverlaySpec('rectangle', OverlayRole.Highlight, draw_highlight_region_overlay), OverlaySpec('rectangle', OverlayRole.Temporary, draw_temporary_region_overlay), OverlaySpec('ellipse', OverlayRole.Permanent, draw_region_overlay), OverlaySpec('ellipse', OverlayRole.Highlight, draw_highlight_region_overlay), OverlaySpec('ellipse', OverlayRole.Temporary, draw_temporary_region_overlay), OverlaySpec('polygon', OverlayRole.Permanent, draw_region_overlay), OverlaySpec('polygon', OverlayRole.Highlight, draw_highlight_region_overlay), OverlaySpec('polygon', OverlayRole.Temporary, draw_temporary_region_overlay), OverlaySpec('region_label', OverlayRole.Label, draw_region_label), ] def __init__(self) -> None: super().__init__() self._session_restore_context: Optional[WorkspaceRestoreContext] = None self._restore_spec: Optional[ToolWorkspace] = None
[docs] def create_controller(self, ctx: 'ToolContext') -> RegionToolController: """ Create a controller for this tool. :param ctx: Tool context :type ctx: ToolContext :return: New region tool controller :rtype: RegionToolController """ self._controller: RegionToolController = RegionToolController(ctx, self) return self._controller
def restore_phase(self) -> int: return 1
[docs] def to_workspace(self, include_data: bool) -> ToolWorkspace: """ Generate a workspace specification for the tool. Creates a `ToolWorkspace` object containing the current state and items of the tool, optionally including data. This specification can be used for saving or restoring the tool's state. :param include_data: Whether to include data in the workspace specification :type include_data: bool :return: Workspace specification for the tool :rtype: ToolWorkspace """ tool_ws = ToolWorkspace(tool_id=self.tool_id, version='1.0') if self._controller is None: # there is no controller for this tool. We cannot continue with the # generation of a workspace specification, return what we have so far return tool_ws if self._controller.current_selected_item: self._controller.context.register_workspace_reference( self._controller.current_selected_item.id, self._controller.current_selected_item ) tool_ws.state['current_selected_item'] = self._controller.current_selected_item.id else: tool_ws.state['current_selected_item'] = None for item in self._controller.item_store: image_controller = item.require_image_controller() region_controller = item.require_region_controller() data_spec = item.to_workspace_spec(include_data) data_spec.image_controller_id = image_controller.id data_spec.region_controller_id = region_controller.id tool_ws.items[item.id] = data_spec tool_ws.order.append(item.id) return tool_ws
[docs] def from_workspace(self, spec: ToolWorkspace, context: WorkspaceReferenceManager) -> None: """ Restore the tool's state from a workspace specification. Reconstructs the tool's state and items from the given `ToolWorkspace` specification, using the provided context to resolve references. :param spec: Workspace specification to restore from :type spec: ToolWorkspace :param context: Reference manager for resolving workspace references :type context: WorkspaceReferenceManager :raise RuntimeError: If no controller is available for the tool """ self._restore_spec = spec if self._controller is None: # not possible to restore a tool without controller raise RuntimeError(f'No controller available for tool {self.tool_id}') if len(spec.items) == 0: # the tool is empty: self._restore_completed() self._session_restore_context = WorkspaceRestoreContext() self._controller.ack_session_completed.connect(self._session_restore_context._on_session_finished) self._session_restore_context.finished.connect(self._on_item_restore_completed) self._increment_item_counter(len(spec.items)) for key, raw_item in spec.items.items(): item = workspace_spec_as_mapping(raw_item) region_data = RegionData( region_id=enforce_uuid(key), name=item['name'], color=Color(*item['color']), region_geometry=item['region_geometry'], selection_type=item['selection_type'], enclosure=item['enclosure'], bins=item['bins'], show_overlay=item.get('show_overlay', True), show_label=item.get('show_label', True), ) # assign the image_controller reference image_window_controller: ImageWindowController = cast( ImageWindowController, context.resolve(enforce_uuid(item['image_controller_id'])) ) if image_window_controller is None: raise RuntimeError(f'Unable to resolve the image controller for profile {region_data.name}') region_data.image_controller = image_window_controller region_data._requested_region_controller_id = enforce_uuid(item['region_controller_id']) # check data availability: data_attrs = [ 'mask', 'histogram', 'bin_edges', 'mean_value', 'std_dev_value', 'max_value', 'min_value', 'area', ] data_available = all([item[attr] is not None for attr in data_attrs]) if data_available: for attr in data_attrs: setattr(region_data, attr, item[attr]) region_data._has_data = data_available context.register(region_data.id, region_data) session = self._controller.create_session(image_window_controller, input_data=region_data) self._session_restore_context.register_session(session) session.start()
[docs] def _increment_item_counter(self, item_count: int) -> None: """ Increment the item counter by a specified count. Updates the item counter to reflect the number of items being restored or added. :param item_count: Number of items to increment the counter by :type item_count: int """ for i in range(item_count): self._controller.item_counter.next()
[docs] def _on_item_restore_completed(self) -> None: """ Handle completion of item restoration. Reorders the item store and restores the selected item based on the workspace specification. """ self._reorder_store() self._restore_selected_item() self._restore_completed()
[docs] def _reorder_store(self) -> None: """ Reorder the item store based on the workspace specification. Adjusts the order of items in the store to match the order specified in the workspace. """ restore_spec = self._require_restore_spec() store = self._controller.item_store.all() by_id: dict[str, RegionData] = {str(item.id): item for item in store} reordered = [by_id[str(pid)] for pid in restore_spec.order if pid in by_id] self._controller.item_store.reset() for item in reordered: self._controller.item_store.add(item)
[docs] def _restore_selected_item(self) -> None: """ Restore the selected item from the workspace specification. Sets the selection in the table model to the item specified in the workspace state. """ restore_spec = self._require_restore_spec() pid = restore_spec.state['current_selected_item'] if pid is None or pid == '__NONE__': return pid = enforce_uuid(pid) row = self._controller.item_store.index_of(pid) if row is None: return index = self._controller.table_model.index(row, 0) self._controller.request_to_change_selection.emit( index, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows )
[docs] def _restore_completed(self) -> None: """ Signal that the workspace restoration process has been completed. Emits the restore_completed signal to notify observers that the profile tool restoration process is finished and all profiles have been properly restored from the workspace specification. This method is called internally by the workspace restoration mechanism to indicate completion of the restoration sequence. """ self._controller.restore_completed.emit(self)