Source code for radioviz.tools.level_tool

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Level tool module for image display parameter adjustment.

This module provides functionality for adjusting image display parameters such as
level ranges, color maps, and interpolation methods through a dock widget interface.
It includes the controller, dock widget, and associated classes for managing
image display properties in the application.
"""

from __future__ import annotations

import sys
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Optional, Tuple, cast
from uuid import UUID, uuid4

import superqt
from matplotlib.artist import Artist
from matplotlib.axes import Axes
from mpl_toolkits.axes_grid1.anchored_artists import AnchoredSizeBar  # type: ignore[import-untyped]
from packaging import version
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
    QButtonGroup,
    QCheckBox,
    QColorDialog,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QDoubleSpinBox,
    QFormLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QPushButton,
    QRadioButton,
    QTabWidget,
    QVBoxLayout,
    QWidget,
)
from superqt import QColormapComboBox, QLabeledRangeSlider

from radioviz.controllers.image_window_controller import ImageWindowController
from radioviz.controllers.sub_window_controller import SubWindowEnum
from radioviz.models.legacy import StrEnum
from radioviz.models.menu_spec import ToolActionSpec, ToolMenuSpec
from radioviz.models.overlays import OverlayModel, OverlayRole, OverlaySpec
from radioviz.services.color_service import Color, to_qcolor
from radioviz.services.signal_blocker import SignalBlocked
from radioviz.services.spatial_calibration import DistanceUnit, nice_value
from radioviz.services.typing_helpers import assume_not_none, require_not_none
from radioviz.services.workspace_manager import ToolWorkspace, WorkspaceReferenceManager
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

if sys.version_info >= (3, 10):
    from typing import TypedDict
else:
    from typing_extensions import TypedDict

if TYPE_CHECKING:
    from radioviz.controllers.sub_window_controller import SubWindowController
    from radioviz.tools.tool_api import BaseToolSession, ToolContext


[docs] class ColormapArgs(TypedDict, total=False): filterable: bool allow_user_colormaps: bool add_colormap_text: str
colormap_kwargs: ColormapArgs = {} try: _superqt_version = getattr(superqt, '__version__', '0') _superqt_version = str(_superqt_version) HAS_FILTERABLE = version.parse(_superqt_version) >= version.parse('0.7.3') except Exception: HAS_FILTERABLE = False if HAS_FILTERABLE: colormap_kwargs['filterable'] = False
[docs] class LevelToolTabKey(StrEnum): """ Tab identifiers for the level tool dock. """ Levels = 'levels' Scale = 'scale'
[docs] class LevelToolDock(ToolDockWidget['LevelToolController']): """ Dock widget for level adjustment controls. This dock widget provides controls for adjusting image display parameters including level ranges, color maps, and interpolation settings. :ivar initial_visibility: Whether the dock is initially visible :vartype initial_visibility: bool :ivar enable_for_window_type: Window type this dock is enabled for :vartype enable_for_window_type: SubWindowEnum """ initial_visibility = True enable_for_window_type = SubWindowEnum.ImageWindow request_image_update = Signal(dict) def __init__(self, parent: QWidget, controller: 'LevelToolController'): """ Initialize the level tool dock widget. :param parent: Parent widget :type parent: QWidget :param controller: Level tool controller instance :type controller: LevelToolController """ super().__init__('Image display tools', parent, controller) self.tool_widget = QWidget() self.main_layout = QVBoxLayout(self.tool_widget) self.setWidget(self.tool_widget) self._content_enabled = True # When the user switches tabs, the dock can steal focus and inadvertently # change the active window. We capture and restore the active image after # the tab switch to preserve user context. self._pending_active_image: Optional[SubWindowController[Any]] = None self._distance_available = False self._scalebar_enabled = False self.level_slider = QLabeledRangeSlider(Qt.Orientation.Vertical) self.level_slider.setRange(0, 2**16) self.level_slider.setValue((0, 65000)) self.level_slider.sliderReleased.connect(self.update_parameters) self.level_slider.editingFinished.connect(self.update_parameters) self.reset_level_button = QPushButton('Reset levels') self.reset_level_button.clicked.connect(self.reset_levels) self.cmap_combo = QColormapComboBox(parent=None, **colormap_kwargs) self.cmap_combo.addColormaps( [ 'gray', 'viridis', 'plasma', 'inferno', 'magma', 'cividis', 'hot', 'cool', 'jet', 'Reds', 'Greens', 'Blues', ] ) self.cmap_combo.setCurrentColormap('hot') self.cmap_combo.currentTextChanged.connect(self.update_parameters) self.interpolation_combo = QComboBox() self.interpolation_combo.addItems(['none', 'nearest', 'bilinear', 'bicubic']) self.interpolation_combo.currentTextChanged.connect(self.update_parameters) axis_group = QGroupBox('Axis labels') axis_layout = QVBoxLayout(axis_group) self.axis_button_group = QButtonGroup(self) self.axis_off = QRadioButton('Off') self.axis_pixel = QRadioButton('Pixel') self.axis_distance = QRadioButton('Distance') self.axis_button_group.addButton(self.axis_off) self.axis_button_group.addButton(self.axis_pixel) self.axis_button_group.addButton(self.axis_distance) axis_layout.addWidget(self.axis_off) axis_layout.addWidget(self.axis_pixel) axis_layout.addWidget(self.axis_distance) self.axis_off.toggled.connect(lambda checked: self._emit_axis_mode('off', checked)) self.axis_pixel.toggled.connect(lambda checked: self._emit_axis_mode('pixel', checked)) self.axis_distance.toggled.connect(lambda checked: self._emit_axis_mode('distance', checked)) self.scalebar_visible = QCheckBox('Show scalebar') self.scalebar_visible.toggled.connect(self._emit_scalebar_toggle) self.scalebar_button = QPushButton('Add/Edit scalebar...') self.scalebar_button.clicked.connect(self._emit_scalebar_edit) self._tabs = QTabWidget(self.tool_widget) self._tabs.addTab(self._build_levels_tab(), 'Levels') self._tabs.addTab(self._build_scale_tab(axis_group), 'Scale') self._tabs.tabBarClicked.connect(self._on_tab_bar_clicked) self._tabs.currentChanged.connect(self._on_tab_changed) self.main_layout.addWidget(self._tabs)
[docs] def _build_levels_tab(self) -> QWidget: """ Build the levels tab widget. :return: Levels tab widget. :rtype: QWidget """ tab = QWidget() layout = QVBoxLayout(tab) layout.addWidget(QLabel('Level selection')) layout.addWidget(self.level_slider, stretch=1) layout.addWidget(self.reset_level_button) layout.addWidget(QLabel('Color map selection')) layout.addWidget(self.cmap_combo) layout.addWidget(QLabel('Interpolation type')) layout.addWidget(self.interpolation_combo) return tab
[docs] def _build_scale_tab(self, axis_group: QGroupBox) -> QWidget: """ Build the scale tab widget. :param axis_group: Axis label group widget. :type axis_group: QGroupBox :return: Scale tab widget. :rtype: QWidget """ tab = QWidget() layout = QVBoxLayout(tab) layout.addWidget(axis_group) layout.addWidget(self.scalebar_visible) layout.addWidget(self.scalebar_button) layout.addStretch() return tab
[docs] def dock_state(self) -> dict[str, str]: """ Return the dock state for workspace persistence. :return: Dock state payload. :rtype: dict """ return {'active_tab': self._tab_key(self._tabs.currentIndex()).value}
[docs] def apply_dock_state(self, state: dict[str, str]) -> None: """ Apply a previously saved dock state. :param state: Dock state payload. :type state: dict """ tab_key = self._normalize_tab_key(state.get('active_tab', LevelToolTabKey.Levels.value)) self._tabs.setCurrentIndex(self._tab_index(tab_key))
[docs] def _tab_key(self, index: int) -> LevelToolTabKey: """ Resolve the tab key for an index. :param index: Tab index. :type index: int :return: Tab key. :rtype: LevelToolTabKey """ if index == 1: return LevelToolTabKey.Scale return LevelToolTabKey.Levels
[docs] def _tab_index(self, tab_key: LevelToolTabKey) -> int: """ Resolve the tab index for a key. :param tab_key: Tab key. :type tab_key: LevelToolTabKey :return: Tab index. :rtype: int """ if tab_key == LevelToolTabKey.Scale: return 1 return 0
[docs] def _normalize_tab_key(self, tab_key: object) -> LevelToolTabKey: """ Normalize persisted tab keys into strings. :param tab_key: Raw tab key value from workspace. :type tab_key: object :return: Normalized tab key. :rtype: LevelToolTabKey """ if isinstance(tab_key, LevelToolTabKey): return tab_key if isinstance(tab_key, str): try: return LevelToolTabKey(tab_key) except ValueError: return LevelToolTabKey.Levels if hasattr(tab_key, 'decode'): try: return LevelToolTabKey(tab_key.decode('utf-8')) except (AttributeError, TypeError, UnicodeDecodeError, ValueError): pass try: return LevelToolTabKey(str(tab_key)) except ValueError: return LevelToolTabKey.Levels
[docs] def setEnabled(self, enabled: bool) -> None: """ Override enabled state to keep tab switching available. :param enabled: Whether to enable the dock content. :type enabled: bool """ self._content_enabled = bool(enabled) super().setEnabled(True) self._apply_content_enabled_state()
[docs] def _apply_content_enabled_state(self) -> None: """ Apply enabled state to the dock contents while leaving tabs available. """ content_enabled = self._content_enabled self.level_slider.setEnabled(content_enabled) self.reset_level_button.setEnabled(content_enabled) self.cmap_combo.setEnabled(content_enabled) self.interpolation_combo.setEnabled(content_enabled) self.axis_off.setEnabled(content_enabled) self.axis_pixel.setEnabled(content_enabled) self.axis_distance.setEnabled(content_enabled and self._distance_available) self.scalebar_visible.setEnabled(content_enabled and self._scalebar_enabled) self.scalebar_button.setEnabled(content_enabled and self._scalebar_enabled)
[docs] def _on_tab_bar_clicked(self, index: int) -> None: """ Preserve the current active image while switching tabs. :param index: Index of the clicked tab. :type index: int """ _ = index if isinstance(self.controller, LevelToolController): self._pending_active_image = self.controller.context.active_image()
[docs] def _on_tab_changed(self, index: int) -> None: """ Restore the active image after a tab change. :param index: Index of the new tab. :type index: int """ if self._pending_active_image is None: return if isinstance(self.controller, LevelToolController): self.controller.context.activate_image(self._pending_active_image) self._pending_active_image = None
[docs] def reset_levels(self) -> None: """ Reset level values to initial state. Resets the level slider values to the initial range defined by the controller's get_initial_levels method. """ if isinstance(self.controller, LevelToolController): vmin, vmax = self.controller.get_initial_levels() params = { 'vmin': vmin, 'vmax': vmax, } self.level_slider.setValue((vmin, vmax)) self.emit_event('update_image_parameters', params)
[docs] def handle_event(self, event: ToolEvent) -> None: """ Handle tool events from the controller. :param event: Tool event to process :type event: ToolEvent """ if event.tool_id != 'level': return if event.event == 'new_active_window': self.update_widget_from_image(event.payload)
[docs] def update_parameters(self) -> None: """ Update image display parameters based on widget values. Collects current values from sliders and comboboxes and emits an event to update image parameters. """ try: current_cmap = require_not_none(self.cmap_combo.currentColormap()) except RuntimeError: current_cmap = assume_not_none(self.cmap_combo.itemColormap(0)) params = { 'vmin': self.level_slider.value()[0], 'vmax': self.level_slider.value()[1], 'cmap': current_cmap.to_mpl(), 'interpolation': self.interpolation_combo.currentText(), } self.emit_event('update_image_parameters', params)
[docs] def update_widget_from_image(self, display_params: dict[str, Any]) -> None: """ Update widget values based on image display parameters. :param display_params: Dictionary containing display parameters :type display_params: dict """ with SignalBlocked( self.level_slider, self.interpolation_combo, self.cmap_combo, self.axis_off, self.axis_pixel, self.axis_distance, self.scalebar_visible, ): self.level_slider.setValue((display_params['vmin'], display_params['vmax'])) self.interpolation_combo.setCurrentText(display_params['interpolation']) if isinstance(display_params['cmap'], str): self.cmap_combo.setCurrentColormap(display_params['cmap']) else: self.cmap_combo.setCurrentColormap(display_params['cmap'].name) axis_mode = display_params.get('axis_label_mode', 'pixel') if axis_mode == 'off': self.axis_off.setChecked(True) elif axis_mode == 'distance': self.axis_distance.setChecked(True) else: self.axis_pixel.setChecked(True) content_enabled = self._content_enabled distance_available = display_params.get('distance_available', False) self.axis_off.setEnabled(content_enabled) self.axis_pixel.setEnabled(content_enabled) self.axis_distance.setEnabled(content_enabled and distance_available) scalebar_visible = display_params.get('scalebar_visible', False) scalebar_enabled = display_params.get('scalebar_enabled', False) self.scalebar_visible.setChecked(scalebar_visible) self.scalebar_visible.setEnabled(content_enabled and scalebar_enabled) self.scalebar_button.setEnabled(content_enabled and scalebar_enabled) self._distance_available = bool(distance_available) self._scalebar_enabled = bool(scalebar_enabled)
[docs] def _emit_axis_mode(self, mode: str, checked: bool) -> None: """ Emit axis label mode change event. :param mode: Axis label mode. :type mode: str :param checked: Whether the radio button is checked. :type checked: bool """ if not checked: return self.emit_event('update_axis_label_mode', {'mode': mode})
[docs] def _emit_scalebar_toggle(self, checked: bool) -> None: """ Emit scalebar visibility change event. :param checked: Whether scalebar is visible. :type checked: bool """ self.emit_event('toggle_scalebar', {'visible': checked})
[docs] def _emit_scalebar_edit(self) -> None: """Emit scalebar edit event.""" self.emit_event('edit_scalebar')
[docs] @dataclass(frozen=True) class ScaleBarSettings: """ Serializable scalebar settings. :param visible: Whether the scalebar is visible. :type visible: bool :param length_m: Scalebar length in meters. :type length_m: float :param height_m: Scalebar height in meters. :type height_m: float :param bar_color: RGB color for the bar. :type bar_color: tuple[float, float, float] :param text_color: RGB color for the text. :type text_color: tuple[float, float, float] :param background_color: Optional RGB background color. :type background_color: tuple[float, float, float] | None :param background_alpha: Background alpha value. :type background_alpha: float :param location: Matplotlib anchor location. :type location: str """ visible: bool length_m: float height_m: float bar_color: tuple[float, float, float] text_color: tuple[float, float, float] background_color: tuple[float, float, float] | None background_alpha: float location: str
[docs] def to_dict(self) -> dict[str, Any]: """ Serialize settings to a dictionary. :return: Serialized settings. :rtype: dict """ return { 'visible': self.visible, 'length_m': self.length_m, 'height_m': self.height_m, 'bar_color': list(self.bar_color), 'text_color': list(self.text_color), 'background_color': list(self.background_color) if self.background_color else None, 'background_alpha': self.background_alpha, 'location': self.location, }
[docs] @classmethod def from_dict(cls, payload: dict[str, Any]) -> 'ScaleBarSettings': """ Deserialize settings from a dictionary. :param payload: Serialized settings. :type payload: dict :return: Parsed settings. :rtype: ScaleBarSettings """ return cls( visible=bool(payload.get('visible', False)), length_m=float(payload.get('length_m', 0.0)), height_m=float(payload.get('height_m', 0.0)), bar_color=tuple(payload.get('bar_color', (1.0, 1.0, 1.0))), text_color=tuple(payload.get('text_color', (1.0, 1.0, 1.0))), background_color=tuple(payload['background_color']) if payload.get('background_color') else None, background_alpha=float(payload.get('background_alpha', 0.0)), location=str(payload.get('location', 'lower right')), )
[docs] def default_scalebar_settings(controller: ImageWindowController) -> ScaleBarSettings | None: """ Build default scalebar settings for an image controller. :param controller: Image window controller. :type controller: ImageWindowController :return: Default scalebar settings or None when unavailable. :rtype: ScaleBarSettings | None """ pixel_size = controller.pixel_size_m() unit = controller.distance_unit() if pixel_size is None or unit is None or controller.image_data is None: return None pixel_x_m, pixel_y_m = pixel_size height, width = controller.image_data.shape[:2] max_extent_m = max(width * pixel_x_m, height * pixel_y_m) length_m = nice_value(max_extent_m * 0.2) height_m = nice_value(length_m * 0.05) return ScaleBarSettings( visible=False, length_m=length_m, height_m=height_m, bar_color=(1.0, 1.0, 1.0), text_color=(1.0, 1.0, 1.0), background_color=None, background_alpha=0.4, location='lower right', )
[docs] def draw_scalebar_overlay(overlay: OverlayModel, axes: Axes) -> list[Artist]: """ Draw a scalebar overlay on Matplotlib axes. :param overlay: Overlay model with scalebar geometry and style. :type overlay: OverlayModel :param axes: Target matplotlib axes. :type axes: matplotlib.axes.Axes :return: List of created artists. :rtype: list """ geometry = overlay.geometry style = overlay.style size = geometry['length_px'] label = geometry['label'] size_vertical = geometry.get('height_px', 0) loc = geometry.get('loc', 'lower right') bar_color = style.get('bar_color', (1.0, 1.0, 1.0)) text_color = style.get('text_color', (1.0, 1.0, 1.0)) background_color = style.get('background_color') background_alpha = style.get('background_alpha', 0.0) scalebar = AnchoredSizeBar( axes.transData, size, label, loc=loc, pad=0.4, borderpad=0.5, sep=4, frameon=background_color is not None, size_vertical=size_vertical, color=bar_color, ) _set_scalebar_text_color(scalebar, text_color) if background_color is not None: scalebar.patch.set_facecolor(background_color) scalebar.patch.set_alpha(background_alpha) axes.add_artist(scalebar) return [scalebar]
[docs] class ScaleBarDialog(QDialog): """ Dialog for configuring scalebar settings. """ def __init__(self, unit: DistanceUnit, settings: ScaleBarSettings, parent: Optional[QWidget] = None): """ Initialize the scalebar dialog. :param unit: Distance unit for display. :type unit: DistanceUnit :param settings: Current scalebar settings. :type settings: ScaleBarSettings :param parent: Parent widget. :type parent: QWidget, optional """ super().__init__(parent) self.setWindowTitle('Scalebar settings') self._unit = unit self._settings = settings self._bar_color = to_qcolor(Color(*settings.bar_color)) self._text_color = to_qcolor(Color(*settings.text_color)) self._background_color = to_qcolor(Color(*settings.background_color)) if settings.background_color else None layout = QVBoxLayout(self) form = QFormLayout() self.length_spin = QDoubleSpinBox() self.length_spin.setRange(0.0, 1.0e9) self.length_spin.setDecimals(3) self.length_spin.setSuffix(f' {unit.name}') self.length_spin.setValue(settings.length_m / unit.meters_per_unit) form.addRow('Length', self.length_spin) self.height_spin = QDoubleSpinBox() self.height_spin.setRange(0.0, 1.0e9) self.height_spin.setDecimals(3) self.height_spin.setSuffix(f' {unit.name}') self.height_spin.setValue(settings.height_m / unit.meters_per_unit) form.addRow('Height', self.height_spin) self.bar_color_btn = QPushButton('Select...') self.bar_color_btn.clicked.connect(self._select_bar_color) self._apply_button_color(self.bar_color_btn, self._bar_color) form.addRow('Bar color', self.bar_color_btn) self.text_color_btn = QPushButton('Select...') self.text_color_btn.clicked.connect(self._select_text_color) self._apply_button_color(self.text_color_btn, self._text_color) form.addRow('Text color', self.text_color_btn) bg_row = QHBoxLayout() self.bg_color_btn = QPushButton('Select...') self.bg_color_btn.clicked.connect(self._select_background_color) self._apply_button_color(self.bg_color_btn, self._background_color) self.bg_transparent = QCheckBox('Transparent') self.bg_transparent.setChecked(self._background_color is None) self.bg_transparent.toggled.connect(self._on_bg_transparent_toggled) bg_row.addWidget(self.bg_color_btn) bg_row.addWidget(self.bg_transparent) form.addRow('Background', bg_row) layout.addLayout(form) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) self._on_bg_transparent_toggled(self.bg_transparent.isChecked())
[docs] def settings(self) -> ScaleBarSettings: """ Return dialog settings. :return: Updated scalebar settings. :rtype: ScaleBarSettings """ length_m = self.length_spin.value() * self._unit.meters_per_unit height_m = self.height_spin.value() * self._unit.meters_per_unit background = None if self.bg_transparent.isChecked() else _qcolor_to_rgb(self._background_color) return ScaleBarSettings( visible=True, length_m=length_m, height_m=height_m, bar_color=_qcolor_to_rgb(self._bar_color), text_color=_qcolor_to_rgb(self._text_color), background_color=background, background_alpha=self._settings.background_alpha, location=self._settings.location, )
def _select_bar_color(self) -> None: color = QColorDialog.getColor(self._bar_color, self, 'Select bar color') if color.isValid(): self._bar_color = color self._apply_button_color(self.bar_color_btn, color) def _select_text_color(self) -> None: color = QColorDialog.getColor(self._text_color, self, 'Select text color') if color.isValid(): self._text_color = color self._apply_button_color(self.text_color_btn, color) def _select_background_color(self) -> None: if self.bg_transparent.isChecked(): return start_color = self._background_color or QColor(0, 0, 0) color = QColorDialog.getColor(start_color, self, 'Select background color') if color.isValid(): self._background_color = color self._apply_button_color(self.bg_color_btn, color) def _on_bg_transparent_toggled(self, checked: bool) -> None: self.bg_color_btn.setEnabled(not checked) if checked: self._apply_button_color(self.bg_color_btn, None) elif self._background_color is not None: self._apply_button_color(self.bg_color_btn, self._background_color) @staticmethod def _apply_button_color(button: QPushButton, color: QColor | None) -> None: if color is None: button.setStyleSheet('') return button.setStyleSheet(f'background-color: {color.name()};')
[docs] def _qcolor_to_rgb(color: QColor | None) -> tuple[float, float, float]: """ Convert QColor to RGB tuple in [0, 1]. :param color: QColor instance. :type color: QColor | None :return: RGB tuple. :rtype: tuple[float, float, float] """ if color is None: return (0.0, 0.0, 0.0) return (color.redF(), color.greenF(), color.blueF())
[docs] def _set_scalebar_text_color(scalebar: AnchoredSizeBar, color: tuple[float, float, float]) -> None: """ Set scalebar label text color across Matplotlib versions. :param scalebar: AnchoredSizeBar instance. :type scalebar: AnchoredSizeBar :param color: RGB color tuple. :type color: tuple[float, float, float] """ text_area = getattr(scalebar, 'txt_label', None) if text_area is None: return text_obj = getattr(text_area, '_text', None) if text_obj is not None: text_obj.set_color(color)
[docs] class LevelToolSession(BaseToolSession['LevelToolController', 'ImageWindowController']): """ Placeholder session for the level tool. """
[docs] def on_start(self) -> None: """Start the session.""" pass
[docs] def on_cancel(self, reason: str) -> None: """ Cancel the session. :param reason: Cancellation reason. :type reason: str """ pass
[docs] def on_finish(self) -> None: """Finish the session.""" pass
[docs] class LevelToolController(ToolController[ImageWindowController, LevelToolSession]): """ Controller for level adjustment tool. Manages the interaction between the tool dock widget and image controllers for updating display parameters. """ axis_label_off_checked = Signal(bool) axis_label_pixel_checked = Signal(bool) axis_label_distance_checked = Signal(bool) axis_label_distance_enabled = Signal(bool) axis_label_menu_enabled = Signal(bool) scalebar_checked = Signal(bool) scalebar_enabled = Signal(bool) restore_completed = Signal(object) def __init__(self, tool_ctx: 'ToolContext', tool: 'Tool[LevelToolController]'): """ Initialize the level tool controller. :param tool_ctx: Tool context :type tool_ctx: ToolContext :param tool: Tool instance :type tool: Tool """ super().__init__(tool_ctx, tool) self._scalebar_ids: dict[UUID, UUID] = {} self._last_payload_signature: dict[UUID, tuple[Any, ...]] = {} self._last_scalebar_signature: dict[UUID, tuple[Any, ...]] = {} self._last_active_image_id: UUID | None = None self.context.on_list_changed(self._on_window_list_changed) self.dock: Optional[LevelToolDock] = None
[docs] def create_session(self, window_controller: ImageWindowController) -> LevelToolSession: """ Create a tool session for the given window controller. :param window_controller: Window controller to create session for :type window_controller: SubWindowController :return: BaseToolSession instance :rtype: BaseToolSession """ return LevelToolSession(self, window_controller)
[docs] def create_dock(self, parent_window: QWidget) -> LevelToolDock: """ Create a dock widget for this tool. :param parent_window: Parent window for the dock :type parent_window: QWidget :return: LevelToolDock instance :rtype: LevelToolDock """ dock = LevelToolDock(parent_window, self) self.dock = dock self.has_dock = True dock.event_emitted.connect(self.on_dock_event) self.controller_event.connect(dock.handle_event) return dock
[docs] def dock_state(self) -> dict[str, str]: """ Return the dock state for workspace persistence. :return: Dock state payload. :rtype: dict """ if self.dock is not None and hasattr(self.dock, 'dock_state'): return self.dock.dock_state() return {'active_tab': LevelToolTabKey.Levels.value}
[docs] def apply_dock_state(self, state: dict[str, str]) -> None: """ Apply a previously saved dock state. :param state: Dock state payload. :type state: dict """ normalized = dict(state) if 'active_tab' in normalized: normalized['active_tab'] = self._normalize_tab_key(normalized.get('active_tab')) if self.dock is not None and hasattr(self.dock, 'apply_dock_state'): self.dock.apply_dock_state(normalized)
[docs] @staticmethod def _normalize_tab_key(tab_key: object) -> str: """ Normalize persisted tab keys into strings. :param tab_key: Raw tab key value from workspace. :type tab_key: object :return: Normalized tab key. :rtype: str """ if isinstance(tab_key, str): return tab_key if hasattr(tab_key, 'decode'): try: return cast(str, tab_key.decode('utf-8')) except (AttributeError, TypeError, UnicodeDecodeError): pass return str(tab_key)
[docs] def _on_active_image_changed(self, active_image_controller: SubWindowController[Any] | None) -> None: """ Handle changes to the active image controller. :param active_image_controller: New active image controller :type active_image_controller: ImageWindowController """ if not isinstance(active_image_controller, ImageWindowController): return payload = self._build_display_payload(active_image_controller) signature = self._display_signature_from_payload(payload) last_signature = self._last_payload_signature.get(active_image_controller.id) active_id = active_image_controller.id if self._last_active_image_id != active_id or signature != last_signature: self.controller_event.emit(ToolEvent(self.tool_id, 'new_active_window', payload)) self._last_payload_signature[active_id] = signature self._last_active_image_id = active_id self._sync_menu_state(active_image_controller) self._apply_scalebar_overlay(active_image_controller)
[docs] def get_initial_levels(self) -> Tuple[int, int]: """ Get initial level values for the active image. :return: Tuple of (vmin, vmax) initial values :rtype: Tuple[int, int] """ active_image_controller = self.context.active_image() if isinstance(active_image_controller, ImageWindowController): return active_image_controller.vmin_init, active_image_controller.vmax_init return 0, 2**16 - 1
[docs] def on_dock_event(self, event: ToolEvent) -> None: """ Handle events emitted from the dock widget. :param event: Tool event from dock :type event: ToolEvent """ if event.event == 'update_image_parameters': active_window_controller = self.context.active_image() if isinstance(active_window_controller, ImageWindowController): active_window_controller.set_display_properties(event.payload) active_window_controller.request_canvas_update.emit() if event.event == 'update_axis_label_mode': self._set_axis_label_mode_from_event(event.payload) if event.event == 'toggle_scalebar': self._set_scalebar_visible_from_event(event.payload) if event.event == 'edit_scalebar': self._edit_scalebar_from_event()
[docs] def menu_specs(self) -> list[ToolMenuSpec]: """ Provide menu specifications for axis labels and scalebar. :return: List of menu specifications. :rtype: list[ToolMenuSpec] """ return [ ToolMenuSpec( title='Display', order=15, entries=[ ToolMenuSpec( title='Axis labels', order=10, entries=[ ToolActionSpec( text='Off', triggered=lambda: None, toggled=lambda checked: self._set_axis_label_mode_checked('off', checked), checkable=True, checked_changed_signal=self.axis_label_off_checked, enabled_changed_signal=self.axis_label_menu_enabled, action_group='axis_labels', ), ToolActionSpec( text='Pixel', triggered=lambda: None, toggled=lambda checked: self._set_axis_label_mode_checked('pixel', checked), checkable=True, checked_changed_signal=self.axis_label_pixel_checked, enabled_changed_signal=self.axis_label_menu_enabled, action_group='axis_labels', ), ToolActionSpec( text='Distance', triggered=lambda: None, toggled=lambda checked: self._set_axis_label_mode_checked('distance', checked), checkable=True, checked_changed_signal=self.axis_label_distance_checked, enabled_changed_signal=self.axis_label_distance_enabled, action_group='axis_labels', ), ], ), ToolActionSpec( text='Scalebar', triggered=lambda: None, toggled=self._set_scalebar_visible_checked, checkable=True, checked_changed_signal=self.scalebar_checked, enabled_changed_signal=self.scalebar_enabled, order=20, ), ToolActionSpec( text='Edit scalebar...', triggered=self._edit_scalebar_from_event, enabled_changed_signal=self.scalebar_enabled, order=21, ), ], ) ]
[docs] def _on_window_list_changed(self) -> None: """ Handle window list changes by syncing scalebar overlays. """ for image_controller in self.context.all_images(): if isinstance(image_controller, ImageWindowController): self._apply_scalebar_overlay(image_controller)
[docs] def _build_display_payload(self, controller: ImageWindowController) -> dict[str, Any]: """ Build payload for dock synchronization. :param controller: Image controller. :type controller: ImageWindowController :return: Payload dictionary. :rtype: dict """ payload = controller.display_properties.copy() distance_available = controller.pixel_size_m() is not None axis_label_mode = controller.axis_label_mode if axis_label_mode == 'distance' and not distance_available: controller.set_display_properties({'axis_label_mode': 'pixel'}) axis_label_mode = 'pixel' payload['axis_label_mode'] = axis_label_mode payload['distance_available'] = distance_available settings = self._resolve_scalebar_settings(controller) payload['scalebar_visible'] = settings.visible if settings else False payload['scalebar_enabled'] = distance_available and settings is not None return payload
[docs] @staticmethod def _display_signature_from_payload(payload: dict[str, Any]) -> tuple[Any, ...]: """ Build a stable signature for display payload comparisons. :param payload: Display payload dictionary. :type payload: dict :return: Tuple signature for payload equality checks. :rtype: tuple """ cmap = payload.get('cmap') cmap_name = getattr(cmap, 'name', str(cmap)) return ( payload.get('vmin'), payload.get('vmax'), cmap_name, payload.get('interpolation'), payload.get('axis_label_mode'), payload.get('distance_available'), payload.get('scalebar_visible'), payload.get('scalebar_enabled'), )
[docs] def _sync_menu_state(self, controller: ImageWindowController) -> None: """ Sync menu check states to the active image. :param controller: Image controller. :type controller: ImageWindowController """ mode = controller.axis_label_mode self.axis_label_off_checked.emit(mode == 'off') self.axis_label_pixel_checked.emit(mode == 'pixel') self.axis_label_distance_checked.emit(mode == 'distance') self.axis_label_menu_enabled.emit(True) has_distance = controller.pixel_size_m() is not None self.axis_label_distance_enabled.emit(has_distance) settings = self._resolve_scalebar_settings(controller) scalebar_visible = settings.visible if settings else False self.scalebar_checked.emit(scalebar_visible) self.scalebar_enabled.emit(has_distance and settings is not None)
[docs] def _set_axis_label_mode_checked(self, mode: str, checked: bool) -> None: """ Set axis label mode when a menu action is checked. :param mode: Axis label mode to set. :type mode: str :param checked: Whether the menu action is checked. :type checked: bool """ if not checked: return active_window_controller = self.context.active_image() if isinstance(active_window_controller, ImageWindowController): active_window_controller.set_display_properties({'axis_label_mode': mode}) active_window_controller.request_canvas_update.emit() self._sync_menu_state(active_window_controller) self._sync_dock_state(active_window_controller)
[docs] def sync_menu_state_for(self, window_controller: SubWindowController[Any]) -> None: """ Sync menu state for the provided window controller. :param window_controller: Active window controller :type window_controller: SubWindowController """ if isinstance(window_controller, ImageWindowController): self._sync_menu_state(window_controller) else: self.axis_label_off_checked.emit(False) self.axis_label_pixel_checked.emit(False) self.axis_label_distance_checked.emit(False) self.axis_label_menu_enabled.emit(False) self.axis_label_distance_enabled.emit(False) self.scalebar_checked.emit(False) self.scalebar_enabled.emit(False)
[docs] def _set_axis_label_mode_from_event(self, payload: dict[str, Any]) -> None: """ Handle axis label change from dock events. :param payload: Event payload. :type payload: dict """ mode = payload.get('mode') active_window_controller = self.context.active_image() if isinstance(active_window_controller, ImageWindowController): active_window_controller.set_display_properties({'axis_label_mode': mode}) active_window_controller.request_canvas_update.emit() self._sync_menu_state(active_window_controller) self._sync_dock_state(active_window_controller)
[docs] def _resolve_scalebar_settings(self, controller: ImageWindowController) -> ScaleBarSettings | None: """ Get scalebar settings from the controller or initialize defaults. :param controller: Image controller. :type controller: ImageWindowController :return: Scalebar settings or None. :rtype: ScaleBarSettings | None """ raw = controller.scalebar_settings if raw: return ScaleBarSettings.from_dict(raw) default = default_scalebar_settings(controller) if default is None: return None controller.set_display_properties({'scalebar': default.to_dict()}) return default
[docs] def _set_scalebar_visible_from_event(self, payload: dict[str, Any]) -> None: """ Handle scalebar toggle from dock. :param payload: Event payload. :type payload: dict """ visible = bool(payload.get('visible', False)) self._set_scalebar_visible_for_active(visible)
[docs] def _set_scalebar_visible_checked(self, checked: bool) -> None: """ Handle scalebar menu toggles. :param checked: Whether scalebar should be visible. :type checked: bool """ self._set_scalebar_visible_for_active(checked)
[docs] def _set_scalebar_visible_for_active(self, visible: bool) -> None: """ Set scalebar visibility for the active image. :param visible: Whether to show the scalebar. :type visible: bool """ controller = self.context.active_image() if not isinstance(controller, ImageWindowController): return settings = self._resolve_scalebar_settings(controller) if settings is None: return updated = replace(settings, visible=visible) controller.set_display_properties({'scalebar': updated.to_dict()}) self._apply_scalebar_overlay(controller) self._sync_menu_state(controller) self._sync_dock_state(controller)
[docs] def _edit_scalebar_from_event(self) -> None: """ Open the scalebar editing dialog for the active image. """ controller = self.context.active_image() if not isinstance(controller, ImageWindowController): return unit = controller.distance_unit() settings = self._resolve_scalebar_settings(controller) if unit is None or settings is None: self.context.show_message('Scalebar requires valid spatial calibration.', 'warning', 3000) return dialog = ScaleBarDialog(unit, settings, parent=self.dock) if dialog.exec() != QDialog.DialogCode.Accepted: return updated = dialog.settings() controller.set_display_properties({'scalebar': updated.to_dict()}) self._apply_scalebar_overlay(controller) self._sync_menu_state(controller) self._sync_dock_state(controller)
[docs] def _apply_scalebar_overlay(self, controller: ImageWindowController) -> None: """ Apply or remove the scalebar overlay on the given image. :param controller: Image controller. :type controller: ImageWindowController """ settings = self._resolve_scalebar_settings(controller) pixel_size = controller.pixel_size_m() unit = controller.distance_unit() if settings is None or pixel_size is None or unit is None: self._remove_scalebar_overlay(controller) self._last_scalebar_signature.pop(controller.id, None) return if not settings.visible: self._remove_scalebar_overlay(controller) self._last_scalebar_signature.pop(controller.id, None) return pixel_x_m, pixel_y_m = pixel_size length_px = settings.length_m / pixel_x_m height_px = settings.height_m / pixel_y_m if settings.height_m > 0 else 0.0 label_value = settings.length_m / unit.meters_per_unit label = f'{label_value:g} {unit.name}' overlay_id = self._scalebar_ids.get(controller.id) if overlay_id is None: overlay_id = uuid4() self._scalebar_ids[controller.id] = overlay_id signature = ( settings.visible, settings.length_m, settings.height_m, settings.location, settings.bar_color, settings.text_color, settings.background_color, settings.background_alpha, unit.name, pixel_size, ) existing = controller.overlay_manager.get_overlay_by_id(overlay_id, OverlayRole.Permanent) if existing and self._last_scalebar_signature.get(controller.id) == signature: return overlay = OverlayModel(id=overlay_id, type='scalebar', role=OverlayRole.Permanent, target_axis='im_axes') overlay.geometry = { 'length_px': length_px, 'height_px': height_px, 'label': label, 'loc': settings.location, } overlay.style = { 'bar_color': settings.bar_color, 'text_color': settings.text_color, 'background_color': settings.background_color, 'background_alpha': settings.background_alpha, } if existing: controller.overlay_manager.update(overlay) else: controller.overlay_manager.add('im_axes', overlay) self._last_scalebar_signature[controller.id] = signature
[docs] def _remove_scalebar_overlay(self, controller: ImageWindowController) -> None: """ Remove the scalebar overlay from an image controller. :param controller: Image controller. :type controller: ImageWindowController """ overlay_id = self._scalebar_ids.get(controller.id) if overlay_id is None: return controller.overlay_manager.remove_id(overlay_id, OverlayRole.Permanent)
[docs] def _sync_dock_state(self, controller: ImageWindowController) -> None: """ Sync dock widget state with the current controller. :param controller: Image controller. :type controller: ImageWindowController """ payload = self._build_display_payload(controller) self.controller_event.emit(ToolEvent(self.tool_id, 'new_active_window', payload))
[docs] class LevelTool(Tool[LevelToolController]): """ Tool for adjusting image display levels. Provides functionality to modify image display parameters such as intensity levels, color mapping, and interpolation methods. """ id = uuid4() tool_id = 'level' name = 'Level Tool' description = 'A tool to change the image display levels' overlays_to_be_registered = [ OverlaySpec('scalebar', OverlayRole.Permanent, draw_scalebar_overlay), ]
[docs] def create_controller(self, ctx: 'ToolContext') -> 'LevelToolController': """ Create a controller instance for this tool. :param ctx: Tool context :type ctx: ToolContext :return: LevelToolController instance :rtype: LevelToolController """ self._controller = LevelToolController(ctx, self) return self._controller
[docs] def restore_phase(self) -> int: """ Return the restore phase for workspace restore ordering. :return: Restore phase index. :rtype: int """ return 0
[docs] def to_workspace(self, include_data: bool) -> ToolWorkspace: """ Convert the current tool state to a workspace specification. :param include_data: Flag indicating whether to include data arrays. :type include_data: bool :return: Workspace specification containing the tool's state. :rtype: ToolWorkspace """ tool_ws = ToolWorkspace(tool_id=self.tool_id, version='1.0') if self._controller is None: return tool_ws tool_ws.state['dock'] = self._controller.dock_state() return tool_ws
[docs] def from_workspace(self, spec: ToolWorkspace, context: WorkspaceReferenceManager) -> None: """ Restore the tool state from a workspace specification. :param spec: Workspace specification to restore from. :type spec: ToolWorkspace :param context: Workspace reference manager. :type context: WorkspaceReferenceManager :raises RuntimeError: When no controller is available for restore. """ if self._controller is None: raise RuntimeError(f'No controller available for tool {self.tool_id}') dock_state = spec.state.get('dock', {}) self._controller.apply_dock_state(dock_state) self._controller.restore_completed.emit(self)