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_cancel(self, reason: str) -> None:
"""
Cancel the session.
:param reason: Cancellation reason.
:type reason: str
"""
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 _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 _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 _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)