Source code for radioviz.tools.profile_tool

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Profile tool module for extracting intensity profiles from images.

This module provides functionality for creating, managing, and visualizing
intensity profiles extracted from images. It includes tools for selecting
line segments on images, computing intensity profiles along those lines,
and displaying the results in dedicated windows.

The main components include:

- ProfileData: Data structure for storing profile information
- ProfileToolController: Controller managing the profile tool workflow
- ProfileToolSession: Session handling the profile extraction process
- ProfileSubWindow: Window for displaying profile plots
- ProfileExtractionWorker: Background worker for profile computation

Workflow descriptions:

Profile Creation Process:

1. User clicks "Add Profile" button in the dock widget
2. ProfileToolController creates a new ProfileToolSession and activates it
3. Session triggers "create_selection_dialog" event to show PointSelectionDialog
4. User interacts with PointSelectionDialog to set parameters and draw line on image
5. LineSelector in the image window captures the line geometry
6. When the user accepts the profile selection (click on Done), session calls
   ``start_profile_computation()``, which:

   - Creates a ProfileExtractionWorker in a separate QThread
   - Passes image data, line geometry, and parameters to worker
   - Worker computes profile using ``skimage.measure.profile_line`` in background
   - Worker emits finished signal with computed x/y values when done
7. Session receives finished signal and finalizes profile:

   - Updates ProfileData with computed values and geometry
   - Adds overlay to image window
   - Creates ProfileSubWindow to display the profile plot
   - Stores profile in ProfileStore

Profile Selection Process:

1. User selects a profile row in the dock's table view
2. ProfileToolDock emits profile_selection_changed signal
3. ProfileToolController handles selection change:

   - Removes highlight from previously selected profile
   - Highlights newly selected profile on image
   - Activates the associated ProfileSubWindow if it exists

Profile Removal Process:

1. User clicks "Delete Profile" button or uses menu action
2. ProfileToolController.on_delete_profile_clicked() is called
3. Controller removes profile from ProfileStore
4. Controller removes overlay from image window
5. Controller closes associated ProfileSubWindow if open
6. Profile is completely removed from all systems

