Source code for radioviz.tools.align_image_tool
# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Align-image tool implementation.
This tool provides functionality for aligning images to a user-defined line.
Users can draw a reference line on an image and rotate the image so that
the line becomes horizontal or vertical. The tool supports both creating
a new aligned image and applying the alignment in-place.
The module implements the following components:
* :class:`AlignImageTool` - Main tool entry point
* :class:`AlignImageToolController` - Controller handling tool logic
* :class:`AlignImageToolSession` - Session managing the alignment workflow
* :class:`AlignImageDialog` - UI dialog for alignment configuration
* :func:`align_image_worker` - Background worker for image rotation
* :func:`calculate_alignment_angle` - Utility for computing rotation angles
* :func:`largest_valid_rectangle` - Utility for finding valid crop regions
.. versionadded:: 1.0.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 (
QButtonGroup,
QComboBox,
QDialog,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
from skimage.transform import rotate
from superqt.utils import thread_worker
from radioviz.controllers.image_window_controller import ImageWindowController
from radioviz.geometry.line_selector import LineGeometry, LineSelector
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.interpolation_utils import INTERPOLATION_LABELS as INTERPOLATION_LABELS_LIST
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 AlignImageRequest:
"""
Parameters required to compute image alignment.
This immutable dataclass encapsulates all the parameters needed to perform
an image alignment operation. It is used to pass configuration from the
session to the background worker.
:ivar image: The input image to be aligned, represented as a numpy array.
:vartype image: np.ndarray
:ivar line_geometry: The geometry of the user-defined reference line.
:vartype line_geometry: LineGeometry
:ivar align_mode: Desired alignment direction; either ``'horizontal'`` or ``'vertical'``.
:vartype align_mode: str
:ivar interpolation_order: Order of interpolation for image rotation (0-5).
:vartype interpolation_order: int
:ivar extension_mode: How to handle image extensions; either ``'fill_value'`` or ``'crop_valid'``.
:vartype extension_mode: str
:ivar fill_value: Value to use for filling extended regions when extension_mode is ``'fill_value'``.
:vartype fill_value: float
"""
image: np.ndarray
line_geometry: LineGeometry
align_mode: str
interpolation_order: int
extension_mode: str
fill_value: float
[docs]
@dataclass(frozen=True)
class AlignImageResult:
"""
Output payload of the image alignment worker.
This immutable dataclass contains the result of an image alignment operation,
including the rotated image and metadata about the transformation applied.
:ivar image: The aligned output image as a numpy array.
:vartype image: np.ndarray
:ivar angle_deg: The rotation angle applied in degrees. Positive values indicate
counter-clockwise rotation.
:vartype angle_deg: float
:ivar crop_rect: The crop rectangle applied to the image when extension_mode is
``'crop_valid'``, given as ``(x0, x1, y0, y1)`` coordinates. None if no
cropping was performed.
:vartype crop_rect: tuple[int, int, int, int] | None
"""
image: np.ndarray
angle_deg: float
crop_rect: tuple[int, int, int, int] | None
INTERPOLATION_LABELS: list[tuple[str, int]] = [(label, idx) for idx, label in enumerate(INTERPOLATION_LABELS_LIST)]
"""
List of interpolation method labels paired with their order values.
This module-level variable provides a mapping between human-readable
interpolation method names and their corresponding order values used
by scikit-image's rotation function.
:ivar list[tuple[str, int]]: List of tuples containing (label, order) pairs.
"""
[docs]
def calculate_alignment_angle(line_geometry: LineGeometry, align_mode: str) -> float:
"""
Compute the rotation angle needed to align *line_geometry*.
The selector geometry is expressed in image coordinates (x to the right,
y to the bottom). The function preserves line direction: opposite selector
directions produce angles differing by 180 degrees.
:param line_geometry: The line geometry to be aligned.
:type line_geometry: LineGeometry
:param align_mode: Desired alignment mode; either ``'horizontal'`` or ``'vertical'``.
:type align_mode: str
:return: Rotation angle in degrees. Positive values rotate the image
counter-clockwise, negative values rotate clockwise.
:rtype: float
"""
v = line_geometry.as_vector()
current_angle = float(np.degrees(np.arctan2(-v[1], v[0])))
target_angle = 0.0 if align_mode == 'horizontal' else 90.0
return target_angle - current_angle
[docs]
def largest_valid_rectangle(mask: np.ndarray) -> tuple[int, int, int, int] | None:
"""
Find the largest axis-aligned rectangle containing only valid pixels.
This function uses a histogram-based algorithm to efficiently find the
largest rectangle in a binary mask where all pixels are True (valid).
The algorithm runs in O(n*m) time for an n x m mask.
:param mask: 2D boolean mask where True indicates valid pixels.
:type mask: np.ndarray
:return: Tuple of rectangle coordinates ``(x0, x1, y0, y1)`` defining the
largest valid rectangle, or None if no valid area exists.
:rtype: tuple[int, int, int, int] | None
:raises ValueError: If mask is not a 2-dimensional array.
"""
if mask.ndim != 2:
raise ValueError('Mask must be 2D')
h, w = mask.shape
if h == 0 or w == 0 or not np.any(mask):
return None
heights = np.zeros(w, dtype=np.int64)
best_area = 0
best_rect: tuple[int, int, int, int] | None = None
for y in range(h):
heights = np.where(mask[y], heights + 1, 0)
stack: list[int] = []
for x in range(w + 1):
curr_h = int(heights[x]) if x < w else 0
while stack and curr_h < int(heights[stack[-1]]):
top = stack.pop()
rect_h = int(heights[top])
left = stack[-1] + 1 if stack else 0
right = x
area = rect_h * (right - left)
if area > best_area and rect_h > 0:
y1 = y + 1
y0 = y1 - rect_h
best_area = area
best_rect = (left, right, y0, y1)
stack.append(x)
return best_rect
[docs]
def _cast_like_input(rotated: np.ndarray, dtype: np.dtype) -> np.ndarray:
"""
Cast rotated image data back to the original input dtype.
This function ensures that the rotated image data is converted to the
specified dtype while preserving reasonable value ranges. For integer
types, values are rounded to the nearest integer and clipped to the
valid range for that type to prevent overflow.
:param rotated: Rotated image data to be cast.
:type rotated: np.ndarray
:param dtype: Desired output data type (typically the original image dtype).
:type dtype: np.dtype
:return: Image data cast to the specified dtype. For integer types, values
are rounded and clipped to the valid range. Float types are cast directly.
:rtype: np.ndarray
"""
if np.issubdtype(dtype, np.integer):
info = np.iinfo(dtype)
return cast(np.ndarray, np.clip(np.rint(rotated), info.min, info.max).astype(dtype))
return rotated.astype(dtype)
[docs]
@thread_worker
def align_image_worker(request: AlignImageRequest) -> AlignImageResult:
"""
Rotate an image in a background thread according to alignment parameters.
This function is decorated with ``@thread_worker`` to execute the image
rotation operation in a separate thread, keeping the UI responsive during
potentially expensive transformations. It handles the complete alignment
workflow including angle calculation, rotation, optional cropping, and
dtype conversion.
:param request: Parameters required to compute image alignment.
:type request: AlignImageRequest
:return: Result of the alignment containing the rotated image, the applied
rotation angle in degrees, and optional crop rectangle coordinates.
:rtype: AlignImageResult
"""
angle_deg = calculate_alignment_angle(request.line_geometry, request.align_mode)
rotated = rotate( # type: ignore[no-untyped-call]
request.image,
angle=angle_deg,
resize=True,
order=request.interpolation_order,
mode='constant',
cval=request.fill_value,
preserve_range=True,
)
crop_rect: tuple[int, int, int, int] | None = None
if request.extension_mode == 'crop_valid':
valid = rotate( # type: ignore[no-untyped-call]
np.ones_like(request.image, dtype=np.float32),
angle=angle_deg,
resize=True,
order=0,
mode='constant',
cval=0.0,
preserve_range=True,
)
crop_rect = largest_valid_rectangle(valid > 0.5)
if crop_rect is not None:
x0, x1, y0, y1 = crop_rect
rotated = rotated[y0:y1, x0:x1]
rotated = _cast_like_input(rotated, request.image.dtype)
return AlignImageResult(image=rotated, angle_deg=angle_deg, crop_rect=crop_rect)
[docs]
class AlignImageDialog(QDialog):
"""
Modal dialog for configuring image alignment parameters.
This dialog provides a user interface for selecting alignment options
including the target orientation (horizontal or vertical), output mode
(new image or in-place), interpolation method, and extension handling
for rotated image boundaries.
The dialog is non-modal (modeless) to allow users to interact with
the image canvas while the dialog is open.
:ivar align_horizontal: Radio button for horizontal alignment.
:vartype align_horizontal: QRadioButton
:ivar align_vertical: Radio button for vertical alignment.
:vartype align_vertical: QRadioButton
:ivar output_new_image: Radio button for creating a new aligned image.
:vartype output_new_image: QRadioButton
:ivar output_in_place: Radio button for applying alignment in-place.
:vartype output_in_place: QRadioButton
:ivar interpolation_combo: Combo box for selecting interpolation order.
:vartype interpolation_combo: QComboBox
:ivar extension_combo: Combo box for selecting extension mode.
:vartype extension_combo: QComboBox
:ivar fill_value_spin: Spin box for specifying fill value.
:vartype fill_value_spin: QDoubleSpinBox
:ivar ok_button: Button to confirm alignment operation.
:vartype ok_button: QPushButton
:ivar cancel_button: Button to cancel the operation.
:vartype cancel_button: QPushButton
"""
def __init__(self, parent: QDialog | None = None) -> None:
super().__init__(parent)
self.setModal(False)
self.setWindowTitle('Align image to line')
layout = QVBoxLayout(self)
layout.addWidget(QLabel('Draw/edit a line on the active image, then choose how to align it.'))
form = QFormLayout()
align_group_box = QGroupBox('Alignment', self)
align_layout = QHBoxLayout(align_group_box)
self.align_horizontal = QRadioButton('Horizontal', align_group_box)
self.align_vertical = QRadioButton('Vertical', align_group_box)
self.align_horizontal.setChecked(True)
self._align_group = QButtonGroup(self)
self._align_group.addButton(self.align_horizontal)
self._align_group.addButton(self.align_vertical)
align_layout.addWidget(self.align_horizontal)
align_layout.addWidget(self.align_vertical)
form.addRow(align_group_box)
output_group_box = QGroupBox('Output', self)
output_layout = QHBoxLayout(output_group_box)
self.output_new_image = QRadioButton('Create new image', output_group_box)
self.output_in_place = QRadioButton('Apply in-place', output_group_box)
self.output_new_image.setChecked(True)
self._output_group = QButtonGroup(self)
self._output_group.addButton(self.output_new_image)
self._output_group.addButton(self.output_in_place)
output_layout.addWidget(self.output_new_image)
output_layout.addWidget(self.output_in_place)
form.addRow(output_group_box)
adv_group = QGroupBox('Advanced parameters', self)
adv_form = QFormLayout(adv_group)
self.interpolation_combo = QComboBox(self)
for label, order in INTERPOLATION_LABELS:
self.interpolation_combo.addItem(label, order)
self.interpolation_combo.setCurrentIndex(1)
adv_form.addRow('Interpolation', self.interpolation_combo)
self.extension_combo = QComboBox(self)
self.extension_combo.addItem('Fill with value', 'fill_value')
self.extension_combo.addItem('Crop to valid rectangle', 'crop_valid')
adv_form.addRow('Image extensions', self.extension_combo)
self.fill_value_spin = QDoubleSpinBox(self)
self.fill_value_spin.setDecimals(3)
self.fill_value_spin.setRange(-1e12, 1e12)
self.fill_value_spin.setValue(0.0)
adv_form.addRow('Fill value', self.fill_value_spin)
layout.addLayout(form)
layout.addWidget(adv_group)
buttons = QHBoxLayout()
self.cancel_button = QPushButton('Cancel', self)
self.ok_button = QPushButton('OK', self)
self.ok_button.setEnabled(False)
self.cancel_button.clicked.connect(self.reject)
self.ok_button.clicked.connect(self.accept)
self.extension_combo.currentIndexChanged.connect(self._on_extension_changed)
buttons.addWidget(self.cancel_button)
buttons.addWidget(self.ok_button)
layout.addLayout(buttons)
[docs]
def _on_extension_changed(self) -> None:
"""
Handle changes to the extension mode selection.
This method enables or disables the fill value spin box based on
whether the user selected 'fill_value' or 'crop_valid' mode.
"""
mode = self.extension_combo.currentData()
self.fill_value_spin.setEnabled(mode == 'fill_value')
[docs]
def selected_align_mode(self) -> str:
"""
Get the currently selected alignment mode.
:return: The selected alignment mode, either ``'horizontal'`` or ``'vertical'``.
:rtype: str
"""
if self.align_vertical.isChecked():
return 'vertical'
return 'horizontal'
[docs]
def selected_output_mode(self) -> str:
"""
Get the currently selected output mode.
:return: The selected output mode, either ``'new_image'`` or ``'in_place'``.
:rtype: str
"""
if self.output_in_place.isChecked():
return 'in_place'
return 'new_image'
[docs]
class AlignImageToolSession(BaseToolSession['AlignImageToolController', 'ImageWindowController']):
"""
Session driving the align-image workflow for a single active image.
This session class manages the complete alignment workflow, including
the configuration dialog, the line selector for defining the reference
line, and the background worker that performs the rotation. It also
handles cleanup of UI elements and error reporting.
The session follows the tool session lifecycle: it is created when the
tool is activated, started via :meth:`on_start`, and cleaned up via
:meth:`on_finish` or :meth:`on_cancel`.
:ivar dialog_window: The alignment configuration dialog instance.
:vartype dialog_window: Optional[AlignImageDialog]
:ivar line_selector: The interactive line selector for defining the reference line.
:vartype line_selector: Optional[LineSelector]
"""
def __init__(self, tool_controller: 'AlignImageToolController', window_controller: ImageWindowController) -> None:
"""
Initialize a new alignment tool session.
:param tool_controller: The tool controller that owns this session.
:type tool_controller: AlignImageToolController
:param window_controller: The image window controller to operate on.
:type window_controller: ImageWindowController
: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.dialog_window: Optional[AlignImageDialog] = None
self.line_selector: Optional[LineSelector] = None
self._request: Optional[AlignImageRequest] = None
[docs]
def on_start(self) -> None:
"""
Start the alignment session.
This method is called when the session becomes active. It creates and
shows the configuration dialog, initializes the line selector for
interactive line drawing, and displays a user instruction message.
"""
self.dialog_window = AlignImageDialog()
self.dialog_window.accepted.connect(self._on_confirmed)
self.dialog_window.rejected.connect(lambda: self.cancel('user cancel'))
self.dialog_window.show()
view = self.window_controller.require_view()
self.line_selector = LineSelector(view.canvas.im_axes, color='yellow', snap_deg=30)
self.line_selector.selector_created.connect(self._on_selector_created)
self.line_selector.selector_canceled.connect(self._on_selector_canceled)
self.tool_controller.context.show_message('Draw a reference line and click OK to align image.', 'info', 4000)
[docs]
def _on_selector_created(self) -> None:
"""
Handle line selector creation event.
This callback is triggered when the user successfully creates a line
selector on the image. It enables the OK button in the dialog to allow
the user to proceed with the alignment.
"""
if self.dialog_window is not None:
self.dialog_window.ok_button.setEnabled(True)
[docs]
def _on_selector_canceled(self) -> None:
"""
Handle line selector cancellation event.
This callback is triggered when the user cancels the line selector
operation. It disables the OK button in the dialog.
"""
if self.dialog_window is not None:
self.dialog_window.ok_button.setEnabled(False)
[docs]
def _on_confirmed(self) -> None:
"""
Handle dialog confirmation event.
This method is called when the user clicks OK in the alignment dialog.
It creates the alignment request, checks for dependencies if in-place
mode is selected, and starts the background worker for image rotation.
"""
if self.line_selector is None or self.line_selector.geometry is None or self.dialog_window is None:
return
self._request = AlignImageRequest(
image=self.window_controller.image_data,
line_geometry=self.line_selector.geometry,
align_mode=self.dialog_window.selected_align_mode(),
interpolation_order=int(self.dialog_window.interpolation_combo.currentData()),
extension_mode=self.dialog_window.extension_combo.currentData(),
fill_value=float(self.dialog_window.fill_value_spin.value()),
)
if self.dialog_window.selected_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)
ans = QMessageBox.warning(
self.dialog_window,
'Dependent items detected',
f'This in-place rotation 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
self._teardown_line_selector()
worker = align_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: AlignImageResult) -> None:
"""
Handle successful completion of the alignment worker.
This method is called when the background worker successfully completes
the image rotation. It commits the result to the controller and finishes
the session.
:param result: The alignment result containing the rotated image and metadata.
:type result: AlignImageResult
"""
if self._request is None or self.dialog_window is None:
self.cancel('Missing request state')
return
output_mode = self.dialog_window.selected_output_mode()
self.tool_controller.commit_result(self.window_controller, self._request, result, output_mode)
self.assign_payload(result)
self.finish()
[docs]
def _on_error(self, error: Exception) -> None:
"""
Handle errors from the alignment worker.
This method is called when the background worker encounters an error.
It displays an error message and cancels the session.
:param error: The exception that was raised by the worker.
:type error: Exception
"""
self.tool_controller.context.show_message(f'Align image failed: {error}', 'error', 6000)
self.cancel(str(error))
[docs]
def on_cancel(self, reason: str) -> None:
"""
Cancel the alignment session.
This method is called when the session is cancelled. It cleans up
the line selector and closes the dialog.
:param reason: The reason for cancellation.
:type reason: str
"""
self._teardown_line_selector()
self._close_dialog()
[docs]
def on_finish(self) -> None:
"""
Finish the alignment session successfully.
This method is called when the session completes successfully.
It cleans up the line selector and closes the dialog.
"""
self._teardown_line_selector()
self._close_dialog()
[docs]
def _teardown_line_selector(self) -> None:
"""
Clean up the line selector.
This method removes the line selector from the canvas and disconnects
its signals to prevent memory leaks.
"""
if self.line_selector is None:
return
self.line_selector.clear()
self.line_selector.disconnect_signals()
self.line_selector = None
[docs]
def _close_dialog(self) -> None:
"""
Close the alignment configuration dialog.
This method closes and clears the reference to the dialog window.
"""
if self.dialog_window is None:
return
self.dialog_window.close()
self.dialog_window = None
[docs]
class AlignImageToolController(ToolController[ImageWindowController, AlignImageToolSession]):
"""
Controller for image alignment to a user-defined line.
This controller manages the image alignment tool, handling the creation
of alignment sessions, dependency tracking for in-place modifications,
and committing alignment results. It follows the layered architecture
by coordinating between the tool session and the image window controller.
The controller provides signals and action descriptors for enabling/disabling
the tool menu entry based on whether an image is currently active.
:ivar active_session: The currently active alignment session, if any.
:vartype active_session: Optional[AlignImageToolSession]
:ivar procedure_can_start: Action descriptor controlling whether the
alignment procedure can be started.
: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[AlignImageToolController]) -> None:
"""
Initialize the alignment 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)
[docs]
def _on_active_image_changed(self, new_window_controller: SubWindowController[Any] | None) -> None:
"""
Handle changes to the active image controller.
This method is called when the active image window changes. It updates
the procedure_can_start action based on whether the new active window
is an image window 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 create_session(self, window_controller: ImageWindowController) -> AlignImageToolSession:
"""
Create a new alignment session for the given window controller.
:param window_controller: The window controller to create a session for.
:type window_controller: SubWindowController
:return: A new AlignImageToolSession instance.
:rtype: AlignImageToolSession
"""
session = AlignImageToolSession(self, window_controller)
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.
This method is used to warn users before performing an in-place
alignment that would invalidate dependent items.
: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.
This method creates a human-readable summary of all items that would
be affected by an in-place alignment operation.
: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: AlignImageRequest,
result: AlignImageResult,
output_mode: str,
) -> None:
"""
Commit the alignment result to the image controller.
This method handles both creating a new aligned image and applying
the alignment in-place. For in-place operations, it also invalidates
any dependent items.
:param image_controller: The image controller to commit the result to.
:type image_controller: ImageWindowController
:param request: The original alignment request parameters.
:type request: AlignImageRequest
:param result: The alignment result containing the rotated image.
:type result: AlignImageResult
: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 aligned ({result.angle_deg:.2f} deg).', '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 aligned ({result.angle_deg:.2f} deg).', 'info', 4000)
[docs]
def _invalidate_dependencies(self, image_controller: ImageWindowController) -> None:
"""
Invalidate and close all items that depend on the given image.
This method is called when an in-place alignment is performed. It
notifies other tools and closes derived image windows that depend
on the modified 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 rotation 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.
This method searches through the window manager's window list to find
all image windows that were derived from the given image controller.
: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: AlignImageRequest,
result: AlignImageResult,
) -> None:
"""
Create a new image window with the aligned image.
This method creates a new ImageWindowController with the aligned image
data and appropriate source metadata, then emits a request to create
a new window.
:param image_controller: The original image controller.
:type image_controller: ImageWindowController
:param request: The original alignment request parameters.
:type request: AlignImageRequest
:param result: The alignment result containing the rotated image.
:type result: AlignImageResult
"""
source_h, source_w = request.image.shape[:2]
label = append_suffix_before_extension(image_controller.name, '-aligned')
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(angle_deg=float(result.angle_deg), mode=request.align_mode, crop_rect=result.crop_rect),
),
),
)
derived_metadata = image_controller.derive_metadata_for_child(source)
aligned_controller = ImageWindowController(source=source, image_data=result.image, metadata=derived_metadata)
self.request_new_window.emit(WindowRequest(controller=aligned_controller, window_type='image'))
[docs]
class AlignImageTool(Tool[AlignImageToolController]):
"""
Tool entry point for aligning images to a user-defined line.
This tool provides functionality for rotating images so that a user-defined
line becomes horizontal or vertical. It follows the tool lifecycle pattern
by creating a controller when activated and managing the alignment workflow
through sessions.
The tool integrates with the main application menu under the Transform
category and provides both modal dialog-based configuration and interactive
line drawing on the image canvas.
: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[AlignImageToolController]
"""
id = uuid4()
tool_id = 'alignline'
name = 'Align Image'
description = 'Rotate an image to align a feature to horizontal or vertical.'
def __init__(self) -> None:
"""
Initialize the align image tool.
This constructor initializes the tool and sets up the controller reference
to None until create_controller is called.
"""
super().__init__()
self._controller: Optional[AlignImageToolController] = None
[docs]
def create_controller(self, ctx: ToolContext) -> AlignImageToolController:
"""
Create and return the tool controller.
This method is called when the tool is activated. It creates an
AlignImageToolController that manages the tool's business logic.
:param ctx: The tool context providing access to application services.
:type ctx: ToolContext
:return: The created tool controller.
:rtype: AlignImageToolController
"""
self._controller = AlignImageToolController(ctx, self)
return self._controller