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]
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 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