The tool integrates with the application's overlay system to visualize profile lines
on images and supports multiple interpolation methods and reduction functions for
profile computation.
"""

from __future__ import annotations

import math
import uuid
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from typing import Any, Callable, List, Optional, Protocol, cast
from uuid import UUID

import h5py
import numpy as np
from matplotlib.artist import Artist
from matplotlib.axes import Axes
from matplotlib.backends.backend_qt import NavigationToolbar2QT
from matplotlib.lines import Line2D
from matplotlib.patches import FancyArrowPatch
from matplotlib.text import Annotation
from matplotlib.ticker import FuncFormatter, MaxNLocator, ScalarFormatter
from PySide6.QtCore import (
    QItemSelection,
    QItemSelectionModel,
    QModelIndex,
    QObject,
    QPersistentModelIndex,
    QPoint,
    Qt,
    QTimer,
    Signal,
)
from PySide6.QtWidgets import (
    QComboBox,
    QDialog,
    QGridLayout,
    QHBoxLayout,
    QLabel,
    QPushButton,
    QSpinBox,
    QTableView,
    QVBoxLayout,
    QWidget,
)
from skimage.measure import profile_line
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.line_selector import LineGeometry, LineSelector
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.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.mpl_helpers import MplContextMenuBridge
from radioviz.services.save_services import SaveSpec
from radioviz.services.signal_blocker import SignalBlocked
from radioviz.services.spatial_calibration import DistanceUnit, axis_decimal_places, select_distance_unit
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.interpolation_utils import INTERPOLATION_LABELS
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


[docs] @dataclass class ProfileDataWorkspaceSpec: """ Data structure for serializing profile data within workspace specifications. This class defines the structure used to store profile information in a workspace context, enabling persistence and restoration of profile configurations across application sessions. It contains all essential profile properties needed for serialization while maintaining compatibility with the ProfileData structure. """ id: UUID name: str image_controller_id: Optional[UUID] = None # it must be set via the context profile_controller_id: Optional[UUID] = None # used for UI restore color: tuple[float, float, float] = field(default_factory=lambda: (0.0, 0.0, 0.0)) p1: tuple[float, float] = field(default_factory=lambda: (0.0, 0.0)) p2: tuple[float, float] = field(default_factory=lambda: (0.0, 0.0)) profile_width: int = 1 reduce_function: str = 'mean' interpolation_order: str = 'Bi-linear' show_overlay: bool = True show_label: bool = True x_values: Optional[np.ndarray] = None y_values: Optional[np.ndarray] = None pixel_size_m: Optional[tuple[float, float]] = None display_properties: dict[str, Any] = field(default_factory=dict)
[docs] class ProfileData: """ Data structure for storing profile information. Stores all relevant information about a profile including its geometric properties, visualization settings, and computed values. """ def __init__( self, name: str, color: Color, p1: Optional[tuple[float, float]] = None, p2: Optional[tuple[float, float]] = None, profile_controller: Optional[ProfileWindowController] = None, image_controller: Optional[ImageWindowController] = None, x_values: Optional[np.ndarray] = None, y_values: Optional[np.ndarray] = None, profile_width: Optional[int] = 1, reduce_function: Optional[str] = 'mean', interpolation_order: Optional[str] = 'Bi-linear', session_id: Optional[UUID] = None, profile_id: Optional[UUID] = None, show_overlay: Optional[bool] = True, show_label: Optional[bool] = True, pixel_size_m: Optional[tuple[float, float]] = None, display_properties: Optional[dict[str, Any]] = None, ) -> None: """ Initialize a ProfileData instance. :param name: Name of the profile :type name: str :param color: Color of the profile line :type color: Color :param p1: Start point of the profile line (x, y) :type p1: tuple[float, float], optional :param p2: End point of the profile line (x, y) :type p2: tuple[float, float], optional :param profile_controller: Controller for the profile window :type profile_controller: ProfileWindowController, optional :param image_controller: Controller for the image window :type image_controller: ImageWindowController, optional :param x_values: X coordinates of profile points :type x_values: np.ndarray, optional :param y_values: Y coordinates (intensity values) of profile points :type y_values: np.ndarray, optional :param profile_width: Width of the profile extraction line :type profile_width: int, optional :param reduce_function: Function to reduce multiple pixel values :type reduce_function: str, optional :param interpolation_order: Interpolation order for profile calculation :type interpolation_order: str, optional :param session_id: ID of the session that created this profile :type session_id: UUID, optional :param profile_id: Unique identifier for this profile :type profile_id: UUID, optional :param pixel_size_m: Pixel size in meters (x, y) for calibration :type pixel_size_m: tuple[float, float], optional :param display_properties: Display properties for the profile window :type display_properties: dict[str, Any], optional """ self.id = profile_id or uuid.uuid4() self.name = name self.color = color self.p1 = p1 self.p2 = p2 self.profile_controller = profile_controller # Reference to the profile window controller self.image_controller = image_controller # Reference to the image window controller self.x_values = x_values self.y_values = y_values self.session_id = session_id self.profile_width = profile_width self.reduce_function = reduce_function self.interpolation_order = interpolation_order self.show_overlay = show_overlay self.show_label = show_label self.pixel_size_m = pixel_size_m self.display_properties = display_properties.copy() if display_properties else {'axis_label_mode': 'pixel'} self.calculate_properties() self._being_removed = False self._requested_profile_controller_id: Optional[UUID] = None
[docs] def calculate_properties(self) -> None: """ Calculate derived properties from profile data. Computes length, minimum and maximum y values from the stored x and y arrays. """ if self.x_values is not None and self.y_values is not None: self.length = self.x_values[-1] - self.x_values[0] self.y_max = np.max(self.y_values) self.y_min = np.min(self.y_values) else: self.length = 0 self.y_max = -1 self.y_min = -1
[docs] def length_m(self) -> float | None: """ Compute the physical length of the profile in meters. :return: Profile length in meters or None if calibration is missing. :rtype: float | None """ if self.pixel_size_m is None or self.p1 is None or self.p2 is None: return None pixel_x_m, pixel_y_m = self.pixel_size_m dx = self.p2[0] - self.p1[0] dy = self.p2[1] - self.p1[1] return math.hypot(dx * pixel_x_m, dy * pixel_y_m)
[docs] def pixel_distance_m(self) -> float | None: """ Compute the physical size per profile pixel along the profile direction. :return: Meters per profile pixel or None if calibration is missing. :rtype: float | None """ length_m = self.length_m() if length_m is None or self.length <= 0: return None return float(length_m / self.length)
[docs] def length_display(self) -> str: """ Format the calibrated profile length for display. :return: Formatted length string or 'n/a' if unavailable. :rtype: str """ length_m = self.length_m() if length_m is None or length_m <= 0: return 'n/a' unit = select_distance_unit(length_m) value = length_m / unit.meters_per_unit return f'{value:.4g} {unit.name}'
[docs] def set_profile_data(self, x_values: np.ndarray, y_values: np.ndarray) -> None: """ Set the profile data arrays. :param x_values: X coordinates of profile points :type x_values: np.ndarray :param y_values: Y coordinates (intensity values) of profile points :type y_values: np.ndarray """ self.x_values = x_values self.y_values = y_values self.calculate_properties()
[docs] def to_workspace_spec(self, include_data: bool) -> ProfileDataWorkspaceSpec: """ Convert this ProfileData instance to a workspace specification. Creates a ProfileDataWorkspaceSpec instance containing all necessary information to serialize this profile for workspace persistence. If include_data is True, the actual profile data arrays (x_values and y_values) are included in the specification. :param include_data: Whether to include profile data arrays in the spec :type include_data: bool :return: Workspace specification for this profile :rtype: ProfileDataWorkspaceSpec :raises RuntimeError: If attempting to serialize incomplete profile data or if include_data is True but profile data is missing. """ if self.p1 is None or self.p2 is None: raise RuntimeError('Attempt to serialize an incomplete ProfileData') if include_data and (self.x_values is None or self.y_values is None): raise RuntimeError('No profile data to include in the serialized ProfileData') ws_spec = ProfileDataWorkspaceSpec( id=self.id, name=self.name, color=to_mpl(self.color), p1=self.p1, p2=self.p2, profile_width=self.profile_width if self.profile_width is not None else 1, reduce_function=self.reduce_function if self.reduce_function is not None else 'mean', interpolation_order=self.interpolation_order if self.interpolation_order is not None else 'Bi-linear', show_overlay=self.show_overlay if self.show_overlay is not None else True, show_label=self.show_label if self.show_label is not None else True, pixel_size_m=self.pixel_size_m, display_properties=self.display_properties.copy(), ) if include_data: ws_spec.x_values = self.x_values ws_spec.y_values = self.y_values return ws_spec
[docs] class ProfileDataCompleteProtocol(Protocol): """ Protocol describing a profile with fully populated numeric data. It contains only a subset of all the attributes and methods used by the :class:`.ProfileWindowController`. This protocol is used to express the invariant that profile arrays are available after a session completes, without adding runtime checks. """ # id: UUID name: str color: Color p1: tuple[float, float] p2: tuple[float, float] profile_controller: Optional['ProfileWindowController'] # image_controller: Optional['ImageWindowController'] x_values: np.ndarray y_values: np.ndarray # session_id: Optional[UUID] profile_width: int reduce_function: str # interpolation_order: Optional[str] # show_overlay: Optional[bool] # show_label: Optional[bool] pixel_size_m: Optional[tuple[float, float]] display_properties: dict[str, Any] # length: float # y_max: float # y_min: float
[docs] def length_m(self) -> float | None: """ Compute the physical length of the profile in meters. :return: Profile length in meters or None if calibration is missing. :rtype: float | None """ ...
[docs] def pixel_distance_m(self) -> float | None: """ Compute the physical size per profile pixel along the profile direction. :return: Meters per profile pixel or None if calibration is missing. :rtype: float | None """ ...
[docs] def length_display(self) -> str: """ Format the calibrated profile length for display. :return: Formatted length string or 'n/a' if unavailable. :rtype: str """ ...
[docs] def set_profile_data(self, x_values: np.ndarray, y_values: np.ndarray) -> None: """ Set the profile data arrays. :param x_values: X coordinates of profile points :type x_values: numpy.ndarray :param y_values: Y coordinates (intensity values) of profile points :type y_values: numpy.ndarray """ ...
[docs] def to_workspace_spec(self, include_data: bool) -> ProfileDataWorkspaceSpec: """ Convert this ProfileData instance to a workspace specification. :param include_data: Whether to include profile data arrays in the spec :type include_data: bool :return: Workspace specification for this profile :rtype: ProfileDataWorkspaceSpec """ ...
[docs] def assume_complete_profile(profile: ProfileData) -> ProfileDataCompleteProtocol: """ Treat a profile data as complete (no None) without runtime checks. :param profile: The profile data assumed to be complete :type profile: ProfileData :return: A view of the profile with non-optional attributes :rtype: ProfileDataCompleteProtocol """ return cast(ProfileDataCompleteProtocol, profile)
ProfileStore = ItemStore[ProfileData]
[docs] class ProfileTableModel(ItemModel[ProfileData]): """ Table model for displaying profile data in a table view. """ def __init__(self, store: ItemStore[ProfileData], parent: Optional[QObject] = None) -> None: """ Initialize the profile table model. :param store: Store containing profile data :type store: ItemStore[ProfileData] :param parent: Parent widget :type parent: QObject, optional """ super().__init__(store, parent) self.headers = [ 'Name', # 0 'Color', # 1 'Show overlay', # 2 'Show label', # 3 'Point 1 (x,y)', # 4 'Point 2 (x,y)', # 5 'Profile width', # 6 'Reduction function', # 7 'Interpolation order', # 8 'Length (px)', # 9 'Length (cal)', # 10 'Min value', # 11 'Max value', # 12 ]
[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 profile_data = self._store.get(index.row()) if profile_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 profile_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, profile: ProfileData, column: int, value: Any, role: int = Qt.ItemDataRole.DisplayRole, ) -> bool: """ Set data for a specific profile cell. :param profile: Profile data object :type profile: ProfileData :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(profile.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(profile.id, show_overlay=show_overlay, show_label=show_label) return True if column == 3 and role == Qt.ItemDataRole.CheckStateRole: self._store.update_by_id(profile.id, show_label=Qt.CheckState(value) == Qt.CheckState.Checked) return True return False
[docs] def data_for(self, profile: ProfileData, column: int, role: int = Qt.ItemDataRole.DisplayRole) -> Any: """ Get data for a specific profile cell. :param profile: Profile data object :type profile: ProfileData :param column: Column index :type column: int :param role: Item data role :type role: Qt.ItemDataRole :return: Data for the specified cell :rtype: Any """ if role == Qt.ItemDataRole.UserRole: return profile.id if role == Qt.ItemDataRole.CheckStateRole: if column == 2: return Qt.CheckState.Checked if profile.show_overlay else Qt.CheckState.Unchecked elif column == 3: return Qt.CheckState.Checked if profile.show_label else Qt.CheckState.Unchecked else: return None elif role == Qt.ItemDataRole.DisplayRole: if column == 0: return profile.name elif column == 1: return f'RGB({profile.color.r:.2f},{profile.color.g:.2f},{profile.color.b:.2f})' elif column == 2: return 'Visible' if profile.show_overlay else 'Hidden' elif column == 3: return 'Visible' if profile.show_label else 'Hidden' elif column == 4: if profile.p1 is None: return '' return f'({profile.p1[0]:.2f}, {profile.p1[1]:.2f})' elif column == 5: if profile.p2 is None: return '' return f'({profile.p2[0]:.2f}, {profile.p2[1]:.2f})' elif column == 6: return f'{profile.profile_width}' elif column == 7: return f'{profile.reduce_function}' elif column == 8: return f'{profile.interpolation_order}' elif column == 9: return f'{profile.length:.2f}' elif column == 10: return profile.length_display() elif column == 11: return f'{profile.y_min:.2f}' elif column == 12: return f'{profile.y_max:.2f}' else: return None elif role == Qt.ItemDataRole.DecorationRole: if column == 1: return to_qcolor(profile.color) else: return None else: return None
[docs] class PointSelectionDialog(QDialog): """ Dialog for selecting profile parameters and points. """ update_params = Signal(dict) def __init__(self, parent: Optional[QWidget] = None) -> None: """ Initialize the point selection dialog. :param parent: Parent widget :type parent: QWidget, optional """ super().__init__(parent) self.setWindowTitle('Select Profile Points') self.setModal(False) # Non-modal dialog layout = QVBoxLayout() self.status_label = QLabel( 'Click and drag to define a profile extraction segment.\nAfter the initial ' 'selection you can still edit the segment by grabbing one of the handles.' ) layout.addWidget(self.status_label) param_layout = QGridLayout() width_label = QLabel('Profile width:') param_layout.addWidget(width_label, 0, 0) self.profile_width = QSpinBox() self.profile_width.setMinimum(1) self.profile_width.setMaximum(200) self.profile_width.setValue(1) self.profile_width.setSuffix(' pixels') self.profile_width.valueChanged.connect(self.on_parameters_changed) param_layout.addWidget(self.profile_width, 0, 1) func_label = QLabel('Reduce function:') param_layout.addWidget(func_label, 1, 0) self.reduce_function = QComboBox() self.reduce_function.addItems(['mean', 'median', 'max', 'min']) self.reduce_function.setCurrentIndex(0) self.reduce_function.currentIndexChanged.connect(self.on_parameters_changed) param_layout.addWidget(self.reduce_function, 1, 1) interpolation_label = QLabel('Interpolation order:') param_layout.addWidget(interpolation_label, 2, 0) self.interpolation_order = QComboBox() self.interpolation_order.addItems(INTERPOLATION_LABELS) self.interpolation_order.setCurrentIndex(1) self.interpolation_order.currentIndexChanged.connect(self.on_parameters_changed) param_layout.addWidget(self.interpolation_order, 2, 1) layout.addLayout(param_layout) self.params = { 'line_width': self.profile_width.value(), 'reduce_function': self.reduce_function.currentText(), 'interpolation_order': self.interpolation_order.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) # Enabled after two points are selected 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 in the dialog. """ self.params['line_width'] = self.profile_width.value() self.params['reduce_function'] = self.reduce_function.currentText() self.params['interpolation_order'] = self.interpolation_order.currentText() self.update_params.emit(self.params)
[docs] class ProfileToolDock(ToolDockWidget['ProfileToolController']): """ Dock widget for the profile tool interface. """ controller: ProfileToolController initial_visibility = True enable_for_window_type = SubWindowEnum.ALL dock_position = Qt.DockWidgetArea.BottomDockWidgetArea profile_selection_changed = Signal(object, object) # selected and deselected profile, both can be none def __init__(self, parent: QWidget, controller: ProfileToolController) -> None: """ Initialize the profile tool dock. :param parent: Parent widget :type parent: QWidget :param controller: Profile tool controller :type controller: ProfileToolController """ super().__init__('Profile tool', parent, controller) if not isinstance(self.controller, ProfileToolController): raise TypeError('The controller must be an instance of ProfileToolController') self.controller: ProfileToolController = controller 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_profile_selected) button_layout = QVBoxLayout() self.add_button = QPushButton('Add Profile') self.add_button.clicked.connect(self.controller.start_add_profile) self.add_button.setEnabled(False) button_layout.addWidget(self.add_button) self.delete_button = QPushButton('Delete Profile') self.delete_button.clicked.connect(self.delete_profile) self.delete_button.setEnabled(False) button_layout.addWidget(self.delete_button) self.redraw_label_button = QPushButton('Redraw Labels') self.redraw_label_button.clicked.connect(self.controller._redraw_all_labels) self.redraw_label_button.setEnabled(False) button_layout.addWidget(self.redraw_label_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) self.selection_dialog: Optional[PointSelectionDialog] = None
[docs] def on_profile_selected(self, selected: QItemSelection, deselected: QItemSelection) -> None: """ Handle profile selection events. :param selected: Selected indexes :type selected: QItemSelection :param deselected: Deselected indexes :type deselected: QItemSelection """ deselected_profile = None for index in deselected.indexes(): if index.column() == 0: profile_id = index.data(Qt.ItemDataRole.UserRole) if profile_id is None: break deselected_profile = self.controller.profile_store.get_by_id(profile_id) break selected_profile = None indexes = self.table_view.selectionModel().selectedRows() if indexes: profile_id = indexes[0].data(Qt.ItemDataRole.UserRole) selected_profile = self.controller.profile_store.get_by_id(profile_id) delete_enable = True else: delete_enable = False self.set_can_remove(delete_enable) self.profile_selection_changed.emit(selected_profile, deselected_profile)
[docs] def set_can_remove(self, can_remove: bool) -> None: """ Set whether deletion is enabled for the selected profile. :param can_remove: Whether removal is enabled :type can_remove: bool """ self.delete_button.setEnabled(can_remove) if isinstance(self.controller, ProfileToolController): self.controller.can_remove = can_remove
[docs] def enable_redraw_label(self, enable: bool) -> None: """ Set wheter the redraw label button is enabled """ self.redraw_label_button.setEnabled(enable)
[docs] def create_selection_dialog(self) -> None: """ Create and show the point selection dialog. """ if self.selection_dialog is not None: self.selection_dialog = None self.selection_dialog = PointSelectionDialog(self) self.selection_dialog.rejected.connect(self.controller.on_cancelled_profile_definition) self.selection_dialog.accepted.connect(self.controller.create_profile_from_points) self.selection_dialog.update_params.connect(self.controller.set_profile_params) self.selection_dialog.on_parameters_changed() self.selection_dialog.show()
[docs] def delete_profile(self) -> None: """ Handle profile deletion request. """ self.controller.on_delete_profile_clicked()
[docs] def handle_event(self, event: ToolEvent) -> None: """ Handle tool events. :param event: Tool event :type event: ToolEvent """ if event.event == 'create_selection_dialog': self.create_selection_dialog() elif event.event == 'selector_created': if self.selection_dialog is not None: self.selection_dialog.done_button.setEnabled(True) elif event.event == 'selector_canceled': if self.selection_dialog is not None: self.selection_dialog.done_button.setEnabled(False) elif event.event == 'selector_accepted': if self.selection_dialog is not None: self.selection_dialog.accept() 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 == 'window_controller_changed': self.on_window_controller_changed(event.payload)
[docs] def on_window_controller_changed(self, is_image_controller: bool) -> None: """ Handle window controller changes. :param is_image_controller: Whether the current window is an image controller :type is_image_controller: bool """ self.add_button.setEnabled(is_image_controller)
[docs] def on_request_to_change_selection( self, index: QModelIndex, flags: QItemSelectionModel.SelectionFlag, ) -> None: """Handle a request to change the selected item in the table view.""" self.table_view.selectionModel().select(index, flags) self.table_view.scrollTo(index)
[docs] class ProfileSubWindow(CanvasWindow['ProfileWindowController', SimpleCanvas]): """ Window for displaying profile plots. """ def __init__(self, controller: 'ProfileWindowController', parent: Optional[QWidget] = None) -> None: """ Initialize the profile sub-window. :param controller: Profile window controller :type controller: ProfileWindowController :param parent: Parent widget :type parent: QWidget, optional """ super().__init__(controller=controller, parent=parent) self.content_widget = QWidget() self.content_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.content_widget.customContextMenuRequested.connect(self._on_context) layout = QVBoxLayout(self.content_widget) self.toolbar = NavigationToolbar2QT(self.canvas, self.content_widget) # type: ignore[no-untyped-call] self.mpl_context_menu_bridge = MplContextMenuBridge(self.canvas) layout.addWidget(self.toolbar) layout.addWidget(self.canvas) self._updating_view = False self.setWidget(self.content_widget) self.set_window_title() self.plot_profile() self.canvas.ax.callbacks.connect('xlim_changed', self._on_view_change) self.controller.axis_label_mode_changed.connect(self._apply_axis_label_mode)
[docs] def create_canvas(self) -> SimpleCanvas: """ Create the profile plot canvas used by this window. :return: A new :class:`SimpleCanvas` instance configured for profiles. :rtype: SimpleCanvas """ return SimpleCanvas(width=5, height=4, dpi=100)
[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 profile's name property. .. seealso:: :meth:`on_data_state_changed` """ window_title = f'Profile: {self.controller.profile_data.name}' if self.controller.is_dirty(): window_title += '*' self.setWindowTitle(window_title)
[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 plot_profile(self) -> None: """ Plot the profile data on the canvas. """ self.canvas.ax.clear() self.canvas.ax.plot( self.controller.profile_data.x_values, self.controller.profile_data.y_values, color=to_mpl(self.controller.profile_data.color), lw=2, ) self.set_canvas_title() self._apply_axis_label_mode() self.canvas.ax.set_ylabel('Intensity') self.canvas.ax.grid(True, linestyle='--', alpha=0.7) self.canvas.fig.tight_layout() self.canvas.draw() # type: ignore[no-untyped-call]
[docs] def _on_view_change(self, _axes: Axes) -> None: """ Handle view change events on the profile axes. :param _axes: The matplotlib axes that triggered the event :type _axes: matplotlib.axes.Axes """ if self._updating_view: return self._apply_axis_label_mode()
[docs] def _apply_axis_label_mode(self) -> None: """ Apply axis label mode to the profile axes. """ if self._updating_view: return self._updating_view = True try: mode = self.controller.axis_label_mode axes = self.canvas.ax if mode == 'pixel': axes.set_xlabel('Distance (px)') axes.xaxis.set_major_formatter(ScalarFormatter()) self.canvas.draw_idle() # type: ignore[no-untyped-call] return pixel_size = self.controller.pixel_size_m() unit = self.controller.distance_unit() pixel_distance_m = self.controller.pixel_distance_m() if pixel_size is None or unit is None or pixel_distance_m is None: axes.set_xlabel('Distance (px)') axes.xaxis.set_major_formatter(ScalarFormatter()) self.canvas.draw_idle() # type: ignore[no-untyped-call] return meters_per_unit = unit.meters_per_unit decimals = axis_decimal_places(pixel_distance_m, meters_per_unit, axes.get_xlim()) def _x_formatter(value: Any, _pos: float) -> str: return f'{(value * pixel_distance_m) / meters_per_unit:.{decimals}f}' axes.set_xlabel(f'Distance ({unit.name})') axes.xaxis.set_major_locator(MaxNLocator(nbins=6)) axes.xaxis.set_major_formatter(FuncFormatter(_x_formatter)) self.canvas.draw_idle() # type: ignore[no-untyped-call] finally: self._updating_view = False
[docs] def set_canvas_title(self) -> None: """Set the title of the canvas""" self.canvas.ax.set_title(f'Intensity profile: {self.controller.profile_data.name}')
[docs] def _on_context(self, pos: QPoint) -> None: """ Handle context menu requests on the profile canvas. :param pos: Position of the context menu request :type pos: QPoint """ global_pos = self.mpl_context_menu_bridge.handle_context_request(self, pos) self.context_menu_requested.emit(global_pos, self.controller)
[docs] def export_to_path(self, path: 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] def on_name_changed(self, new_name: str) -> None: """Handle a change of profile name :param new_name: The new profile name :type new_name: str """ self.set_window_title() self.set_canvas_title() self.canvas.draw_idle() # type: ignore[no-untyped-call]
[docs] class ProfileWindowController(SubWindowController[ProfileSubWindow]): """ Controller for the profile window. """ sub_window_type = SubWindowEnum.ProfileWindow request_profile_removal = Signal(object) # emits ProfileData name_changed = Signal(str) axis_label_mode_changed = Signal(str) axis_label_pixel_checked = Signal(bool) axis_label_distance_checked = Signal(bool) axis_label_distance_enabled = Signal(bool) def __init__( self, source: DataSource, profile_data: 'ProfileData', storage: Optional[DataStorage] = None, view: Optional['ProfileSubWindow'] = None, ) -> None: """ Initialize the profile window controller. :param profile_data: Profile data to display :type profile_data: ProfileData :param view: Profile sub-window view :type view: ProfileSubWindow, optional """ super().__init__(source, storage, view) self.profile_data = assume_complete_profile(profile_data) self.profile_data.profile_controller = self self.name = profile_data.name # to be used in the window menu self.display_properties = self._set_default_display_properties() self.profile_data.display_properties = self.display_properties.copy() self.state_changed.connect(self.application_services.app_state.changed) self.save_specs = [ SaveSpec( key='profile-data', label='Profile data', filter='CSV (*.csv);;HDF5 (*.h5)', default_suffix='csv', suffix_map={'CSV (*.csv)': 'csv', 'HDF5 (*.h5)': 'h5'}, writer=self.save_profile_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 _set_default_display_properties(self) -> dict[str, Any]: """ Initialize default display properties for the profile window. :return: Dictionary containing default display properties :rtype: dict[str, Any] """ props = self.profile_data.display_properties or {} if 'axis_label_mode' not in props: props['axis_label_mode'] = 'pixel' if props.get('axis_label_mode') == 'distance' and self.profile_data.pixel_size_m is None: props['axis_label_mode'] = 'pixel' return props.copy()
[docs] def set_display_properties(self, params: dict[str, Any]) -> None: """ Update display properties with the provided parameters. :param params: Dictionary containing display property updates :type params: dict[str, Any] """ for key, value in params.items(): if key == 'axis_label_mode': if value in {'pixel', 'distance'}: if value == 'distance' and not self.has_distance(): value = 'pixel' self.display_properties['axis_label_mode'] = value self.axis_label_mode_changed.emit(value) else: self.display_properties[key] = value self.profile_data.display_properties = self.display_properties.copy()
@property def axis_label_mode(self) -> str: """ Current axis label mode. :return: Axis label mode. :rtype: str """ return str(self.display_properties.get('axis_label_mode', 'pixel'))
[docs] def pixel_size_m(self) -> tuple[float, float] | None: """ Pixel size in meters derived from profile calibration, if available. :return: Tuple of pixel sizes in meters or None. :rtype: tuple[float, float] | None """ return self.profile_data.pixel_size_m
[docs] def pixel_distance_m(self) -> float | None: """ Physical size per profile pixel along the profile direction. :return: Meters per profile pixel or None. :rtype: float | None """ return self.profile_data.pixel_distance_m()
[docs] def distance_unit(self) -> DistanceUnit | None: """ Select a suitable distance unit based on profile length. :return: Distance unit or None when unavailable. :rtype: DistanceUnit | None """ length_m = self.profile_data.length_m() if length_m is None or length_m <= 0: return None return select_distance_unit(length_m)
[docs] def has_distance(self) -> bool: """ Check if distance calibration is available for this profile. :return: True if calibration is available, False otherwise. :rtype: bool """ return self.profile_data.pixel_distance_m() is not None
[docs] def default_file_name(self) -> str: """ Get the default file name for saving the profile data. :return: Default file name based on profile name :rtype: str """ return self.profile_data.name
[docs] def get_save_specs(self) -> list[SaveSpec]: """ Get the available save specifications for this profile. :return: List of save specifications :rtype: list[SaveSpec] """ return self.save_specs
[docs] def on_view_closed(self) -> None: """ Handle view closing event. """ self.request_profile_removal.emit(self.profile_data)
[docs] def save_profile_data(self, path: Path) -> None: """ Save the profile data to a file in CSV or HDF5 format. This method dispatches the saving operation to either CSV or HDF5 specific save methods based on the file extension of the provided path. It handles the file writing process and marks the data as saved upon successful completion. :param path: The file path where the profile data should be saved :type path: pathlib.Path :raises Exception: If an error occurs during the saving process """ save_dispacther: dict[str, Callable[[Path], None]] = { '.csv': self._save_profile_data_csv, '.h5': self._save_profile_data_hdf5, } try: save_dispacther[path.suffix.lower()](path) self.mark_saved(path) except Exception as e: raise e
[docs] def _save_profile_data_csv(self, path: Path) -> None: """ Save profile data to a CSV file with metadata headers. Writes the profile data along with metadata comments to a CSV file. The metadata includes profile name, start and end points, profile width, and reduction function. The data is written in x,y format. :param path: The file path where the CSV data should be saved :type path: pathlib.Path """ profile = self.profile_data if profile.x_values is None or profile.y_values is None: raise RuntimeError('No profile data available to save.') with path.open('w', newline='') as f: f.write(f'# name = {profile.name}\n') f.write(f'# start = {profile.p1}\n') f.write(f'# stop = {profile.p2}\n') f.write(f'# width = {profile.profile_width}\n') f.write(f'# reduce_function = {profile.reduce_function}\n') f.write('x,y\n') np.savetxt( f, np.column_stack([profile.x_values, profile.y_values]), delimiter=',', )
[docs] def _save_profile_data_hdf5(self, path: Path) -> None: """ Save profile data to an HDF5 file with metadata attributes. Creates an HDF5 file containing the profile data as datasets and metadata as attributes. The dataset structure includes x and y values while attributes store profile metadata such as name, start/end points, width, and reduction function. :param path: The file path where the HDF5 data should be saved :type path: pathlib.Path """ profile = self.profile_data if profile.x_values is None or profile.y_values is None: raise RuntimeError('No profile data available to save.') with h5py.File(path, 'w') as f: grp = f.create_group('profile') grp.create_dataset('x', data=profile.x_values) grp.create_dataset('y', data=profile.y_values) grp.attrs['name'] = profile.name grp.attrs['start'] = profile.p1 grp.attrs['stop'] = profile.p2 grp.attrs['width'] = profile.profile_width grp.attrs['reduce_function'] = profile.reduce_function
[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: 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 if isinstance(self.view, ProfileSubWindow): 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 set_view(self, view: ProfileSubWindow) -> None: super().set_view(view) if isinstance(self.view, ProfileSubWindow): self.name_changed.connect(self.view.on_name_changed)
[docs] def context_menu_specs(self) -> List[ToolMenuSpec]: """ Provide context menu specifications for profile windows. :return: List of menu specifications :rtype: List[ToolMenuSpec] """ return [ ToolMenuSpec( title='Display', order=5, entries=[ ToolMenuSpec( title='Axis labels', order=10, entries=[ ToolActionSpec( text='Pixel', triggered=lambda: None, toggled=lambda checked: self._set_axis_label_mode_checked('pixel', checked), checked_changed_signal=self.axis_label_pixel_checked, action_group='profile_axis_labels', ), ToolActionSpec( text='Distance', triggered=lambda: None, toggled=lambda checked: self._set_axis_label_mode_checked('distance', checked), checked_changed_signal=self.axis_label_distance_checked, enabled_changed_signal=self.axis_label_distance_enabled, action_group='profile_axis_labels', ), ], ) ], ) ]
[docs] def sync_context_menu_state(self) -> None: """ Sync context menu check state for profile-specific entries. """ mode = self.axis_label_mode self.axis_label_pixel_checked.emit(mode == 'pixel') self.axis_label_distance_checked.emit(mode == 'distance') self.axis_label_distance_enabled.emit(self.has_distance())
[docs] def _set_axis_label_mode_checked(self, mode: str, checked: bool) -> None: """ Set axis label mode when a menu action is checked. :param mode: Axis label mode. :type mode: str :param checked: Whether the menu action is checked. :type checked: bool """ if not checked: return self.set_display_properties({'axis_label_mode': mode})
[docs] @thread_worker def extract_profile( data: np.ndarray, geometry: LineGeometry, session_id: UUID, profile_width: int = 1, reduce_function: str = 'mean', interpolation_order: str = 'bi-linear', **kwargs: Any, ) -> tuple[np.ndarray, np.ndarray, UUID]: """ Extract intensity profile from image data along a specified line. Computes an intensity profile along a line segment defined by start and end points in the image data. Uses skimage.measure.profile_line for the actual computation with configurable interpolation and reduction methods. This function is decorated with the superqt thread_worker, so it is not possible to call it directly because it will be executed in a separated thread. The typical usage is as follows: .. code-block:: python worker = extract_profile( data=self.window_controller.image_data, geometry=self.geometry, session_id=self.session_id, profile_width=self.profile_parameters['line_width'], interpolation_order=self.profile_parameters[ 'interpolation_order' ], reduce_function=self.profile_parameters[ 'reduce_function' ], ) worker.returned.connect(self.on_profile_ready) worker.errored.connect(self._on_error) worker.start() :param data: Input image data array :type data: np.ndarray :param geometry: Line geometry defining start and end points :type geometry: LineGeometry :param session_id: Identifier for the profile extraction session :type session_id: UUID :param profile_width: Width of the profile extraction line (default: 1) :type profile_width: int :param reduce_function: Function to reduce multiple pixel values (default: 'mean') :type reduce_function: str :param interpolation_order: Interpolation order for profile calculation (default: 'bi-linear') :type interpolation_order: str :param kwargs: Additional keyword arguments passed to profile_line :return: Tuple of (x_coordinates, y_coordinates, session_id) :rtype: tuple[np.ndarray, np.ndarray, UUID] """ start = geometry.start[::-1] end = geometry.end[::-1] line_width = profile_width func_lut = {'mean': np.mean, 'median': np.median, 'max': np.max, 'min': np.min} reduce_func = func_lut.get(reduce_function, np.mean) if line_width > 1 else None interpolation_order_lut = { 'nearest-neighbor': 0, 'bi-linear': 1, 'bi-quadratic': 2, 'bi-cubic': 3, 'bi-quartic': 4, 'bi-quintic': 5, } interpolation = interpolation_order_lut.get(interpolation_order, 1) length = np.linalg.norm(np.array(end) - np.array(start)) y = np.array( profile_line(data, start, end, linewidth=line_width, reduce_func=reduce_func, order=interpolation, **kwargs) # type: ignore[no-untyped-call] ) x = np.linspace(0, length, y.shape[0]) return x, y, session_id
[docs] class ProfileToolSession(BaseToolSession['ProfileToolController', 'ImageWindowController']): """ Session for handling profile extraction process. """ window_controller: ImageWindowController def __init__( self, tool_controller: ProfileToolController, window_controller: ImageWindowController, input_data: Optional[ProfileData] = None, ) -> None: """ Initialize the profile tool session. :param tool_controller: Profile tool controller :type tool_controller: ProfileToolController :param window_controller: Image window controller :type window_controller: ImageWindowController """ if not isinstance(tool_controller, ProfileToolController): raise TypeError('The tool controller must be an instance of ProfileToolController') 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.window_controller: ImageWindowController = window_controller self.line_selector: Optional[LineSelector] = None self.geometry: Optional[LineGeometry] = None self.current_profile: Optional[ProfileData] = None self.profile_parameters: dict[str, Any] = {}
[docs] def on_start(self) -> None: """ Start the profile extraction session. """ if self._input_data is None: self._on_interactive_definition() else: self._define_initial_condition() if self._input_data.x_values is None or self._input_data.y_values is None: self.start_profile_computation() else: QTimer.singleShot( 0, lambda results=( self._input_data.x_values, # type: ignore[union-attr] self._input_data.y_values, # type: ignore[union-attr] self.session_id, ): self.on_profile_ready(profile_results=results), )
[docs] def _on_interactive_definition(self) -> None: """ Initialize interactive profile definition mode. Sets up the interactive profile creation process by: 1. Sending a status message to the user 2. Emitting an event to create the point selection dialog 3. Creating a new ProfileData instance with a generated name and color 4. Setting up a LineSelector for capturing user-drawn line geometry 5. Connecting the selector signals to appropriate handler methods This method is called when starting an interactive profile definition session where the user will manually draw the profile line on the image. """ self.tool_controller.context.message_requested.emit('Profile extraction procedure on going...', 'info', 3000) self.tool_controller.emit_event('create_selection_dialog') self.current_profile = ProfileData( name=f'profile-{self.tool_controller.profile_counter.next()}', color=self.tool_controller.context.get_color_service().next_color(), session_id=self.session_id, pixel_size_m=self.window_controller.pixel_size_m(), ) view = self.window_controller.require_view() self.line_selector = LineSelector(view.canvas.im_axes) self.line_selector.selector_created.connect(self.on_selector_created) self.line_selector.selector_accepted.connect(self.on_selector_accepted) self.line_selector.selector_canceled.connect(self.on_selector_canceled)
[docs] def _define_initial_condition(self) -> None: """ Define profile parameters from pre-existing data. Initializes the session using existing profile data instead of interactive creation. Sets up the geometry from the provided start and end points, and copies the profile parameters from the input data to the session's parameters dictionary. This method is used when restoring a profile from workspace data or when initializing a profile with predefined parameters. :raises TypeError: If _input_data is None (should not occur in normal flow) """ if self._input_data is None or self._input_data.p1 is None or self._input_data.p2 is None: raise TypeError('Expected input profile data for initial condition setup.') self.line_selector = None self.geometry = LineGeometry(self._input_data.p1, self._input_data.p2) self.current_profile = self._input_data if self.current_profile.pixel_size_m is None: self.current_profile.pixel_size_m = self.window_controller.pixel_size_m() self.profile_parameters['line_width'] = self._input_data.profile_width self.profile_parameters['interpolation_order'] = self._input_data.interpolation_order self.profile_parameters['reduce_function'] = self._input_data.reduce_function
[docs] def on_selector_created(self) -> None: """ Handle selector creation event. """ self.tool_controller.emit_event('selector_created')
[docs] def on_selector_accepted(self) -> None: """ Handle selector acceptance event. """ self.tool_controller.emit_event('selector_accepted')
[docs] def on_selector_canceled(self) -> None: """ Handle selector cancellation event. """ self.geometry = None self.tool_controller.emit_event('selector_canceled')
[docs] def on_cancel(self, reason: str) -> None: """ Cancel the session. :param reason: Reason for cancellation :type reason: str """ self.cleanup()
[docs] def on_finish(self) -> None: """ Finish the session. """ if self.current_profile is None: raise RuntimeError('No profile data available to finish.') self.assign_payload(self.current_profile) self.tool_controller.context.message_requested.emit('Profile extraction procedure finished', 'info', 3000) self.cleanup()
[docs] def cleanup(self) -> None: """ Clean up resources used by the session. """ self.geometry = None if self.line_selector: self.line_selector.clear() self.line_selector.disconnect_signals() self.tool_controller.emit_event('destroy_selection_dialog')
[docs] def overlay_from_geometry(self) -> OverlayModel: """ Create overlay from geometry. :return: Overlay model for the profile :rtype: OverlayModel """ if self.current_profile is None or self.geometry is None: raise RuntimeError('Profile geometry is not available.') id_ = self.current_profile.id label = self.current_profile.name color = to_mpl(self.current_profile.color) overlay = OverlayModel(id=id_, role=OverlayRole.Permanent, label=label) overlay.type = 'profile_arrow' overlay.style = { 'ls': 'solid', 'lw': 1.5, 'color': color, 'label': label, 'arrowstyle': '-|>, head_width=4, head_length=8', } overlay.visible = self.current_profile.show_overlay if self.current_profile.show_overlay is not None else True overlay.geometry = {'posA': self.geometry.start, 'posB': self.geometry.end} overlay.extra_props = {'geometry': self.geometry} return overlay
[docs] def create_label_overlay(self) -> OverlayModel: """Create an overlay for the arrow label""" if self.current_profile is None or self.geometry is None: raise RuntimeError('Profile geometry is not available.') id_ = self.current_profile.id label = self.current_profile.name color = to_mpl(self.current_profile.color) overlay = OverlayModel(id=id_, role=OverlayRole.Label, label=label) overlay.type = 'profile_label' overlay.geometry = dict( text=label, xy=self.geometry.start, xycoords='data', textcoords='offset pixels', transform_rotates_text=True ) overlay.style = {'color': color, 'ha': 'right', 'va': 'center', 'size': 12, 'clip_on': False} overlay.extra_props['geometry'] = self.geometry overlay.extra_props['offset'] = 10 overlay.visible = self.current_profile.show_label if self.current_profile.show_label is not None else True return overlay
[docs] def start_profile_computation(self) -> None: """ Start the profile computation process. """ if self.line_selector is not None: # it means that the session is not interactive self.geometry = self.line_selector.geometry if self.geometry is None: # the selector was cancelled # we cancel the session as well. self.cancel() return worker = extract_profile( data=self.window_controller.image_data, geometry=self.geometry, session_id=self.session_id, profile_width=self.profile_parameters['line_width'], interpolation_order=self.profile_parameters['interpolation_order'], reduce_function=self.profile_parameters['reduce_function'], ) worker.returned.connect(self.on_profile_ready) worker.errored.connect(self._on_error) worker.start()
[docs] def on_profile_ready(self, profile_results: tuple[np.ndarray, np.ndarray, UUID]) -> None: """ Handle profile ready event. :param x: X coordinates of profile points :type x: np.ndarray :param y: Y coordinates (intensity values) of profile points :type y: np.ndarray :param session_id: Session identifier :type session_id: UUID """ x, y, session_id = profile_results if not self.is_active() or session_id != self.session_id: self.cancel() return if self.current_profile is None or self.geometry is None: self.cancel('Missing profile state for computation.') return self.current_profile.image_controller = self.window_controller if self.current_profile.pixel_size_m is None: self.current_profile.pixel_size_m = self.window_controller.pixel_size_m() self.current_profile.set_profile_data(x, y) self.current_profile.p1 = self.geometry.start self.current_profile.p2 = self.geometry.end self.current_profile.profile_width = self.profile_parameters['line_width'] self.current_profile.reduce_function = self.profile_parameters['reduce_function'] self.current_profile.interpolation_order = self.profile_parameters['interpolation_order'] self.window_controller.overlay_manager.add('im_axes', self.overlay_from_geometry()) self.window_controller.overlay_manager.add('im_axes', self.create_label_overlay()) self.finish()
[docs] def _on_error(self, e: Exception) -> None: """ Handle error events. :param e: Exception that occurred :type e: Exception """ self.cancel(str(e)) raise e
[docs] class ProfileToolController(ToolController[ImageWindowController, ProfileToolSession]): """ Controller for managing the profile tool workflow. """ add_enabled_changed = Signal(bool) remove_enabled_changed = Signal(bool) redraw_enabled_changed = Signal(bool) ack_session_completed = Signal(UUID) request_to_change_selection = Signal(object, object) restore_completed = Signal(object) # corresponding descriptors can_add = ActionDescriptor() can_remove = ActionDescriptor() can_redraw = ActionDescriptor() def __init__(self, tool_ctx: ToolContext, tool: Tool[ProfileToolController]) -> None: """ Initialize the profile tool controller. :param tool_ctx: Tool context :type tool_ctx: ToolContext :param tool: Tool instance :type tool: Tool """ super().__init__(tool_ctx, tool) self.profile_store = ProfileStore() self.profile_store.add_update_listener(self.update_editable_fields) self.active_session: Optional[ProfileToolSession] = None self.profile_counter = ItemCounter(0) self.table_model = ProfileTableModel(self.profile_store, self) self.current_profile: Optional[ProfileData] = None self.geometry_overlay_model: Optional[OverlayModel] = None self.label_overlay_model: Optional[OverlayModel] = None self.current_selected_profile: Optional[ProfileData] = None
[docs] def create_session( self, window_controller: ImageWindowController, input_data: Optional[ProfileData] = None ) -> ProfileToolSession: """ Create a new profile tool session. :param window_controller: Image window controller :type window_controller: SubWindowController :return: New profile tool session :rtype: BaseToolSession """ if not isinstance(window_controller, ImageWindowController): raise TypeError('Profile tool sessions require an ImageWindowController.') session = ProfileToolSession(self, window_controller, input_data) session.finished.connect(self.deactivate) session.returned.connect(self.finalize_profile) session.canceled.connect(self.deactivate) return session
[docs] def create_dock(self, parent_window: QWidget) -> ProfileToolDock: """ Create the tool dock widget. :param parent_window: Parent window :type parent_window: QWidget :return: Profile tool dock widget :rtype: ProfileToolDock """ dock = ProfileToolDock(parent_window, self) dock.event_emitted.connect(self.on_dock_event) dock.profile_selection_changed.connect(self.on_profile_selection_changed) self.redraw_enabled_changed.connect(dock.enable_redraw_label) self.request_to_change_selection.connect(dock.on_request_to_change_selection) self.controller_event.connect(dock.handle_event) return dock
[docs] def set_profile_params(self, params: dict[str, Any]) -> None: """ Set profile parameters. :param params: Profile parameters dictionary :type params: dict[str, Any] """ if self.active_session: self.active_session.profile_parameters = params
[docs] def _on_active_image_changed(self, new_window_controller: 'SubWindowController[Any] | None') -> None: """ Handle active image change event. :param new_window_controller: 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.emit_event('window_controller_changed', is_image_controller) self.can_add = is_image_controller
[docs] def on_profile_selection_changed( self, selected: ProfileData | None, deselected: ProfileData | None, ) -> None: """ Handle profile selection change event. :param selected: Selected profile :type selected: ProfileData | None :param deselected: Deselected profile :type deselected: ProfileData | None """ if deselected is not None and deselected.image_controller is not None: self.current_selected_profile = deselected deselected.image_controller.overlay_manager.remove_highlight(deselected.id) self.current_selected_profile = selected if selected is not None: if selected.profile_controller is not None: self._activate_profile_window(selected.profile_controller) # we add the overlay, only if the user wants to show the overlay. if selected.show_overlay and selected.image_controller is not None: selected.image_controller.overlay_manager.add_highlight(selected.id)
[docs] def on_delete_profile_clicked(self) -> None: """ Handle delete profile click event. """ profile = require_not_none(self.current_selected_profile, 'Profile selected for removal') self.remove_profile(profile)
[docs] def _activate_profile_window(self, window_controller: ProfileWindowController) -> None: """ Activate the profile window. :param window_controller: Profile window controller :type window_controller: ProfileWindowController """ self.context.activate_image(window_controller)
[docs] def on_dock_event(self, event: ToolEvent) -> None: """ Handle dock events. :param event: Tool event :type event: ToolEvent """ return
[docs] def start_add_profile(self) -> None: """ Start adding a new profile. """ # this is the super method in the base controller. # it create a new session and start it self.activate()
[docs] def on_cancelled_profile_definition(self) -> None: """ Handle cancelled profile definition. """ if self.active_session: self.active_session.cancel()
[docs] def create_profile_from_points(self) -> None: """ Create profile from selected points. """ if self.active_session: self.active_session.start_profile_computation()
[docs] def finalize_profile(self, session_result: ToolSessionResult) -> None: """ Finalize the profile creation process. """ profile_data: ProfileData = cast(ProfileData, session_result.payload) if profile_data.image_controller is None: raise RuntimeError('Profile data is missing an image controller.') source = DataSource('profile', DataOrigin.DERIVED, profile_data.image_controller.source) profile_window_controller = ProfileWindowController(source, profile_data) profile_window_controller.request_profile_removal.connect(self.remove_profile) profile_data.image_controller.about_to_close.connect( partial(self._on_image_about_to_close, profile_data.image_controller) ) request = WindowRequest( controller=profile_window_controller, window_type='profile', requested_id=profile_data._requested_profile_controller_id, ) self.request_new_window.emit(request) self.profile_store.add(profile_data) self._sync_profile(profile_data) self.can_redraw = len(self.profile_store) > 0 self.ack_session_completed.emit(session_result.session_id)
[docs] def _on_image_about_to_close(self, image_controller: ImageWindowController) -> None: """ Handle image window about to close event. :param image_controller: Image window controller :type image_controller: ImageWindowController """ profiles_to_remove = [p for p in self.profile_store.all() if p.image_controller is image_controller] for profile in profiles_to_remove: self.remove_profile(profile)
[docs] def remove_profile(self, profile: ProfileData) -> None: """ Remove a profile from the store. :param profile: Profile to remove :type profile: ProfileData """ if profile is None: return if profile._being_removed: return profile._being_removed = True if profile.image_controller is not None: profile.image_controller.overlay_manager.remove_id(profile.id) profile.image_controller.overlay_manager.remove_id(profile.id, OverlayRole.Label) profile.image_controller.overlay_manager.remove_highlight(profile.id) if profile.profile_controller is not None: profile.profile_controller.close() self.profile_store.remove_item(profile)
[docs] def menu_specs(self) -> List[ToolMenuSpec]: """ Get menu specifications for the tool. :return: List of menu specifications :rtype: List[ToolMenuSpec] """ return [ ToolMenuSpec( title='Analysis', order=10, entries=[ ToolMenuSpec( title='Profile', order=10, entries=[ ToolActionSpec( text='Add profile...', triggered=self.start_add_profile, enabled_changed_signal=self.add_enabled_changed, shortcut='Ctrl+P', ), ToolActionSpec( text='Remove profile', triggered=self.on_delete_profile_clicked, enabled_changed_signal=self.remove_enabled_changed, ), ToolActionSpec( text='Redraw labels', triggered=self._redraw_all_labels, enabled_changed_signal=self.redraw_enabled_changed, ), ], ) ], ) ]
[docs] def update_editable_fields(self, data: ProfileData, index: int = 0) -> None: """ Update editable fields when a profile is updated in the store. This method is triggered by update events and synchronizes the profile data with its associated controllers and overlays. It handles name updates and visibility changes for both permanent and highlight overlays. :param data: The profile data that was updated :type data: ProfileData :param index: The index of the updated item in the store :type index: int """ self._sync_profile(data)
def _redraw_all_labels(self) -> None: for item in self.profile_store.all(): image_controller = item.image_controller if image_controller is None: continue label_overlay = image_controller.overlay_manager.get_overlay_by_id(item.id, OverlayRole.Label) if label_overlay is None: continue image_controller.overlay_manager.update(label_overlay)
[docs] def _sync_profile(self, profile_data: ProfileData) -> None: """ Synchronize profile data with its associated controllers and overlays. Updates the profile name in the controller and label overlay, and synchronizes the visibility settings for permanent, highlight, and label overlays. If the profile is currently selected, ensures the highlight overlay is properly managed. :param profile_data: The profile data to synchronize :type profile_data: ProfileData """ profile_controller = profile_data.profile_controller image_controller = profile_data.image_controller if profile_controller is None or image_controller is None: return # update the name profile_controller.name_changed.emit(profile_data.name) # we need to update the overlay label as well label_overlay = image_controller.overlay_manager.get_overlay_by_id(profile_data.id, OverlayRole.Label) if label_overlay is not None: label_overlay.geometry['text'] = profile_data.name image_controller.overlay_manager.update(label_overlay) # update the visibility show_overlay = profile_data.show_overlay if profile_data.show_overlay is not None else True show_label = profile_data.show_label if profile_data.show_label is not None else True image_controller.overlay_manager.set_visibility(profile_data.id, OverlayRole.Permanent, show_overlay) image_controller.overlay_manager.set_visibility(profile_data.id, OverlayRole.Highlight, show_overlay) image_controller.overlay_manager.set_visibility(profile_data.id, OverlayRole.Label, show_label) if self.current_selected_profile and self.current_selected_profile.id == profile_data.id: # this is the currently selected profile, if show_overlay: image_controller.overlay_manager.add_highlight(profile_data.id)
[docs] def count_dependencies_for_image(self, window_controller: SubWindowController[Any]) -> int: """ Count profile items attached to *window_controller*. """ if not isinstance(window_controller, ImageWindowController): return 0 return len([p for p in self.profile_store.all() if p.image_controller is window_controller])
[docs] def invalidate_dependencies_for_image(self, window_controller: SubWindowController[Any]) -> int: """ Remove profile items attached to *window_controller*. """ if not isinstance(window_controller, ImageWindowController): return 0 to_remove = [p for p in self.profile_store.all() if p.image_controller is window_controller] for profile in to_remove: self.remove_profile(profile) return len(to_remove)
[docs] def draw_label_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a label overlay for a profile on matplotlib axes. Calculates the appropriate text rotation and positioning based on the profile line geometry to ensure proper label placement and orientation. :param overlay: The overlay model containing label 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] """ initialized = getattr(axes.figure.canvas, 'initialized', False) if not initialized: setattr(axes.figure.canvas, 'initialized', True) axes.figure.canvas.draw() axes.figure.canvas.flush_events() geometry = overlay.extra_props['geometry'] p0 = axes.transData.transform(geometry.start) p1 = axes.transData.transform(geometry.end) ha = 'right' if p0[0] > p1[0]: ha = 'left' dx, dy = p1 - p0 angle = np.degrees(np.arctan2(dy, dx)) if angle > 90 or angle < -90: angle += 180 norm = np.hypot(dx, dy) ux, uy = dx / norm, dy / norm offset_px = overlay.extra_props['offset'] dx_px = -offset_px * ux dy_px = -offset_px * uy overlay.geometry['rotation'] = angle overlay.geometry['rotation_mode'] = 'anchor' overlay.geometry['xytext'] = (dx_px, dy_px) overlay.style['ha'] = ha new_annotation = Annotation(**overlay.geometry, **overlay.style) axes.add_artist(new_annotation) return [new_annotation]
[docs] def draw_profile_arrow(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a profile arrow overlay. :param overlay: Overlay model :type overlay: OverlayModel :param axes: Matplotlib axes :type axes: Axes :return: List of patches drawn :rtype: list """ new_patch = FancyArrowPatch(**overlay.geometry, **overlay.style) axes.add_artist(new_patch) return [new_patch]
[docs] def draw_highlight_profile(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a highlighted profile overlay. :param overlay: Overlay model :type overlay: OverlayModel :param axes: Matplotlib axes :type axes: Axes :return: List of patches drawn :rtype: list """ geometry: LineGeometry = overlay.extra_props['geometry'] xs = (geometry.start[0], geometry.end[0]) ys = (geometry.start[1], geometry.end[1]) if overlay.role != OverlayRole.Highlight: new_overlay = overlay.change_role_to(OverlayRole.Highlight) else: new_overlay = overlay geometry_dict = dict(xdata=xs, ydata=ys) style_dict = dict(linewidth=10, color=new_overlay.style['color'], alpha=0.5) new_overlay.geometry = geometry_dict new_overlay.style = style_dict patch = Line2D(xs, ys, **style_dict) axes.add_line(patch) return [patch]
[docs] class ProfileTool(Tool[ProfileToolController]): """ Tool for extracting intensity profiles from images. """ id = uuid.uuid4() tool_id = 'profile' name = 'Profile Tool' description = 'A tool to extract profiles from images' windows_to_be_registered = [WindowSpec('profile', ProfileSubWindow, False)] overlays_to_be_registered = [ OverlaySpec('profile_arrow', OverlayRole.Permanent, draw_profile_arrow), OverlaySpec('profile_arrow', OverlayRole.Highlight, draw_highlight_profile), OverlaySpec('profile_label', OverlayRole.Label, draw_label_overlay), ] 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') -> ProfileToolController: """ Create a controller for this tool. :param ctx: Tool context :type ctx: ToolContext :return: Profile tool controller :rtype: ProfileToolController """ self._controller: ProfileToolController = ProfileToolController(ctx, self) return self._controller
def restore_phase(self) -> int: return 1
[docs] def to_workspace(self, include_data: bool) -> ToolWorkspace: """ Convert the current tool state to a workspace specification. Serializes the profile tool's current state including all profile data, their configuration, and the currently selected profile into a ToolWorkspace object. This allows for persistent storage and restoration of the tool's state across application sessions. :param include_data: Flag indicating whether to include profile data arrays :type include_data: bool :return: Workspace specification containing the tool's state :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 we got here, we have the controller and we know how to proceed. # this is the reference of the currently selected profile if self._controller.current_selected_profile: self._controller.context.register_workspace_reference( self._controller.current_selected_profile.id, self._controller.current_selected_profile ) tool_ws.state['current_selected_profile'] = self._controller.current_selected_profile.id else: tool_ws.state['current_selected_profile'] = None # now loop over the profiles in the store. for profile_data in self._controller.profile_store.all(): profile_data_spec = profile_data.to_workspace_spec(include_data) if profile_data.image_controller is not None: profile_data_spec.image_controller_id = profile_data.image_controller.id if profile_data.profile_controller is not None: profile_data_spec.profile_controller_id = profile_data.profile_controller.id tool_ws.items[profile_data.id] = profile_data_spec tool_ws.order.append(profile_data.id) return tool_ws
[docs] def from_workspace(self, spec: ToolWorkspace, context: WorkspaceReferenceManager) -> None: """ Restore the tool state from a workspace specification. Initializes the profile tool from a serialized ToolWorkspace specification, recreating all profile data and their associated controllers. Handles workspace reference resolution and manages asynchronous session creation for profile restoration. :param spec: Workspace specification to restore from :type spec: ToolWorkspace :param context: Manager for resolving workspace references :type context: WorkspaceReferenceManager :raises RuntimeError: If no controller is available for tool restoration """ 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_profile_restore_completed) self._increment_item_counter(len(spec.items)) for key, raw_item in spec.items.items(): item = workspace_spec_as_mapping(raw_item) # let's create a ProfileData from encoded data profile_data = ProfileData( profile_id=enforce_uuid(key), name=item['name'], color=Color(*item['color']), p1=item['p1'], p2=item['p2'], profile_width=item['profile_width'], reduce_function=item['reduce_function'], interpolation_order=item['interpolation_order'], show_overlay=item['show_overlay'], show_label=item['show_label'], pixel_size_m=item.get('pixel_size_m'), display_properties=item.get('display_properties'), ) # 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 {profile_data.name}') profile_data.image_controller = image_window_controller profile_data._requested_profile_controller_id = enforce_uuid(item['profile_controller_id']) # check if we have the data, if so assign them if item['x_values'] is not None and item['y_values'] is not None: profile_data.x_values = item['x_values'] profile_data.y_values = item['y_values'] # we register the current profile data for future use context.register(profile_data.id, profile_data) session = self._controller.create_session(image_window_controller, input_data=profile_data) self._session_restore_context.register_session(session) session.start()
[docs] def _on_profile_restore_completed(self) -> None: """ Complete the profile restoration process. Finalizes the workspace restoration by reordering the profile store, restoring the previously selected profile, and redrawing all profile labels to ensure proper visualization after restoration. """ # lastly self._reorder_store() self._restore_selected_item() self._redraw_all_labels() self._restore_completed()
[docs] def _increment_item_counter(self, item_count: int) -> None: """ Increment the internal item counter. Advances the profile counter by the specified number of items to maintain consistent naming for restored profiles. :param item_count: Number of items to increment the counter by :type item_count: int """ for i in range(item_count): self._controller.profile_counter.next()
[docs] def _redraw_all_labels(self) -> None: """ Redraw all profile labels in their respective image windows. Triggers a complete refresh of all profile label overlays across all image windows to ensure proper positioning and visibility after workspace restoration or other state changes. """ self._controller._redraw_all_labels()
[docs] def _reorder_store(self) -> None: """ Reorder the profile store according to workspace specification. Restores the original ordering of profiles as specified in the workspace by reorganizing the profile store based on the saved order sequence. .. note:: This method assumes that all profiles in the workspace specification exist in the current store and maintains the relative ordering. """ restore_spec = self._require_restore_spec() store = self._controller.profile_store.all() by_id: dict[str, ProfileData] = {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.profile_store.reset() for item in reordered: self._controller.profile_store.add(item)
[docs] def _restore_selected_item(self) -> None: """ Restore the previously selected profile item. Selects the profile that was active before workspace restoration by finding it in the store and emitting a selection change event to update the UI accordingly. .. note:: This method handles both UUID and string representations of profile IDs and gracefully handles cases where the profile may no longer exist. """ restore_spec = self._require_restore_spec() pid = restore_spec.state['current_selected_profile'] if pid is None or pid == '__NONE__': return pid = enforce_uuid(pid) row = self._controller.profile_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)