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 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)