Source code for radioviz.tools.flip_image_tool

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Flip image tool implementation.

This tool provides horizontal and vertical flipping of images. The flip can be
applied either in-place to the current image or by creating a derived image.
The implementation follows the standard tool workflow, executing the flip in a
background thread and using a tool session for lifecycle management.

The module implements the following components:

* :class:`FlipImageTool` - Main tool entry point
* :class:`FlipImageToolController` - Controller handling tool logic
* :class:`FlipImageToolSession` - Session managing the flip workflow
* :func:`flip_image_worker` - Background worker performing the flip

.. versionadded:: 1.1.0
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, List, Optional, cast
from uuid import uuid4

import numpy as np
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox, QWidget
from superqt.utils import thread_worker

from radioviz.controllers.image_window_controller import ImageWindowController
from radioviz.models.data_model import DataOrigin, DataSource, SpatialDerivation
from radioviz.models.menu_spec import ToolActionSpec, ToolMenuSpec
from radioviz.models.roi_model import ROI, ROIEnclosure, ROIType
from radioviz.models.transform_model import Transform, TransformType
from radioviz.services.action_descriptor import ActionDescriptor
from radioviz.services.window_manager import WindowRequest
from radioviz.tools.base_controller import ToolController
from radioviz.tools.base_tool import Tool
from radioviz.tools.naming_utils import append_suffix_before_extension
from radioviz.tools.tool_api import BaseToolSession, ToolContext

if TYPE_CHECKING:
    from radioviz.controllers.sub_window_controller import SubWindowController


