# Copyright 2025–2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Module for image window views and canvas management.
This module provides the view components for displaying images in a dedicated
window with support for overlays, context menus, and interactive features.
It includes specialized canvas widgets and window containers for handling
image data visualization.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Generic, Optional
import numpy as np
from matplotlib.backend_bases import Event, MouseEvent
from matplotlib.colorbar import Colorbar
from matplotlib.image import AxesImage
from matplotlib.ticker import FuncFormatter, MaxNLocator, ScalarFormatter
from PySide6.QtCore import QPoint, Qt, QTimer, Signal
from PySide6.QtWidgets import QVBoxLayout, QWidget
from typing_extensions import TYPE_CHECKING
if sys.version_info >= (3, 10):
from typing import TypeVar
else:
from typing_extensions import TypeVar
from radioviz.models.overlays import OverlayRenderer
from radioviz.services.color_service import cmap_to_mpl
from radioviz.services.mpl_helpers import MplContextMenuBridge, NavigationModeSuspender
from radioviz.services.spatial_calibration import axis_decimal_places
from radioviz.views.canvas_widget import AdvancedToolbar, MPLCanvas, TiffCanvas
from radioviz.views.sub_window import SubWindow
if TYPE_CHECKING:
from radioviz.controllers.image_window_controller import ImageWindowController
from radioviz.controllers.sub_window_controller import SubWindowController
from radioviz.models.data_model import DataSource
T_SubWindowController = TypeVar(
'T_SubWindowController',
bound='SubWindowController[Any]',
)
"""Type of sub-window controller paired with :class:`CanvasWindow`."""
T_Canvas = TypeVar(
'T_Canvas',
bound=MPLCanvas,
)
"""Type of matplotlib canvas hosted inside :class:`CanvasWindow`."""
[docs]
class CanvasWindow(SubWindow[T_SubWindowController], Generic[T_SubWindowController, T_Canvas]):
"""
Base window class for canvas-based views.
This class serves as a base for windows that contain matplotlib canvases,
providing common functionality for canvas management and interaction.
"""
def __init__(self, controller: T_SubWindowController, parent: Optional[QWidget] = None) -> None:
"""
Initialize the canvas window.
:param controller: The controller managing this window
:type controller: SubWindowController[Any]
:param parent: Parent widget, defaults to None
:type parent: QWidget, optional
"""
super().__init__(controller, parent)
self.canvas: T_Canvas = self.create_canvas()
[docs]
def create_canvas(self) -> T_Canvas:
"""
Factory hook used to build the matplotlib canvas widget for the window.
Subclasses must override this to provide the concrete canvas type they need.
"""
raise NotImplementedError('Subclasses must implement create_canvas.')
[docs]
class ImageWindow(CanvasWindow['ImageWindowController', TiffCanvas]):
"""
Window class for displaying image data with advanced features.
This class implements a specialized window for displaying image data
with support for overlays, context menus, and interactive point selection.
It manages the matplotlib canvas and handles various user interactions.
"""
mouse_pressed = Signal(object)
"""Signal emitted when mouse button is pressed on the canvas."""
mouse_moved = Signal(object)
"""Signal emitted when mouse is moved over the canvas."""
def __init__(self, controller: 'ImageWindowController', parent: Optional[QWidget] = None) -> None:
"""
Initialize the image window.
:param controller: The controller managing this window
:type controller: ImageWindowController
:param parent: Parent widget, defaults to None
:type parent: QWidget, optional
"""
super().__init__(parent=parent, controller=controller)
self.source: 'DataSource' = self.controller.source
self.path = self.controller.storage.path # it might be None!!!
# this is the output of the imshow, it starts as None
self.plot: Optional[AxesImage] = None
w = QWidget()
w.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
w.customContextMenuRequested.connect(self._on_context)
layout = QVBoxLayout(w)
self.toolbar = AdvancedToolbar(self.canvas, w)
self.mpl_context_menu_bridge = MplContextMenuBridge(self.canvas)
self.toolbar_suspender = NavigationModeSuspender(self.toolbar)
self.overlay_renderer = self.initialize_overlay_renderer()
layout.addWidget(self.toolbar)
layout.addWidget(self.canvas)
self.setWidget(w)
self.set_window_title()
self.resize(600, 500)
self._colorbar: Optional[Colorbar] = None
self._colorbar_signature: Optional[tuple[Optional[float], Optional[float], str]] = None
self._canvas_update_timer = QTimer(self)
self._canvas_update_timer.setSingleShot(True)
self._canvas_update_timer.timeout.connect(self._apply_pending_canvas_update)
self._pending_image: Optional[np.ndarray] = None
self._pending_factor: Optional[int] = None
self._view_change_timer = QTimer(self)
self._view_change_timer.setSingleShot(True)
self._view_change_timer.timeout.connect(self._handle_view_change)
# connect mpl events
self.canvas.mpl_connect('button_press_event', self._on_press)
self.canvas.mpl_connect('motion_notify_event', self._on_move)
self._first_draw_cid = self.canvas.mpl_connect('draw_event', self._on_first_draw)
self.canvas.im_axes.callbacks.connect('xlim_changed', self._on_view_change)
self.canvas.im_axes.callbacks.connect('ylim_changed', self._on_view_change)
self.controller.point_selection_mode.connect(self.handle_point_selection_mode)
self.controller.display_resolution_ready.connect(self._on_level_ready)
self.controller.axis_label_mode_changed.connect(self._apply_axis_label_mode)
self._requested_factor = self.controller._coarse_factor
self._request_display_update()
[docs]
def create_canvas(self) -> TiffCanvas:
"""
Create the TIFF canvas used by image windows.
:return: A newly constructed TIFF canvas
:rtype: TiffCanvas
"""
return TiffCanvas()
[docs]
def _on_first_draw(self, event: Event) -> None:
"""
Handle the first draw event of the canvas.
Connects resize event handler and disconnects first draw handler after first draw.
Triggers view change handling after initial drawing.
:param event: The matplotlib draw event
:type event: matplotlib.backend_bases.DrawEvent
"""
if self.plot is not None:
self.canvas.mpl_connect('resize_event', self._on_view_change)
self.canvas.mpl_disconnect(self._first_draw_cid)
self._handle_view_change()
[docs]
def _on_view_change(self, event: Event) -> None:
"""
Handle view change events on the axes.
Prevents recursive calls during updates and schedules view change handling.
"""
if getattr(self, '_updating_view', False):
return
self._view_change_timer.start(0)
[docs]
def _handle_view_change(self) -> None:
"""
Process view change by updating requested factor and requesting display update.
Calculates the appropriate display resolution factor based on current view
and requests an update to the display with that factor.
"""
self._update_requested_factor_from_view()
self._apply_axis_label_mode()
self._request_display_update()
[docs]
def _update_requested_factor_from_view(self) -> None:
"""
Calculate the requested display factor based on current view density.
Determines the appropriate scaling factor based on how many pixels per screen
unit are currently visible in the view.
:return: The calculated display factor
:rtype: int
"""
x0, x1 = self.canvas.im_axes.get_xlim()
y0, y1 = self.canvas.im_axes.get_ylim()
data_width = abs(x1 - x0)
data_height = abs(y1 - y0)
self.canvas.draw_idle() # type: ignore[no-untyped-call]
self.canvas.flush_events() # type: ignore[no-untyped-call]
# axis size in screen pixels
bbox = self.canvas.im_axes.get_window_extent()
axis_width_px = bbox.width
axis_height_px = bbox.height
if axis_width_px <= 0 or axis_height_px <= 0:
return
px_per_screen_x = data_width / axis_width_px
px_per_screen_y = data_height / axis_height_px
px_per_screen = max(px_per_screen_x, px_per_screen_y)
self._requested_factor = self._factor_from_density(px_per_screen)
[docs]
def _factor_from_density(self, px_per_screen: float) -> int:
"""
Choose the coarsest factor that still preserves visual information.
Selects the largest allowed scale factor that doesn't exceed the pixel density
threshold to maintain visual quality while optimizing performance.
:param px_per_screen: Pixels per screen unit in current view
:type px_per_screen: float
:return: The selected scale factor
:rtype: int
"""
for factor in sorted(self.controller.allowed_scale_factor):
if px_per_screen <= factor:
return factor
return self.controller.allowed_scale_factor[-1]
[docs]
def _request_display_update(self) -> None:
"""
Request a display update with the current requested factor.
Sends a request to the controller to prepare the appropriate resolution level
for display based on the currently requested factor.
"""
self.controller.request_resolution_level(self._requested_factor)
[docs]
def _on_level_ready(self, factor: int) -> None:
"""
Handle when a requested display level is ready for rendering.
Processes the received image data and updates the canvas display if the
factor matches the current request.
:param factor: The resolution factor for which data is ready
:type factor: int
"""
if not self._factor_matches_request(factor):
return
image = self.controller.get_resolution_level(factor)
if image is None:
return
self._schedule_canvas_update(image, factor)
[docs]
def _factor_matches_request(self, factor: int) -> bool:
"""
Check if the given factor matches the current requested factor.
Compares the provided factor against the snapped version of the requested factor.
:param factor: The factor to check
:type factor: int
:return: True if factors match, False otherwise
:rtype: bool
"""
snapped = self.controller._snap_factor(self._requested_factor)
return factor == snapped
[docs]
def _update_canvas(self, image: np.ndarray, factor: int) -> None:
"""
Update the canvas with the provided image data.
Redraws the image on the canvas with appropriate scaling and maintains
current view limits and colorbar.
:param image: The image data to display
:type image: numpy.ndarray
:param factor: The scale factor used for the image
:type factor: int
"""
self._updating_view = True
try:
if self.plot is None:
xlim = None
ylim = None
else:
xlim = self.canvas.im_axes.get_xlim()
ylim = self.canvas.im_axes.get_ylim()
scale = 1 / factor
extent: tuple[float, float, float, float] = (
0,
image.shape[1] / scale,
image.shape[0] / scale,
0,
)
if self.plot is None:
self.plot = self.canvas.im_axes.imshow(image, extent=extent, **self.controller.mpl_display_properties())
else:
# If the image shape changed (e.g., after in-place rotation),
# reset view limits to keep the image centered.
prev_shape = getattr(self, '_last_image_shape', None)
if prev_shape is not None and prev_shape != image.shape:
xlim = None
ylim = None
self.plot.set_data(image)
self.plot.set_extent(extent)
self.set_image_properties()
self._last_image_shape = image.shape
self._update_colorbar_if_needed()
if xlim and ylim:
self.canvas.im_axes.set_xlim(xlim)
self.canvas.im_axes.set_ylim(ylim)
self._apply_axis_label_mode()
self.canvas.draw_idle() # type: ignore[no-untyped-call]
finally:
self._updating_view = False
[docs]
def set_window_title(self) -> None:
"""
Set the window title based on controller name and dirty state.
Updates the window title to include an asterisk (*) suffix if the
controller indicates that the data has been modified but not yet saved.
The title is derived from the controller's name property.
.. seealso::
:meth:`on_data_state_changed`
"""
window_title = self.controller.name
if self.controller.is_dirty():
window_title += '*'
self.setWindowTitle(window_title)
[docs]
def _update_colorbar_if_needed(self) -> None:
"""
Ensure the colorbar exists and update it only when display properties change.
"""
if self.plot is None:
return
signature = self._build_colorbar_signature()
if self._colorbar is None:
self._colorbar = self.canvas.fig.colorbar(self.plot, cax=self.canvas.cb_axes)
self._colorbar_signature = signature
return
if signature != self._colorbar_signature:
self._colorbar.update_normal(self.plot)
self._colorbar_signature = signature
[docs]
def _build_colorbar_signature(self) -> tuple[Optional[float], Optional[float], str]:
"""
Build a signature of display properties that affect the colorbar.
:return: Tuple of (vmin, vmax, cmap_name).
:rtype: tuple[object, object, object]
"""
props = self.controller.mpl_display_properties()
cmap = props.get('cmap')
cmap_name = getattr(cmap, 'name', str(cmap))
return props.get('vmin'), props.get('vmax'), cmap_name
[docs]
def initialize_overlay_renderer(self) -> OverlayRenderer:
"""
Initialize the overlay renderer for the canvas.
Creates and configures the overlay renderer to manage visual overlays
on the image display.
:return: The initialized overlay renderer
:rtype: OverlayRenderer
"""
self.overlay_renderer = OverlayRenderer(
self.canvas, self.canvas.axes, self.application_services.overlay_factory
)
self.controller.overlay_manager.request_to_add_overlay.connect(self.overlay_renderer.on_overlay_add)
self.controller.overlay_manager.request_to_remove_overlay.connect(self.overlay_renderer.on_overlay_remove)
self.controller.overlay_manager.request_to_update_overlay.connect(self.overlay_renderer.on_overlay_update)
self.controller.overlay_manager.request_to_change_overlay_visibility.connect(
self.overlay_renderer.on_overlay_visibility_change
)
self.controller.overlay_manager.request_to_toggle_overlay_visibility.connect(
self.overlay_renderer.on_overlay_visibility_toggle
)
return self.overlay_renderer
[docs]
def _on_press(self, event: object) -> None:
"""
Handle mouse press events on the canvas.
:param event: The mouse event data
:type event: MouseEvent
"""
if not isinstance(event, MouseEvent):
return
self.mouse_pressed.emit(event)
[docs]
def _on_move(self, ev: object) -> None:
"""
Handle mouse move events on the canvas.
:param ev: The mouse event data
:type ev: MouseEvent
"""
if not isinstance(ev, MouseEvent):
return
self.mouse_moved.emit(ev)
[docs]
def _on_context(self, pos: QPoint) -> None:
"""
Handle context menu requests on the canvas.
:param pos: Position of the context menu request
:type pos: QPoint
"""
global_pos = self.mpl_context_menu_bridge.handle_context_request(self, pos)
self.context_menu_requested.emit(global_pos, self.controller)
[docs]
def update_canvas(self) -> None:
"""
Update the canvas with current image settings.
Applies current colormap, interpolation, and level settings to
the image and redraws the canvas.
"""
factor = self._current_factor
image = self.controller.get_resolution_level(factor)
if image is None:
self._request_display_update()
return
self._schedule_canvas_update(image, factor)
return
[docs]
def _schedule_canvas_update(self, image: np.ndarray, factor: int) -> None:
"""
Schedule a canvas update and coalesce rapid updates.
:param image: The image data to render.
:type image: numpy.ndarray
:param factor: The scale factor used for the image.
:type factor: int
"""
self._pending_image = image
self._pending_factor = factor
if not self._canvas_update_timer.isActive():
self._canvas_update_timer.start(0)
[docs]
def _apply_pending_canvas_update(self) -> None:
"""
Apply the most recent pending canvas update request.
"""
if self._pending_image is None or self._pending_factor is None:
return
image = self._pending_image
factor = self._pending_factor
self._pending_image = None
self._pending_factor = None
self._current_factor = factor
self._update_canvas(image, factor)
[docs]
def handle_point_selection_mode(self, activate: bool) -> None:
"""
Handle activation of point selection mode.
Enables or disables point selection mode on the canvas and manages
toolbar suspension accordingly.
:param activate: Whether to activate point selection mode
:type activate: bool
"""
self.canvas.point_selection_active = activate
if activate:
self.toolbar_suspender.suspend()
else:
self.toolbar_suspender.restore()
[docs]
def set_image_properties(self) -> None:
"""
Set image properties on the plot.
Updates the clim and other properties of the image plot based on
current controller settings.
:raises ValueError: If invalid property values are encountered
"""
if self.plot is None:
return
props = self.controller.mpl_display_properties()
cmap_ = props.get('cmap', 'hot')
if isinstance(cmap_, str):
# convert it into a cmap
props['cmap'] = cmap_to_mpl(cmap_)
vmin = props.pop('vmin', 0)
vmax = props.pop('vmax', 2**16 - 1)
self.plot.set_clim(vmin, vmax)
if props:
self.plot.update(props)
[docs]
def _apply_axis_label_mode(self) -> None:
"""
Apply axis label mode to the image axes.
This method updates axis visibility, labels, and tick formatting based
on the controller's axis label mode and pixel size metadata.
"""
mode = self.controller.axis_label_mode
axes = self.canvas.im_axes
if mode == 'off':
axes.set_axis_off()
self.canvas.draw_idle() # type: ignore[no-untyped-call]
return
axes.set_axis_on()
if mode == 'pixel':
axes.set_xlabel('X (px)')
axes.set_ylabel('Y (px)')
axes.xaxis.set_major_formatter(ScalarFormatter())
axes.yaxis.set_major_formatter(ScalarFormatter())
self.canvas.draw_idle() # type: ignore[no-untyped-call]
return
pixel_size = self.controller.pixel_size_m()
unit = self.controller.distance_unit()
if pixel_size is None or unit is None:
axes.set_xlabel('X (px)')
axes.set_ylabel('Y (px)')
self.canvas.draw_idle() # type: ignore[no-untyped-call]
return
pixel_x_m, pixel_y_m = pixel_size
unit_name = unit.name
meters_per_unit = unit.meters_per_unit
x_decimals = axis_decimal_places(pixel_x_m, meters_per_unit, axes.get_xlim())
y_decimals = axis_decimal_places(pixel_y_m, meters_per_unit, axes.get_ylim())
def _x_formatter(value: float, _pos: int) -> str:
return f'{(value * pixel_x_m) / meters_per_unit:.{x_decimals}f}'
def _y_formatter(value: float, _pos: int) -> str:
return f'{(value * pixel_y_m) / meters_per_unit:.{y_decimals}f}'
axes.set_xlabel(f'X ({unit_name})')
axes.set_ylabel(f'Y ({unit_name})')
axes.xaxis.set_major_locator(MaxNLocator(nbins=6))
axes.yaxis.set_major_locator(MaxNLocator(nbins=6))
axes.xaxis.set_major_formatter(FuncFormatter(_x_formatter))
axes.yaxis.set_major_formatter(FuncFormatter(_y_formatter))
self.canvas.draw_idle() # type: ignore[no-untyped-call]
[docs]
def on_data_state_changed(self) -> None:
"""
Update the window title when data state changes.
This method is called when the underlying data state changes, such as
when the data is modified or saved. It updates the window title to
reflect the current persistence status.
.. seealso::
:meth:`set_window_title`
"""
self.set_window_title()
[docs]
def export_to_path(self, path: Path) -> None:
"""
Export the current image display to a file.
Saves the current figure displayed in the canvas to the specified path
using matplotlib's savefig functionality.
:param path: The file path where the image should be saved
:type path: pathlib.Path
"""
self.canvas.figure.savefig(path)