Source code for radioviz.views.image_window

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