Source code for radioviz.tools.measurement_tool

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

This module implements a built-in tool to measure segment lengths and
angle amplitudes on image windows. It provides a dock widget with two
tables (segments and angles), interactive selection sessions, overlays,
and workspace serialization for both JSON and HDF5 formats.
"""

from __future__ import annotations

import math
import pathlib
import sys
import uuid
from dataclasses import dataclass
from functools import partial
from typing import Any, Optional, Protocol, TypeVar, cast
from uuid import UUID

import numpy as np
from matplotlib.artist import Artist
from matplotlib.axes import Axes
from matplotlib.lines import Line2D
from matplotlib.patches import Arc
from matplotlib.text import Annotation
from PySide6.QtCore import (
    QItemSelection,
    QItemSelectionModel,
    QModelIndex,
    QObject,
    QPersistentModelIndex,
    QSortFilterProxyModel,
    Qt,
    Signal,
)
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import (
    QCheckBox,
    QDialog,
    QFileDialog,
    QGridLayout,
    QHBoxLayout,
    QLabel,
    QMenu,
    QPushButton,
    QTableView,
    QTabWidget,
    QToolBar,
    QToolButton,
    QVBoxLayout,
    QWidget,
)
from superqt import QIconifyIcon

from radioviz.controllers.image_window_controller import ImageWindowController
from radioviz.controllers.sub_window_controller import SubWindowController, SubWindowEnum
from radioviz.geometry.angle_selector import AngleSelector
from radioviz.geometry.line_selector import LineSelector
from radioviz.models.item_store import ItemStore, StoreEvent
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.item_counter import ItemCounter
from radioviz.services.spatial_calibration import select_distance_unit
from radioviz.services.typing_helpers import assume_not_none, require_not_none
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.item_model import ItemModel

if sys.version_info >= (3, 11):
    from enum import StrEnum
else:
    from radioviz.models.legacy import StrEnum


[docs] class MeasurementType(StrEnum): """ Measurement types supported by the measurement tool. """ SEGMENT = 'Segment' ANGLE = 'Angle'
[docs] @dataclass class SegmentMeasurementWorkspaceSpec: """ Workspace specification for a segment measurement. """ id: UUID name: str image_controller_id: Optional[UUID] = None color: tuple[float, float, float] = (0.0, 0.0, 0.0) p1: tuple[float, float] = (0.0, 0.0) p2: tuple[float, float] = (0.0, 0.0) show_overlay: bool = True show_label: bool = False show_measure: bool = True length_px: float = 0.0 length_m: Optional[float] = None unit_name: Optional[str] = None pixel_size_m: Optional[tuple[float, float]] = None
[docs] @dataclass class AngleMeasurementWorkspaceSpec: """ Workspace specification for an angle measurement. """ id: UUID name: str image_controller_id: Optional[UUID] = None color: tuple[float, float, float] = (0.0, 0.0, 0.0) p1: tuple[float, float] = (0.0, 0.0) p2: tuple[float, float] = (0.0, 0.0) p3: tuple[float, float] = (0.0, 0.0) show_overlay: bool = True show_label: bool = False show_measure: bool = True angle_deg: float = 0.0
[docs] class SegmentMeasurementData: """ Data class representing a segment measurement. """ def __init__( self, name: str, color: Color, p1: Optional[tuple[float, float]] = None, p2: Optional[tuple[float, float]] = None, image_controller: Optional[ImageWindowController] = None, session_id: Optional[UUID] = None, measurement_id: Optional[UUID] = None, show_overlay: Optional[bool] = True, show_label: Optional[bool] = False, show_measure: Optional[bool] = True, pixel_size_m: Optional[tuple[float, float]] = None, length_px: Optional[float] = None, length_m: Optional[float] = None, unit_name: Optional[str] = None, ) -> None: """ Initialize a SegmentMeasurementData instance. :param name: Measurement name :type name: str :param color: Measurement color :type color: Color :param p1: Start point (x, y) :type p1: tuple[float, float], optional :param p2: End point (x, y) :type p2: tuple[float, float], optional :param image_controller: Image controller owning the measurement :type image_controller: Optional[ImageWindowController] :param session_id: Session identifier :type session_id: Optional[UUID] :param measurement_id: Unique identifier for the measurement :type measurement_id: Optional[UUID] :param show_overlay: Whether the overlay is visible :type show_overlay: Optional[bool] :param show_label: Whether the label text is visible :type show_label: Optional[bool] :param show_measure: Whether the measurement value is visible :type show_measure: Optional[bool] :param pixel_size_m: Pixel size in meters (x, y) :type pixel_size_m: Optional[tuple[float, float]] :param length_px: Precomputed length in pixels :type length_px: Optional[float] :param length_m: Precomputed length in meters :type length_m: Optional[float] :param unit_name: Display unit name for calibrated length :type unit_name: Optional[str] """ self.id = measurement_id or uuid.uuid4() self.name = name self.color = color self.p1 = p1 self.p2 = p2 self.image_controller = image_controller self.session_id = session_id self.show_overlay = bool(show_overlay) self.show_label = bool(show_label) self.show_measure = bool(show_measure) self.pixel_size_m = pixel_size_m self.length_px = float(length_px) if length_px is not None else 0.0 self.length_m = float(length_m) if length_m is not None else None self.unit_name = unit_name self._being_removed = False self.calculate_properties()
[docs] def calculate_properties(self) -> None: """ Compute measurement properties from current geometry. """ if self.p1 is None or self.p2 is None: return dx = self.p2[0] - self.p1[0] dy = self.p2[1] - self.p1[1] self.length_px = float(math.hypot(dx, dy)) if self.pixel_size_m is not None: pixel_x_m, pixel_y_m = self.pixel_size_m self.length_m = float(math.hypot(dx * pixel_x_m, dy * pixel_y_m)) unit = select_distance_unit(self.length_m) self.unit_name = unit.name else: self.length_m = None self.unit_name = None
[docs] def length_display(self) -> str: """ Format the calibrated length for display. :return: Formatted length string or 'n/a' :rtype: str """ if self.length_m is None or self.length_m <= 0: return 'n/a' unit = select_distance_unit(self.length_m) value = self.length_m / unit.meters_per_unit return f'{value:.4g} {unit.name}'
[docs] def measurement_display(self) -> str: """ Format the preferred measurement value for display. :return: Measurement display string :rtype: str """ if self.length_m is not None and self.length_m > 0: return self.length_display() return f'{self.length_px:.2f} px'
[docs] def to_workspace_spec(self, include_data: bool) -> SegmentMeasurementWorkspaceSpec: """ Convert this measurement to a workspace specification. :param include_data: Whether to include computed data :type include_data: bool :return: Workspace specification :rtype: SegmentMeasurementWorkspaceSpec :raise RuntimeError: when attempting to serialize incomplete geometry """ if self.p1 is None or self.p2 is None: raise RuntimeError('Attempt to serialize an incomplete SegmentMeasurementData') spec = SegmentMeasurementWorkspaceSpec( id=self.id, name=self.name, color=to_mpl(self.color), p1=self.p1, p2=self.p2, show_overlay=self.show_overlay, show_label=self.show_label, show_measure=self.show_measure, length_px=self.length_px, length_m=self.length_m, unit_name=self.unit_name, pixel_size_m=self.pixel_size_m, ) return spec
[docs] class AngleMeasurementData: """ Data class representing an angle measurement. """ def __init__( self, name: str, color: Color, p1: Optional[tuple[float, float]] = None, p2: Optional[tuple[float, float]] = None, p3: Optional[tuple[float, float]] = None, image_controller: Optional[ImageWindowController] = None, session_id: Optional[UUID] = None, measurement_id: Optional[UUID] = None, show_overlay: Optional[bool] = True, show_label: Optional[bool] = False, show_measure: Optional[bool] = True, angle_deg: Optional[float] = None, ) -> None: """ Initialize an AngleMeasurementData instance. :param name: Measurement name :type name: str :param color: Measurement color :type color: Color :param p1: First point (x, y) :type p1: tuple[float, float], optional :param p2: Vertex point (x, y) :type p2: tuple[float, float], optional :param p3: Third point (x, y) :type p3: tuple[float, float], optional :param image_controller: Image controller owning the measurement :type image_controller: Optional[ImageWindowController] :param session_id: Session identifier :type session_id: Optional[UUID] :param measurement_id: Unique identifier for the measurement :type measurement_id: Optional[UUID] :param show_overlay: Whether the overlay is visible :type show_overlay: Optional[bool] :param show_label: Whether the label text is visible :type show_label: Optional[bool] :param show_measure: Whether the measurement value is visible :type show_measure: Optional[bool] :param angle_deg: Precomputed angle in degrees :type angle_deg: Optional[float] """ self.id = measurement_id or uuid.uuid4() self.name = name self.color = color self.p1 = p1 self.p2 = p2 self.p3 = p3 self.image_controller = image_controller self.session_id = session_id self.show_overlay = bool(show_overlay) self.show_label = bool(show_label) self.show_measure = bool(show_measure) self.angle_deg = float(angle_deg) if angle_deg is not None else 0.0 self._being_removed = False self.calculate_properties()
[docs] def calculate_properties(self) -> None: """ Compute angle measurement from current geometry. """ if self.p1 is None or self.p2 is None or self.p3 is None: return v1 = np.array(self.p1) - np.array(self.p2) v2 = np.array(self.p3) - np.array(self.p2) n1 = np.linalg.norm(v1) n2 = np.linalg.norm(v2) if n1 == 0 or n2 == 0: self.angle_deg = 0.0 return cosang = float(np.dot(v1, v2) / (n1 * n2)) cosang = max(-1.0, min(1.0, cosang)) self.angle_deg = float(np.degrees(np.arccos(cosang)))
[docs] def to_workspace_spec(self, include_data: bool) -> AngleMeasurementWorkspaceSpec: """ Convert this measurement to a workspace specification. :param include_data: Whether to include computed data :type include_data: bool :return: Workspace specification :rtype: AngleMeasurementWorkspaceSpec :raise RuntimeError: when attempting to serialize incomplete geometry """ if self.p1 is None or self.p2 is None or self.p3 is None: raise RuntimeError('Attempt to serialize an incomplete AngleMeasurementData') spec = AngleMeasurementWorkspaceSpec( id=self.id, name=self.name, color=to_mpl(self.color), p1=self.p1, p2=self.p2, p3=self.p3, show_overlay=self.show_overlay, show_label=self.show_label, show_measure=self.show_measure, angle_deg=self.angle_deg, ) return spec
SegmentStore = ItemStore[SegmentMeasurementData] AngleStore = ItemStore[AngleMeasurementData]
[docs] class MeasurementDataProtocol(Protocol): """ Protocol defining the common interface for measurement data types. """ id: UUID name: str image_controller: Optional[ImageWindowController] show_overlay: bool _being_removed: bool
MeasurementDataType = TypeVar('MeasurementDataType', SegmentMeasurementData, AngleMeasurementData)
[docs] class SegmentTableModel(ItemModel[SegmentMeasurementData]): """ Table model for displaying segment measurements. """ def __init__(self, store: SegmentStore, parent: Optional[QObject] = None) -> None: """ Initialize the segment table model. :param store: Store containing segment data :type store: SegmentStore :param parent: Parent object :type parent: QObject, optional """ super().__init__(store, parent) self.headers = [ 'Name', 'Length', 'Show overlay', 'Show label', 'Show measure', 'Color', 'Point 1 (x,y)', 'Point 2 (x,y)', ]
[docs] def flags(self, index: QModelIndex | QPersistentModelIndex, /) -> Qt.ItemFlag: """ Return item flags for a segment cell. :param index: Index of the item :type index: QModelIndex :return: Item flags :rtype: Qt.ItemFlag """ if not index.isValid(): return Qt.ItemFlag.NoItemFlags if len(self._store) == 0: return Qt.ItemFlag.NoItemFlags segment = self._store.get(index.row()) if segment 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 in (3, 4): if segment.show_overlay: return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsSelectable return Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsSelectable return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
[docs] def set_data_for( self, item: SegmentMeasurementData, column: int, value: Any, role: int = Qt.ItemDataRole.DisplayRole, ) -> bool: """ Set data for a specific segment cell. :param item: Segment data item :type item: SegmentMeasurementData :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 :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 self._store.update_by_id(item.id, show_overlay=show_overlay) 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 if column == 4 and role == Qt.ItemDataRole.CheckStateRole: self._store.update_by_id(item.id, show_measure=Qt.CheckState(value) == Qt.CheckState.Checked) return True return False
[docs] def data_for(self, item: SegmentMeasurementData, column: int, role: int = Qt.ItemDataRole.DisplayRole) -> Any: """ Return data for a segment cell. :param item: Segment data item :type item: SegmentMeasurementData :param column: Column index :type column: int :param role: Item data role :type role: Qt.ItemDataRole :return: Cell data :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 if column == 3: return Qt.CheckState.Checked if item.show_label else Qt.CheckState.Unchecked if column == 4: return Qt.CheckState.Checked if item.show_measure else Qt.CheckState.Unchecked return None if role == Qt.ItemDataRole.DisplayRole: if column == 0: return item.name if column == 1: return item.measurement_display() if column == 2: return 'Visible' if item.show_overlay else 'Hidden' if column == 3: return 'Visible' if item.show_label else 'Hidden' if column == 4: return 'Visible' if item.show_measure else 'Hidden' if column == 5: return f'RGB({item.color.r:.2f},{item.color.g:.2f},{item.color.b:.2f})' if column == 6: return f'({item.p1[0]:.2f}, {item.p1[1]:.2f})' if item.p1 else 'n/a' if column == 7: return f'({item.p2[0]:.2f}, {item.p2[1]:.2f})' if item.p2 else 'n/a' return None if role == Qt.ItemDataRole.DecorationRole: if column == 5: return to_qcolor(item.color) return None return None
[docs] class AngleTableModel(ItemModel[AngleMeasurementData]): """ Table model for displaying angle measurements. """ def __init__(self, store: AngleStore, parent: Optional[QObject] = None) -> None: """ Initialize the angle table model. :param store: Store containing angle data :type store: AngleStore :param parent: Parent object :type parent: QObject, optional """ super().__init__(store, parent) self.headers = [ 'Name', 'Angle (deg)', 'Show overlay', 'Show label', 'Show measure', 'Color', 'Point 1 (x,y)', 'Vertex (x,y)', 'Point 3 (x,y)', ]
[docs] def flags(self, index: QModelIndex | QPersistentModelIndex, /) -> Qt.ItemFlag: """ Return item flags for an angle cell. :param index: Index of the item :type index: QModelIndex :return: Item flags :rtype: Qt.ItemFlag """ if not index.isValid(): return Qt.ItemFlag.NoItemFlags if len(self._store) == 0: return Qt.ItemFlag.NoItemFlags angle = self._store.get(index.row()) if angle 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 in (3, 4): if angle.show_overlay: return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsSelectable return Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsSelectable return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
[docs] def set_data_for( self, item: AngleMeasurementData, column: int, value: Any, role: int = Qt.ItemDataRole.DisplayRole, ) -> bool: """ Set data for a specific angle cell. :param item: Angle data item :type item: AngleMeasurementData :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 :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 self._store.update_by_id(item.id, show_overlay=show_overlay) 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 if column == 4 and role == Qt.ItemDataRole.CheckStateRole: self._store.update_by_id(item.id, show_measure=Qt.CheckState(value) == Qt.CheckState.Checked) return True return False
[docs] def data_for(self, item: AngleMeasurementData, column: int, role: int = Qt.ItemDataRole.DisplayRole) -> Any: """ Return data for an angle cell. :param item: Angle data item :type item: AngleMeasurementData :param column: Column index :type column: int :param role: Item data role :type role: Qt.ItemDataRole :return: Cell data :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 if column == 3: return Qt.CheckState.Checked if item.show_label else Qt.CheckState.Unchecked if column == 4: return Qt.CheckState.Checked if item.show_measure else Qt.CheckState.Unchecked return None if role == Qt.ItemDataRole.DisplayRole: if column == 0: return item.name if column == 1: return f'{item.angle_deg:.2f}' if column == 2: return 'Visible' if item.show_overlay else 'Hidden' if column == 3: return 'Visible' if item.show_label else 'Hidden' if column == 4: return 'Visible' if item.show_measure else 'Hidden' if column == 5: return f'RGB({item.color.r:.2f},{item.color.g:.2f},{item.color.b:.2f})' if column == 6: return f'({item.p1[0]:.2f}, {item.p1[1]:.2f})' if item.p1 else 'n/a' if column == 7: return f'({item.p2[0]:.2f}, {item.p2[1]:.2f})' if item.p2 else 'n/a' if column == 8: return f'({item.p3[0]:.2f}, {item.p3[1]:.2f})' if item.p3 else 'n/a' return None if role == Qt.ItemDataRole.DecorationRole: if column == 5: return to_qcolor(item.color) return None return None
[docs] class MeasurementFilterProxyModel(QSortFilterProxyModel): """ Proxy model for filtering measurements by active image. """ def __init__(self, parent: Optional[QObject] = None) -> None: """ Initialize the proxy model. :param parent: Parent object :type parent: QObject, optional """ super().__init__(parent) self._active_image_id: Optional[UUID] = None self._filter_enabled = False
[docs] def set_active_image(self, image_controller: Optional[ImageWindowController]) -> None: """ Set the active image controller for filtering. :param image_controller: Active image controller :type image_controller: Optional[ImageWindowController] """ self._active_image_id = image_controller.id if image_controller else None self.invalidateFilter()
[docs] def set_filter_enabled(self, enabled: bool) -> None: """ Enable or disable filtering. :param enabled: Whether filtering is enabled :type enabled: bool """ self._filter_enabled = bool(enabled) self.invalidateFilter()
[docs] def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex | QPersistentModelIndex) -> bool: """ Decide whether a row should be accepted. :param source_row: Row in the source model :type source_row: int :param source_parent: Source parent index :type source_parent: QModelIndex :return: True if accepted :rtype: bool """ if not self._filter_enabled: return True if self._active_image_id is None: return False source_model = cast(ItemModel[Any], assume_not_none(self.sourceModel())) item = source_model._store.get(source_row) if item is None or item.image_controller is None or self._active_image_id is None: return False return item.image_controller.id is self._active_image_id
[docs] class MeasurementSelectionDialog(QDialog): """ Dialog for selecting measurement parameters. """ measurement_type_changed = Signal(object) """Signal emitted when measurement type is changed.""" def __init__(self, measurement_type: MeasurementType, parent: Optional[QWidget] = None) -> None: """ Initialize the measurement selection dialog. :param measurement_type: Initial measurement type :type measurement_type: MeasurementType :param parent: Parent widget :type parent: QWidget, optional """ super().__init__(parent) self.setModal(False) self.measurement_type = measurement_type self.setWindowTitle('Select measurement...') layout = QVBoxLayout() self.status_label = QLabel('') self.status_label.setWordWrap(True) layout.addWidget(self.status_label) param_layout = QGridLayout() toolbar_label = QLabel('Measurement type:') param_layout.addWidget(toolbar_label, 0, 0) toolbar = QToolBar(self) group = QActionGroup(toolbar) group.setExclusive(True) for type_ in MeasurementType: action = QAction(type_.value, toolbar) action.setCheckable(True) action.setChecked(self.measurement_type == type_) if type_ == MeasurementType.SEGMENT: action.setIcon(QIconifyIcon('tabler:line')) else: action.setIcon(QIconifyIcon('tabler:angle')) action.triggered.connect(partial(self._on_measurement_changed, type_)) group.addAction(action) toolbar.addAction(action) param_layout.addWidget(toolbar, 0, 1) layout.addLayout(param_layout) 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) self._update_instructions()
[docs] def _update_instructions(self) -> None: """ Update instruction text based on measurement type. """ if self.measurement_type == MeasurementType.SEGMENT: text = ( 'Click and drag to define a measurement segment.\n' 'After the initial selection you can still edit the segment ' 'by grabbing one of the handles.\n' 'Hold CTRL to snap to 90° angles.' ) else: text = ( 'Click three points to define an angle (the second point is the vertex).\n' 'After the initial selection you can still edit the vertices by ' 'dragging the handles.\n' 'Hold CTRL to snap to 90° angles.' ) self.status_label.setText(text) self.adjustSize()
[docs] def _on_measurement_changed(self, measurement_type: MeasurementType) -> None: """ Handle measurement type changes. :param measurement_type: New measurement type :type measurement_type: MeasurementType """ self.measurement_type = measurement_type self._update_instructions() self.measurement_type_changed.emit(measurement_type)
[docs] class MeasurementToolDock(ToolDockWidget['MeasurementToolController']): """ Dock widget for the measurement tool. """ initial_visibility = True enable_for_window_type = SubWindowEnum.ALL dock_position = Qt.DockWidgetArea.BottomDockWidgetArea segment_selection_changed = Signal(object, object) angle_selection_changed = Signal(object, object) controller: MeasurementToolController def __init__(self, parent: QWidget, controller: 'MeasurementToolController') -> None: """ Initialize the measurement tool dock. :param parent: Parent widget :type parent: QWidget :param controller: Measurement tool controller :type controller: MeasurementToolController """ super().__init__('Measurement tool', parent, controller) if not isinstance(self.controller, MeasurementToolController): raise TypeError('The controller must be an instance of MeasurementToolController') self._active_image: Optional[ImageWindowController] = None self._can_remove_segment = False self._can_remove_angle = False self._segment_filter_enabled = False self._angle_filter_enabled = False self._segment_count = 0 self._angle_count = 0 self.segment_proxy = MeasurementFilterProxyModel(self) self.segment_proxy.setSourceModel(self.controller.segment_table_model) self.angle_proxy = MeasurementFilterProxyModel(self) self.angle_proxy.setSourceModel(self.controller.angle_table_model) self.segment_table = QTableView() self.segment_table.setModel(self.segment_proxy) self.segment_table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) self.segment_table.selectionModel().selectionChanged.connect(self.on_segment_selected) self.angle_table = QTableView() self.angle_table.setModel(self.angle_proxy) self.angle_table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) self.angle_table.selectionModel().selectionChanged.connect(self.on_angle_selected) self.add_button = QToolButton(self) self.add_button.setText('Add Measurement') self.add_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) self.add_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) self.add_button.setEnabled(False) menu = QMenu(self) self._actions: dict[MeasurementType, QAction] = {} for type_ in MeasurementType: action = QAction(f'Add {type_.value}') action.setData(type_) if type_ == MeasurementType.SEGMENT: action.setIcon(QIconifyIcon('tabler:line')) else: action.setIcon(QIconifyIcon('tabler:angle')) action.triggered.connect(self._on_add_action_triggered) menu.addAction(action) self._actions[type_] = action self.add_button.setMenu(menu) self._set_default_action(MeasurementType.SEGMENT) self._delete_button = QPushButton('Delete Segment') self._delete_button.clicked.connect(self._on_delete_clicked) self._delete_button.setEnabled(False) self._export_button = QPushButton('Save Segments CSV') self._export_button.clicked.connect(self._on_export_clicked) self._export_button.setEnabled(False) self._filter_checkbox = QCheckBox('Only active image') self._filter_checkbox.toggled.connect(self._on_filter_toggled) add_layout = QVBoxLayout() add_layout.addWidget(self.add_button) add_layout.addWidget(self._delete_button) add_layout.addWidget(self._export_button) add_layout.addWidget(self._filter_checkbox) add_layout.addStretch() self._tabs = QTabWidget() self._tabs.addTab(self.segment_table, 'Segments') self._tabs.addTab(self.angle_table, 'Angles') self._tabs.currentChanged.connect(self._on_tab_changed) self.main_widget = QWidget() self.main_layout = QHBoxLayout(self.main_widget) self.main_layout.addLayout(add_layout) self.main_layout.addWidget(self._tabs) self.setWidget(self.main_widget) self.selection_dialog: Optional[MeasurementSelectionDialog] = None self.controller.segment_count_changed.connect(self._on_segment_count_changed) self.controller.angle_count_changed.connect(self._on_angle_count_changed) self._on_tab_changed(self._tabs.currentIndex())
[docs] def _set_default_action(self, measurement_type: MeasurementType) -> None: """ Set the default action for the add button. :param measurement_type: Measurement type to set as default :type measurement_type: MeasurementType """ self.add_button.setDefaultAction(self._actions[measurement_type])
[docs] def _on_add_action_triggered(self) -> None: """ Handle add action triggers. """ assume_not_none(self.controller) action = cast(QAction, self.sender()) measurement_type = MeasurementType(action.data()) self.add_button.setDefaultAction(action) if measurement_type == MeasurementType.SEGMENT: self._tabs.setCurrentIndex(0) self.controller.start_add_segment() else: self._tabs.setCurrentIndex(1) self.controller.start_add_angle()
[docs] def _on_export_clicked(self) -> None: """ Export current tab data to CSV. """ if self._tabs.currentIndex() == 0: path, _ = QFileDialog.getSaveFileName( self, 'Save segments to CSV', '', 'CSV (*.csv)', ) if not path: return self.controller.export_segments_csv(pathlib.Path(path)) else: path, _ = QFileDialog.getSaveFileName( self, 'Save angles to CSV', '', 'CSV (*.csv)', ) if not path: return self.controller.export_angles_csv(pathlib.Path(path))
[docs] def _on_filter_toggled(self, enabled: bool) -> None: """ Toggle filtering for the current tab. """ if self._tabs.currentIndex() == 0: self._segment_filter_enabled = bool(enabled) self.segment_proxy.set_filter_enabled(enabled) else: self._angle_filter_enabled = bool(enabled) self.angle_proxy.set_filter_enabled(enabled)
[docs] def _on_delete_clicked(self) -> None: """ Delete the currently selected measurement from the active tab. """ if self._tabs.currentIndex() == 0: self.controller.on_delete_segment_clicked() else: self.controller.on_delete_angle_clicked()
[docs] def _on_tab_changed(self, index: int) -> None: """ Update labels and states when switching tabs. """ if index == 0: self._delete_button.setText('Delete Segment') self._export_button.setText('Save Segments CSV') self._filter_checkbox.blockSignals(True) self._filter_checkbox.setChecked(self._segment_filter_enabled) self._filter_checkbox.blockSignals(False) self._update_delete_enabled() self._update_export_enabled() else: self._delete_button.setText('Delete Angle') self._export_button.setText('Save Angles CSV') self._filter_checkbox.blockSignals(True) self._filter_checkbox.setChecked(self._angle_filter_enabled) self._filter_checkbox.blockSignals(False) self._update_delete_enabled() self._update_export_enabled()
[docs] def _update_delete_enabled(self) -> None: """ Update delete button enabled state for the active tab. """ if self._tabs.currentIndex() == 0: self._delete_button.setEnabled(self._can_remove_segment) else: self._delete_button.setEnabled(self._can_remove_angle)
[docs] def _update_export_enabled(self) -> None: """ Update export button enabled state for the active tab. """ if self._tabs.currentIndex() == 0: self._export_button.setEnabled(self._segment_count > 0) else: self._export_button.setEnabled(self._angle_count > 0)
[docs] def _on_segment_count_changed(self, count: int) -> None: """ Handle segment count changes. """ self._segment_count = count self._update_export_enabled()
[docs] def _on_angle_count_changed(self, count: int) -> None: """ Handle angle count changes. """ self._angle_count = count self._update_export_enabled()
[docs] def on_segment_selected(self, selected: QItemSelection, deselected: QItemSelection) -> None: """ Handle segment selection changes. """ 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.segment_store.get_by_id(item_id) break selected_item = None indexes = self.segment_table.selectionModel().selectedRows() if indexes: item_id = indexes[0].data(Qt.ItemDataRole.UserRole) selected_item = self.controller.segment_store.get_by_id(item_id) delete_enable = True else: delete_enable = False self.set_can_remove_segment(delete_enable) self.segment_selection_changed.emit(selected_item, deselected_item)
[docs] def on_angle_selected(self, selected: QItemSelection, deselected: QItemSelection) -> None: """ Handle angle selection changes. """ 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.angle_store.get_by_id(item_id) break selected_item = None indexes = self.angle_table.selectionModel().selectedRows() if indexes: item_id = indexes[0].data(Qt.ItemDataRole.UserRole) selected_item = self.controller.angle_store.get_by_id(item_id) delete_enable = True else: delete_enable = False self.set_can_remove_angle(delete_enable) self.angle_selection_changed.emit(selected_item, deselected_item)
[docs] def set_can_remove_segment(self, enable: bool) -> None: """ Set whether segment deletion is enabled. """ self._can_remove_segment = enable if self._tabs.currentIndex() == 0: self._delete_button.setEnabled(enable) self.controller.can_remove_segment = enable
[docs] def set_can_remove_angle(self, enable: bool) -> None: """ Set whether angle deletion is enabled. """ self._can_remove_angle = enable if self._tabs.currentIndex() == 1: self._delete_button.setEnabled(enable) self.controller.can_remove_angle = enable
[docs] def handle_event(self, event: ToolEvent) -> None: """ Handle tool events. """ 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: self.selection_dialog.close() self.selection_dialog = None elif event.event == 'selector_created': if self.selection_dialog: self.selection_dialog.done_button.setEnabled(True) elif event.event == 'selector_canceled': if self.selection_dialog: self.selection_dialog.done_button.setEnabled(False) elif event.event == 'selector_accepted': if self.selection_dialog: self.selection_dialog.accept()
[docs] def create_selection_dialog(self) -> None: """ Create and show the selection dialog. """ if self.selection_dialog is not None: self.selection_dialog = None self.selection_dialog = MeasurementSelectionDialog(self.controller.measurement_requested_type, parent=self) self.selection_dialog.rejected.connect(self.controller.on_cancelled_measurement_definition) self.selection_dialog.accepted.connect(self.controller.on_confirmed_measurement_definition) self.selection_dialog.measurement_type_changed.connect(self.controller.on_measurement_type_change) self.selection_dialog.show()
[docs] def on_window_controller_changed(self, window_controller: Optional[SubWindowController[Any]]) -> None: """ Handle window controller changes. :param window_controller: Active window controller :type window_controller: Optional[SubWindowController[Any]] """ is_image_controller = isinstance(window_controller, ImageWindowController) self.add_button.setEnabled(is_image_controller) self._active_image = cast(Optional[ImageWindowController], window_controller) if is_image_controller else None self.segment_proxy.set_active_image(self._active_image) self.angle_proxy.set_active_image(self._active_image)
[docs] def on_request_to_change_segment_selection( self, index: QModelIndex, flags: QItemSelectionModel.SelectionFlag, ) -> None: """ Update segment selection from controller. """ proxy_index = self.segment_proxy.mapFromSource(index) if not proxy_index.isValid(): return self.segment_table.selectionModel().select(proxy_index, flags) self.segment_table.scrollTo(proxy_index)
[docs] def on_request_to_change_angle_selection( self, index: QModelIndex, flags: QItemSelectionModel.SelectionFlag, ) -> None: """ Update angle selection from controller. """ proxy_index = self.angle_proxy.mapFromSource(index) if not proxy_index.isValid(): return self.angle_table.selectionModel().select(proxy_index, flags) self.angle_table.scrollTo(proxy_index)
[docs] class MeasurementToolSession(BaseToolSession['MeasurementToolController', 'ImageWindowController']): """ Session for measurement creation. """ def __init__( self, tool_controller: 'MeasurementToolController', window_controller: ImageWindowController, measurement_type: MeasurementType, input_data: Optional[SegmentMeasurementData | AngleMeasurementData] = None, ) -> None: """ Initialize the measurement tool session. :param tool_controller: Measurement tool controller :type tool_controller: MeasurementToolController :param window_controller: Image window controller :type window_controller: ImageWindowController :param measurement_type: Measurement type :type measurement_type: MeasurementType :param input_data: Input data for workspace restore :type input_data: Optional[SegmentMeasurementData | AngleMeasurementData] """ if not isinstance(tool_controller, MeasurementToolController): raise TypeError('The tool controller must be an instance of MeasurementToolController') 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.measurement_type = measurement_type self.segment_selector: Optional[LineSelector] = None self.angle_selector: Optional[AngleSelector] = None self.current_segment: Optional[SegmentMeasurementData] = None self.current_angle: Optional[AngleMeasurementData] = None
[docs] def on_start(self) -> None: """ Start the measurement session. """ if self._input_data is None: self._on_interactive_definition() else: self._define_initial_condition() self._finalize_from_restore()
[docs] def _on_interactive_definition(self) -> None: """ Initialize interactive measurement definition. """ self.tool_controller.emit_event('create_selection_dialog') self._setup_interactive(self.measurement_type)
[docs] def _setup_interactive(self, measurement_type: MeasurementType) -> None: """ Initialize selector and data for the requested measurement type. :param measurement_type: Measurement type to initialize :type measurement_type: MeasurementType """ view = self.window_controller.require_view() if measurement_type == MeasurementType.SEGMENT: self.current_segment = SegmentMeasurementData( name=f'segment-{self.tool_controller.segment_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(), ) self.current_segment.image_controller = self.window_controller self.segment_selector = LineSelector( view.canvas.im_axes, arrowstyle='-', snap_deg=90, ) self.segment_selector.selector_created.connect(self.on_selector_created) self.segment_selector.selector_accepted.connect(self.on_selector_accepted) self.segment_selector.selector_canceled.connect(self.on_selector_canceled) self.angle_selector = None self.current_angle = None else: self.current_angle = AngleMeasurementData( name=f'angle-{self.tool_controller.angle_counter.next()}', color=self.tool_controller.context.get_color_service().next_color(), session_id=self.session_id, ) self.current_angle.image_controller = self.window_controller self.angle_selector = AngleSelector(view.canvas.im_axes) self.angle_selector.selector_created.connect(self.on_selector_created) self.angle_selector.selector_accepted.connect(self.on_selector_accepted) self.angle_selector.selector_canceled.connect(self.on_selector_canceled) self.segment_selector = None self.current_segment = None
[docs] def _define_initial_condition(self) -> None: """ Define parameters from pre-existing data. """ if isinstance(self._input_data, SegmentMeasurementData): self.measurement_type = MeasurementType.SEGMENT self.current_segment = self._input_data if self.current_segment.pixel_size_m is None: self.current_segment.pixel_size_m = self.window_controller.pixel_size_m() self.current_segment.calculate_properties() # we might have gotten a new calibration value (very # unlikely) elif isinstance(self._input_data, AngleMeasurementData): self.measurement_type = MeasurementType.ANGLE self.current_angle = self._input_data
[docs] def _finalize_from_restore(self) -> None: """ Finalize the session for restored measurements. """ if self.measurement_type == MeasurementType.SEGMENT and self.current_segment is not None: self.window_controller.overlay_manager.add('im_axes', self._segment_overlay_from_data()) self.finish() elif self.measurement_type == MeasurementType.ANGLE and self.current_angle is not None: self.window_controller.overlay_manager.add('im_axes', self._angle_overlay_from_data()) self.finish()
[docs] def on_selector_created(self) -> None: """ Handle selector creation. """ self.tool_controller.emit_event('selector_created')
[docs] def on_selector_accepted(self) -> None: """ Handle selector acceptance. """ self.tool_controller.emit_event('selector_accepted')
[docs] def on_selector_canceled(self) -> None: """ Handle selector cancellation. """ 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.measurement_type == MeasurementType.SEGMENT: self.assign_payload(self.current_segment) else: self.assign_payload(self.current_angle) self.cleanup()
[docs] def cleanup(self) -> None: """ Clean up selection resources. """ self._teardown_selectors() self.tool_controller.emit_event('destroy_selection_dialog')
[docs] def _teardown_selectors(self) -> None: """ Tear down selectors without closing the dialog. """ if self.segment_selector: self.segment_selector.clear() self.segment_selector.disconnect_signals() self.segment_selector = None if self.angle_selector: self.angle_selector.clear() self.angle_selector.disconnect_signals() self.angle_selector = None
[docs] def switch_measurement_type(self, new_type: MeasurementType) -> None: """ Switch the active measurement type during an interactive session. :param new_type: New measurement type :type new_type: MeasurementType """ self._teardown_selectors() self.measurement_type = new_type self._setup_interactive(new_type) self.tool_controller.emit_event('selector_canceled')
[docs] def finalize_measurement(self) -> None: """ Finalize measurement creation after dialog confirmation. """ if self.measurement_type == MeasurementType.SEGMENT and self.segment_selector: geom_s = self.segment_selector.geometry if geom_s is None: return current_segment = assume_not_none(self.current_segment) if current_segment.image_controller is None: current_segment.image_controller = self.window_controller current_segment.p1 = geom_s.start current_segment.p2 = geom_s.end current_segment.calculate_properties() self.window_controller.overlay_manager.add('im_axes', self._segment_overlay_from_data()) self.finish() return if self.measurement_type == MeasurementType.ANGLE and self.angle_selector: geom_a = self.angle_selector.geometry if geom_a is None: return current_angle = assume_not_none(self.current_angle) if current_angle.image_controller is None: current_angle.image_controller = self.window_controller current_angle.p1 = geom_a.p1 current_angle.p2 = geom_a.p2 current_angle.p3 = geom_a.p3 current_angle.calculate_properties() self.window_controller.overlay_manager.add('im_axes', self._angle_overlay_from_data()) self.finish()
[docs] def _segment_overlay_from_data(self) -> OverlayModel: """ Build segment overlay from current segment data. :return: Overlay model :rtype: OverlayModel """ segment = assume_not_none(self.current_segment) overlay = OverlayModel(id=segment.id, role=OverlayRole.Permanent, label=segment.name) overlay.type = 'measure_segment' overlay.style = { 'color': to_mpl(segment.color), 'lw': 2.0, } overlay.extra_props = { 'p1': segment.p1, 'p2': segment.p2, 'label_text': segment.name, 'measure_text': segment.measurement_display(), 'show_label': segment.show_label, 'show_measure': segment.show_measure, } overlay.visible = segment.show_overlay return overlay
[docs] def _angle_overlay_from_data(self) -> OverlayModel: """ Build angle overlay from current angle data. :return: Overlay model :rtype: OverlayModel """ angle = assume_not_none(self.current_angle) overlay = OverlayModel(id=angle.id, role=OverlayRole.Permanent, label=angle.name) overlay.type = 'measure_angle' overlay.style = { 'color': to_mpl(angle.color), 'lw': 2.0, } overlay.extra_props = { 'p1': angle.p1, 'p2': angle.p2, 'p3': angle.p3, 'label_text': angle.name, 'measure_text': f'{angle.angle_deg:.2f} deg', 'angle_deg': angle.angle_deg, 'show_label': angle.show_label, 'show_measure': angle.show_measure, } overlay.visible = angle.show_overlay return overlay
[docs] class MeasurementToolController(ToolController[ImageWindowController, MeasurementToolSession]): """ Controller for the measurement tool. """ add_enabled_changed = Signal(bool) remove_segment_enabled_changed = Signal(bool) remove_angle_enabled_changed = Signal(bool) ack_session_completed = Signal(UUID) segment_count_changed = Signal(int) angle_count_changed = Signal(int) request_to_change_segment_selection = Signal(object, object) request_to_change_angle_selection = Signal(object, object) restore_completed = Signal(object) can_add = ActionDescriptor() can_remove_segment = ActionDescriptor(action_name='remove_segment', signal_name='remove_segment_enabled_changed') can_remove_angle = ActionDescriptor(action_name='remove_angle', signal_name='remove_angle_enabled_changed') def __init__(self, tool_ctx: ToolContext, tool: Tool[MeasurementToolController]) -> None: """ Initialize the measurement tool controller. :param tool_ctx: Tool context :type tool_ctx: ToolContext :param tool: Tool instance :type tool: Tool """ super().__init__(tool_ctx, tool) self.segment_store = SegmentStore() self.angle_store = AngleStore() self.segment_store.add_listener(self.update_segment_fields) self.angle_store.add_listener(self.update_angle_fields) self.segment_table_model = SegmentTableModel(self.segment_store, self) self.angle_table_model = AngleTableModel(self.angle_store, self) self.segment_counter = ItemCounter(0) self.angle_counter = ItemCounter(0) self.active_session: Optional[MeasurementToolSession] = None self.measurement_requested_type: MeasurementType = MeasurementType.SEGMENT self.current_selected_segment: Optional[SegmentMeasurementData] = None self.current_selected_angle: Optional[AngleMeasurementData] = None
[docs] def create_session( self, window_controller: ImageWindowController, measurement_type: Optional[MeasurementType] = None, input_data: Optional[SegmentMeasurementData | AngleMeasurementData] = None, ) -> MeasurementToolSession: """ Create a new measurement tool session. :param window_controller: Image window controller :type window_controller: ImageWindowController :param measurement_type: Measurement type override :type measurement_type: Optional[MeasurementType] :param input_data: Input data for workspace restore :type input_data: Optional[SegmentMeasurementData | AngleMeasurementData] :return: New tool session :rtype: MeasurementToolSession """ mtype = measurement_type or self.measurement_requested_type session = MeasurementToolSession(self, window_controller, mtype, input_data) session.finished.connect(self.deactivate) session.returned.connect(self.finalize_measurement) session.canceled.connect(self.deactivate) return session
[docs] def create_dock(self, parent_window: QWidget) -> MeasurementToolDock: """ Create and configure the measurement tool dock. :param parent_window: Parent window for the dock widget :type parent_window: QWidget :return: Configured dock widget :rtype: MeasurementToolDock """ dock = MeasurementToolDock(parent_window, self) dock.event_emitted.connect(self.on_dock_event) dock.segment_selection_changed.connect(self.on_segment_selection_changed) dock.angle_selection_changed.connect(self.on_angle_selection_changed) self.request_to_change_segment_selection.connect(dock.on_request_to_change_segment_selection) self.request_to_change_angle_selection.connect(dock.on_request_to_change_angle_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 active image changes. :param new_window_controller: New active window controller :type new_window_controller: SubWindowController[Any] """ super()._on_active_image_changed(new_window_controller) is_image_controller = isinstance(new_window_controller, ImageWindowController) self.can_add = is_image_controller self.emit_event('window_controller_changed', new_window_controller)
[docs] def start_add_segment(self) -> None: """ Start adding a new segment measurement. """ self.measurement_requested_type = MeasurementType.SEGMENT self.activate()
[docs] def start_add_angle(self) -> None: """ Start adding a new angle measurement. """ self.measurement_requested_type = MeasurementType.ANGLE self.activate()
[docs] def on_measurement_type_change(self, new_type: MeasurementType) -> None: """ Handle measurement type changes during selection. :param new_type: New measurement type :type new_type: MeasurementType """ if self.active_session is None or self.measurement_requested_type == new_type: return self.measurement_requested_type = new_type self.active_session.switch_measurement_type(new_type)
[docs] def on_cancelled_measurement_definition(self) -> None: """ Handle cancellation of measurement definition. """ if self.active_session: self.active_session.cancel()
[docs] def on_confirmed_measurement_definition(self) -> None: """ Handle confirmation of measurement definition. """ if self.active_session: self.active_session.finalize_measurement()
[docs] def finalize_measurement(self, session_result: ToolSessionResult) -> None: """ Finalize a measurement creation. :param session_result: Tool session result :type session_result: ToolSessionResult """ payload = session_result.payload if isinstance(payload, SegmentMeasurementData): segment = payload image_controller = assume_not_none(segment.image_controller) image_controller.about_to_close.connect(partial(self._on_image_about_to_close, image_controller)) self.segment_store.add(segment) elif isinstance(payload, AngleMeasurementData): angle = payload image_controller = assume_not_none(angle.image_controller) image_controller.about_to_close.connect(partial(self._on_image_about_to_close, image_controller)) self.angle_store.add(angle) self.ack_session_completed.emit(session_result.session_id)
[docs] def on_segment_selection_changed( self, selected: Optional[SegmentMeasurementData], deselected: Optional[SegmentMeasurementData] ) -> None: """ Handle changes to segment selection. :param selected: Newly selected segment :type selected: Optional[SegmentMeasurementData] :param deselected: Deselected segment :type deselected: Optional[SegmentMeasurementData] """ self._handle_selection_changed(selected, deselected) self.current_selected_segment = selected
[docs] def on_angle_selection_changed( self, selected: Optional[AngleMeasurementData], deselected: Optional[AngleMeasurementData] ) -> None: """ Handle changes to angle selection. :param selected: Newly selected angle :type selected: Optional[AngleMeasurementData] :param deselected: Deselected angle :type deselected: Optional[AngleMeasurementData] """ self._handle_selection_changed(selected, deselected) self.current_selected_angle = selected
[docs] def _handle_selection_changed( self, selected: Optional[MeasurementDataProtocol], deselected: Optional[MeasurementDataProtocol], ) -> None: """ Handle selection changes for measurement data. :param selected: Newly selected measurement :type selected: Optional[MeasurementDataProtocol] :param deselected: Deselected measurement :type deselected: Optional[MeasurementDataProtocol] """ if deselected is not None: image_controller = assume_not_none(deselected.image_controller) image_controller.overlay_manager.remove_highlight(deselected.id) if selected is not None: image_controller = assume_not_none(selected.image_controller) self.context.activate_image(image_controller) if selected.show_overlay: image_controller.overlay_manager.add_highlight(selected.id)
[docs] def on_delete_segment_clicked(self) -> None: """ Handle delete segment button click. """ self.remove_segment(self.current_selected_segment)
[docs] def on_delete_angle_clicked(self) -> None: """ Handle delete angle button click. """ self.remove_angle(self.current_selected_angle)
[docs] def remove_segment(self, item: Optional[SegmentMeasurementData]) -> None: """ Remove a segment measurement. :param item: Segment measurement to remove :type item: Optional[SegmentMeasurementData] """ self._remove_measurement(item, self.segment_store)
[docs] def remove_angle(self, item: Optional[AngleMeasurementData]) -> None: """ Remove an angle measurement. :param item: Angle measurement to remove :type item: Optional[AngleMeasurementData] """ self._remove_measurement(item, self.angle_store)
[docs] def _remove_measurement( self, item: Optional[MeasurementDataType], store: ItemStore[MeasurementDataType], ) -> None: """ Remove a measurement from the store. :param item: Measurement to remove :type item: Optional[MeasurementDataProtocol] :param store: Store to remove from :type store: SegmentStore | AngleStore """ if item is None or item._being_removed: return item._being_removed = True image_controller = assume_not_none(item.image_controller) image_controller.overlay_manager.remove_id(item.id) image_controller.overlay_manager.remove_id(item.id, OverlayRole.Highlight) store.remove_item(item)
[docs] def _on_image_about_to_close(self, image_controller: ImageWindowController) -> None: """ Remove measurements associated with the closing image. :param image_controller: Image controller that is closing :type image_controller: ImageWindowController """ segs = [s for s in self.segment_store if s.image_controller is image_controller] angs = [a for a in self.angle_store if a.image_controller is image_controller] for item_s in segs: self.remove_segment(item_s) for item_a in angs: self.remove_angle(item_a)
[docs] def update_segment_fields( self, event: StoreEvent, data: Optional[SegmentMeasurementData] = None, index: int = 0 ) -> None: """ Sync segment overlays after store updates. """ if event == StoreEvent.UPDATED and data is not None: self._sync_segment(data) if event in (StoreEvent.ADDED, StoreEvent.REMOVED, StoreEvent.CLEARED): self.segment_count_changed.emit(len(self.segment_store))
[docs] def update_angle_fields( self, event: StoreEvent, data: Optional[AngleMeasurementData] = None, index: int = 0 ) -> None: """ Sync angle overlays after store updates. """ if event == StoreEvent.UPDATED and data is not None: self._sync_angle(data) if event in (StoreEvent.ADDED, StoreEvent.REMOVED, StoreEvent.CLEARED): self.angle_count_changed.emit(len(self.angle_store))
[docs] def _sync_segment(self, segment: SegmentMeasurementData) -> None: """ Synchronize segment overlays and metadata. :param segment: Segment measurement :type segment: SegmentMeasurementData """ image_controller = assume_not_none(segment.image_controller) overlay = image_controller.overlay_manager.get_overlay_by_id(segment.id, OverlayRole.Permanent) if overlay is None: return overlay.label = segment.name overlay.extra_props['label_text'] = segment.name overlay.extra_props['measure_text'] = segment.measurement_display() overlay.extra_props['show_label'] = segment.show_label overlay.extra_props['show_measure'] = segment.show_measure image_controller.overlay_manager.update(overlay) image_controller.overlay_manager.set_visibility(segment.id, OverlayRole.Permanent, segment.show_overlay) image_controller.overlay_manager.set_visibility(segment.id, OverlayRole.Highlight, segment.show_overlay) if self.current_selected_segment and self.current_selected_segment.id == segment.id: if segment.show_overlay: image_controller.overlay_manager.add_highlight(segment.id)
[docs] def _sync_angle(self, angle: AngleMeasurementData) -> None: """ Synchronize angle overlays and metadata. :param angle: Angle measurement :type angle: AngleMeasurementData """ image_controller = assume_not_none(angle.image_controller) overlay = image_controller.overlay_manager.get_overlay_by_id(angle.id, OverlayRole.Permanent) if overlay is None: return overlay.label = angle.name overlay.extra_props['label_text'] = angle.name overlay.extra_props['measure_text'] = f'{angle.angle_deg:.2f} deg' overlay.extra_props['angle_deg'] = angle.angle_deg overlay.extra_props['show_label'] = angle.show_label overlay.extra_props['show_measure'] = angle.show_measure image_controller.overlay_manager.update(overlay) image_controller.overlay_manager.set_visibility(angle.id, OverlayRole.Permanent, angle.show_overlay) image_controller.overlay_manager.set_visibility(angle.id, OverlayRole.Highlight, angle.show_overlay) if self.current_selected_angle and self.current_selected_angle.id == angle.id: if angle.show_overlay: image_controller.overlay_manager.add_highlight(angle.id)
[docs] def export_segments_csv(self, path: pathlib.Path) -> None: """ Export all segment measurements to CSV. :param path: Output file path :type path: pathlib.Path """ rows = [] for item in self.segment_store: image_name = item.image_controller.name if item.image_controller else 'n/a' unit = item.unit_name or '' length_unit_value = '' if item.length_m is not None and item.length_m > 0: unit_obj = select_distance_unit(item.length_m) length_unit_value = f'{item.length_m / unit_obj.meters_per_unit:.4g}' p1 = assume_not_none(item.p1) p2 = assume_not_none(item.p2) rows.append( [ image_name, item.name, item.color.r, item.color.g, item.color.b, p1[0], p1[1], p2[0], p2[1], item.length_px, item.length_m if item.length_m is not None else '', unit, length_unit_value, item.show_overlay, item.show_label, item.show_measure, ] ) header = ( 'image_name,name,color_r,color_g,color_b,' 'p1_x,p1_y,p2_x,p2_y,' 'length_px,length_m,unit,length_unit_value,' 'show_overlay,show_label,show_measure' ) with path.open('w', newline='') as f: f.write(header + '\n') for row in rows: f.write(','.join([str(v) for v in row]) + '\n')
[docs] def export_angles_csv(self, path: pathlib.Path) -> None: """ Export all angle measurements to CSV. :param path: Output file path :type path: pathlib.Path """ rows = [] for item in self.angle_store: image_name = item.image_controller.name if item.image_controller else 'n/a' p1 = assume_not_none(item.p1) p2 = assume_not_none(item.p2) p3 = assume_not_none(item.p3) rows.append( [ image_name, item.name, item.color.r, item.color.g, item.color.b, p1[0], p1[1], p2[0], p2[1], p3[0], p3[1], item.angle_deg, item.show_overlay, item.show_label, item.show_measure, ] ) header = ( 'image_name,name,color_r,color_g,color_b,' 'p1_x,p1_y,p2_x,p2_y,p3_x,p3_y,' 'angle_deg,show_overlay,show_label,show_measure' ) with path.open('w', newline='') as f: f.write(header + '\n') for row in rows: f.write(','.join([str(v) for v in row]) + '\n')
[docs] def menu_specs(self) -> list[ToolMenuSpec]: """ Get the menu specifications for the measurement tool. :return: Menu specifications :rtype: list[ToolMenuSpec] """ return [ ToolMenuSpec( title='Measure', order=15, entries=[ ToolMenuSpec( title='Segment', entries=[ ToolActionSpec( text='New', triggered=self.start_add_segment, enabled_changed_signal=self.add_enabled_changed, ), ToolActionSpec( text='Delete', triggered=self.on_delete_segment_clicked, enabled_changed_signal=self.remove_segment_enabled_changed, ), ], ), ToolMenuSpec( title='Angle', entries=[ ToolActionSpec( text='New', triggered=self.start_add_angle, enabled_changed_signal=self.add_enabled_changed, ), ToolActionSpec( text='Delete', triggered=self.on_delete_angle_clicked, enabled_changed_signal=self.remove_angle_enabled_changed, ), ], ), ], ) ]
[docs] def count_dependencies_for_image(self, window_controller: SubWindowController[Any]) -> int: """ Count measurement items attached to *window_controller*. """ if not isinstance(window_controller, ImageWindowController): return 0 segment_count = len([s for s in self.segment_store if s.image_controller is window_controller]) angle_count = len([a for a in self.angle_store if a.image_controller is window_controller]) return segment_count + angle_count
[docs] def invalidate_dependencies_for_image(self, window_controller: SubWindowController[Any]) -> int: """ Remove measurement items attached to *window_controller*. """ if not isinstance(window_controller, ImageWindowController): return 0 to_remove_segments = [s for s in self.segment_store if s.image_controller is window_controller] to_remove_angles = [a for a in self.angle_store if a.image_controller is window_controller] for item_s in to_remove_segments: self.remove_segment(item_s) for item_a in to_remove_angles: self.remove_angle(item_a) return len(to_remove_segments) + len(to_remove_angles)
[docs] def _segment_caps(p1: tuple[float, float], p2: tuple[float, float], cap_length: float) -> list[Line2D]: """ Create cap lines for a segment. :param p1: Segment start point :type p1: tuple[float, float] :param p2: Segment end point :type p2: tuple[float, float] :param cap_length: Cap length in data units :type cap_length: float :return: List of Line2D cap artists :rtype: list[Line2D] """ dx = p2[0] - p1[0] dy = p2[1] - p1[1] norm = math.hypot(dx, dy) if norm == 0: return [] ux = -dy / norm uy = dx / norm cap_dx = ux * cap_length cap_dy = uy * cap_length caps = [] caps.append(Line2D([p1[0] - cap_dx, p1[0] + cap_dx], [p1[1] - cap_dy, p1[1] + cap_dy])) caps.append(Line2D([p2[0] - cap_dx, p2[0] + cap_dx], [p2[1] - cap_dy, p2[1] + cap_dy])) return caps
[docs] def draw_segment_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw segment overlays. :param overlay: Overlay model :type overlay: OverlayModel :param axes: Matplotlib axes :type axes: matplotlib.axes.Axes :return: List of artists :rtype: list """ p1 = overlay.extra_props['p1'] p2 = overlay.extra_props['p2'] color = overlay.style.get('color', 'orange') lw = overlay.style.get('lw', 2.0) alpha = 0.9 if overlay.role == OverlayRole.Highlight: lw = max(8.0, lw * 2.0) alpha = 0.5 line = Line2D([p1[0], p2[0]], [p1[1], p2[1]], color=color, linewidth=lw, alpha=alpha) axes.add_line(line) artists: list[Artist] = [line] if overlay.role == OverlayRole.Highlight: return artists cap_length = 0.02 * max(1.0, math.hypot(p2[0] - p1[0], p2[1] - p1[1])) caps = _segment_caps(p1, p2, cap_length) for cap in caps: cap.set_color(color) cap.set_linewidth(lw) axes.add_line(cap) artists.extend(caps) mid = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0) dx = p2[0] - p1[0] dy = p2[1] - p1[1] angle_deg = math.degrees(math.atan2(-dy, dx)) if angle_deg > 90 or angle_deg < -90: angle_deg += 180 if overlay.extra_props.get('show_measure', True): measure_text = overlay.extra_props.get('measure_text', '') ann = Annotation( measure_text, xy=mid, xytext=(0, 10), textcoords='offset points', ha='center', va='bottom', rotation=angle_deg, rotation_mode='anchor', ) axes.add_artist(ann) artists.append(ann) if overlay.extra_props.get('show_label', False): label_text = overlay.extra_props.get('label_text', '') ann = Annotation( label_text, xy=mid, xytext=(0, -10), textcoords='offset points', ha='center', va='top', rotation=angle_deg, rotation_mode='anchor', ) axes.add_artist(ann) artists.append(ann) return artists
[docs] def draw_angle_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw angle overlays. :param overlay: Overlay model :type overlay: OverlayModel :param axes: Matplotlib axes :type axes: matplotlib.axes.Axes :return: List of artists :rtype: list """ # Extract geometry and draw style. p1 = overlay.extra_props['p1'] p2 = overlay.extra_props['p2'] p3 = overlay.extra_props['p3'] color = overlay.style.get('color', 'orange') lw = overlay.style.get('lw', 2.0) alpha = 0.9 if overlay.role == OverlayRole.Highlight: # Highlight overlays are thicker to improve visibility on selection. lw = max(8.0, lw * 2.0) alpha = 0.5 # Draw the two rays of the angle (vertex at p2). line1 = Line2D([p2[0], p1[0]], [p2[1], p1[1]], color=color, linewidth=lw, alpha=alpha) line2 = Line2D([p2[0], p3[0]], [p2[1], p3[1]], color=color, linewidth=lw, alpha=alpha) axes.add_line(line1) axes.add_line(line2) artists: list[Artist] = [line1, line2] if overlay.role == OverlayRole.Highlight: # Highlight overlays only show the rays, not the arc or labels. return artists # Compute the two direction vectors starting from the vertex. v1 = np.array(p1) - np.array(p2) v2 = np.array(p3) - np.array(p2) n1 = np.linalg.norm(v1) n2 = np.linalg.norm(v2) # Set the arc radius as a fraction of the shorter leg length. r = 0.25 * min(n1, n2) if min(n1, n2) > 0 else 1.0 # Convert vectors to display coordinates. The image y-axis grows downward, so we # flip y to compute angles in the standard mathematical sense. dv1 = np.array([v1[0], -v1[1]]) dv2 = np.array([v2[0], -v2[1]]) # Normalize both ray angles to [0, 360) degrees. ang1 = (math.degrees(math.atan2(-dv1[1], dv1[0])) + 360.0) % 360.0 ang2 = (math.degrees(math.atan2(-dv2[1], dv2[0])) + 360.0) % 360.0 # Choose the smaller counter-clockwise arc between the two rays. delta_ccw = (ang2 - ang1) % 360.0 if delta_ccw <= 180.0: theta1 = ang1 theta2 = ang1 + delta_ccw else: theta1 = ang2 theta2 = ang2 + (360.0 - delta_ccw) arc = Arc(p2, float(2 * r), float(2 * r), angle=0.0, theta1=theta1, theta2=theta2, color=color, lw=lw) axes.add_patch(arc) artists.append(arc) # Place the measurement text at the midpoint of the arc. mid_angle = math.radians((theta1 + theta2) / 2.0) text_pos = (p2[0] + r * math.cos(mid_angle), p2[1] + r * math.sin(mid_angle)) if overlay.extra_props.get('show_measure', True): measure_text = overlay.extra_props.get('measure_text', '') ann = Annotation(measure_text, xy=text_pos, xytext=(0, 5), textcoords='offset points', ha='center', va='bottom') axes.add_artist(ann) artists.append(ann) if overlay.extra_props.get('show_label', False): # Keep the label close to the vertex so it does not overlap the arc. label_text = overlay.extra_props.get('label_text', '') ann = Annotation(label_text, xy=p2, xytext=(0, -12), textcoords='offset points', ha='center', va='top') axes.add_artist(ann) artists.append(ann) return artists
[docs] class MeasurementTool(Tool[MeasurementToolController]): """ Tool for measuring segment lengths and angle amplitudes. """ id = uuid.uuid4() tool_id = 'measure' name = 'Measurement Tool' description = 'A tool to measure segment lengths and angle amplitudes' overlays_to_be_registered = [ OverlaySpec('measure_segment', OverlayRole.Permanent, draw_segment_overlay), OverlaySpec('measure_segment', OverlayRole.Highlight, draw_segment_overlay), OverlaySpec('measure_angle', OverlayRole.Permanent, draw_angle_overlay), OverlaySpec('measure_angle', OverlayRole.Highlight, draw_angle_overlay), ] def __init__(self) -> None: super().__init__() self._session_restore_context: Optional[WorkspaceRestoreContext] = None self._restore_spec: Optional[ToolWorkspace] = None self._restore_emitted = False
[docs] def create_controller(self, ctx: 'ToolContext') -> MeasurementToolController: """ Create a controller for this tool. :param ctx: Tool context :type ctx: ToolContext :return: Tool controller :rtype: MeasurementToolController """ self._controller: MeasurementToolController = MeasurementToolController(ctx, self) return self._controller
[docs] def restore_phase(self) -> int: """ Return the restore phase for workspace restore. :return: Restore phase :rtype: int """ return 1
[docs] def to_workspace(self, include_data: bool) -> ToolWorkspace: """ Generate a workspace specification for the measurement tool. :param include_data: Whether to include computed data :type include_data: bool :return: Workspace specification :rtype: ToolWorkspace """ tool_ws = ToolWorkspace(tool_id=self.tool_id, version='1.0') if self._controller is None: return tool_ws if self._controller.current_selected_segment: self._controller.context.register_workspace_reference( self._controller.current_selected_segment.id, self._controller.current_selected_segment ) tool_ws.state['current_selected_segment'] = self._controller.current_selected_segment.id else: tool_ws.state['current_selected_segment'] = None if self._controller.current_selected_angle: self._controller.context.register_workspace_reference( self._controller.current_selected_angle.id, self._controller.current_selected_angle ) tool_ws.state['current_selected_angle'] = self._controller.current_selected_angle.id else: tool_ws.state['current_selected_angle'] = None for item_s in self._controller.segment_store: image_controller = self._require_image_controller(item_s) spec_s = item_s.to_workspace_spec(include_data) spec_s.image_controller_id = image_controller.id tool_ws.items[item_s.id] = spec_s tool_ws.order.append(item_s.id) for item_a in self._controller.angle_store: image_controller = self._require_image_controller(item_a) spec_a = item_a.to_workspace_spec(include_data) spec_a.image_controller_id = image_controller.id tool_ws.items[item_a.id] = spec_a tool_ws.order.append(item_a.id) return tool_ws
[docs] def from_workspace(self, spec: ToolWorkspace, context: WorkspaceReferenceManager) -> None: """ Restore the tool from workspace specification. :param spec: Workspace specification :type spec: ToolWorkspace :param context: Workspace reference manager :type context: WorkspaceReferenceManager :raise RuntimeError: when no controller is available """ self._restore_emitted = False self._restore_spec = spec if self._controller is None: raise RuntimeError(f'No controller available for tool {self.tool_id}') if len(spec.items) == 0: self._restore_completed() return 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) for key, raw_item in spec.items.items(): item = workspace_spec_as_mapping(raw_item) if 'angle_deg' in item: self._controller.angle_counter.next() angle_data = AngleMeasurementData( measurement_id=enforce_uuid(key), name=item['name'], color=Color(*item['color']), p1=item['p1'], p2=item['p2'], p3=item['p3'], show_overlay=item.get('show_overlay', True), show_label=item.get('show_label', False), show_measure=item.get('show_measure', True), angle_deg=item.get('angle_deg', 0.0), ) image_controller = cast( ImageWindowController, context.resolve(enforce_uuid(item['image_controller_id'])) ) angle_data.image_controller = image_controller context.register(angle_data.id, angle_data) session = self._controller.create_session( image_controller, measurement_type=MeasurementType.ANGLE, input_data=angle_data ) self._session_restore_context.register_session(session) session.start() else: self._controller.segment_counter.next() segment_data = SegmentMeasurementData( measurement_id=enforce_uuid(key), name=item['name'], color=Color(*item['color']), p1=item['p1'], p2=item['p2'], show_overlay=item.get('show_overlay', True), show_label=item.get('show_label', False), show_measure=item.get('show_measure', True), length_px=item.get('length_px', 0.0), length_m=item.get('length_m', None), unit_name=item.get('unit_name', None), pixel_size_m=item.get('pixel_size_m', None), ) image_controller = cast( ImageWindowController, context.resolve(enforce_uuid(item['image_controller_id'])) ) segment_data.image_controller = image_controller if segment_data.pixel_size_m is None: segment_data.pixel_size_m = image_controller.pixel_size_m() context.register(segment_data.id, segment_data) session = self._controller.create_session( image_controller, measurement_type=MeasurementType.SEGMENT, input_data=segment_data ) self._session_restore_context.register_session(session) session.start()
[docs] def _on_item_restore_completed(self) -> None: """ Handle completion of item restoration. """ self._reorder_store() self._restore_selected_items() self._restore_completed()
[docs] def _reorder_store(self) -> None: """ Reorder stores based on workspace order. """ segs = {str(item.id): item for item in self._controller.segment_store.all()} angs = {str(item.id): item for item in self._controller.angle_store.all()} restore_spec = self._require_restore_spec() ordered = [pid for pid in restore_spec.order] self._controller.segment_store.reset() self._controller.angle_store.reset() for pid in ordered: if str(pid) in segs: self._controller.segment_store.add(segs[str(pid)]) elif str(pid) in angs: self._controller.angle_store.add(angs[str(pid)])
[docs] def _restore_selected_items(self) -> None: """ Restore selected items from workspace state. """ restore_spec = self._require_restore_spec() seg_id = self._normalize_workspace_id(restore_spec.state.get('current_selected_segment')) if seg_id is not None: try: seg_uuid = enforce_uuid(seg_id) except (ValueError, TypeError): seg_uuid = None if seg_uuid is not None: try: row = self._controller.segment_store.index_of(seg_uuid) except KeyError: row = None if row is not None: index = self._controller.segment_table_model.index(row, 0) self._controller.request_to_change_segment_selection.emit( index, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows ) ang_id = self._normalize_workspace_id(restore_spec.state.get('current_selected_angle')) if ang_id is not None: try: ang_uuid = enforce_uuid(ang_id) except (ValueError, TypeError): ang_uuid = None if ang_uuid is not None: try: row = self._controller.angle_store.index_of(ang_uuid) except KeyError: row = None if row is not None: index = self._controller.angle_table_model.index(row, 0) self._controller.request_to_change_angle_selection.emit( index, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows )
[docs] @staticmethod def _normalize_workspace_id(value: object) -> Optional[str]: """ Normalize a workspace id that may be stored as bytes or None. :param value: Raw id value :type value: object :return: Normalized string id or None :rtype: Optional[str] """ if value is None: return None if isinstance(value, (bytes, bytearray)): try: value = value.decode() except Exception: return None value = str(value) if value in {'__NONE__', 'None', ''}: return None return value
[docs] @staticmethod def _require_image_controller( item: SegmentMeasurementData | AngleMeasurementData, ) -> ImageWindowController: """ Return the image controller for a measurement item or raise if missing. :param item: Segment or angle measurement data :type item: SegmentMeasurementData | AngleMeasurementData :return: Image window controller for the measurement :rtype: ImageWindowController :raises RuntimeError: If the measurement has no associated image controller """ return require_not_none(item.image_controller, f'Measurement {item.name} image controller')
[docs] def _restore_completed(self) -> None: """ Signal completion of workspace restore. """ if self._restore_emitted: return self._restore_emitted = True self._controller.restore_completed.emit(self)