[docs] @dataclass(frozen=True) class FlipImageRequest: """ Parameters required to compute an image flip. :ivar image: Input image data. :vartype image: np.ndarray :ivar flip_mode: Flip mode, either ``'horizontal'`` or ``'vertical'``. :vartype flip_mode: str """ image: np.ndarray flip_mode: str
[docs] @dataclass(frozen=True) class FlipImageResult: """ Output payload of the flip worker. :ivar image: Flipped image data. :vartype image: np.ndarray :ivar flip_mode: Flip mode applied. :vartype flip_mode: str """ image: np.ndarray flip_mode: str
[docs] @thread_worker def flip_image_worker(request: FlipImageRequest) -> FlipImageResult: """ Flip an image in a background thread. :param request: Parameters required to compute an image flip. :type request: FlipImageRequest :return: Result containing the flipped image and flip mode. :rtype: FlipImageResult :raise ValueError: If an unknown flip mode is requested. """ if request.flip_mode == 'horizontal': flipped = np.flip(request.image, axis=1).copy() elif request.flip_mode == 'vertical': flipped = np.flip(request.image, axis=0).copy() else: raise ValueError(f'Unknown flip mode: {request.flip_mode}') return FlipImageResult(image=flipped, flip_mode=request.flip_mode)
[docs] class FlipImageToolSession(BaseToolSession['FlipImageToolController', 'ImageWindowController']): """ Session handling a single flip operation. :ivar dialog_window: Optional dialog window (unused for this tool). :vartype dialog_window: Optional[object] """ def __init__( self, tool_controller: 'FlipImageToolController', window_controller: ImageWindowController, flip_mode: str, output_mode: str, ) -> None: """ Initialize a new flip tool session. :param tool_controller: The tool controller that owns this session. :type tool_controller: FlipImageToolController :param window_controller: The image window controller to operate on. :type window_controller: SubWindowController :param flip_mode: Flip mode to apply. :type flip_mode: str :param output_mode: Output mode, either ``'new_image'`` or ``'in_place'``. :type output_mode: str :raises TypeError: If window_controller is not an ImageWindowController. """ if not isinstance(window_controller, ImageWindowController): raise TypeError('The window controller must be an image window controller.') super().__init__(tool_controller, window_controller) self.flip_mode = flip_mode self.output_mode = output_mode self._request: Optional[FlipImageRequest] = None
[docs] def on_start(self) -> None: """ Start the flip session. Creates the flip request, checks in-place dependencies if needed, and starts the background worker. """ self._request = FlipImageRequest(image=self.window_controller.image_data, flip_mode=self.flip_mode) if self.output_mode == 'in_place': dependencies = self.tool_controller.count_inplace_dependencies(self.window_controller) if dependencies > 0: summary = self.tool_controller.inplace_dependency_summary(self.window_controller) view = self.window_controller.require_view() ans = QMessageBox.warning( view, 'Dependent items detected', f'This in-place flip will invalidate dependent items:\n\n{summary}\n\nContinue?', QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if ans != QMessageBox.StandardButton.Yes: self.cancel('user cancel') return worker = flip_image_worker(self._request) worker.returned.connect(self._on_result_ready) worker.errored.connect(self._on_error) worker.start()
[docs] def _on_result_ready(self, result: FlipImageResult) -> None: """ Handle successful completion of the flip worker. :param result: The flip result containing the flipped image. :type result: FlipImageResult """ if self._request is None: self.cancel('Missing request state') return self.tool_controller.commit_result(self.window_controller, self._request, result, self.output_mode) self.assign_payload(result) self.finish()
[docs] def _on_error(self, error: Exception) -> None: """ Handle errors from the flip worker. :param error: The exception that was raised by the worker. :type error: Exception """ self.tool_controller.context.show_message(f'Flip image failed: {error}', 'error', 6000) self.cancel(str(error))
[docs] def on_cancel(self, reason: str) -> None: """ Cancel the flip session. :param reason: The reason for cancellation. :type reason: str """ return
[docs] def on_finish(self) -> None: """ Finish the flip session successfully. """ return
[docs] class FlipImageToolController(ToolController[ImageWindowController, FlipImageToolSession]): """ Controller for horizontal/vertical image flips. This controller manages flip sessions, dependency tracking for in-place modifications, and committing flip results. :ivar active_session: The currently active flip session, if any. :vartype active_session: Optional[FlipImageToolSession] :ivar procedure_can_start: Action descriptor controlling menu enablement. :vartype procedure_can_start: ActionDescriptor """ procedure_start_enable = Signal(bool) procedure_can_start = ActionDescriptor('procedure_can_start', 'procedure_start_enable') def __init__(self, tool_ctx: ToolContext, tool: Tool[FlipImageToolController]) -> None: """ Initialize the flip tool controller. :param tool_ctx: The tool context providing access to application services. :type tool_ctx: ToolContext :param tool: The tool instance that owns this controller. :type tool: Tool """ super().__init__(tool_ctx, tool) self.active_session: Optional[FlipImageToolSession] = None
[docs] def _on_active_image_changed(self, new_window_controller: SubWindowController[Any] | None) -> None: """ Handle changes to the active image controller. :param new_window_controller: The newly active sub-window controller. :type new_window_controller: SubWindowController """ super()._on_active_image_changed(new_window_controller) self.procedure_can_start = isinstance(new_window_controller, ImageWindowController)
[docs] def activate(self, flip_mode: str = 'horizontal', output_mode: str = 'new_image') -> None: """ Activate the tool and start a flip session. :param flip_mode: Flip mode to apply. :type flip_mode: str :param output_mode: Output mode, either ``'new_image'`` or ``'in_place'``. :type output_mode: str """ window_controller = cast(ImageWindowController, self.context.active_image()) if window_controller is None: return self.context.request_tool_activation(self) self.active_session = self.create_session(window_controller, flip_mode, output_mode) window_controller.active_tool = self self.active_session.start()
[docs] def create_session( self, window_controller: ImageWindowController, flip_mode: str = 'horizontal', output_mode: str = 'new_image', ) -> FlipImageToolSession: """ Create a new flip session for the given window controller. :param window_controller: The window controller to create a session for. :type window_controller: SubWindowController :param flip_mode: Flip mode to apply. :type flip_mode: str :param output_mode: Output mode to use. :type output_mode: str :return: A new FlipImageToolSession instance. :rtype: FlipImageToolSession """ session = FlipImageToolSession(self, window_controller, flip_mode, output_mode) session.finished.connect(self.deactivate) session.canceled.connect(self.deactivate) return session
[docs] def create_dock(self, parent_window: 'QWidget') -> None: """ Create a dock widget for this tool. This tool does not use a dock widget, so this method returns None. :param parent_window: The parent window to attach the dock to. :return: None, as this tool does not use a dock. """ return None
[docs] def menu_specs(self) -> List[ToolMenuSpec]: """ Get the menu specifications for this tool. :return: List of menu specifications defining the tool's menu entries. :rtype: List[ToolMenuSpec] """ return [ ToolMenuSpec( title='Transform', order=40, entries=[ ToolMenuSpec( title='Horizontal flip', order=30, entries=[ ToolActionSpec( text='New image...', triggered=lambda: self.activate('horizontal', 'new_image'), enabled_changed_signal=self.procedure_start_enable, ), ToolActionSpec( text='In place...', triggered=lambda: self.activate('horizontal', 'in_place'), enabled_changed_signal=self.procedure_start_enable, ), ], ), ToolMenuSpec( title='Vertical flip', order=31, entries=[ ToolActionSpec( text='New image...', triggered=lambda: self.activate('vertical', 'new_image'), enabled_changed_signal=self.procedure_start_enable, ), ToolActionSpec( text='In place...', triggered=lambda: self.activate('vertical', 'in_place'), enabled_changed_signal=self.procedure_start_enable, ), ], ), ], ) ]
[docs] def count_inplace_dependencies(self, image_controller: ImageWindowController) -> int: """ Count the number of items that depend on the given image. :param image_controller: The image controller to check dependencies for. :type image_controller: ImageWindowController :return: The total count of dependent items. :rtype: int """ counts = 0 for controller in self.context.iter_tool_controllers(): if controller is self: continue counts += controller.count_dependencies_for_image(image_controller) counts += len(self._dependent_derived_images(image_controller)) return counts
[docs] def inplace_dependency_summary(self, image_controller: ImageWindowController) -> str: """ Generate a summary of items that depend on the given image. :param image_controller: The image controller to get dependencies for. :type image_controller: ImageWindowController :return: A formatted string summarizing the dependent items. :rtype: str """ parts: list[str] = [] for controller in self.context.iter_tool_controllers(): if controller is self: continue count = controller.count_dependencies_for_image(image_controller) if count: parts.append(f'- {count} {controller.tool_id} item(s)') derived = self._dependent_derived_images(image_controller) if derived: parts.append(f'- {len(derived)} derived image(s)') return '\n'.join(parts) if parts else '- No dependent items'
[docs] def commit_result( self, image_controller: ImageWindowController, request: FlipImageRequest, result: FlipImageResult, output_mode: str, ) -> None: """ Commit the flip result to the image controller. :param image_controller: The image controller to commit the result to. :type image_controller: ImageWindowController :param request: The original flip request parameters. :type request: FlipImageRequest :param result: The flip result containing the flipped image. :type result: FlipImageResult :param output_mode: The output mode, either ``'new_image'`` or ``'in_place'``. :type output_mode: str """ if output_mode == 'new_image': self._create_new_image(image_controller, request, result) self.context.show_message(f'Image flipped ({result.flip_mode}).', 'info', 4000) return self._invalidate_dependencies(image_controller) image_controller.image_data = result.image image_controller._resolution_levels.clear() image_controller._pending_resolutions.clear() image_controller.initialize_display_data() vmin, vmax = image_controller._set_vmin_vmax() image_controller.vmin_init = vmin image_controller.vmax_init = vmax current_vmin = image_controller.display_properties.get('vmin', vmin) current_vmax = image_controller.display_properties.get('vmax', vmax) image_controller.display_properties['vmin'] = float(max(vmin, min(current_vmin, vmax))) image_controller.display_properties['vmax'] = float(max(vmin, min(current_vmax, vmax))) image_controller.request_canvas_update.emit() image_controller.mark_modified() self.context.show_message(f'Image flipped ({result.flip_mode}).', 'info', 4000)
[docs] def _invalidate_dependencies(self, image_controller: ImageWindowController) -> None: """ Invalidate and close all items that depend on the given image. :param image_controller: The image controller whose dependencies should be invalidated. :type image_controller: ImageWindowController """ removed: list[str] = [] for controller in self.context.iter_tool_controllers(): if controller is self: continue count = controller.invalidate_dependencies_for_image(image_controller) if count: removed.append(f'{count} {controller.tool_id}') derived = self._dependent_derived_images(image_controller) for dependent in derived: dependent.close() if derived: removed.append(f'{len(derived)} derived-image') if removed: self.context.show_message( 'In-place flip invalidated dependencies: ' + ', '.join(removed), 'warning', 7000, )
[docs] def _dependent_derived_images(self, image_controller: ImageWindowController) -> list[ImageWindowController]: """ Find all derived images that depend on the given image. :param image_controller: The image controller to find dependents for. :type image_controller: ImageWindowController :return: List of derived image controllers that depend on the given image. :rtype: list[ImageWindowController] """ dependents: list[ImageWindowController] = [] for window in self.context.application_services.window_manager.window_list: if not isinstance(window, ImageWindowController): continue if window is image_controller: continue derivation = window.source.derivation if derivation is None: continue if derivation.parent_id == image_controller.id: dependents.append(window) return dependents
[docs] def _create_new_image( self, image_controller: ImageWindowController, request: FlipImageRequest, result: FlipImageResult, ) -> None: """ Create a new image window with the flipped image. :param image_controller: The original image controller. :type image_controller: ImageWindowController :param request: The original flip request parameters. :type request: FlipImageRequest :param result: The flip result containing the flipped image. :type result: FlipImageResult """ source_h, source_w = request.image.shape[:2] suffix = 'h' if result.flip_mode == 'horizontal' else 'v' label = append_suffix_before_extension(image_controller.name, f'-flipped-{suffix}') source = DataSource( label=label, origin=DataOrigin.DERIVED, parent=image_controller.source, derivation=SpatialDerivation( parent_id=image_controller.id, roi=ROI( ROIType.RECTANGLE, ROIEnclosure.Inside, geometry=dict(extents=(0.0, float(source_w), 0.0, float(source_h)), angle=0.0), ), transform=Transform( TransformType.AFFINE, params=dict(mode=result.flip_mode), ), ), ) derived_metadata = image_controller.derive_metadata_for_child(source) flipped_controller = ImageWindowController(source=source, image_data=result.image, metadata=derived_metadata) self.request_new_window.emit(WindowRequest(controller=flipped_controller, window_type='image'))
[docs] class FlipImageTool(Tool[FlipImageToolController]): """ Tool entry point for flipping images. :ivar id: Unique identifier for this tool instance. :vartype id: uuid.UUID :ivar tool_id: String identifier used for tool registration and lookup. :vartype tool_id: str :ivar name: Human-readable name displayed in menus and dialogs. :vartype name: str :ivar description: Brief description of the tool's functionality. :vartype description: str :ivar _controller: The controller instance created by this tool. :vartype _controller: Optional[FlipImageToolController] """ id = uuid4() tool_id = 'flipimage' name = 'Flip Image' description = 'Flip an image horizontally or vertically.' def __init__(self) -> None: """ Initialize the flip image tool. """ super().__init__() self._controller: Optional[FlipImageToolController] = None
[docs] def create_controller(self, ctx: ToolContext) -> FlipImageToolController: """ Create and return the tool controller. :param ctx: The tool context providing access to application services. :type ctx: ToolContext :return: The created tool controller. :rtype: FlipImageToolController """ self._controller = FlipImageToolController(ctx, self) return self._controller