Source code for spindoctor.ui.manual_nav_dialog

"""Interactive manual-navigation dialog (PyQt6).

Renders a composite of the predicted scene against the source image and
lets an operator pick a translation offset by hand.  An ``Auto`` button
runs the same masked-NCC pyramid that correlation-based techniques use,
so the operator can either accept the auto-pick or override it.

This module is GUI-only; no autonomous-pipeline code imports it.  It is
constructed by ``NavTechniqueManual`` (which is opted out of the
auto-discovery registry) on demand.
"""

from __future__ import annotations

import math
from pathlib import Path
from typing import Any, cast

import numpy as np
from filecache import FCPath
from matplotlib.backends.backend_qt import NavigationToolbar2QT
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.figure import Figure
from matplotlib.patches import Circle
from PIL import Image
from PyQt6.QtCore import QPoint, Qt
from PyQt6.QtGui import QImage, QMouseEvent, QPainter, QPixmap, QWheelEvent
from PyQt6.QtWidgets import (
    QApplication,
    QCheckBox,
    QDialog,
    QDoubleSpinBox,
    QFileDialog,
    QFormLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QMessageBox,
    QPushButton,
    QScrollArea,
    QSlider,
    QStatusBar,
    QVBoxLayout,
    QWidget,
)

from spindoctor._version import __version__ as _spindoctor_version
from spindoctor.annotation import Annotations
from spindoctor.config import Config
from spindoctor.obs import ObsSnapshot
from spindoctor.support.correlate import masked_ncc, navigate_with_pyramid_kpeaks
from spindoctor.support.image import apply_linear_gamma_stretch
from spindoctor.support.summary_png import render_annotated_summary_rgb
from spindoctor.support.types import NDArrayBoolType, NDArrayFloatType, NDArrayUint8Type
from spindoctor.ui.common import (
    ZoomPanController,
    build_stretch_controls,
)
from spindoctor.ui.library_entry import build_sidecar_yaml, compute_image_url, infer_obs_metadata

# Correlation map popup (_CorrMapDialog) layout sizing
_CORR_MAP_MIN_WIDTH = 550
_CORR_MAP_MIN_HEIGHT = 520
_CORR_MAP_MARGIN = 4
_CORR_MAP_SPACING = 2

# Semi-transparent white tint when overlaying the binary model mask on RGB
_MASK_OVERLAY_ALPHA = 0.4


def _apply_stretch_gamma(
    image: NDArrayFloatType, black: float, white: float, gamma: float
) -> NDArrayUint8Type:
    """Apply black/white/gamma to a float image and return uint8 mono."""
    scaled = apply_linear_gamma_stretch(image, black=black, white=white, gamma=gamma)
    return cast(NDArrayUint8Type, (scaled * 255.0).astype(np.uint8))


def _bilinear_interpolate_fov(arr: NDArrayFloatType, img_v: float, img_u: float) -> float:
    """Bilinear sample a 2D FOV array at fractional pixel ``(img_v, img_u)``.

    Parameters:
        arr: 2-D float image in FOV coordinates.
        img_v: Fractional row index (v).
        img_u: Fractional column index (u).

    Returns:
        Interpolated sample as a scalar ``float``.
    """
    h, w = arr.shape
    v0 = math.floor(img_v)
    u0 = math.floor(img_u)
    v1 = min(v0 + 1, h - 1)
    u1 = min(u0 + 1, w - 1)
    dv = img_v - v0
    du = img_u - u0
    return float(
        arr[v0, u0] * (1 - du) * (1 - dv)
        + arr[v0, u1] * du * (1 - dv)
        + arr[v1, u0] * (1 - du) * dv
        + arr[v1, u1] * du * dv
    )


def _bilinear_sample_periodic(arr: NDArrayFloatType, y: float, x: float) -> float:
    """Periodic bilinear sample on 2D array arr at float indices (y, x)."""
    h, w = arr.shape
    # Wrap
    x = x % w
    y = y % h
    x0 = math.floor(x)
    y0 = math.floor(y)
    x1 = (x0 + 1) % w
    y1 = (y0 + 1) % h
    dx = x - x0
    dy = y - y0
    v00 = arr[y0, x0]
    v01 = arr[y0, x1]
    v10 = arr[y1, x0]
    v11 = arr[y1, x1]
    return float(
        v00 * (1 - dx) * (1 - dy) + v01 * dx * (1 - dy) + v10 * (1 - dx) * dy + v11 * dx * dy
    )


class _CorrMapDialog(QDialog):
    """Modal popup showing the full 2-D normalized cross-correlation (NCC) surface.

    The NCC surface is rearranged via ``np.fft.fftshift`` for display. The global
    peak marker uses ``np.argmax`` on the NCC surface itself, restricted (if
    ``max_offset_vu`` is provided) to the physically plausible offset range, so
    it matches the peak that the pyramid auto-search would find. Axes are labeled
    in offset pixels (dU for columns, dV for rows). The offset supplied at
    construction time is overlaid as a red cross marker and displayed in the
    legend.
    """

    def __init__(
        self,
        *,
        corr_surface: NDArrayFloatType,
        dv: float,
        du: float,
        max_offset_vu: tuple[int, int] | None = None,
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self.setWindowTitle('Correlation Map')
        self.setMinimumSize(_CORR_MAP_MIN_WIDTH, _CORR_MAP_MIN_HEIGHT)

        layout = QVBoxLayout(self)
        m = _CORR_MAP_MARGIN
        layout.setContentsMargins(m, m, m, m)
        layout.setSpacing(_CORR_MAP_SPACING)

        h, w = corr_surface.shape
        shifted = np.fft.fftshift(corr_surface)

        # Map FFT index ranges to signed offset extents for imshow axes.
        du_min = -(w // 2)
        du_max = (w - 1) // 2
        dv_min = -(h // 2)
        dv_max = (h - 1) // 2

        # Locate the global peak from the NCC surface; restrict the search to
        # the same signed-offset window the pyramid uses so the highlighted peak
        # matches what an auto-navigate would pick.
        search = shifted
        if max_offset_vu is not None:
            search = shifted.copy()
            dv_range = np.arange(dv_min, dv_max + 1)
            du_range = np.arange(du_min, du_max + 1)
            search[np.abs(dv_range) > max_offset_vu[0], :] = -np.inf
            search[:, np.abs(du_range) > max_offset_vu[1]] = -np.inf
        peak_row, peak_col = np.unravel_index(np.argmax(search), search.shape)
        peak_dv = dv_min + int(peak_row)
        peak_du = du_min + int(peak_col)

        fig = Figure(figsize=(5.8, 5.0))
        fig.subplots_adjust(left=0.12, right=0.95, top=0.93, bottom=0.10)
        canvas = FigureCanvasQTAgg(fig)  # type: ignore[no-untyped-call]
        toolbar = NavigationToolbar2QT(canvas, self)  # type: ignore[no-untyped-call]
        ax = fig.add_subplot(111)

        # extent=[left, right, bottom, top] with origin='upper' places dv_min at
        # the top and dv_max at the bottom, consistent with image-row convention.
        extent_arg: tuple[float, float, float, float] = (
            du_min - 0.5,
            du_max + 0.5,
            dv_max + 0.5,
            dv_min - 0.5,
        )
        im = ax.imshow(
            shifted,
            origin='upper',
            extent=extent_arg,
            aspect='equal',
            cmap='viridis',
            interpolation='nearest',
        )
        fig.colorbar(im, ax=ax, label='Normalized Cross-Correlation (NCC)')

        ax.axhline(0, color='white', linewidth=0.5, alpha=0.5)
        ax.axvline(0, color='white', linewidth=0.5, alpha=0.5)

        # Circle around peak; radius scaled to ~4 % of the shorter axis.
        radius = max(2, min(h, w) * 0.04)
        ax.add_patch(
            Circle(
                (peak_du, peak_dv),
                radius=radius,
                fill=False,
                edgecolor='lime',
                linewidth=1.5,
            )
        )
        ax.annotate(
            f'peak  dV={peak_dv:+d}, dU={peak_du:+d}',
            xy=(peak_du, peak_dv),
            xytext=(8, 8),
            textcoords='offset points',
            color='lime',
            fontsize=8,
        )

        ax.plot(
            du,
            dv,
            'r+',
            markersize=14,
            markeredgewidth=2,
            label=f'current  dV={dv:.3f}, dU={du:.3f}',
        )
        ax.legend(loc='upper right', fontsize=8, framealpha=0.7)
        ax.set_xlabel('dU (column offset, pixels)')
        ax.set_ylabel('dV (row offset, pixels)\n[positive = down]')
        ax.set_title('Normalized Cross-Correlation Map')

        layout.addWidget(toolbar)
        layout.addWidget(canvas)

        close_btn = QPushButton('Close')
        close_btn.clicked.connect(self.accept)
        close_row = QHBoxLayout()
        close_row.addStretch()
        close_row.addWidget(close_btn)
        layout.addLayout(close_row)


class _ImageLabel(QLabel):
    """Image label that forwards input events to the dialog handlers."""

    def __init__(self, owner_dialog: ManualNavDialog) -> None:
        super().__init__()
        self._owner = owner_dialog

    def mousePressEvent(self, event: QMouseEvent | None) -> None:
        if event is not None:
            self._owner._on_mouse_press(event)

    def mouseMoveEvent(self, event: QMouseEvent | None) -> None:
        if event is not None:
            self._owner._on_mouse_move(event)

    def mouseReleaseEvent(self, event: QMouseEvent | None) -> None:
        if event is not None:
            self._owner._on_mouse_release(event)

    def wheelEvent(self, event: QWheelEvent | None) -> None:
        if event is not None:
            self._owner._on_wheel(event)


[docs] class ManualNavDialog(QDialog): """Manual navigation dialog for overlaying image and combined model. The viewport uses false color (image in red/blue, blend in green). Separate **Image Stretch** and **Model Stretch** groups each provide black/white/gamma and a display toggle. Model transparency and an optional binary mask overlay sit under model stretch. """ def __init__( self, *, obs: ObsSnapshot, model_img_ext: NDArrayFloatType, model_mask_ext: NDArrayBoolType, config: Config | None, annotations: Annotations | None = None, constraint_normal_seed_deg: float | None = None, parent: QWidget | None = None, ) -> None: """Initialize the manual-navigation dialog. Parameters: obs: The observation snapshot under navigation. model_img_ext: Composite predicted-scene image in ext-FOV coordinates (the union of every NavModel template painted Z-buffer-style by subject range). model_mask_ext: Boolean mask matching ``model_img_ext``; True wherever the composite carries signal. config: Optional ``Config`` override. annotations: Merged ``Annotations`` collection (one entry per NavModel that contributed). Drawn at the chosen ``(dv, du)`` into the labelled summary PNG written alongside a saved sidecar. ``None`` (or empty) is permitted; the saved PNG then carries the source-image grayscale alone. constraint_normal_seed_deg: Optional seed for the constrained-drag normal angle (degrees in the ``(v, u)`` frame, ``+v`` toward ``+u``). Supplied by the caller when the scene's observable axis is known — e.g. the aggregate edge normal of an all-straight flat-ring scene — so the operator can toggle the constraint on without measuring the angle by eye. ``None`` leaves the angle at 0. parent: Optional Qt parent widget. """ super().__init__(parent) self.setWindowTitle('Manual Navigation') self.setMinimumSize(1200, 800) self._obs = obs self._config = config self._annotations: Annotations = annotations if annotations is not None else Annotations() self.setWindowTitle(f'Manual Navigation - {obs.abspath.name}') # Image and model arrays self._img_fov = obs.data # V x U, float64 self._img_ext = obs.extdata # for correlation if model_img_ext.shape != model_mask_ext.shape: raise ValueError( f'model_img_ext shape {model_img_ext.shape} does not match ' f'model_mask_ext shape {model_mask_ext.shape}' ) self._model_img_ext = model_img_ext self._model_mask_ext = model_mask_ext # Stretch/gamma parameters (image) self._image_black = float(np.quantile(self._img_fov, 0.001)) self._image_white = float(np.quantile(self._img_fov, 0.999)) if self._image_black >= self._image_white: self._image_white = self._image_black + 0.01 self._image_gamma = 1.0 # Model stretch (defaults from model at offset (0, 0)) _m0 = np.asarray( self._obs.extract_offset_array(self._model_img_ext, (0.0, 0.0)), dtype=np.float64, ) self._model_stretch_min = float(np.min(_m0)) self._model_stretch_max = float(np.max(_m0)) if self._model_stretch_min >= self._model_stretch_max: self._model_stretch_max = self._model_stretch_min + 1e-6 self._model_black = float(np.quantile(_m0, 0.001)) self._model_white = float(np.quantile(_m0, 0.999)) if self._model_black >= self._model_white: self._model_white = self._model_black + 1e-6 self._model_gamma = 1.0 # 0 = fully opaque model in green blend, 1 = fully transparent (image only) self._model_transparency = 0.5 self._show_image = True # show observation in R/B (and base of G) self._show_model = True # show model contribution in green blend self._show_mask = False # overlay model mask as a binary white tint # For slider mapping (image) self._stretch_min = float(np.min(self._img_fov)) self._stretch_max = float(np.max(self._img_fov)) # Offsets (dv, du) self._dv = 0.0 self._du = 0.0 # Constrained-drag state (rank-1 scenes): when enabled, drags move # the offset only along the normal at ``_constraint_angle_deg``. self._constraint_seed_deg = constraint_normal_seed_deg # Zoom/pan state self._zoom = 1.0 self._drag_start_pos: QPoint | None = None self._drag_mode: str | None = None # 'offset' (right) self._drag_start_offset: tuple[float, float] | None = None # Zoom rendering mode self._zoom_sharp = True # Model slice in FOV coords (aligned with _img_fov); for status bar sampling self._model_slice_fov: NDArrayFloatType | None = None # Precompute correlation surface once for status bar display self._precompute_correlation_surface() # Build UI self._build_ui() self._refresh_overlay() # ---- Correlation helpers ---- def _precompute_correlation_surface(self) -> None: """Compute masked NCC surface on padded arrays; reuse for sampling.""" image = np.asarray(self._img_ext, dtype=np.float64) model = np.asarray(self._model_img_ext, dtype=np.float64) mask = np.asarray(self._model_mask_ext, dtype=bool) # Use the bi-directional (data-mask aware) NCC so the surface the user # sees -- and the peak the Auto button finds -- matches the # composite-template stream this dialog consumes from # ``compose_template_features``; without data_mask the zero-padded # extfov margin biases the peak toward |dV| = extfov_margin_v. data_mask = self._obs.extfov_data_sensor_mask() self._corr_surface, _ = masked_ncc(image, model, mask, data_mask=data_mask) self._corr_h, self._corr_w = self._corr_surface.shape def _offset_to_corr_indices(self, dv: float, du: float) -> tuple[float, float]: """Map signed (dv, du) to correlation surface indices for sampling.""" # Same mapping as int_to_signed inverse: idx = s if s >= 0 else s + size y = dv if dv >= 0 else dv + self._corr_h x = du if du >= 0 else du + self._corr_w return y, x def _current_corr_value(self) -> float: y, x = self._offset_to_corr_indices(self._dv, self._du) return _bilinear_sample_periodic(self._corr_surface, y, x) # ---- UI construction ---- def _build_ui(self) -> None: layout = QHBoxLayout(self) layout.setSpacing(10) layout.setContentsMargins(10, 10, 10, 10) # Left: image viewport with zoom controls left = QVBoxLayout() zoom_row = QHBoxLayout() self._btn_zoom_out = QPushButton('Zoom -') self._btn_zoom_in = QPushButton('Zoom +') self._btn_reset = QPushButton('Reset View') self._zoom_sharp_check = QCheckBox('Sharp zoom') self._zoom_sharp_check.setChecked(self._zoom_sharp) self._btn_zoom_out.clicked.connect(self._zoom_out_center) self._btn_zoom_in.clicked.connect(self._zoom_in_center) self._btn_reset.clicked.connect(self._reset_view) self._zoom_sharp_check.stateChanged.connect(self._toggle_zoom_sharp) # Prevent Enter from triggering zoom buttons; keep them out of focus chain for btn in (self._btn_zoom_out, self._btn_zoom_in, self._btn_reset): try: btn.setAutoDefault(False) btn.setDefault(False) except Exception: pass btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) zoom_row.addStretch() zoom_row.addWidget(self._btn_zoom_out) zoom_row.addWidget(self._btn_zoom_in) zoom_row.addWidget(self._zoom_sharp_check) zoom_row.addWidget(self._btn_reset) zoom_row.addStretch() left.addLayout(zoom_row) self._scroll = QScrollArea() self._scroll.setWidgetResizable(False) self._scroll.setMinimumSize(700, 700) self._scroll.setStyleSheet('background-color: black;') self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter) self._label = _ImageLabel(self) self._label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._label.setStyleSheet('background-color: black;') self._label.setMouseTracking(True) self._scroll.setWidget(self._label) left.addWidget(self._scroll) layout.addLayout(left, stretch=2) # Status bar (within dialog) status = QStatusBar() self._status_label = QLabel('V, U: --, -- Image: -- Model: -- Correlation: --') self._zoom_label = QLabel('Zoom: 1.00x') status.addWidget(self._status_label) status.addPermanentWidget(self._zoom_label) left.addWidget(status) # Right: controls right = QVBoxLayout() # Image Stretch stretch_group = QGroupBox('Image Stretch') stretch_form = QFormLayout() self._check_show_image = QCheckBox('Display image') self._check_show_image.setChecked(True) self._check_show_image.setToolTip( 'Show the stretched observation in red/blue channels (and as the base in green).' ) self._check_show_image.stateChanged.connect(self._on_show_image_changed) stretch_form.addRow(self._check_show_image) controls = build_stretch_controls( stretch_form, img_min=self._stretch_min, img_max=self._stretch_max, black_init=self._image_black, white_init=self._image_white, gamma_init=self._image_gamma, on_black_changed=self._on_image_black_changed, on_white_changed=self._on_image_white_changed, on_gamma_changed=self._on_image_gamma_changed, value_label_min_width=52, slider_horizontal_stretch=1, ) self._slider_image_black = controls['slider_black'] self._slider_image_white = controls['slider_white'] self._slider_image_gamma = controls['slider_gamma'] self._lbl_image_black = controls['label_black'] self._lbl_image_white = controls['label_white'] self._lbl_image_gamma = controls['label_gamma'] self._stretch_controls = controls self._btn_reset_stretch = QPushButton('Reset Image Stretch') self._btn_reset_stretch.clicked.connect(self._on_reset_stretch) reset_img_row = QHBoxLayout() reset_img_row.addStretch(1) reset_img_row.addWidget(self._btn_reset_stretch) reset_img_row.addStretch(1) reset_img_holder = QWidget() reset_img_holder.setLayout(reset_img_row) stretch_form.addRow(reset_img_holder) stretch_group.setLayout(stretch_form) right.addWidget(stretch_group) # Model Stretch (same controls as image + transparency) model_stretch_group = QGroupBox('Model Stretch') model_stretch_form = QFormLayout() self._check_show_model = QCheckBox('Display model') self._check_show_model.setChecked(True) self._check_show_model.setToolTip( 'Show the stretched navigation model in the green channel (blended with the image).' ) self._check_show_model.stateChanged.connect(self._on_show_model_changed) model_stretch_form.addRow(self._check_show_model) self._check_show_mask = QCheckBox('Display mask') self._check_show_mask.setChecked(False) self._check_show_mask.setToolTip( 'Overlay the model mask (binary) as a semi-transparent white tint on the image.' ) self._check_show_mask.stateChanged.connect(self._on_show_mask_changed) model_stretch_form.addRow(self._check_show_mask) model_controls = build_stretch_controls( model_stretch_form, img_min=self._model_stretch_min, img_max=self._model_stretch_max, black_init=self._model_black, white_init=self._model_white, gamma_init=self._model_gamma, on_black_changed=self._on_model_black_changed, on_white_changed=self._on_model_white_changed, on_gamma_changed=self._on_model_gamma_changed, value_label_min_width=52, slider_horizontal_stretch=1, ) self._slider_model_black = model_controls['slider_black'] self._slider_model_white = model_controls['slider_white'] self._slider_model_gamma = model_controls['slider_gamma'] self._lbl_model_black = model_controls['label_black'] self._lbl_model_white = model_controls['label_white'] self._lbl_model_gamma = model_controls['label_gamma'] self._model_stretch_controls = model_controls self._slider_model_transparency = QSlider(Qt.Orientation.Horizontal) self._slider_model_transparency.setRange(0, 100) self._slider_model_transparency.setValue(round(self._model_transparency * 100)) self._lbl_model_transparency = QLabel(f'{self._model_transparency:.2f}') self._lbl_model_transparency.setMinimumWidth(52) self._lbl_model_transparency.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) self._slider_model_transparency.valueChanged.connect( lambda v: self._on_model_transparency_changed(v / 100.0) ) self._slider_model_transparency.setToolTip( '0 = fully opaque model, 1 = fully transparent (image only in the blend).' ) row_a = QHBoxLayout() row_a.setSpacing(4) row_a.addWidget(self._slider_model_transparency, stretch=1) row_a.addWidget(self._lbl_model_transparency) model_stretch_form.addRow('Model transparency:', row_a) self._btn_reset_model_stretch = QPushButton('Reset Model Stretch') self._btn_reset_model_stretch.clicked.connect(self._on_reset_model_stretch) reset_model_row = QHBoxLayout() reset_model_row.addStretch(1) reset_model_row.addWidget(self._btn_reset_model_stretch) reset_model_row.addStretch(1) reset_model_holder = QWidget() reset_model_holder.setLayout(reset_model_row) model_stretch_form.addRow(reset_model_holder) model_stretch_group.setLayout(model_stretch_form) right.addWidget(model_stretch_group) # Offsets offset_group = QGroupBox('Offset (pixels)') offset_form = QFormLayout() # V and U with 0.001 precision self._spin_dv = QDoubleSpinBox() self._spin_du = QDoubleSpinBox() self._spin_dv.setDecimals(3) self._spin_du.setDecimals(3) # Bounds based on extfov margins self._spin_dv.setRange(-self._obs.extfov_margin_v, self._obs.extfov_margin_v) self._spin_du.setRange(-self._obs.extfov_margin_u, self._obs.extfov_margin_u) self._spin_dv.setSingleStep(0.1) self._spin_du.setSingleStep(0.1) self._spin_dv.setValue(self._dv) self._spin_du.setValue(self._du) self._spin_dv.valueChanged.connect(self._on_spin_dv) self._spin_du.valueChanged.connect(self._on_spin_du) offset_form.addRow('dV (rows):', self._spin_dv) offset_form.addRow('dU (cols):', self._spin_du) # Constrained drag (rank-1 scenes): restrict right-drag motion to the # normal direction so the operator cannot move the model along an # axis the scene does not constrain (a straight ring edge's tangent). self._chk_constrain = QCheckBox('Constrain drag to normal') self._chk_constrain.setChecked(False) self._chk_constrain.setToolTip( 'Restrict right-drag to the normal direction below. Use for ' 'rank-1 scenes (straight ring edges) where only the edge-normal ' 'offset component is observable; the saved sidecar then carries ' 'a ground_truth.constraint block.' ) self._spin_constraint_angle = QDoubleSpinBox() self._spin_constraint_angle.setDecimals(2) self._spin_constraint_angle.setRange(-180.0, 180.0) self._spin_constraint_angle.setSingleStep(1.0) self._spin_constraint_angle.setValue( self._constraint_seed_deg if self._constraint_seed_deg is not None else 0.0 ) self._spin_constraint_angle.setToolTip( 'Normal direction in the (v, u) frame, measured from +v toward ' '+u; the unit normal is (cos, sin). Seeded from the aggregate ' 'ring-edge normal when the scene is all-straight.' ) offset_form.addRow(self._chk_constrain) offset_form.addRow('Normal angle (deg):', self._spin_constraint_angle) offset_group.setLayout(offset_form) right.addWidget(offset_group) # Buttons: Correlation Map, Auto, Save Library Entry, OK/Cancel btn_row = QHBoxLayout() self._btn_corr_map = QPushButton('Correlation Map...') self._btn_auto = QPushButton('Auto') self._btn_save_library = QPushButton('Save as Library Entry...') self._btn_ok = QPushButton('OK') self._btn_cancel = QPushButton('Cancel') self._btn_corr_map.clicked.connect(self._on_show_corr_map) self._btn_auto.clicked.connect(self._on_auto) self._btn_save_library.clicked.connect(self._on_save_library_entry) self._btn_ok.clicked.connect(self.accept) self._btn_cancel.clicked.connect(self.reject) btn_row.addStretch() btn_row.addWidget(self._btn_corr_map) btn_row.addWidget(self._btn_auto) btn_row.addWidget(self._btn_save_library) btn_row.addWidget(self._btn_ok) btn_row.addWidget(self._btn_cancel) right.addLayout(btn_row) right.addStretch(1) layout.addLayout(right, stretch=1) # Initialize zoom/pan controller for left-pan and wheel zoom self._zoom_ctl = ZoomPanController( label=self._label, scroll_area=self._scroll, get_zoom=lambda: self._zoom, set_zoom=lambda z: setattr(self, '_zoom', float(z)), update_display=self._update_display_only, set_zoom_label_text=lambda s: self._zoom_label.setText(s), ) # ---- Event handlers ---- @staticmethod def _is_checked(state: Any) -> bool: """Return True if ``state`` is the Qt Checked checkbox state.""" return Qt.CheckState(state) == Qt.CheckState.Checked def _on_image_black_changed(self, val: float) -> None: self._image_black = float(val) self._lbl_image_black.setText(f'{self._image_black:.5f}') self._refresh_overlay() def _on_image_white_changed(self, val: float) -> None: self._image_white = float(val) self._lbl_image_white.setText(f'{self._image_white:.5f}') self._refresh_overlay() def _on_image_gamma_changed(self, val: float) -> None: self._image_gamma = float(val) self._lbl_image_gamma.setText(f'{self._image_gamma:.5f}') self._refresh_overlay() def _on_model_black_changed(self, val: float) -> None: """Apply a new model black-point level from the Model Stretch black slider. Updates the in-memory model stretch lower bound used when compositing the navigation model into the overlay, keeps the model black value label in sync with the slider mapping, and redraws the RGB overlay. Parameters: val: Black level in data units after ``build_stretch_controls`` maps the slider position through ``from_slider`` (same linear range as ``_model_stretch_min`` / ``_model_stretch_max``). """ self._model_black = float(val) self._lbl_model_black.setText(f'{self._model_black:.5f}') self._refresh_overlay() def _on_model_white_changed(self, val: float) -> None: """Apply a new model white-point level from the Model Stretch white slider. Updates the in-memory model stretch upper bound used when compositing the model, refreshes the model white value label, and redraws the overlay. Parameters: val: White level in data units from the slider mapping (see ``_on_model_black_changed``). """ self._model_white = float(val) self._lbl_model_white.setText(f'{self._model_white:.5f}') self._refresh_overlay() def _on_model_gamma_changed(self, val: float) -> None: """Apply a new model gamma from the Model Stretch gamma slider. The shared stretch helper clamps gamma to at least ``0.10`` before this callback runs; this handler stores the value and redraws the overlay. Parameters: val: Gamma factor (``>= 0.10``) after ``build_stretch_controls`` gamma slot applies ``max(0.10, v / 100.0)``. """ self._model_gamma = float(val) self._lbl_model_gamma.setText(f'{self._model_gamma:.5f}') self._refresh_overlay() def _on_model_transparency_changed(self, val: float) -> None: """Apply model transparency for the green-channel blend (0 opaque, 1 clear). Values are clipped to ``[0.0, 1.0]`` so the overlay stays well-defined even if the caller passes an out-of-range float. Parameters: val: Nominal transparency in ``[0, 1]``. The model transparency slider connects via ``lambda v: self._on_model_transparency_changed(v / 100.0)``, so ``val`` is the slider position divided by ``100``. """ self._model_transparency = float(np.clip(val, 0.0, 1.0)) self._lbl_model_transparency.setText(f'{self._model_transparency:.2f}') self._refresh_overlay() def _on_show_image_changed(self, state: Any) -> None: """Toggle whether the stretched observation contributes to the overlay. Parameters: state: ``QCheckBox.stateChanged`` argument (typically an ``int`` Qt check state). Interpreted via ``_is_checked`` as ``True`` only for ``Qt.CheckState.Checked``. """ self._show_image = self._is_checked(state) self._refresh_overlay() def _on_show_model_changed(self, state: Any) -> None: """Toggle model visibility and enablement of the transparency controls. When the model is hidden, the transparency slider and label are disabled so the user cannot adjust a blend that is not shown. Parameters: state: Same contract as ``_on_show_image_changed`` for the ``Display model`` checkbox. """ self._show_model = self._is_checked(state) self._slider_model_transparency.setEnabled(self._show_model) self._lbl_model_transparency.setEnabled(self._show_model) self._refresh_overlay() def _on_show_mask_changed(self, state: Any) -> None: """Toggle overlay of the binary model mask on the composited image. When enabled, pixels where the model mask is ``True`` are highlighted with a semi-transparent white tint so the mask boundary is clearly visible against the false-color composite. Parameters: state: ``QCheckBox.stateChanged`` argument. Interpreted via ``_is_checked`` as ``True`` only for ``Qt.CheckState.Checked``. """ self._show_mask = self._is_checked(state) self._refresh_overlay() def _on_reset_stretch(self) -> None: # Recompute defaults from current image (FOV only; extended margins are zero) self._image_black = float(np.quantile(self._img_fov, 0.001)) self._image_white = float(np.quantile(self._img_fov, 0.999)) if self._image_black >= self._image_white: self._image_white = self._image_black + 0.01 self._image_gamma = 1.0 # Update UI via common helper self._stretch_controls['set_values']( self._image_black, self._image_white, self._image_gamma ) # Redraw self._refresh_overlay() def _on_reset_model_stretch(self) -> None: """Reset model black/white/gamma from quantiles of the current model slice.""" ms = np.asarray( self._obs.extract_offset_array(self._model_img_ext, (self._dv, self._du)), dtype=np.float64, ) self._model_black = float(np.quantile(ms, 0.001)) self._model_white = float(np.quantile(ms, 0.999)) if self._model_black >= self._model_white: self._model_white = self._model_black + 1e-6 self._model_gamma = 1.0 self._model_stretch_min = float(np.min(ms)) self._model_stretch_max = float(np.max(ms)) if self._model_stretch_min >= self._model_stretch_max: self._model_stretch_max = self._model_stretch_min + 1e-6 self._model_stretch_controls['set_range'](self._model_stretch_min, self._model_stretch_max) self._model_stretch_controls['set_values']( self._model_black, self._model_white, self._model_gamma ) self._refresh_overlay() def _on_spin_dv(self, val: float) -> None: self._dv = float(val) self._refresh_overlay() def _on_spin_du(self, val: float) -> None: self._du = float(val) self._refresh_overlay() def _on_show_corr_map(self) -> None: """Open a modal dialog displaying the NCC surface with the current offset marked. The correlation surface is fftshift-ed so zero offset is at the center. Axes are labeled in offset pixels: X = dU (cols), Y = dV (rows). The current ``(dV, dU)`` offset is overlaid as a red cross marker. """ dlg = _CorrMapDialog( corr_surface=self._corr_surface, dv=self._dv, du=self._du, max_offset_vu=self._obs.extfov_margin_vu, parent=self, ) dlg.exec() def _on_auto(self) -> None: # Auto-pick: run the same KPeaks pyramid NCC against the composite # template the dialog assembled via ``compose_template_features``. # The bi-directional ``data_mask`` keeps a body model that extends # into the extfov zero-padded margin from biasing the peak toward # |dV| = extfov_margin_v. up_factor = ( getattr(self._config.offset, 'correlation_fft_upsample_factor', 128) if self._config else 128 ) res = navigate_with_pyramid_kpeaks( image=self._img_ext, model=self._model_img_ext, mask=self._model_mask_ext, upsample_factor=up_factor, max_offset_vu=self._obs.extfov_margin_vu, data_mask=self._obs.extfov_data_sensor_mask(), logger=None, ) dv, du = float(res['offset'][0]), float(res['offset'][1]) self._dv, self._du = dv, du self._spin_dv.blockSignals(True) self._spin_du.blockSignals(True) self._spin_dv.setValue(self._dv) self._spin_du.setValue(self._du) self._spin_dv.blockSignals(False) self._spin_du.blockSignals(False) self._refresh_overlay() def _on_save_library_entry(self) -> None: """Write the current dv/du as a fresh library sidecar. Pops a save-file dialog, defaults to ``<image_id>.yaml`` in whatever directory the operator was last in, and writes a sidecar seeded with the auto-fillable fields plus ``TODO`` placeholders for the operator-curated ones. No round-trip into the image library is performed; the operator chooses the destination so an existing sidecar is not silently clobbered. After the YAML is written, the dialog also drops an annotated ``<stem>.png`` next to it: the same red-image / green-model composite the operator was looking at when they hit save. The PNG is only an orientation aid (so a future reviewer skimming the sidecar directory can see the scene at a glance); it is not consumed by any test or downstream tool. """ draft = infer_obs_metadata(self._obs) suggested_name = f'{draft.image_id or "TODO"}.yaml' path_str, _selected_filter = QFileDialog.getSaveFileName( self, 'Save Library Sidecar', suggested_name, 'Sidecar YAML (*.yaml);;All files (*)', ) if not path_str: return image_url = compute_image_url(self._obs, self._config) constraint_angle: float | None = None if self._chk_constrain.isChecked(): constraint_angle = float(self._spin_constraint_angle.value()) try: yaml_text = build_sidecar_yaml( draft=draft, image_url=image_url, offset_dv_px=self._dv, offset_du_px=self._du, ui_version=_spindoctor_version, constraint_normal_angle_deg=constraint_angle, ) FCPath(path_str).write_text(yaml_text) except OSError as exc: QMessageBox.critical( self, 'Save failed', f'Could not write sidecar to {path_str}:\n{exc}', ) return # Companion PNG: render the same labelled annotation overlay # (body / star / ring labels) the autonomous summary PNG would # produce, drawn at the chosen (dv, du) so the snapshot matches # the YAML's offset. Falls back to the source-image grayscale # alone when no NavModel emitted any annotations. PNG-write # failures are non-fatal — the YAML is the load-bearing # artefact. png_path = Path(path_str).with_suffix('.png') png_warning = '' try: rgb = render_annotated_summary_rgb(self._obs, self._annotations, (self._dv, self._du)) Image.fromarray(rgb, mode='RGB').save(str(png_path), format='PNG') except (OSError, ValueError) as exc: png_warning = f'\n\n(Could not write companion PNG to {png_path}: {exc})' QMessageBox.information( self, 'Sidecar saved', ( f'Wrote sidecar to:\n{path_str}\n' f'Wrote summary PNG to:\n{png_path}\n\n' 'Edit the TODO_REPLACE_* fields (scene_tags, primary_technique, ' 'notes, etc.) before committing.' + png_warning ), ) # Mouse handling def _on_mouse_press(self, event: QMouseEvent) -> None: if event.button() == Qt.MouseButton.LeftButton: # Use common zoom/pan controller for left-button pan self._zoom_ctl.on_mouse_press(event) self._label.setCursor(Qt.CursorShape.ClosedHandCursor) elif event.button() == Qt.MouseButton.RightButton: self._drag_mode = 'offset' self._drag_start_pos = event.globalPosition().toPoint() self._drag_start_offset = (self._dv, self._du) self._label.setCursor(Qt.CursorShape.SizeAllCursor) def _on_mouse_move(self, event: QMouseEvent) -> None: if self._drag_mode == 'offset' and self._drag_start_pos is not None: current_pos = event.globalPosition().toPoint() delta = current_pos - self._drag_start_pos if self._drag_start_offset is not None: # Convert label-pixel delta to image pixels via zoom ddv = delta.y() / max(self._zoom, 1e-6) ddu = delta.x() / max(self._zoom, 1e-6) if self._chk_constrain.isChecked(): # Project the drag onto the constraint normal so the # offset moves only along the observable axis. angle = math.radians(self._spin_constraint_angle.value()) n_v = math.cos(angle) n_u = math.sin(angle) along = ddv * n_v + ddu * n_u ddv = along * n_v ddu = along * n_u dv = self._drag_start_offset[0] + ddv du = self._drag_start_offset[1] + ddu # Clamp within extfov bounds (minus 1 to keep slices valid after rounding) dv = float( np.clip( dv, -self._obs.extfov_margin_v + 1, self._obs.extfov_margin_v - 1, ) ) du = float( np.clip( du, -self._obs.extfov_margin_u + 1, self._obs.extfov_margin_u - 1, ) ) self._dv, self._du = dv, du # Update spin boxes without feedback loop self._spin_dv.blockSignals(True) self._spin_du.blockSignals(True) self._spin_dv.setValue(self._dv) self._spin_du.setValue(self._du) self._spin_dv.blockSignals(False) self._spin_du.blockSignals(False) self._refresh_overlay() else: # Delegate to common controller for hover/move and update status self._zoom_ctl.on_mouse_move(event) self._update_status_from_mouse(event) def _on_mouse_release(self, event: QMouseEvent) -> None: if event.button() == Qt.MouseButton.LeftButton: self._zoom_ctl.on_mouse_release(event) self._label.setCursor(Qt.CursorShape.ArrowCursor) elif event.button() == Qt.MouseButton.RightButton: self._drag_start_pos = None self._drag_start_offset = None self._drag_mode = None self._label.setCursor(Qt.CursorShape.ArrowCursor) def _on_wheel(self, event: QWheelEvent) -> None: # Ignore wheel-zoom if focus is on editable controls fw = self.focusWidget() if isinstance(fw, (QDoubleSpinBox, QSlider)): event.ignore() return # Delegate wheel zoom to common controller self._zoom_ctl.on_wheel(event) # ---- Zoom/pan helpers (parity with sim_body_gui) ---- def _zoom_in_center(self) -> None: # Centre-anchored zoom is provided by the controller (it wraps this # window's scroll area); delegate instead of re-implementing it. self._zoom_ctl.zoom_in_center() def _zoom_out_center(self) -> None: self._zoom_ctl.zoom_out_center() def _zoom_at_point( self, factor: float, vx: int, vy: int, scaled_x: float, scaled_y: float ) -> None: if self._pixmap_base is None: return # The ZoomPanController owns the zoom clamp + no-op short-circuit, so # delegate directly instead of re-deriving the clamped zoom here. self._zoom_ctl.zoom_at_point(factor, vx, vy, scaled_x, scaled_y) def _reset_view(self) -> None: self._zoom = 1.0 self._zoom_label.setText(f'zoom: {self._zoom:.2f}x') self._update_display_only() # ---- Rendering ---- def _compose_overlay_rgb_fov(self) -> NDArrayUint8Type: """Build the FOV-shaped RGB overlay at the current ``(dv, du)``. Encapsulates the pixel logic shared between the on-screen pixmap and the on-disk summary PNG written alongside a saved sidecar: red + blue carry the stretched image, green carries the image-and-model blend, and the optional binary-mask overlay applies a semi-transparent white tint. Returns: ``(H, W, 3)`` uint8 array in FOV coordinates. """ # Primary image (FOV) -> red channel (mono repeated into RGB then tinted) img_u8 = _apply_stretch_gamma( self._img_fov, self._image_black, self._image_white, self._image_gamma ) h, w = img_u8.shape img_layer = img_u8 if self._show_image else np.zeros((h, w), dtype=np.uint8) # Extract model slice at current (dv, du) # Note: extract_offset_array will round inside; for display that's acceptable model_slice = self._obs.extract_offset_array(self._model_img_ext, (self._dv, self._du)) self._model_slice_fov = np.asarray(model_slice, dtype=np.float64) model_u8 = _apply_stretch_gamma( self._model_slice_fov, self._model_black, self._model_white, self._model_gamma, ) # Build RGB with red for image, green for model blend, blue for image rgb = np.zeros((h, w, 3), dtype=np.uint8) rgb[:, :, 0] = img_layer # Green: blend image and model when both visible; image only if model hidden; # model-tinted black if image hidden but model visible if self._show_model: img_f = img_layer.astype(np.float32) mod_f = model_u8.astype(np.float32) # transparency 0 => opaque model; 1 => model invisible (image only in green) t = self._model_transparency green = t * img_f + (1.0 - t) * mod_f rgb[:, :, 1] = np.clip(green, 0, 255).astype(np.uint8) else: rgb[:, :, 1] = img_layer rgb[:, :, 2] = img_layer # Overlay binary model mask as a semi-transparent white tint when enabled if self._show_mask: mask_raw = self._obs.extract_offset_array(self._model_mask_ext, (self._dv, self._du)) mask_slice = np.asarray(mask_raw, dtype=np.float64) > 0.5 for c in range(3): ch = rgb[:, :, c].astype(np.float32) ch[mask_slice] = ( ch[mask_slice] * (1.0 - _MASK_OVERLAY_ALPHA) + 255.0 * _MASK_OVERLAY_ALPHA ) rgb[:, :, c] = np.clip(ch, 0, 255).astype(np.uint8) return rgb def _compose_overlay_pixmap(self) -> None: """Compose the RGB overlay pixmap based on current stretch/offset/alpha.""" rgb = self._compose_overlay_rgb_fov() h, w = rgb.shape[:2] # Create QImage/QPixmap qimage = QImage(rgb.tobytes(), w, h, 3 * w, QImage.Format.Format_RGB888).copy() pixmap = QPixmap(w, h) painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.drawImage(0, 0, qimage) painter.end() self._pixmap_base = pixmap def _update_display_only(self) -> None: """Update label to show scaled/panned image.""" if self._pixmap_base is None: return scaled_w = int(self._pixmap_base.width() * self._zoom) scaled_h = int(self._pixmap_base.height() * self._zoom) transform_mode = ( Qt.TransformationMode.FastTransformation if self._zoom_sharp else Qt.TransformationMode.SmoothTransformation ) scaled = self._pixmap_base.scaled( scaled_w, scaled_h, Qt.AspectRatioMode.KeepAspectRatio, transform_mode ) self._label.setPixmap(scaled) self._label.resize(scaled_w, scaled_h) # Update status bar with latest corr (no mouse move) self._status_label.setText( f'V, U: --, -- Image: -- Model: -- Correlation: {self._current_corr_value():.6f}' ) def _refresh_overlay(self) -> None: """Rebuild overlay pixmap and update view.""" self._compose_overlay_pixmap() self._update_display_only() def _update_status_from_mouse(self, event: QMouseEvent) -> None: # Position in label coordinates == scaled image coordinates scaled_x = float(event.position().x()) scaled_y = float(event.position().y()) # Convert to original image coords img_u = scaled_x / max(self._zoom, 1e-6) img_v = scaled_y / max(self._zoom, 1e-6) h, w = self._img_fov.shape mod_arr = self._model_slice_fov if 0 <= img_v < h and 0 <= img_u < w and mod_arr is not None and mod_arr.shape == (h, w): val_img = _bilinear_interpolate_fov(self._img_fov, img_v, img_u) val_model = _bilinear_interpolate_fov(mod_arr, img_v, img_u) corr_val = self._current_corr_value() self._status_label.setText( f'V, U: {img_v:.2f}, {img_u:.2f} ' f'Image: {val_img:.6f} Model: {val_model:.6f} Correlation: {corr_val:.6f}' ) else: self._status_label.setText( f'V, U: --, -- Image: -- Model: -- Correlation: {self._current_corr_value():.6f}' ) # ---- Dialog control ----
[docs] def run_modal(self) -> tuple[bool, tuple[float, float] | None, float | None]: """Run the dialog modally, creating a QApplication if necessary.""" app_created = False app = QApplication.instance() if app is None: app = QApplication([]) app_created = True result = self.exec() accepted = result == QDialog.DialogCode.Accepted chosen = (self._dv, self._du) if accepted else None corr = self._current_corr_value() if accepted else None if app_created: # Do not quit an existing app app.quit() return accepted, chosen, corr
# ---- Zoom options ---- def _toggle_zoom_sharp(self, state: Any) -> None: """Turn sharp (pixel-crisp) zoom scaling on or off. When enabled, the image pixmap is rescaled with a mode that preserves sharp edges at high zoom; when disabled, a smoother filter is used. This affects only how the label pixmap is scaled, not stretch or offset state. Parameters: state: ``QCheckBox.stateChanged`` argument for the ``Sharp zoom`` checkbox. ``True`` means ``Qt.CheckState.Checked`` per ``_is_checked``. """ self._zoom_sharp = self._is_checked(state) self._update_display_only() # Internal buffers _pixmap_base: QPixmap | None = None